Skip to content

fix: keep ONNX models from dropping whole recordings - #256

Merged
jatinkrmalik merged 3 commits into
VocaHQ:mainfrom
Mr-Sunglasses:fix/onnx-empty-transcription
Sep 5, 2026
Merged

fix: keep ONNX models from dropping whole recordings#256
jatinkrmalik merged 3 commits into
VocaHQ:mainfrom
Mr-Sunglasses:fix/onnx-empty-transcription

Conversation

@Mr-Sunglasses

Copy link
Copy Markdown
Member

The bug

The specialized ONNX models sometimes transcribe a recording to nothing at all — no text injected, no error. It looked random: it hit clips of 3s, 50s and a minute alike, with no pattern in the audio.

Root cause

It is not random, and it is not about length or language.

sherpa-onnx's NeMo-derived decoders (Canary here; the same decoder family elsewhere) run greedy attention decoding and stop the moment they emit <|endoftext|>:

std::vector<int32_t> tokens = {max_token_id};   // first predicted token
for (...) { if (tokens.back() == eos) break; ... }
tokens.pop_back();                              // remove the last eos token

When speech begins in sample zero, the model reads the clip as an utterance already in progress and emits end-of-transcript as its first token. tokens is then just {eos}, pop_back() empties it, and sherpa-onnx returns an empty string rather than an error — so nothing downstream can tell the recording was dropped. Push-to-talk makes this common: capture starts on the first frame the tap delivers, so whether a clip has a lead-in is luck.

The signature in the logs is a decode that finishes far too fast:

ONNX transcribing 9.0s of audio...
ONNX transcription completed in 0.24s     <- decoder stopped on token one
Result: ...
Transcription produced no usable text (silence or blank audio)

Reproduced with the headless CLI against Canary 180M: an 8.7s English clip returned "", and truncations of that same file alternated between good text and empty at 3s / 5s / 7s / 8s.

The fix

Give every segment 200ms of silence on each side before it reaches native code (SherpaAudioPreparation.prepare).

A lead-in as short as 50ms recovers every clip that failed this way; 200ms leaves margin, and the matching tail keeps a final word from being cut off mid-decode. The recording's reported duration is measured from the original samples before padding, so audioLengthSeconds is unaffected.

Also log a warning when a decode still comes back empty. The old INFO line read Result: ... whether or not there was a result, which is why this went unnoticed in the logs for so long.

Verification

14 clips through the built app's --transcribe-file, before and after:

clip before after
8.7s English "" full text
same file truncated at 3s / 5s / 7s / 8s "" full text
8.4s non-English "" full text
8.1s / 7.0s English, 10.5s, 54s, 3s, 9s fine unchanged or slightly better

Every previously empty clip now transcribes; nothing that already worked regressed.

swift test: 474 tests, 0 failures. Adds a regression test that speech never starts in the first sample, and updates the two existing tests whose expected sample counts shift by the padding.

Notes

  • The padding is applied to all sherpa-onnx models, not just Canary. Only Canary 180M was installed locally to measure against, but a silent lead-in is normal for these models and the same empty-result failure mode is inherent to the shared decoder loop — Moonshine is already documented in this repo as "returns nothing at all" past a length threshold.
  • Cost is ~0.4s of extra audio per segment (~25KB, a few percent of encoder work on a 20s segment).

The specialized ONNX models silently returned nothing for perfectly good
audio, at any length and seemingly at random. The user-visible effect is a
recording that transcribes to nothing at all.

Cause: sherpa-onnx's NeMo-derived decoders (Canary here, same decoder
family elsewhere) run greedy attention decoding and stop as soon as they
emit end-of-transcript. When speech begins in sample zero — which
push-to-talk recording makes common, since capture starts on the first
frame the tap delivers — the model reads the clip as an utterance already
in progress and emits end-of-transcript as its *first* token. sherpa-onnx
then pops that token and hands back an empty string rather than an error,
so nothing downstream can tell the recording was dropped.

