Skip to content

Commit ded538f

Browse files
committed
fix: handle short ONNX audio and update sherpa-onnx
1 parent 0629ef1 commit ded538f

11 files changed

Lines changed: 242 additions & 78 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ Version-bump changelog tables go in the **PR description**, not a tracked file.
190190
|------------|---------|-----|
191191
| [WhisperKit](https://github.com/argmaxinc/WhisperKit) | Whisper CoreML | `from: "0.9.4"` |
192192
| [FluidAudio](https://github.com/FluidInference/FluidAudio) | Parakeet CoreML / ANE | `.upToNextMinor(from: "0.15.5")` (pre-1.0) |
193-
| [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | Specialized ONNX, CPU | revision pin (SPM not in a tagged release; xcframework v1.13.4) |
193+
| [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) | Specialized ONNX, CPU | exact `1.13.7` (matching xcframework) |
194194

195195
Keep dependencies minimal. Do not bump FluidAudio across a minor without checking `AsrManager.loadModels` / TDT decoder APIs.
196196

Package.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,10 @@ let package = Package(
2727
.package(url: "https://github.com/FluidInference/FluidAudio.git", .upToNextMinor(from: "0.15.5")),
2828
// sherpa-onnx — specialized ONNX models (Moonshine, SenseVoice,
2929
// GigaAM, Canary) via ONNX Runtime, CPU-only.
30-
// Pinned to a revision: the SPM manifest is not in a tagged release
31-
// yet; the pinned manifest references the v1.13.4 binary xcframework.
30+
// Pin the release and its matching binary xcframework for reproducible builds.
3231
.package(
3332
url: "https://github.com/k2-fsa/sherpa-onnx",
34-
revision: "00ad9a19a63751a6c4b12050a00eacfeb204814e"
33+
exact: "1.13.7"
3534
),
3635
],
3736
targets: [
@@ -62,7 +61,8 @@ let package = Package(
6261
.testTarget(
6362
name: "VocaMacTests",
6463
dependencies: ["VocaMac"],
65-
path: "Tests/VocaMacTests"
64+
path: "Tests/VocaMacTests",
65+
exclude: ["Fixtures"]
6666
)
6767
]
6868
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SherpaAudioPreparation.swift
2+
// VocaMac
3+
4+
/// Conditions individual 16 kHz segments before they reach native ONNX code.
5+
/// Very short waveforms can have too few frames for feature extraction or
6+
/// encoder subsampling. Padding preserves the speech and gives the decoder
7+
/// trailing context without changing the recording's reported duration.
8+
enum SherpaAudioPreparation {
9+
static let minimumSampleCount = 16_000
10+
11+
/// Reject malformed input before segmentation or any native inference.
12+
static func validate(_ samples: [Float]) throws {
13+
guard !samples.isEmpty else { throw SherpaError.emptyAudio }
14+
guard samples.allSatisfy({ $0.isFinite }) else {
15+
throw SherpaError.transcriptionFailed(reason: "Audio contains non-finite samples.")
16+
}
17+
}
18+
19+
static func prepare(_ samples: [Float]) -> [Float] {
20+
// Do not ask generative decoders to invent words for digital silence.
21+
guard samples.contains(where: { $0 != 0 }) else { return [] }
22+
guard samples.count < minimumSampleCount else { return samples }
23+
return samples + [Float](repeating: 0, count: minimumSampleCount - samples.count)
24+
}
25+
}

Sources/VocaMac/Services/SherpaService.swift

Lines changed: 88 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,36 @@ final class SherpaService: @unchecked Sendable {
105105

106106
// MARK: - Properties
107107

108-
/// The active C recognizer (created when a model is loaded)
109-
private var recognizer: OpaquePointer?
108+
/// A request retains its native model even if Settings unloads or replaces it.
109+
/// The per-model lock serializes native decodes without blocking state reads.
110+
private final class LoadedRecognizer: @unchecked Sendable {
111+
let pointer: OpaquePointer
112+
let size: ModelSize
113+
let decodeLock = NSLock()
114+
115+
init(pointer: OpaquePointer, size: ModelSize) {
116+
self.pointer = pointer
117+
self.size = size
118+
}
110119

111-
/// Which model is currently loaded
112-
private var loadedSize: ModelSize?
120+
deinit {
121+
SherpaOnnxDestroyOfflineRecognizer(pointer)
122+
}
123+
}
113124

114-
/// Serializes recognizer lifecycle against decoding
125+
private var loadedRecognizer: LoadedRecognizer?
126+
private var loadGeneration = UUID()
115127
private let recognizerLock = NSLock()
116128

117-
var isModelLoaded: Bool { recognizer != nil }
129+
var isModelLoaded: Bool { snapshot() != nil }
130+
var loadedModelName: String? { snapshot()?.size.rawValue }
118131

119-
var loadedModelName: String? { loadedSize?.rawValue }
132+
/// Retain the model atomically so its lifetime includes all request segments.
133+
private func snapshot() -> LoadedRecognizer? {
134+
recognizerLock.lock()
135+
defer { recognizerLock.unlock() }
136+
return loadedRecognizer
137+
}
120138

121139
deinit {
122140
unloadModel()
@@ -147,7 +165,7 @@ final class SherpaService: @unchecked Sendable {
147165
language: String?,
148166
onPhaseChange: ((String) -> Void)? = nil
149167
) async throws {
150-
unloadModel()
168+
let generation = clearModel()
151169

152170
guard let size = modelName.flatMap(ModelSize.init(rawValue:)),
153171
let spec = SherpaModelCatalog.spec(for: size) else {
@@ -191,38 +209,49 @@ final class SherpaService: @unchecked Sendable {
191209
throw SherpaError.initializationFailed(reason: "sherpa-onnx rejected the model files at \(directory.path)")
192210
}
193211

194-
adopt(recognizer: created, size: size)
212+
guard !Task.isCancelled else {
213+
SherpaOnnxDestroyOfflineRecognizer(created)
214+
throw CancellationError()
215+
}
216+
guard adopt(recognizer: created, size: size, generation: generation) else {
217+
throw CancellationError()
218+
}
195219

196220
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
197221
VocaLogger.info(.sherpaService, "ONNX model loaded in \(String(format: "%.2f", elapsed))s")
198222
}
199223

200-
/// Take ownership of a freshly created recognizer.
201-
///
202-
/// Destroys whatever was installed before rather than overwriting it:
203-
/// the pointer is native memory, so dropping the reference would leak the
204-
/// model. Loads are serialized upstream, but this keeps the object safe
205-
/// on its own terms.
206-
private func adopt(recognizer created: OpaquePointer, size: ModelSize) {
224+
/// Atomically install the model; existing requests retain their previous model.
225+
private func adopt(recognizer created: OpaquePointer, size: ModelSize, generation: UUID) -> Bool {
226+
let model = LoadedRecognizer(pointer: created, size: size)
207227
recognizerLock.lock()
208-
defer { recognizerLock.unlock() }
209-
if let existing = recognizer {
210-
SherpaOnnxDestroyOfflineRecognizer(existing)
228+
guard loadGeneration == generation else {
229+
recognizerLock.unlock()
230+
return false
211231
}
212-
recognizer = created
213-
loadedSize = size
232+
let previous = loadedRecognizer
233+
loadedRecognizer = model
234+
recognizerLock.unlock()
235+
// Release native memory outside the state lock.
236+
withExtendedLifetime(previous) {}
237+
return true
214238
}
215239

216-
/// Unload the current model and free memory
240+
/// Remove the active model. In-flight requests release it when decoding finishes.
217241
func unloadModel() {
242+
_ = clearModel()
243+
}
244+
245+
/// Invalidate pending loads as well as removing the active model.
246+
private func clearModel() -> UUID {
218247
recognizerLock.lock()
219-
if let recognizer {
220-
SherpaOnnxDestroyOfflineRecognizer(recognizer)
221-
VocaLogger.info(.sherpaService, "ONNX model unloaded")
222-
}
223-
recognizer = nil
224-
loadedSize = nil
248+
let generation = UUID()
249+
loadGeneration = generation
250+
let previous = loadedRecognizer
251+
loadedRecognizer = nil
225252
recognizerLock.unlock()
253+
withExtendedLifetime(previous) {}
254+
return generation
226255
}
227256

228257
// MARK: - Transcription
@@ -236,13 +265,10 @@ final class SherpaService: @unchecked Sendable {
236265
audioData: [Float],
237266
language: String? = nil
238267
) async throws -> VocaTranscription {
239-
guard isModelLoaded, let size = loadedSize else {
240-
throw SherpaError.modelNotLoaded
241-
}
268+
guard let request = snapshot() else { throw SherpaError.modelNotLoaded }
269+
let size = request.size
242270

243-
guard !audioData.isEmpty else {
244-
throw SherpaError.emptyAudio
245-
}
271+
try SherpaAudioPreparation.validate(audioData)
246272

247273
let audioLengthSeconds = Double(audioData.count) / 16000.0
248274
VocaLogger.info(.sherpaService, "ONNX transcribing \(String(format: "%.1f", audioLengthSeconds))s of audio...")
@@ -264,36 +290,32 @@ final class SherpaService: @unchecked Sendable {
264290
segments = [audioData]
265291
}
266292

267-
let decoded: (text: String, lang: String)? = await withCheckedContinuation { continuation in
268-
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
269-
guard let self else {
270-
continuation.resume(returning: nil)
271-
return
272-
}
273-
var pieces: [String] = []
274-
var detected = ""
275-
for segment in segments {
276-
guard let result = self.decodeLocked(samples: segment) else {
277-
continuation.resume(returning: nil)
278-
return
293+
let decoded: (text: String, lang: String) = try await withCheckedThrowingContinuation { continuation in
294+
DispatchQueue.global(qos: .userInitiated).async {
295+
do {
296+
// Keep one recognizer for the entire recording, including all segments.
297+
request.decodeLock.lock()
298+
defer { request.decodeLock.unlock() }
299+
var pieces: [String] = []
300+
var detected = ""
301+
for segment in segments {
302+
let samples = SherpaAudioPreparation.prepare(segment)
303+
guard !samples.isEmpty else { continue }
304+
let result = try Self.decode(samples: samples, recognizer: request.pointer)
305+
let piece = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
306+
if !piece.isEmpty { pieces.append(piece) }
307+
if detected.isEmpty { detected = result.lang }
279308
}
280-
let piece = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
281-
if !piece.isEmpty { pieces.append(piece) }
282-
if detected.isEmpty { detected = result.lang }
309+
let joinLanguage = detected.isEmpty ? (language ?? "") : detected
310+
continuation.resume(returning: (
311+
Self.joinTranscriptPieces(pieces, language: joinLanguage), detected
312+
))
313+
} catch {
314+
continuation.resume(throwing: error)
283315
}
284-
// Prefer the model's detected language; fall back to the caller's
285-
// preference so CJK SenseVoice output is not space-joined.
286-
let joinLanguage = detected.isEmpty ? (language ?? "") : detected
287-
continuation.resume(
288-
returning: (Self.joinTranscriptPieces(pieces, language: joinLanguage), detected)
289-
)
290316
}
291317
}
292318

293-
guard let decoded else {
294-
throw SherpaError.transcriptionFailed(reason: "The model was unloaded during transcription.")
295-
}
296-
297319
let elapsed = CFAbsoluteTimeGetCurrent() - startTime
298320
let text = decoded.text.trimmingCharacters(in: .whitespacesAndNewlines)
299321

@@ -326,16 +348,12 @@ final class SherpaService: @unchecked Sendable {
326348
return pieces.joined(separator: cjk ? "" : " ")
327349
}
328350

329-
/// Run one decode against the active recognizer. Returns nil if no model
330-
/// is loaded. Called off the main thread; holds the lock so the
331-
/// recognizer cannot be destroyed mid-decode.
332-
private func decodeLocked(samples: [Float]) -> (text: String, lang: String)? {
333-
recognizerLock.lock()
334-
defer { recognizerLock.unlock() }
335-
336-
guard let recognizer,
337-
let stream = SherpaOnnxCreateOfflineStream(recognizer) else {
338-
return nil
351+
/// Decode while the caller holds the recognizer lock for the whole request.
352+
private static func decode(
353+
samples: [Float], recognizer: OpaquePointer
354+
) throws -> (text: String, lang: String) {
355+
guard let stream = SherpaOnnxCreateOfflineStream(recognizer) else {
356+
throw SherpaError.transcriptionFailed(reason: "Could not create an ONNX audio stream.")
339357
}
340358
defer { SherpaOnnxDestroyOfflineStream(stream) }
341359

@@ -345,7 +363,7 @@ final class SherpaService: @unchecked Sendable {
345363
SherpaOnnxDecodeOfflineStream(recognizer, stream)
346364

347365
guard let result = SherpaOnnxGetOfflineStreamResult(stream) else {
348-
return ("", "")
366+
throw SherpaError.transcriptionFailed(reason: "The ONNX decoder returned no result.")
349367
}
350368
defer { SherpaOnnxDestroyOfflineRecognizerResult(result) }
351369

Sources/VocaMac/Vendor/SherpaOnnxConfigBuilders.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// VocaMac (vendored)
33
//
44
// Trimmed copy of swift-api-examples/SherpaOnnx.swift from the sherpa-onnx
5-
// project (https://github.com/k2-fsa/sherpa-onnx), revision
6-
// 00ad9a19a63751a6c4b12050a00eacfeb204814e, Copyright (c) 2023 Xiaomi
5+
// project (https://github.com/k2-fsa/sherpa-onnx), release
6+
// v1.13.7, Copyright (c) 2023 Xiaomi
77
// Corporation, Apache License 2.0.
88
//
99
// The upstream file lives in the package's example target and declares
@@ -22,6 +22,7 @@ func toCPointer(_ s: String) -> UnsafePointer<Int8>! {
2222
let cs = (s as NSString).utf8String
2323
return UnsafePointer<Int8>(cs)
2424
}
25+
2526
func sherpaOnnxFeatureConfig(
2627
sampleRate: Int = 16000,
2728
featureDim: Int = 80
@@ -50,6 +51,7 @@ func sherpaOnnxHomophoneReplacerConfig(
5051
lexicon: toCPointer(lexicon),
5152
rule_fsts: toCPointer(ruleFsts))
5253
}
54+
5355
func sherpaOnnxOfflineTransducerModelConfig(
5456
encoder: String = "",
5557
decoder: String = "",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Short English speech fixtures generated locally with macOS `say`, Samantha,
2+
220 words per minute, mono signed 16-bit PCM at 16 kHz. Leading/trailing
3+
samples below amplitude 150 were trimmed, preserving 10 ms on either side.
4+
5+
- `short-yes.wav`: "Yes", 0.369875 seconds. Before padding, Moonshine Tiny
6+
v2 with sherpa-onnx 1.13.4 returned "Yes, yes, yes".
7+
- `short-stop.wav`: "Stop", 0.3684375 seconds. Before padding, the same
8+
configuration returned "Star".
9+
10+
These are synthetic voices, not user recordings. Integration tests skip
11+
models which are not installed; audio preparation tests always run in CI.
11.6 KB
Binary file not shown.
11.6 KB
Binary file not shown.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import XCTest
2+
@testable import VocaMac
3+
4+
final class SherpaAudioPreparationTests: XCTestCase {
5+
func testValidationRejectsEmptyAndNonFiniteInput() {
6+
let invalid: [[Float]] = [[], [.nan], [.infinity], [-.infinity], [0.1, .nan]]
7+
for samples in invalid {
8+
XCTAssertThrowsError(try SherpaAudioPreparation.validate(samples))
9+
}
10+
XCTAssertNoThrow(try SherpaAudioPreparation.validate([0, 0.000001, -1, 1]))
11+
}
12+
13+
func testShortSpeechPreservesEverySampleAndAddsTrailingSilence() {
14+
let speech: [Float] = [0.1, -0.3, 0.2]
15+
let prepared = SherpaAudioPreparation.prepare(speech)
16+
XCTAssertEqual(prepared.count, 16_000)
17+
XCTAssertEqual(Array(prepared.prefix(speech.count)), speech)
18+
XCTAssertTrue(prepared.dropFirst(speech.count).allSatisfy { $0 == 0 })
19+
}
20+
21+
func testEmptyAndDigitalSilenceDoNotReachDecoder() {
22+
XCTAssertEqual(SherpaAudioPreparation.prepare([]), [])
23+
XCTAssertEqual(SherpaAudioPreparation.prepare([Float](repeating: 0, count: 32_000)), [])
24+
}
25+
26+
func testQuietSpeechIsNotDiscarded() {
27+
XCTAssertEqual(SherpaAudioPreparation.prepare([0.000001]).first, 0.000001)
28+
}
29+
30+
func testNormalLengthAudioIsUnchanged() {
31+
let samples = [Float](repeating: 0.1, count: 16_001)
32+
XCTAssertEqual(SherpaAudioPreparation.prepare(samples), samples)
33+
}
34+
35+
func testShortFinalSegmentAlsoReceivesPadding() {
36+
var recording = [Float](repeating: 0.1, count: 128_001)
37+
recording.replaceSubrange(108_800..<128_000, with: repeatElement(Float.zero, count: 19_200))
38+
let segments = AudioSegmenter.segment(recording, maxSeconds: 8)
39+
XCTAssertTrue(segments.contains { $0.count < 16_000 })
40+
for segment in segments {
41+
let prepared = SherpaAudioPreparation.prepare(segment)
42+
XCTAssertGreaterThanOrEqual(prepared.count, 16_000)
43+
XCTAssertEqual(Array(prepared.prefix(segment.count)), segment)
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)