Skip to content

Commit b0c562c

Browse files
authored
Add Swift API for source separation (#3426)
This pull request expands the Swift API by integrating offline audio source separation capabilities. It provides developers with tools to easily separate audio into different stems (e.g., vocals and accompaniment) using both Spleeter and UVR models, enhancing the framework's utility for audio processing applications.
1 parent 74ffda3 commit b0c562c

8 files changed

Lines changed: 379 additions & 2 deletions

File tree

.github/scripts/test-swift.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ echo "pwd: $PWD"
77
cd swift-api-examples
88
ls -lh
99

10+
./run-source-separation-spleeter.sh
11+
rm -rf sherpa-onnx-spleeter-*
12+
rm vocals.wav accompaniment.wav
13+
14+
./run-source-separation-uvr.sh
15+
rm -rf UVR-MDX-NET-Voc_FT.onnx
16+
rm uvr-vocals.wav uvr-non-vocals.wav
17+
rm -f qi-feng-le-zh.wav
18+
1019
./run-qwen3-asr.sh
1120
rm -rf sherpa-onnx-qwen3-*
1221

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,4 @@ sherpa-onnx-kws-zipformer-wenetspeech-3.3M-2024-01-01-mobile
183183
sherpa-onnx-punct-ct-transformer-zh-en-vocab272727-2024-04-12-int8
184184
doxygen-docs
185185
sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25
186+
sherpa-onnx-spleeter-2stems-fp16

swift-api-examples/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ fire-red-asr-ctc
3535
moonshine-v2-asr
3636
tts-supertonic-en
3737
tts-zipvoice
38+
source-separation-spleeter
39+
source-separation-uvr

swift-api-examples/SherpaOnnx.swift

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,8 @@ func sherpaOnnxOfflineTtsModelConfig(
11221122
kitten: SherpaOnnxOfflineTtsKittenModelConfig = sherpaOnnxOfflineTtsKittenModelConfig(),
11231123
zipvoice: SherpaOnnxOfflineTtsZipvoiceModelConfig = sherpaOnnxOfflineTtsZipvoiceModelConfig(),
11241124
pocket: SherpaOnnxOfflineTtsPocketModelConfig = sherpaOnnxOfflineTtsPocketModelConfig(),
1125-
supertonic: SherpaOnnxOfflineTtsSupertonicModelConfig = sherpaOnnxOfflineTtsSupertonicModelConfig()
1125+
supertonic: SherpaOnnxOfflineTtsSupertonicModelConfig =
1126+
sherpaOnnxOfflineTtsSupertonicModelConfig()
11261127
) -> SherpaOnnxOfflineTtsModelConfig {
11271128
return SherpaOnnxOfflineTtsModelConfig(
11281129
vits: vits,
@@ -1353,7 +1354,8 @@ class SherpaOnnxOfflineTtsWrapper {
13531354
let p = Unmanaged<SherpaOnnxCallbackPair>.fromOpaque(rawArg!).takeUnretainedValue()
13541355
return p.cb!(samples, n, p.arg)
13551356
}
1356-
let result = generateWithConfig(text: text, config: config, callback: wrapper, arg: unmanaged.toOpaque())
1357+
let result = generateWithConfig(
1358+
text: text, config: config, callback: wrapper, arg: unmanaged.toOpaque())
13571359
unmanaged.release()
13581360
return result
13591361
}
@@ -2037,3 +2039,163 @@ func getSherpaOnnxGitSha1() -> String {
20372039
func getSherpaOnnxGitDate() -> String {
20382040
return String(cString: SherpaOnnxGetGitDate())
20392041
}
2042+
//---------------------------
2043+
// Source separation
2044+
//---------------------------
2045+
2046+
// MARK: - Audio Buffer
2047+
struct AudioBuffer {
2048+
private enum Storage {
2049+
case owned([Float])
2050+
case wrapped(ManagedWave)
2051+
}
2052+
2053+
private class ManagedWave {
2054+
let pointer: UnsafePointer<SherpaOnnxMultiChannelWave>
2055+
init(_ p: UnsafePointer<SherpaOnnxMultiChannelWave>) { self.pointer = p }
2056+
deinit { SherpaOnnxFreeMultiChannelWave(pointer) }
2057+
}
2058+
2059+
private let storage: Storage
2060+
let channelCount: Int
2061+
let samplesPerChannel: Int
2062+
let sampleRate: Int
2063+
2064+
init(samples: [Float], channelCount: Int, sampleRate: Int) {
2065+
self.storage = .owned(samples)
2066+
self.channelCount = channelCount
2067+
self.sampleRate = sampleRate
2068+
self.samplesPerChannel = channelCount > 0 ? samples.count / channelCount : 0
2069+
}
2070+
2071+
init?(filename: String) {
2072+
guard let ptr = SherpaOnnxReadWaveMultiChannel(filename) else { return nil }
2073+
self.storage = .wrapped(ManagedWave(ptr))
2074+
self.channelCount = Int(ptr.pointee.num_channels)
2075+
self.samplesPerChannel = Int(ptr.pointee.num_samples)
2076+
self.sampleRate = Int(ptr.pointee.sample_rate)
2077+
}
2078+
2079+
func withUnsafeBufferPointer<R>(_ body: (UnsafeBufferPointer<Float>) -> R) -> R {
2080+
switch storage {
2081+
case .owned(let array):
2082+
return array.withUnsafeBufferPointer(body)
2083+
case .wrapped(let managed):
2084+
let total = Int(managed.pointer.pointee.num_channels * managed.pointer.pointee.num_samples)
2085+
// Ensure we start from the first channel's pointer
2086+
return body(UnsafeBufferPointer(start: managed.pointer.pointee.samples[0], count: total))
2087+
}
2088+
}
2089+
2090+
@discardableResult
2091+
func save(to filename: String) -> Bool {
2092+
return withUnsafeBufferPointer { buf in
2093+
guard let base = buf.baseAddress else { return false }
2094+
// FIX: Explicitly type the array as Optional pointers to match C 'float* const*'
2095+
var ptrs: [UnsafePointer<Float>?] = (0..<channelCount).map { base + ($0 * samplesPerChannel) }
2096+
2097+
return SherpaOnnxWriteWaveMultiChannel(
2098+
&ptrs,
2099+
Int32(samplesPerChannel),
2100+
Int32(sampleRate),
2101+
Int32(channelCount),
2102+
filename
2103+
) == 1
2104+
}
2105+
}
2106+
}
2107+
2108+
// MARK: - Configuration Struct
2109+
struct SourceSeparationConfig {
2110+
struct Spleeter {
2111+
var vocals: String
2112+
var accompaniment: String
2113+
}
2114+
struct Uvr { var model: String }
2115+
2116+
var spleeter: Spleeter?
2117+
var uvr: Uvr?
2118+
var numThreads: Int = 1
2119+
var debug: Bool = false
2120+
var provider: String = "cpu"
2121+
2122+
func withCConfig<R>(_ body: (UnsafePointer<SherpaOnnxOfflineSourceSeparationConfig>) -> R) -> R {
2123+
var cConfig = SherpaOnnxOfflineSourceSeparationConfig()
2124+
cConfig.model.num_threads = Int32(self.numThreads)
2125+
cConfig.model.debug = self.debug ? 1 : 0
2126+
2127+
var s: [String: [Int8]] = [:]
2128+
func b(_ k: String, _ v: String?) -> UnsafePointer<Int8>? {
2129+
guard let v = v else { return nil }
2130+
s[k] = Array(v.utf8CString)
2131+
return s[k]!.withUnsafeBufferPointer { $0.baseAddress }
2132+
}
2133+
2134+
cConfig.model.provider = b("provider", self.provider)
2135+
cConfig.model.spleeter.vocals = b("spleeter.vocals", self.spleeter?.vocals)
2136+
cConfig.model.spleeter.accompaniment = b("spleeter.accompaniment", self.spleeter?.accompaniment)
2137+
cConfig.model.uvr.model = b("uvr.model", self.uvr?.model)
2138+
2139+
return body(&cConfig)
2140+
}
2141+
}
2142+
2143+
class SourceSeparator {
2144+
private var engine: OpaquePointer?
2145+
2146+
init?(config: SourceSeparationConfig) {
2147+
self.engine = config.withCConfig { SherpaOnnxCreateOfflineSourceSeparation($0) }
2148+
2149+
if self.engine == nil { return nil }
2150+
}
2151+
2152+
deinit {
2153+
if let e = engine {
2154+
SherpaOnnxDestroyOfflineSourceSeparation(e)
2155+
}
2156+
}
2157+
2158+
func process(buffer: AudioBuffer) -> [AudioBuffer]? {
2159+
guard let engine = engine else { return nil }
2160+
2161+
return buffer.withUnsafeBufferPointer { flatBuf in
2162+
guard let base = flatBuf.baseAddress else { return nil }
2163+
var ptrs: [UnsafePointer<Float>?] = (0..<buffer.channelCount).map {
2164+
base + ($0 * buffer.samplesPerChannel)
2165+
}
2166+
2167+
guard
2168+
let raw = SherpaOnnxOfflineSourceSeparationProcess(
2169+
engine,
2170+
&ptrs,
2171+
Int32(buffer.channelCount),
2172+
Int32(buffer.samplesPerChannel),
2173+
Int32(buffer.sampleRate)
2174+
)
2175+
else { return nil }
2176+
2177+
let stemCount = Int(raw.pointee.num_stems)
2178+
let result = (0..<stemCount).map { i in
2179+
let stem = raw.pointee.stems[i]
2180+
let chs = Int(stem.num_channels)
2181+
let n = Int(stem.n)
2182+
var flat = [Float](repeating: 0, count: chs * n)
2183+
2184+
for c in 0..<chs {
2185+
if let src = stem.samples[c] {
2186+
let offset = c * n
2187+
flat.withUnsafeMutableBufferPointer { dest in
2188+
let destPtr = dest.baseAddress!.advanced(by: offset)
2189+
destPtr.initialize(from: src, count: n)
2190+
}
2191+
}
2192+
}
2193+
return AudioBuffer(
2194+
samples: flat, channelCount: chs, sampleRate: Int(raw.pointee.sample_rate))
2195+
}
2196+
2197+
SherpaOnnxDestroySourceSeparationOutput(raw)
2198+
return result
2199+
}
2200+
}
2201+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env bash
2+
3+
set -ex
4+
5+
if [ ! -d ../build-swift-macos ]; then
6+
echo "Please run ../build-swift-macos.sh first!"
7+
exit 1
8+
fi
9+
10+
if [ ! -f ./sherpa-onnx-spleeter-2stems-fp16/vocals.fp16.onnx ]; then
11+
curl -SL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/source-separation-models/sherpa-onnx-spleeter-2stems-fp16.tar.bz2
12+
tar xjf sherpa-onnx-spleeter-2stems-fp16.tar.bz2
13+
rm sherpa-onnx-spleeter-2stems-fp16.tar.bz2
14+
fi
15+
16+
if [ ! -f ./qi-feng-le-zh.wav ]; then
17+
curl -SL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/source-separation-models/qi-feng-le-zh.wav
18+
fi
19+
20+
if [ ! -e ./source-separation-spleeter ]; then
21+
# Note: We use -lc++ to link against libc++ instead of libstdc++
22+
swiftc \
23+
-lc++ \
24+
-I ../build-swift-macos/install/include \
25+
-import-objc-header ./SherpaOnnx-Bridging-Header.h \
26+
./source-separation-spleeter.swift ./SherpaOnnx.swift \
27+
-L ../build-swift-macos/install/lib/ \
28+
-l sherpa-onnx \
29+
-l onnxruntime \
30+
-o source-separation-spleeter
31+
32+
strip source-separation-spleeter
33+
else
34+
echo "./source-separation-spleeter exists - skip building"
35+
fi
36+
37+
export DYLD_LIBRARY_PATH=$PWD/../build-swift-macos/install/lib:$DYLD_LIBRARY_PATH
38+
./source-separation-spleeter
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
3+
set -ex
4+
5+
if [ ! -d ../build-swift-macos ]; then
6+
echo "Please run ../build-swift-macos.sh first!"
7+
exit 1
8+
fi
9+
10+
if [ ! -f ./UVR-MDX-NET-Voc_FT.onnx ]; then
11+
curl -SL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/source-separation-models/UVR-MDX-NET-Voc_FT.onnx
12+
fi
13+
14+
if [ ! -f ./qi-feng-le-zh.wav ]; then
15+
curl -SL -O https://github.com/k2-fsa/sherpa-onnx/releases/download/source-separation-models/qi-feng-le-zh.wav
16+
fi
17+
18+
if [ ! -e ./source-separation-uvr ]; then
19+
# Note: We use -lc++ to link against libc++ instead of libstdc++
20+
swiftc \
21+
-lc++ \
22+
-I ../build-swift-macos/install/include \
23+
-import-objc-header ./SherpaOnnx-Bridging-Header.h \
24+
./source-separation-uvr.swift ./SherpaOnnx.swift \
25+
-L ../build-swift-macos/install/lib/ \
26+
-l sherpa-onnx \
27+
-l onnxruntime \
28+
-o source-separation-uvr
29+
30+
strip source-separation-uvr
31+
else
32+
echo "./source-separation-uvr exists - skip building"
33+
fi
34+
35+
export DYLD_LIBRARY_PATH=$PWD/../build-swift-macos/install/lib:$DYLD_LIBRARY_PATH
36+
./source-separation-uvr
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// swift-api-examples/source-separation-spleeter.swift
2+
//
3+
// Copyright (c) 2026 Xiaomi Corporation
4+
5+
// This file demonstrates how to use source separation with the
6+
// Spleeter 2-stems model (UNet-based).
7+
//
8+
// Please refer to
9+
// https://github.com/k2-fsa/sherpa-onnx/releases/tag/source-separation-models
10+
// to download files used in this script
11+
12+
import Foundation
13+
14+
func run() {
15+
let modelDir = "./sherpa-onnx-spleeter-2stems-fp16"
16+
17+
let config = SourceSeparationConfig(
18+
spleeter: .init(
19+
vocals: "\(modelDir)/vocals.fp16.onnx",
20+
accompaniment: "\(modelDir)/accompaniment.fp16.onnx"
21+
),
22+
numThreads: 1,
23+
debug: true
24+
)
25+
26+
guard let separator = SourceSeparator(config: config) else {
27+
print("Failed to create SourceSeparator")
28+
return
29+
}
30+
31+
guard let input = AudioBuffer(filename: "./qi-feng-le-zh.wav") else {
32+
print("Failed to read ./qi-feng-le-zh.wav")
33+
return
34+
}
35+
print(
36+
"Input: channels=\(input.channelCount), samples=\(input.samplesPerChannel), sampleRate=\(input.sampleRate)"
37+
)
38+
39+
let start = CFAbsoluteTimeGetCurrent()
40+
guard let stems = separator.process(buffer: input) else {
41+
print("Source separation failed")
42+
return
43+
}
44+
let elapsed = CFAbsoluteTimeGetCurrent() - start
45+
let audioDuration = Double(input.samplesPerChannel) / Double(input.sampleRate)
46+
let rtf = elapsed / audioDuration
47+
48+
print("Output: \(stems.count) stems, sampleRate=\(input.sampleRate)")
49+
print("Elapsed: \(String(format: "%.2f", elapsed))s, Audio: \(String(format: "%.2f", audioDuration))s, RTF = \(String(format: "%.3f", rtf))")
50+
51+
let stemNames = ["vocals", "accompaniment"]
52+
for i in 0..<min(stems.count, stemNames.count) {
53+
let filename = "\(stemNames[i]).wav"
54+
if stems[i].save(to: filename) {
55+
print("Saved \(filename)")
56+
} else {
57+
print("Failed to save \(filename)")
58+
}
59+
}
60+
}
61+
62+
@main
63+
struct App {
64+
static func main() {
65+
run()
66+
}
67+
}

0 commit comments

Comments
 (0)