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