Give every segment 200ms of silence on each side before it reaches native
code. Reproduced against Canary 180M with clips that returned "" — a
lead-in as short as 50ms recovers all of them; 200ms leaves margin, and
the matching tail keeps a final word from being cut off mid-decode.
Verified across 14 clips: every previously empty one now transcribes and
the ones that already worked are unchanged. The recording's reported
duration is measured before padding, so it is unaffected.

Also log a warning when a decode still comes back empty. The old INFO
line read "Result: ..." either way, which is why this went unnoticed in
the logs for so long.
@netlify

netlify Bot commented Sep 5, 2026

Copy link
Copy Markdown

Deploy Preview for voca-mac canceled.

Name Link
🔨 Latest commit 16cf83f
🔍 Latest deploy log https://app.netlify.com/projects/voca-mac/deploys/6a9bb41a17f1770007799ba7

@github-actions github-actions Bot added app bug Something isn't working ci and removed ci labels Sep 5, 2026
@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown

Greptile Summary

The PR prevents ONNX decoders from dropping recordings that begin immediately with speech and completes the fixes requested in the previous review.

  • Adds 200 ms of silence to both ends of each non-silent segment.
  • Reduces segmentation budgets so padded segments remain within each model’s one-pass limit.
  • Distinguishes intentionally skipped digital silence from unexpected empty decoder results.
  • Adds regression and boundary coverage for padding, segment limits, and silent segments.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
Sources/VocaMac/Services/SherpaAudioPreparation.swift Adds symmetric edge silence while preserving the minimum sample count and bypassing digital silence.
Sources/VocaMac/Services/SherpaService.swift Accounts for padding in segment limits and correctly separates skipped silence from unexpected empty decodes.
Tests/VocaMacTests/SherpaAudioPreparationTests.swift Covers edge padding, minimum length, model-limit compatibility, and preservation of original samples.
Tests/VocaMacTests/SherpaServiceTests.swift Updates segment-processing expectations to account for symmetric padding.

Reviews (3): Last reviewed commit: "Merge branch 'main' into fix/onnx-empty-..." | Re-trigger Greptile

Comment thread Sources/VocaMac/Services/SherpaAudioPreparation.swift
Comment thread Sources/VocaMac/Services/SherpaService.swift Outdated
Two follow-ups from review.

The lead-in and tail count against the same length limit the segmenter is
there to respect: capping a segment at maxSegmentSeconds and then adding
400ms sent up to 8.4s to a model documented to degrade past 8s, which is
the failure this change set out to fix. Take the padding out of the
segment budget so what reaches the decoder stays inside the limit, and
pin that with a test over every model in the catalog.

Digital silence is skipped before native inference, so reporting it as a
decode that returned nothing blurs the very signal the new warning exists
to make obvious. Log it as the no-op it is. Both the GUI and the CLI
already reject silence upstream, so this is reachable only through direct
service calls today.
@Mr-Sunglasses

Copy link
Copy Markdown
Member Author

Both Greptile findings were real. Fixed in 321c1b0.

P1 — padding exceeds decoder limits: true, and the more serious of the two.

AudioSegmenter.segment(audioData, maxSeconds: maxSeconds) capped segments at exactly the model's limit and then prepare added 400ms on top, so up to maxSeconds + 0.4 reached the decoder. Worse, a clip just under the limit (7.9s on Moonshine) was never segmented at all and still became 8.3s. Adding length to segments that were deliberately capped at a length limit is precisely the failure this PR set out to fix, so it had to go.

The segment budget now subtracts what prepare adds:

let segmentLimit = SherpaModelCatalog.spec(for: size)?.maxSegmentSeconds
let maxSeconds = segmentLimit.map { $0 - SherpaAudioPreparation.addedSilenceSeconds }

Moonshine/SenseVoice get a 7.6s speech budget, the NeMo models 19.6s. The trade is that clips within 400ms of a limit now split where they previously ran in one pass — a join is the normal path for anything longer anyway, and it beats overrunning a limit the catalog documents as "returns nothing at all".

Two tests pin it: one asserts addedSilenceSeconds still equals what prepare actually adds (so the two cannot drift), and one walks every spec in the catalog, prepares a budget-length segment and asserts it fits that model's limit.

P2 — warning mislabels skipped silence: true, but not reachable in production.

