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)