Decode as Float32, ignore stale runs, guard empty stems

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: siGit Code <noreply@sigit.si>

Seto committed Sep 26, 2026 at 14:23 UTC 5bc4a3ef4e436eb06be4a0a7e07b12667a840a6c
1 file changed +25 -11
Examples/DemucsExample/SeparationViewModel.swift
+25 -11
@@ -25,6 +25,9 @@ final class SeparationViewModel: ObservableObject {
25
26 private let engine = DemucsEngine()
27 private var task: Task<Void, Never>?
28 + /// Bumped on every new run so a cancelled run that unwinds late can't
29 + /// overwrite the state of the run that replaced it.
30 + private var generation = 0
31
32 var isBusy: Bool {
33 switch phase {
@@ -36,7 +39,9 @@ final class SeparationViewModel: ObservableObject {
39 func separate(fileAt url: URL) {
40 task?.cancel()
41 stems = []
39 - task = Task { await run(url: url) }
42 + generation += 1
43 + let current = generation
44 + task = Task { await run(url: url, generation: current) }
45 }
46
47 func cancel() {
@@ -54,7 +59,7 @@ final class SeparationViewModel: ObservableObject {
59 }
60 }
61
57 - private func run(url: URL) async {
62 + private func run(url: URL, generation current: Int) async {
63 do {
64 let model = model
65 if !DemucsEngine.isDownloaded(model) {
@@ -81,18 +86,22 @@ final class SeparationViewModel: ObservableObject {
86 let outputDirectory = FileManager.default.temporaryDirectory
87 .appendingPathComponent(url.deletingPathExtension().lastPathComponent, isDirectory: true)
88 try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
89 + guard current == generation else { return }
90 stems = try separated.map { stem in
91 let file = outputDirectory.appendingPathComponent("\(stem.kind.rawValue).wav")
92 try AudioFile.write(stem, sampleRate: audio.sampleRate, to: file)
93 return StemFile(kind: stem.kind, url: file)
94 }
95 + guard current == generation else { return }
96 phase = .done
90 - } catch DemucsError.cancelled {
91 - phase = .idle
92 - } catch is CancellationError {
93 - phase = .idle
97 } catch {
95 - phase = .failed(error.localizedDescription)
98 + guard current == generation else { return }
99 + switch error {
100 + case DemucsError.cancelled, is CancellationError:
101 + phase = .idle
102 + default:
103 + phase = .failed(error.localizedDescription)
104 + }
105 }
106 }
107 }
@@ -110,8 +119,9 @@ enum AudioFile {
119 let scoped = url.startAccessingSecurityScopedResource()
120 defer { if scoped { url.stopAccessingSecurityScopedResource() } }
121
113 - let file = try AVAudioFile(forReading: url)
114 - let format = file.processingFormat // Float32, deinterleaved
122 + // Force Float32 so 16/24-bit PCM files decode into floatChannelData.
123 + let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false)
124 + let format = file.processingFormat
125 guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(file.length)) else {
126 throw DemucsError.invalidInput("Could not allocate an audio buffer.")
127 }
@@ -141,8 +151,12 @@ enum AudioFile {
151 throw DemucsError.io("Could not allocate an audio buffer.")
152 }
153 buffer.frameLength = AVAudioFrameCount(stem.left.count)
144 - stem.left.withUnsafeBufferPointer { channels[0].update(from: $0.baseAddress!, count: $0.count) }
145 - stem.right.withUnsafeBufferPointer { channels[1].update(from: $0.baseAddress!, count: $0.count) }
154 + for (index, samples) in [stem.left, stem.right].enumerated() {
155 + samples.withUnsafeBufferPointer { source in
156 + guard let base = source.baseAddress else { return }
157 + channels[index].update(from: base, count: source.count)
158 + }
159 + }
160
161 try? FileManager.default.removeItem(at: url)
162 let file = try AVAudioFile(forWriting: url, settings: format.settings)