Skip to content

Commit f3d8f31

Browse files
fix: harden ONNX transcription and update sherpa-onnx to 1.13.7 (#253)
* fix: handle short ONNX audio and update sherpa-onnx * fix: preserve ONNX pause boundaries and cancellation
1 parent 0629ef1 commit f3d8f31

17 files changed

Lines changed: 473 additions & 94 deletions

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
)

Sources/VocaMac/Services/AudioSegmenter.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ enum AudioSegmenter {
9696
guard !energies.isEmpty else { return fallback }
9797

9898
let sorted = energies.sorted()
99-
let quietThreshold = sorted[sorted.count / 4]
99+
// A percentile alone marks flat/continuous sound as quiet and can
100+
// bury short pauses when they occupy less than a quarter of the window.
101+
// Require a 10 dB energy drop relative to the louder frames as well.
102+
let quietThreshold = min(sorted[sorted.count / 4], sorted[sorted.count * 3 / 4] * 0.1)
100103

101104
var bestStart = 0, bestLength = 0
102105
var runStart = 0, runLength = 0
@@ -118,7 +121,11 @@ enum AudioSegmenter {
118121
return frameOffsets[min(middle, frameOffsets.count - 1)] + frameLength / 2
119122
}
120123

121-
// No real pause — fall back to the single quietest frame.
124+
// With no meaningful energy dip, use the full window instead of
125+
// inventing an early boundary in continuous sound.
126+
guard let minimum = sorted.first, minimum <= quietThreshold else { return fallback }
127+
128+
// No sustained pause — fall back to the single quietest frame.
122129
var quietestIndex = 0
123130
for (index, energy) in energies.enumerated() where energy < energies[quietestIndex] {
124131
quietestIndex = index

Sources/VocaMac/Services/LoadSerializer.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,22 @@ actor LoadSerializer {
1919
/// Run `operation` after every operation queued before it has settled.
2020
///
2121
/// A failure in one operation does not prevent later ones from running;
22-
/// the error is delivered to whoever queued that operation.
22+
/// the error is delivered to whoever queued that operation. Cancellation
23+
/// reaches queued/running work, but it still owns its queue position until
24+
/// it finishes. Cleanup can opt out so cancellation cannot skip teardown.
2325
func run<T: Sendable>(
26+
cancellable: Bool = true,
2427
_ operation: @escaping @Sendable () async throws -> T
2528
) async throws -> T {
2629
let previous = tail
2730

2831
let task = Task<Result<T, Error>, Never> {
2932
await previous?.value
3033
do {
31-
return .success(try await operation())
34+
if cancellable { try Task.checkCancellation() }
35+
let value = try await operation()
36+
if cancellable { try Task.checkCancellation() }
37+
return .success(value)
3238
} catch {
3339
return .failure(error)
3440
}
@@ -38,7 +44,12 @@ actor LoadSerializer {
3844
// a thrown error cannot cancel the queue.
3945
tail = Task { _ = await task.value }
4046

41-
switch await task.value {
47+
let result = await withTaskCancellationHandler {
48+
await task.value
49+
} onCancel: {
50+
if cancellable { task.cancel() }
51+
}
52+
switch result {
4253
case .success(let value):
4354
return value
4455
case .failure(let error):

Sources/VocaMac/Services/ModelManager.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ final class ModelManager {
488488
size: ModelSize,
489489
onProgress: @escaping (Double) -> Void
490490
) async throws {
491+
try Task.checkCancellation()
491492
guard let spec = SherpaModelCatalog.spec(for: size) else {
492493
throw ModelManagerError.modelNotAvailable(size.rawValue)
493494
}
@@ -548,6 +549,11 @@ final class ModelManager {
548549
)
549550
}
550551

552+
// Cancellation may arrive during hashing/extraction. Stop before
553+
// changing the installed model; once the swap begins, finish its
554+
// rollback-safe transaction without interruption.
555+
try Task.checkCancellation()
556+
551557
// Swap the finished model in without a window where neither copy
552558
// is in place: move any existing install aside first, and only
553559
// delete it once the new one has landed.
@@ -588,6 +594,8 @@ final class ModelManager {
588594

589595
onProgress(1.0)
590596
VocaLogger.info(.modelManager, "ONNX model '\(size.rawValue)' installed at: \(destination.path)")
597+
} catch is CancellationError {
598+
throw CancellationError()
591599
} catch {
592600
VocaLogger.error(.modelManager, "Download failed for '\(size.rawValue)': \(error.localizedDescription)")
593601
throw ModelManagerError.downloadFailed(reason: error.localizedDescription)
@@ -597,23 +605,27 @@ final class ModelManager {
597605
/// SHA-256 of a file, read in chunks so a large archive never has to be
598606
/// held in memory all at once.
599607
static func sha256Hex(ofFileAt url: URL) throws -> String {
608+
try Task.checkCancellation()
600609
let handle = try FileHandle(forReadingFrom: url)
601610
defer { try? handle.close() }
602611

603612
var hasher = SHA256()
604613
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
614+
try Task.checkCancellation()
605615
hasher.update(data: chunk)
606616
}
607617
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
608618
}
609619

610620
/// Extract a .tar.bz2 archive using the system tar.
611621
static func extractTarArchive(at archive: URL, into directory: URL) throws {
622+
try Task.checkCancellation()
612623
let process = Process()
613624
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
614625
process.arguments = ["xjf", archive.path, "-C", directory.path]
615626
try process.run()
616627
process.waitUntilExit()
628+
try Task.checkCancellation()
617629
guard process.terminationStatus == 0 else {
618630
throw ModelManagerError.downloadFailed(
619631
reason: "Archive extraction failed (tar exited with \(process.terminationStatus))"
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+
}

0 commit comments

Comments
 (0)