| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import AVFoundation |
| import CoreML |
|
|
| public enum StemKind: Int, CaseIterable { |
| case vocals = 0 |
| case drums = 1 |
| case bass = 2 |
| case other = 3 |
| } |
|
|
| public enum StemSeparatorError: LocalizedError { |
| case modelNotFound |
| case unsupportedFormat |
| case inferenceFailed(String) |
|
|
| public var errorDescription: String? { |
| switch self { |
| case .modelNotFound: return "HTDemucs_CoreML.mlpackage not found in bundle." |
| case .unsupportedFormat: return "Audio must be 44.1 kHz stereo Float32." |
| case .inferenceFailed(let m): return "Inference failed: \(m)" |
| } |
| } |
| } |
|
|
| public final class StemSeparator { |
|
|
| |
| |
| |
| private let segmentSamples = 441_000 |
| private let overlapSamples = 44_100 |
| private let sampleRate = 44_100.0 |
|
|
| private let model: MLModel |
| private let inferenceLock = NSLock() |
|
|
| public init() throws { |
| guard let url = Bundle.main.url( |
| forResource: "HTDemucs_CoreML", withExtension: "mlpackage" |
| ) ?? Bundle.main.url( |
| forResource: "HTDemucs_CoreML", withExtension: "mlmodelc" |
| ) else { |
| throw StemSeparatorError.modelNotFound |
| } |
|
|
| let config = MLModelConfiguration() |
| config.computeUnits = .cpuAndGPU |
| self.model = try MLModel(contentsOf: url, configuration: config) |
| } |
|
|
| |
| |
| public func separate( |
| fileURL: URL, |
| progress: @Sendable @escaping (Double) -> Void = { _ in } |
| ) async throws -> [StemKind: AVAudioPCMBuffer] { |
| let mix = try loadAndResample(fileURL: fileURL) |
| return try await Task.detached(priority: .userInitiated) { |
| try self.runChunked(mix: mix, progress: progress) |
| }.value |
| } |
|
|
| |
|
|
| private func loadAndResample(fileURL: URL) throws -> AVAudioPCMBuffer { |
| let file = try AVAudioFile(forReading: fileURL) |
| let target = AVAudioFormat( |
| commonFormat: .pcmFormatFloat32, |
| sampleRate: sampleRate, |
| channels: 2, |
| interleaved: false |
| )! |
| guard let converter = AVAudioConverter(from: file.processingFormat, to: target) else { |
| throw StemSeparatorError.unsupportedFormat |
| } |
|
|
| let outFrames = AVAudioFrameCount( |
| Double(file.length) * sampleRate / file.processingFormat.sampleRate |
| ) |
| guard let out = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else { |
| throw StemSeparatorError.unsupportedFormat |
| } |
|
|
| let input = AVAudioPCMBuffer(pcmFormat: file.processingFormat, |
| frameCapacity: AVAudioFrameCount(file.length))! |
| try file.read(into: input) |
|
|
| var consumed = false |
| var error: NSError? |
| converter.convert(to: out, error: &error) { _, status in |
| if consumed { status.pointee = .endOfStream; return nil } |
| consumed = true |
| status.pointee = .haveData |
| return input |
| } |
| if let error { throw StemSeparatorError.inferenceFailed(error.localizedDescription) } |
| return out |
| } |
|
|
| |
|
|
| private func runChunked( |
| mix: AVAudioPCMBuffer, |
| progress: @Sendable @escaping (Double) -> Void |
| ) throws -> [StemKind: AVAudioPCMBuffer] { |
| let total = Int(mix.frameLength) |
| let stride = segmentSamples - overlapSamples |
| let chunks = max(1, Int(ceil(Double(total) / Double(stride)))) |
|
|
| |
| var outputs: [StemKind: UnsafeMutablePointer<Float>] = [:] |
| var weights = [Float](repeating: 0, count: total) |
| defer { outputs.values.forEach { $0.deallocate() } } |
|
|
| for kind in StemKind.allCases { |
| outputs[kind] = UnsafeMutablePointer<Float>.allocate(capacity: total * 2) |
| outputs[kind]!.initialize(repeating: 0, count: total * 2) |
| } |
|
|
| |
| var window = [Float](repeating: 1, count: segmentSamples) |
| for i in 0..<overlapSamples { |
| let w = Float(i) / Float(overlapSamples) |
| window[i] = w |
| window[segmentSamples - 1 - i] = w |
| } |
|
|
| for c in 0..<chunks { |
| let start = c * stride |
| let chunk = sliceChunk(mix: mix, start: start) |
| let stemChunks = try predict(chunk: chunk) |
|
|
| for kind in StemKind.allCases { |
| guard let dst = outputs[kind] else { continue } |
| let src = stemChunks[kind]! |
| for f in 0..<segmentSamples { |
| let global = start + f |
| if global >= total { break } |
| let w = window[f] |
| dst[global * 2 + 0] += src[f * 2 + 0] * w |
| dst[global * 2 + 1] += src[f * 2 + 1] * w |
| if kind == .vocals { |
| weights[global] += w |
| } |
| } |
| } |
| progress(Double(c + 1) / Double(chunks)) |
| } |
|
|
| |
| for kind in StemKind.allCases { |
| guard let dst = outputs[kind] else { continue } |
| for f in 0..<total { |
| let w = max(weights[f], 1e-6) |
| dst[f * 2 + 0] /= w |
| dst[f * 2 + 1] /= w |
| } |
| } |
|
|
| |
| let outFormat = AVAudioFormat( |
| commonFormat: .pcmFormatFloat32, |
| sampleRate: sampleRate, |
| channels: 2, |
| interleaved: false |
| )! |
| var result: [StemKind: AVAudioPCMBuffer] = [:] |
| for kind in StemKind.allCases { |
| let buf = AVAudioPCMBuffer(pcmFormat: outFormat, |
| frameCapacity: AVAudioFrameCount(total))! |
| buf.frameLength = AVAudioFrameCount(total) |
| let src = outputs[kind]! |
| for f in 0..<total { |
| buf.floatChannelData![0][f] = src[f * 2 + 0] |
| buf.floatChannelData![1][f] = src[f * 2 + 1] |
| } |
| result[kind] = buf |
| } |
| return result |
| } |
|
|
| |
|
|
| private func predict(chunk: [Float]) throws -> [StemKind: [Float]] { |
| precondition(chunk.count == segmentSamples * 2, |
| "chunk must be \(segmentSamples * 2) interleaved floats") |
|
|
| let array = try MLMultiArray( |
| shape: [1, 2, NSNumber(value: segmentSamples)], |
| dataType: .float32 |
| ) |
| |
| let ptr = array.dataPointer.bindMemory(to: Float.self, capacity: array.count) |
| for f in 0..<segmentSamples { |
| ptr[0 * segmentSamples + f] = chunk[f * 2 + 0] |
| ptr[1 * segmentSamples + f] = chunk[f * 2 + 1] |
| } |
|
|
| inferenceLock.lock() |
| defer { inferenceLock.unlock() } |
|
|
| let provider = try MLDictionaryFeatureProvider(dictionary: ["audio": array]) |
| let result = try model.prediction(from: provider) |
| guard let out = result.featureValue(for: "sources")?.multiArrayValue else { |
| throw StemSeparatorError.inferenceFailed("missing 'sources' output") |
| } |
|
|
| |
| var stems: [StemKind: [Float]] = [:] |
| let outPtr = out.dataPointer.bindMemory(to: Float.self, capacity: out.count) |
| for kind in StemKind.allCases { |
| var samples = [Float](repeating: 0, count: segmentSamples * 2) |
| let stemBase = kind.rawValue * 2 * segmentSamples |
| for f in 0..<segmentSamples { |
| samples[f * 2 + 0] = outPtr[stemBase + 0 * segmentSamples + f] |
| samples[f * 2 + 1] = outPtr[stemBase + 1 * segmentSamples + f] |
| } |
| stems[kind] = samples |
| } |
| return stems |
| } |
|
|
| private func sliceChunk(mix: AVAudioPCMBuffer, start: Int) -> [Float] { |
| let total = Int(mix.frameLength) |
| var out = [Float](repeating: 0, count: segmentSamples * 2) |
| let l = mix.floatChannelData![0] |
| let r = mix.floatChannelData![1] |
| for f in 0..<segmentSamples { |
| let g = start + f |
| if g < total { |
| out[f * 2 + 0] = l[g] |
| out[f * 2 + 1] = r[g] |
| } |
| } |
| return out |
| } |
| } |
|
|