| 1 | import AVFoundation |
| 2 | import Demucs |
| 3 | import Foundation |
| 4 | |
| 5 | struct StemFile: Identifiable { |
| 6 | let kind: StemKind |
| 7 | let url: URL |
| 8 | var id: StemKind { kind } |
| 9 | } |
| 10 | |
| 11 | @MainActor |
| 12 | final class SeparationViewModel: ObservableObject { |
| 13 | enum Phase: Equatable { |
| 14 | case idle |
| 15 | case downloading(Double) |
| 16 | case loading |
| 17 | case separating(Double) |
| 18 | case done |
| 19 | case failed(String) |
| 20 | } |
| 21 | |
| 22 | @Published var model: DemucsModel = .fourStem |
| 23 | @Published private(set) var phase: Phase = .idle |
| 24 | @Published private(set) var stems: [StemFile] = [] |
| 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 { |
| 34 | case .downloading, .loading, .separating: true |
| 35 | default: false |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func separate(fileAt url: URL) { |
| 40 | task?.cancel() |
| 41 | stems = [] |
| 42 | generation += 1 |
| 43 | let current = generation |
| 44 | task = Task { await run(url: url, generation: current) } |
| 45 | } |
| 46 | |
| 47 | func cancel() { |
| 48 | task?.cancel() |
| 49 | } |
| 50 | |
| 51 | /// Progress arrives from background threads and can land after the step |
| 52 | /// it describes has finished, or after a newer run has started; only |
| 53 | /// apply it while that run and step are current. |
| 54 | private func update(_ progress: Phase, generation current: Int) { |
| 55 | guard current == generation else { return } |
| 56 | switch (phase, progress) { |
| 57 | case (.downloading, .downloading), (.separating, .separating): |
| 58 | phase = progress |
| 59 | default: |
| 60 | break |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | private func run(url: URL, generation current: Int) async { |
| 65 | do { |
| 66 | let model = model |
| 67 | if !DemucsEngine.isDownloaded(model) { |
| 68 | phase = .downloading(0) |
| 69 | try await DemucsEngine.download(model) { progress in |
| 70 | Task { @MainActor in self.update(.downloading(progress.fraction), generation: current) } |
| 71 | } |
| 72 | } |
| 73 | if engine.loadedModel != model { |
| 74 | phase = .loading |
| 75 | try await engine.load(model) |
| 76 | } |
| 77 | |
| 78 | let audio = try AudioFile.read(url) |
| 79 | phase = .separating(0) |
| 80 | let separated = try await engine.separate( |
| 81 | left: audio.left, |
| 82 | right: audio.right, |
| 83 | sampleRate: audio.sampleRate |
| 84 | ) { progress in |
| 85 | Task { @MainActor in self.update(.separating(progress.fraction), generation: current) } |
| 86 | } |
| 87 | |
| 88 | let outputDirectory = FileManager.default.temporaryDirectory |
| 89 | .appendingPathComponent(url.deletingPathExtension().lastPathComponent, isDirectory: true) |
| 90 | try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) |
| 91 | guard current == generation else { return } |
| 92 | stems = try separated.map { stem in |
| 93 | let file = outputDirectory.appendingPathComponent("\(stem.kind.rawValue).wav") |
| 94 | try AudioFile.write(stem, sampleRate: audio.sampleRate, to: file) |
| 95 | return StemFile(kind: stem.kind, url: file) |
| 96 | } |
| 97 | guard current == generation else { return } |
| 98 | phase = .done |
| 99 | } catch { |
| 100 | guard current == generation else { return } |
| 101 | switch error { |
| 102 | case DemucsError.cancelled, is CancellationError: |
| 103 | phase = .idle |
| 104 | default: |
| 105 | phase = .failed(error.localizedDescription) |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Minimal AVFoundation decode/encode. Demucs itself takes and returns plain |
| 112 | /// per-channel Float arrays at any sample rate. |
| 113 | enum AudioFile { |
| 114 | struct Decoded { |
| 115 | let left: [Float] |
| 116 | let right: [Float] |
| 117 | let sampleRate: UInt32 |
| 118 | } |
| 119 | |
| 120 | static func read(_ url: URL) throws -> Decoded { |
| 121 | let scoped = url.startAccessingSecurityScopedResource() |
| 122 | defer { if scoped { url.stopAccessingSecurityScopedResource() } } |
| 123 | |
| 124 | // Force Float32 so 16/24-bit PCM files decode into floatChannelData. |
| 125 | let file = try AVAudioFile(forReading: url, commonFormat: .pcmFormatFloat32, interleaved: false) |
| 126 | let format = file.processingFormat |
| 127 | guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(file.length)) else { |
| 128 | throw DemucsError.invalidInput("Could not allocate an audio buffer.") |
| 129 | } |
| 130 | try file.read(into: buffer) |
| 131 | guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { |
| 132 | throw DemucsError.invalidInput("The file has no audio.") |
| 133 | } |
| 134 | let frames = Int(buffer.frameLength) |
| 135 | let left = Array(UnsafeBufferPointer(start: channels[0], count: frames)) |
| 136 | let right = format.channelCount > 1 |
| 137 | ? Array(UnsafeBufferPointer(start: channels[1], count: frames)) |
| 138 | : left |
| 139 | return Decoded(left: left, right: right, sampleRate: UInt32(format.sampleRate)) |
| 140 | } |
| 141 | |
| 142 | static func write(_ stem: Stem, sampleRate: UInt32, to url: URL) throws { |
| 143 | guard |
| 144 | let format = AVAudioFormat( |
| 145 | commonFormat: .pcmFormatFloat32, |
| 146 | sampleRate: Double(sampleRate), |
| 147 | channels: 2, |
| 148 | interleaved: false |
| 149 | ), |
| 150 | let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(stem.left.count)), |
| 151 | let channels = buffer.floatChannelData |
| 152 | else { |
| 153 | throw DemucsError.io("Could not allocate an audio buffer.") |
| 154 | } |
| 155 | buffer.frameLength = AVAudioFrameCount(stem.left.count) |
| 156 | for (index, samples) in [stem.left, stem.right].enumerated() { |
| 157 | samples.withUnsafeBufferPointer { source in |
| 158 | guard let base = source.baseAddress else { return } |
| 159 | channels[index].update(from: base, count: min(source.count, stem.left.count)) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | try? FileManager.default.removeItem(at: url) |
| 164 | let file = try AVAudioFile(forWriting: url, settings: format.settings) |
| 165 | try file.write(from: buffer) |
| 166 | } |
| 167 | } |