Add a Swift API over the generated bindings

DemucsEngine is an actor that wraps FfiDemucsEngine, with its own model, stem, progress and error types. Progress callbacks double as the cancel signal: once the calling task is cancelled they return false, and Rust stops at the next chunk or download read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto committed Sep 26, 2026 at 14:42 UTC 3928858a2942beb80eba8ab85ba87170b98dc824
1 file changed +381
Sources/Demucs/DemucsEngine.swift new
+381
@@ -0,0 +1,381 @@
1 +import Foundation
2 +
3 +/// One source the models can separate out of a mix.
4 +public enum StemKind: String, Sendable, CaseIterable {
5 + case drums
6 + case bass
7 + case other
8 + case vocals
9 + case guitar
10 + case piano
11 +}
12 +
13 +/// Which HTDemucs variant to download or load.
14 +public enum DemucsModel: Sendable, Hashable {
15 + /// htdemucs: drums, bass, other, vocals. ~84 MB.
16 + case fourStem
17 +
18 + /// htdemucs_6s: adds guitar and piano. ~84 MB.
19 + case sixStem
20 +
21 + /// htdemucs_ft: one fine-tuned sub-model per stem, best quality. ~333 MB.
22 + ///
23 + /// Only drums, bass, other and vocals are available. Each requested stem
24 + /// runs its own sub-model, so asking for fewer is proportionally faster.
25 + /// Every selection shares the same downloaded weights.
26 + case fineTuned(stems: [StemKind])
27 +
28 + /// The fine-tuned model with all four of its stems.
29 + public static let fineTunedAll = DemucsModel.fineTuned(stems: [.drums, .bass, .other, .vocals])
30 +}
31 +
32 +public struct DemucsModelInfo: Sendable, Hashable, Identifiable {
33 + public let model: DemucsModel
34 + /// The model's upstream name, such as `htdemucs`.
35 + public let id: String
36 + public let label: String
37 + public let description: String
38 + public let sizeMB: UInt32
39 + public let stems: [StemKind]
40 +}
41 +
42 +/// A separated source at the input sample rate, the same length as the input.
43 +public struct Stem: Sendable, Equatable {
44 + public let kind: StemKind
45 + public let left: [Float]
46 + public let right: [Float]
47 +}
48 +
49 +public struct DownloadProgress: Sendable, Equatable {
50 + public let downloadedBytes: UInt64
51 + /// From the server's `Content-Length`, or the model's nominal size.
52 + public let totalBytes: UInt64
53 + public let fraction: Double
54 +}
55 +
56 +public struct SeparationProgress: Sendable, Equatable {
57 + public let fraction: Double
58 + public let chunkIndex: UInt64
59 + public let totalChunks: UInt64
60 +}
61 +
62 +public enum DemucsError: Error, Sendable, Equatable, LocalizedError {
63 + /// The task was cancelled, or a progress handler asked to stop.
64 + case cancelled
65 + /// `separate` or `warmup` was called before `load`.
66 + case notLoaded
67 + /// `load` was called before the model's weights were downloaded.
68 + case notDownloaded(modelID: String)
69 + case invalidInput(String)
70 + case downloadFailed(String)
71 + case io(String)
72 + case inferenceFailed(String)
73 +
74 + public var errorDescription: String? {
75 + switch self {
76 + case .cancelled: "The operation was cancelled."
77 + case .notLoaded: "No model is loaded."
78 + case .notDownloaded(let id): "Model \(id) has not been downloaded."
79 + case .invalidInput(let reason): reason
80 + case .downloadFailed(let reason): "Download failed: \(reason)"
81 + case .io(let reason): "File error: \(reason)"
82 + case .inferenceFailed(let reason): reason
83 + }
84 + }
85 +}
86 +
87 +/// On-device music source separation (HTDemucs v4), GPU-accelerated through
88 +/// Metal.
89 +///
90 +/// An engine holds at most one loaded model. Loading, warming up and
91 +/// separating on one engine run one at a time; create another engine to run
92 +/// a second model side by side, memory permitting.
93 +///
94 +/// ```swift
95 +/// try await DemucsEngine.download(.fourStem)
96 +/// let engine = DemucsEngine()
97 +/// try await engine.load(.fourStem)
98 +/// let stems = try await engine.separate(left: left, right: right, sampleRate: 44_100)
99 +/// ```
100 +public actor DemucsEngine {
101 + private nonisolated let core: FfiDemucsEngine
102 +
103 + public init() {
104 + core = FfiDemucsEngine()
105 + }
106 +
107 + // MARK: Model files
108 +
109 + /// Sets where model weights are stored. Call once at launch, before any
110 + /// other Demucs call; sandboxed apps should pass a directory inside their
111 + /// container, such as one under Caches. Without it, weights go to the
112 + /// platform cache directory under `demucs-rs/`.
113 + public static func configureCacheDirectory(_ url: URL) {
114 + configureCacheDir(path: url.path)
115 + }
116 +
117 + public static var models: [DemucsModelInfo] {
118 + allModels().map(\.swift)
119 + }
120 +
121 + public static func info(for model: DemucsModel) -> DemucsModelInfo {
122 + modelMetadata(model: model.ffi).swift
123 + }
124 +
125 + public static func isDownloaded(_ model: DemucsModel) -> Bool {
126 + isModelCached(model: model.ffi)
127 + }
128 +
129 + /// The downloaded weights file, or `nil` if it is not on disk.
130 + public static func downloadedFileURL(for model: DemucsModel) -> URL? {
131 + cachedModelPath(model: model.ffi).map { URL(fileURLWithPath: $0) }
132 + }
133 +
134 + /// Removes the downloaded weights. Returns `false` if there was nothing
135 + /// to remove.
136 + @discardableResult
137 + public static func deleteDownload(_ model: DemucsModel) -> Bool {
138 + deleteCachedModel(model: model.ffi)
139 + }
140 +
141 + /// Downloads the model's weights from Hugging Face. Returns straight away
142 + /// if they are already downloaded. Cancel the calling task to stop; a
143 + /// partial download is discarded.
144 + ///
145 + /// `progress` is called on a background thread.
146 + public static func download(
147 + _ model: DemucsModel,
148 + progress: (@Sendable (DownloadProgress) -> Void)? = nil
149 + ) async throws {
150 + let listener = DownloadListener(handler: progress)
151 + try await withTaskCancellationHandler {
152 + try await translatingErrors {
153 + try await downloadModel(model: model.ffi, listener: listener)
154 + }
155 + } onCancel: {
156 + listener.cancel()
157 + }
158 + }
159 +
160 + // MARK: Loading
161 +
162 + /// Loads a downloaded model, replacing the current one. Returns the load
163 + /// time in seconds.
164 + @discardableResult
165 + public func load(_ model: DemucsModel) async throws -> Double {
166 + try await translatingErrors {
167 + try await core.loadModel(model: model.ffi)
168 + }
169 + }
170 +
171 + /// Runs a dummy separation so the first real one does not pay for GPU
172 + /// shader compilation. Worth calling once after `load`, off the critical
173 + /// path.
174 + public func warmup() async throws {
175 + try await translatingErrors {
176 + try await core.warmup()
177 + }
178 + }
179 +
180 + /// Frees the loaded model. Returns `false` if nothing was loaded.
181 + @discardableResult
182 + public func unload() async -> Bool {
183 + await core.unloadModel()
184 + }
185 +
186 + public nonisolated var isLoaded: Bool {
187 + core.isLoaded()
188 + }
189 +
190 + public nonisolated var loadedModel: DemucsModel? {
191 + core.loadedModel()?.swift
192 + }
193 +
194 + // MARK: Separation
195 +
196 + /// Separates stereo PCM at any sample rate into the loaded model's stems.
197 + ///
198 + /// Both channels must have the same, non-zero length. Audio is processed
199 + /// in chunks of about 7.8 seconds; cancelling the calling task stops at
200 + /// the next chunk boundary. `progress` is called on a background thread.
201 + public func separate(
202 + left: [Float],
203 + right: [Float],
204 + sampleRate: UInt32,
205 + progress: (@Sendable (SeparationProgress) -> Void)? = nil
206 + ) async throws -> [Stem] {
207 + let listener = SeparationListener(handler: progress)
208 + let stems = try await withTaskCancellationHandler {
209 + try await translatingErrors {
210 + try await core.separate(
211 + left: left,
212 + right: right,
213 + sampleRate: sampleRate,
214 + listener: listener
215 + )
216 + }
217 + } onCancel: {
218 + listener.cancel()
219 + }
220 + return stems.map(\.swift)
221 + }
222 +
223 + /// Separates mono PCM. The stems come back stereo with identical channels.
224 + public func separate(
225 + mono samples: [Float],
226 + sampleRate: UInt32,
227 + progress: (@Sendable (SeparationProgress) -> Void)? = nil
228 + ) async throws -> [Stem] {
229 + try await separate(left: samples, right: samples, sampleRate: sampleRate, progress: progress)
230 + }
231 +
232 + /// How many chunks `separate` splits this many samples into, for sizing
233 + /// progress UI up front.
234 + public static func chunkCount(sampleCount: Int, sampleRate: UInt32) -> Int {
235 + Int(numChunks(nSamples: UInt64(max(sampleCount, 0)), sampleRate: sampleRate))
236 + }
237 +}
238 +
239 +// MARK: - Callback adapters
240 +
241 +/// Shared by both listeners: the progress callback returns `false` once the
242 +/// Swift task is cancelled, which is how Rust learns to stop.
243 +private final class CancellationFlag: @unchecked Sendable {
244 + private let lock = NSLock()
245 + private var cancelled = false
246 +
247 + var isCancelled: Bool { lock.withLock { cancelled } }
248 +
249 + func cancel() { lock.withLock { cancelled = true } }
250 +}
251 +
252 +private final class DownloadListener: FfiDownloadListener, @unchecked Sendable {
253 + private let handler: (@Sendable (DownloadProgress) -> Void)?
254 + private let flag = CancellationFlag()
255 +
256 + init(handler: (@Sendable (DownloadProgress) -> Void)?) {
257 + self.handler = handler
258 + }
259 +
260 + func cancel() { flag.cancel() }
261 +
262 + func onProgress(progress: FfiDownloadProgress) -> Bool {
263 + guard !flag.isCancelled else { return false }
264 + handler?(DownloadProgress(
265 + downloadedBytes: progress.downloadedBytes,
266 + totalBytes: progress.totalBytes,
267 + fraction: progress.fraction
268 + ))
269 + return true
270 + }
271 +}
272 +
273 +private final class SeparationListener: FfiSeparationListener, @unchecked Sendable {
274 + private let handler: (@Sendable (SeparationProgress) -> Void)?
275 + private let flag = CancellationFlag()
276 +
277 + init(handler: (@Sendable (SeparationProgress) -> Void)?) {
278 + self.handler = handler
279 + }
280 +
281 + func cancel() { flag.cancel() }
282 +
283 + func onProgress(progress: FfiSeparationProgress) -> Bool {
284 + guard !flag.isCancelled else { return false }
285 + handler?(SeparationProgress(
286 + fraction: progress.fraction,
287 + chunkIndex: progress.chunkIndex,
288 + totalChunks: progress.totalChunks
289 + ))
290 + return true
291 + }
292 +}
293 +
294 +// MARK: - Conversions
295 +
296 +private func translatingErrors<T>(_ body: () async throws -> T) async throws -> T {
297 + do {
298 + return try await body()
299 + } catch let error as FfiDemucsError {
300 + throw DemucsError(error)
301 + }
302 +}
303 +
304 +extension DemucsError {
305 + init(_ error: FfiDemucsError) {
306 + switch error {
307 + case .Cancelled: self = .cancelled
308 + case .NotLoaded: self = .notLoaded
309 + case .NotCached(let modelId): self = .notDownloaded(modelID: modelId)
310 + case .InvalidInput(let reason): self = .invalidInput(reason)
311 + case .Download(let reason): self = .downloadFailed(reason)
312 + case .Io(let reason): self = .io(reason)
313 + case .Inference(let reason): self = .inferenceFailed(reason)
314 + }
315 + }
316 +}
317 +
318 +extension StemKind {
319 + var ffi: FfiStemKind {
320 + switch self {
321 + case .drums: .drums
322 + case .bass: .bass
323 + case .other: .other
324 + case .vocals: .vocals
325 + case .guitar: .guitar
326 + case .piano: .piano
327 + }
328 + }
329 +}
330 +
331 +extension FfiStemKind {
332 + var swift: StemKind {
333 + switch self {
334 + case .drums: .drums
335 + case .bass: .bass
336 + case .other: .other
337 + case .vocals: .vocals
338 + case .guitar: .guitar
339 + case .piano: .piano
340 + }
341 + }
342 +}
343 +
344 +extension DemucsModel {
345 + var ffi: FfiDemucsModel {
346 + switch self {
347 + case .fourStem: .fourStem
348 + case .sixStem: .sixStem
349 + case .fineTuned(let stems): .fineTuned(stems: stems.map(\.ffi))
350 + }
351 + }
352 +}
353 +
354 +extension FfiDemucsModel {
355 + var swift: DemucsModel {
356 + switch self {
357 + case .fourStem: .fourStem
358 + case .sixStem: .sixStem
359 + case .fineTuned(let stems): .fineTuned(stems: stems.map(\.swift))
360 + }
361 + }
362 +}
363 +
364 +private extension FfiModelMetadata {
365 + var swift: DemucsModelInfo {
366 + DemucsModelInfo(
367 + model: model.swift,
368 + id: id,
369 + label: label,
370 + description: description,
371 + sizeMB: sizeMb,
372 + stems: stems.map(\.swift)
373 + )
374 + }
375 +}
376 +
377 +private extension FfiStem {
378 + var swift: Stem {
379 + Stem(kind: kind.swift, left: left, right: right)
380 + }
381 +}