Correct that digital silence never reaches native inference, so calling it a decode that returned nothing muddies the exact signal the warning exists to provide. It now logs as the no-op it is.

Worth noting for reviewers that the branch is unreachable through today's entry points: AppState.stopRecordingAndTranscribe guards on abs($0) >= 0.0001 and AudioFileLoader throws invalid_audio: Audio file is silent, so all-zero audio only reaches SherpaService.transcribe through direct service calls. Fixed anyway — it is six lines and it keeps the log honest for any future caller without a guard of its own.

Verification: swift test 476 tests, 0 failures. All 14 audio clips re-run through the rebuilt app — every previously empty clip still transcribes, nothing regressed.

@jatinkrmalik

Copy link
Copy Markdown
Member

Both Greptile findings were real. Fixed in 321c1b0.

P1 — padding exceeds decoder limits: true, and the more serious of the two.

AudioSegmenter.segment(audioData, maxSeconds: maxSeconds) capped segments at exactly the model's limit and then prepare added 400ms on top, so up to maxSeconds + 0.4 reached the decoder. Worse, a clip just under the limit (7.9s on Moonshine) was never segmented at all and still became 8.3s. Adding length to segments that were deliberately capped at a length limit is precisely the failure this PR set out to fix, so it had to go.

The segment budget now subtracts what prepare adds:

let segmentLimit = SherpaModelCatalog.spec(for: size)?.maxSegmentSeconds
let maxSeconds = segmentLimit.map { $0 - SherpaAudioPreparation.addedSilenceSeconds }

Moonshine/SenseVoice get a 7.6s speech budget, the NeMo models 19.6s. The trade is that clips within 400ms of a limit now split where they previously ran in one pass — a join is the normal path for anything longer anyway, and it beats overrunning a limit the catalog documents as "returns nothing at all".

Two tests pin it: one asserts addedSilenceSeconds still equals what prepare actually adds (so the two cannot drift), and one walks every spec in the catalog, prepares a budget-length segment and asserts it fits that model's limit.

P2 — warning mislabels skipped silence: true, but not reachable in production.

Correct that digital silence never reaches native inference, so calling it a decode that returned nothing muddies the exact signal the warning exists to provide. It now logs as the no-op it is.

Worth noting for reviewers that the branch is unreachable through today's entry points: AppState.stopRecordingAndTranscribe guards on abs($0) >= 0.0001 and AudioFileLoader throws invalid_audio: Audio file is silent, so all-zero audio only reaches SherpaService.transcribe through direct service calls. Fixed anyway — it is six lines and it keeps the log honest for any future caller without a guard of its own.

Verification: swift test 476 tests, 0 failures. All 14 audio clips re-run through the rebuilt app — every previously empty clip still transcribes, nothing regressed.

I am glad we are finding greptile useful.

@jatinkrmalik
jatinkrmalik merged commit 50a76d6 into VocaHQ:main Sep 5, 2026
10 checks passed
@Mr-Sunglasses
Mr-Sunglasses deleted the fix/onnx-empty-transcription branch September 5, 2026 06:31
jatinkrmalik added a commit that referenced this pull request Sep 5, 2026
* feat: save the audio behind an ONNX decode that returns nothing

An empty decode is the one failure with nothing to debug: no error, no
text, and the audio is gone the moment the buffer is released. The bug in
#256 took two wrong fixes before a dumped recording showed what was
actually happening — the decode turned out to be so sensitive to its input
that scaling the same samples by 1.001 changed the result, which no
synthetic clip reproduced and no log line could have revealed.

Write the samples to a WAV next to the logs so the failure can be replayed
through --transcribe-file. Off unless asked for, since it puts recorded
speech on disk:

    defaults write com.vocamac.app vocamac.debug.saveFailedAudio -bool true

Keeps the newest 20 recordings so it cannot fill the disk, and only fires
for audio that actually reached the decoder — digital silence is skipped
before inference and is not a failure.

* Avoid colliding failed-audio dump filenames

Include a short UUID token so two empty ONNX dumps in the same
second keep both WAV files. Sanitize model path characters too.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants