Add SwiftUI example app and rewrite the README
Examples/DemucsExample is a small SwiftUI app (XcodeGen project.yml). Pick a model and an audio file; it downloads the weights on first use, separates the file with progress and cancellation, and writes each stem as a shareable WAV. The README covers installation, the DemucsEngine API, and building the framework locally against a demucs-rs checkout. Co-Authored-By: siGit Code <noreply@sigit.si> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto committed
Sep 26, 2026 at 10:43 UTC
9c882e2c828a87c403f6c730ec5ae7d4ed4f909e
6 files changed
+345
-42
Examples/DemucsExample/ContentView.swift
new
+66
@@ -0,0 +1,66 @@
1
+import Demucs
2
+import SwiftUI
3
+import UniformTypeIdentifiers
4
+
5
+struct ContentView: View {
6
+ @StateObject private var viewModel = SeparationViewModel()
7
+ @State private var importing = false
8
+
9
+ var body: some View {
10
+ NavigationStack {
11
+ Form {
12
+ Section("Model") {
13
+ Picker("Model", selection: $viewModel.model) {
14
+ ForEach(DemucsEngine.models) { info in
15
+ Text("\(info.label) · \(info.sizeMB) MB").tag(info.model)
16
+ }
17
+ }
18
+ .disabled(viewModel.isBusy)
19
+ }
20
+
21
+ Section {
22
+ Button("Choose Audio File…") { importing = true }
23
+ .disabled(viewModel.isBusy)
24
+ status
25
+ if viewModel.isBusy {
26
+ Button("Cancel", role: .destructive) { viewModel.cancel() }
27
+ }
28
+ }
29
+
30
+ if !viewModel.stems.isEmpty {
31
+ Section("Stems") {
32
+ ForEach(viewModel.stems) { stem in
33
+ ShareLink(item: stem.url) {
34
+ Label(stem.kind.rawValue.capitalized, systemImage: "waveform")
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ .navigationTitle("Demucs")
41
+ .fileImporter(isPresented: $importing, allowedContentTypes: [.audio]) { result in
42
+ if case .success(let url) = result {
43
+ viewModel.separate(fileAt: url)
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ @ViewBuilder private var status: some View {
50
+ switch viewModel.phase {
51
+ case .idle:
52
+ EmptyView()
53
+ case .downloading(let fraction):
54
+ ProgressView("Downloading model", value: fraction)
55
+ case .loading:
56
+ ProgressView("Loading model")
57
+ case .separating(let fraction):
58
+ ProgressView("Separating", value: fraction)
59
+ case .done:
60
+ Label("Done", systemImage: "checkmark.circle")
61
+ case .failed(let message):
62
+ Label(message, systemImage: "exclamationmark.triangle")
63
+ .foregroundStyle(.red)
64
+ }
65
+ }
66
+}
Examples/DemucsExample/DemucsExampleApp.swift
new
+17
@@ -0,0 +1,17 @@
1
+import SwiftUI
2
+import Demucs
3
+
4
+@main
5
+struct DemucsExampleApp: App {
6
+ init() {
7
+ // Keep the weights inside the app container.
8
+ let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
9
+ DemucsEngine.configureCacheDirectory(caches.appendingPathComponent("demucs", isDirectory: true))
10
+ }
11
+
12
+ var body: some Scene {
13
+ WindowGroup {
14
+ ContentView()
15
+ }
16
+ }
17
+}
Examples/DemucsExample/README.md
new
+10
@@ -0,0 +1,10 @@
1
+# DemucsExample
2
+
3
+1. Run `make ios` from the repository root.
4
+2. Install XcodeGen if needed (`brew install xcodegen`).
5
+3. Run `xcodegen generate` in this directory.
6
+4. Open `DemucsExample.xcodeproj` and run on a device or an arm64 simulator.
7
+
8
+Pick a model and an audio file. The app downloads the weights on first use
9
+(84–333 MB), separates the file on the GPU, and writes each stem as a WAV you
10
+can share. Separation is much faster on a device than in the simulator.
Examples/DemucsExample/SeparationViewModel.swift
new
+151
@@ -0,0 +1,151 @@
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
+
29
+ var isBusy: Bool {
30
+ switch phase {
31
+ case .downloading, .loading, .separating: true
32
+ default: false
33
+ }
34
+ }
35
+
36
+ func separate(fileAt url: URL) {
37
+ task?.cancel()
38
+ stems = []
39
+ task = Task { await run(url: url) }
40
+ }
41
+
42
+ func cancel() {
43
+ task?.cancel()
44
+ }
45
+
46
+ /// Progress arrives from background threads and can land after the step
47
+ /// it describes has finished; only apply it while that step is current.
48
+ private func update(_ progress: Phase) {
49
+ switch (phase, progress) {
50
+ case (.downloading, .downloading), (.separating, .separating):
51
+ phase = progress
52
+ default:
53
+ break
54
+ }
55
+ }
56
+
57
+ private func run(url: URL) async {
58
+ do {
59
+ let model = model
60
+ if !DemucsEngine.isDownloaded(model) {
61
+ phase = .downloading(0)
62
+ try await DemucsEngine.download(model) { progress in
63
+ Task { @MainActor in self.update(.downloading(progress.fraction)) }
64
+ }
65
+ }
66
+ if engine.loadedModel != model {
67
+ phase = .loading
68
+ try await engine.load(model)
69
+ }
70
+
71
+ let audio = try AudioFile.read(url)
72
+ phase = .separating(0)
73
+ let separated = try await engine.separate(
74
+ left: audio.left,
75
+ right: audio.right,
76
+ sampleRate: audio.sampleRate
77
+ ) { progress in
78
+ Task { @MainActor in self.update(.separating(progress.fraction)) }
79
+ }
80
+
81
+ let outputDirectory = FileManager.default.temporaryDirectory
82
+ .appendingPathComponent(url.deletingPathExtension().lastPathComponent, isDirectory: true)
83
+ try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
84
+ stems = try separated.map { stem in
85
+ let file = outputDirectory.appendingPathComponent("\(stem.kind.rawValue).wav")
86
+ try AudioFile.write(stem, sampleRate: audio.sampleRate, to: file)
87
+ return StemFile(kind: stem.kind, url: file)
88
+ }
89
+ phase = .done
90
+ } catch DemucsError.cancelled {
91
+ phase = .idle
92
+ } catch is CancellationError {
93
+ phase = .idle
94
+ } catch {
95
+ phase = .failed(error.localizedDescription)
96
+ }
97
+ }
98
+}
99
+
100
+/// Minimal AVFoundation decode/encode. Demucs itself takes and returns plain
101
+/// per-channel Float arrays at any sample rate.
102
+enum AudioFile {
103
+ struct Decoded {
104
+ let left: [Float]
105
+ let right: [Float]
106
+ let sampleRate: UInt32
107
+ }
108
+
109
+ static func read(_ url: URL) throws -> Decoded {
110
+ let scoped = url.startAccessingSecurityScopedResource()
111
+ defer { if scoped { url.stopAccessingSecurityScopedResource() } }
112
+
113
+ let file = try AVAudioFile(forReading: url)
114
+ let format = file.processingFormat // Float32, deinterleaved
115
+ guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(file.length)) else {
116
+ throw DemucsError.invalidInput("Could not allocate an audio buffer.")
117
+ }
118
+ try file.read(into: buffer)
119
+ guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else {
120
+ throw DemucsError.invalidInput("The file has no audio.")
121
+ }
122
+ let frames = Int(buffer.frameLength)
123
+ let left = Array(UnsafeBufferPointer(start: channels[0], count: frames))
124
+ let right = format.channelCount > 1
125
+ ? Array(UnsafeBufferPointer(start: channels[1], count: frames))
126
+ : left
127
+ return Decoded(left: left, right: right, sampleRate: UInt32(format.sampleRate))
128
+ }
129
+
130
+ static func write(_ stem: Stem, sampleRate: UInt32, to url: URL) throws {
131
+ guard
132
+ let format = AVAudioFormat(
133
+ commonFormat: .pcmFormatFloat32,
134
+ sampleRate: Double(sampleRate),
135
+ channels: 2,
136
+ interleaved: false
137
+ ),
138
+ let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(stem.left.count)),
139
+ let channels = buffer.floatChannelData
140
+ else {
141
+ throw DemucsError.io("Could not allocate an audio buffer.")
142
+ }
143
+ 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) }
146
+
147
+ try? FileManager.default.removeItem(at: url)
148
+ let file = try AVAudioFile(forWriting: url, settings: format.settings)
149
+ try file.write(from: buffer)
150
+ }
151
+}
Examples/DemucsExample/project.yml
new
+33
@@ -0,0 +1,33 @@
1
+name: DemucsExample
2
+options:
3
+ bundleIdPrefix: ai.splitfire
4
+ deploymentTarget:
5
+ iOS: "16.0"
6
+ xcodeVersion: "16.0"
7
+
8
+packages:
9
+ Demucs:
10
+ path: ../..
11
+
12
+targets:
13
+ DemucsExample:
14
+ type: application
15
+ platform: iOS
16
+ sources:
17
+ - path: .
18
+ excludes:
19
+ - project.yml
20
+ - README.md
21
+ - "*.xcodeproj"
22
+ dependencies:
23
+ - package: Demucs
24
+ product: Demucs
25
+ settings:
26
+ base:
27
+ PRODUCT_BUNDLE_IDENTIFIER: ai.splitfire.DemucsExample
28
+ GENERATE_INFOPLIST_FILE: YES
29
+ INFOPLIST_KEY_CFBundleDisplayName: Demucs Example
30
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
31
+ INFOPLIST_KEY_UILaunchScreen_Generation: YES
32
+ SWIFT_VERSION: "5.0"
33
+ "EXCLUDED_ARCHS[sdk=iphonesimulator*]": x86_64
README.md
+68
-42
@@ -1,68 +1,66 @@
1
-# demucs-swift
1
+# Demucs for Swift
2
3
-Swift SDK for [demucs-rs](https://github.com/ondeinference/demucs-rs) — on-device
4
-music source separation (HTDemucs v4) for iOS, macOS, tvOS, and visionOS,
5
-GPU-accelerated via Metal.
3
+On-device music source separation for Swift apps. Give it a song; get back
4
+drums, bass, vocals and the rest as separate tracks. It runs Meta's HTDemucs v4
5
+through [demucs-rs](https://github.com/SplitFireAI/demucs-rs) on the GPU via
6
+Metal, entirely on the device.
7
7
-> **This repository is machine-published.** Every release of
8
-> [demucs-rs](https://github.com/ondeinference/demucs-rs) rewrites
9
-> `Package.swift` and `Sources/Demucs/demucs.swift` via CI. Do not edit them
10
-> by hand — changes belong in the `demucs-ffi` crate in demucs-rs.
8
+Supports iOS 16, macOS 14 (Apple silicon), tvOS 16 and visionOS 1.
9
10
## Installation
11
12
+In Xcode, choose **File > Add Package Dependencies** and enter:
13
+
14
+```text
15
+https://github.com/SplitFireAI/demucs-swift
16
+```
17
+
18
+Or in `Package.swift`:
19
+
20
```swift
21
dependencies: [
16
- .package(url: "https://github.com/ondeinference/demucs-swift", from: "0.1.0")
22
+ .package(url: "https://github.com/SplitFireAI/demucs-swift", from: "0.1.0"),
23
]
24
```
25
20
-## Usage
26
+Then add the `Demucs` product to your target.
27
+
28
+## Quick start
29
30
```swift
31
import Demucs
32
25
-// 1. Sandboxed platforms (iOS/tvOS/visionOS): point the weight cache inside
26
-// the app container once at launch.
33
+// Once at launch: keep the weights inside the app container.
34
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
28
-configureCacheDir(path: caches.appendingPathComponent("demucs").path)
29
-
30
-// 2. Download weights (84–333 MB, from HuggingFace, cached on disk).
31
-final class DownloadHandler: DownloadProgressListener {
32
- func onProgress(progress: DownloadProgress) -> Bool {
33
- print("download \(Int(progress.fraction * 100))%")
34
- return true // return false to cancel
35
- }
35
+DemucsEngine.configureCacheDirectory(caches.appendingPathComponent("demucs"))
36
+
37
+// Download the weights once (84–333 MB from Hugging Face, cached on disk).
38
+try await DemucsEngine.download(.fourStem) { progress in
39
+ print("download \(Int(progress.fraction * 100))%")
40
}
37
-try await downloadModel(model: .fourStem, listener: DownloadHandler())
41
39
-// 3. Load the model and pre-compile GPU shaders.
42
+// Load the model. Warmup compiles the GPU shaders ahead of the first song.
43
let engine = DemucsEngine()
41
-let seconds = try await engine.loadModel(model: .fourStem)
44
+try await engine.load(.fourStem)
45
try await engine.warmup()
46
44
-// 4. Separate. Input is per-channel PCM at any sample rate (use AVFoundation
45
-// to decode files); output stems are stereo PCM at 44100 Hz.
46
-final class ProgressHandler: SeparationProgressListener {
47
- func onProgress(progress: SeparationProgress) -> Bool {
48
- print("separating \(Int(progress.fraction * 100))%")
49
- return true // return false to cancel
50
- }
47
+// Separate. Input is per-channel Float PCM at any sample rate (decode files
48
+// with AVFoundation); each stem comes back at the same rate and length.
49
+let stems = try await engine.separate(left: left, right: right, sampleRate: 44_100) { progress in
50
+ print("separating \(Int(progress.fraction * 100))%")
51
}
52
-let stems = try await separateWithProgress(
53
- engine: engine,
54
- left: leftSamples, right: rightSamples,
55
- sampleRate: 44100,
56
- listener: ProgressHandler()
57
-)
52
for stem in stems {
53
print(stem.kind, stem.left.count)
54
}
61
-
62
-// Or without progress reporting:
63
-let stems2 = try await engine.separate(left: leftSamples, right: rightSamples, sampleRate: 44100)
55
```
56
57
+Cancelling the calling task cancels a download immediately and a separation at
58
+the next ~7.8 second chunk boundary. Progress handlers run on a background
59
+thread.
60
+
61
+See [`Examples/DemucsExample`](Examples/DemucsExample) for a SwiftUI app that
62
+decodes a file with AVFoundation, separates it and shares the stems as WAV.
63
+
64
## Models
65
66
| Model | Case | Stems | Size |
@@ -71,9 +69,37 @@ let stems2 = try await engine.separate(left: leftSamples, right: rightSamples, s
69
| htdemucs_6s | `.sixStem` | + guitar, piano | ~84 MB |
70
| htdemucs_ft | `.fineTuned(stems:)` | up to 4, best quality | ~333 MB |
71
74
-Weights download from HuggingFace on demand and are cached locally
75
-(`allModels()`, `isModelCached(model:)`, `deleteCachedModel(model:)` manage
76
-the cache).
72
+The fine-tuned model runs one sub-model per stem, so
73
+`.fineTuned(stems: [.vocals])` is about four times faster than
74
+`.fineTunedAll`. All selections share one download.
75
+
76
+`DemucsEngine.models`, `isDownloaded(_:)`, `downloadedFileURL(for:)` and
77
+`deleteDownload(_:)` manage the files.
78
+
79
+## How this package is built
80
+
81
+`Sources/Demucs/demucs_ffi.swift` is generated by UniFFI from the `demucs-ffi`
82
+crate in demucs-rs, and `DemucsFramework` is the matching static library. On
83
+every `swift-vX.Y.Z` tag, demucs-rs's **Release SDK Swift** workflow uploads the
84
+XCFramework, then rewrites the release URL and checksum in `Package.swift` and
85
+the generated file here, and tags this repository. Do not edit those by hand;
86
+the hand-written API lives in `Sources/Demucs/DemucsEngine.swift`.
87
+
88
+## Local development
89
+
90
+Keep `demucs-rs` and `demucs-swift` next to each other, then build a local
91
+framework before running tests:
92
+
93
+```sh
94
+make macos
95
+swift test
96
+```
97
+
98
+Use `make ios`, `make tvos` or `make visionos` for another platform (tvOS and
99
+visionOS need a nightly toolchain with `rust-src`). Set `DEMUCS_REPO` when the
100
+repositories are not siblings. The package uses `DemucsFramework.xcframework`
101
+automatically when that local file exists; without it, SwiftPM downloads the
102
+release asset.
103
104
## License
105