Skip to content

fix(desktop): end an on-device transcription window when the speaker pauses - #12181

Merged
kodjima33 merged 2 commits into
BasedHardware:mainfrom
aryanorastar:fix/local-transcription-pause-endpointing
Aug 26, 2026
Merged

fix(desktop): end an on-device transcription window when the speaker pauses#12181
kodjima33 merged 2 commits into
BasedHardware:mainfrom
aryanorastar:fix/local-transcription-pause-endpointing

Conversation

@aryanorastar

@aryanorastar aryanorastar commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

On Apple Silicon, STTSessionState.resolveMode selects the on-device Parakeet path by default for microphone and system audio — cloud STT is the fallback, not the default. So ambient transcription runs through LocalTranscriptionService, which only closed a transcription window on a fixed 10-second boundary:

/// Window length transcribed at a time. Not real-time — gives a ~10 s "lag" like the user wants.
private let windowSeconds = 10.0

An utterance therefore waited for wherever it happened to land inside that window. Uniform arrival means ~5 s expected and ~10 s worst case, for speech that finished seconds earlier.

This closes a window when the speaker pauses instead. The 10 s cap is unchanged and still bounds continuous speech.

What changed

Three changes, all inside the on-device path:

  1. A window closes on a pause. Once the buffer holds ≥1 s of voiced audio followed by 0.6 s of quiet under the shared noise floor, transcribe it. The minimum is measured in voiced samples, not buffer length — a mostly-quiet window with one blip in it is exactly what Parakeet answers with a hallucinated word (a 1.1 s window at rms 0.0067 decoded to "Yeah." live).
  2. Leading silence is dropped, advancing the emitted-seconds cursor over it so absolute timestamps stay exact. Without this, quiet counts against the 10 s cap and the window fills partway through the next sentence.
  3. Pump tick 1 s → 0.5 s, since it is now the floor on how soon a finished utterance can be transcribed rather than a poll for a full window.

Measured

Real microphone, named dev bundle, on-device Parakeet, MacBook Air. Latency is speech-end wall clock to the transcription log line.

Before — fixed 10 s window, three identical utterances:

utterance latency window
Omi, what time is it now 1.08 s [90.0s–100.0s]
Omi, what time is it now 7.65 s [120.0s–130.0s]
Omi, what time is it now 6.18 s [140.0s–150.0s]

Identical speech, 1.08 s vs 7.65 s — the only variable is where the utterance fell inside the window. Every window is exactly 10.0 s wide and boundary-aligned, which is the tell.

After — 14 utterances across two builds:

before after
mean 4.97 s 0.74 s
min 1.08 s 0.53 s
max 7.65 s 1.04 s
spread 6.57 s 0.51 s
theoretical worst case 10.0 s 1.2 s
window width emitted 10.0 s fixed 2.1–3.4 s, the utterance itself

The spread collapsing from 6.6 s to 0.5 s is the part that matters: transcription arrives at the same moment every time instead of anywhere in a ten-second range.

Tests

LocalTranscriptionEndpointTests — 13 new cases pinning each half of the rule in both directions: speech-then-pause closes, speech-still-running does not, a pause shorter than the tail does not, pure silence never drains, a blip in a mostly-quiet window does not, voiced audio accumulates across short gaps, room tone under the noise floor reads as a pause, leading silence is trimmed with lead-in kept, a buffer opening with speech is not trimmed, and a pause between two utterances is never trimmed.

Verified on this branch, which contains no other change:

  • xcrun swift build -c debug --package-path Desktop — clean
  • LocalTranscriptionEndpointTests + LocalTranscriptionDuplicatePolicyTests — 18 tests, 0 failures

Why this is its own PR

Split out of #11801 (wake word) at the reviewer's request. Pause endpointing applies to every on-device transcription session, whether or not anyone enables the wake word, so it should not wait on a product decision about an opt-in toggle. It stands alone: this branch is main plus one commit, and the endpointing tests pass on it with no wake-word code present.

Honest gaps

  • Parakeet still hallucinates a single word on quiet windows — twice in ~3 minutes of near-silence it emitted "Yeah." at rms 0.027–0.030, conf 0.60–0.61. Shorter windows surface these more often than 10 s windows did, so this change makes an existing behaviour more visible. I did not add a confidence floor: real speech in the same session ran conf 0.78–0.98 and the quiet-window hallucinations ran 0.35–0.79, so the distributions overlap and any threshold I picked would be fitted to one room.
  • One machine, one room, one voice. 21 timed utterances is a clean before/after on a large effect, not a population.
  • BLE/pendant capture is unaffected — resolveMode sends .bleDevice to the cloud path, untouched here.

Failure-Class: none

Review in cubic

…pauses

The wake word's latency was not the cloud ambient lane. On Apple Silicon
`STTSessionState.resolveMode` picks the on-device Parakeet path by default, and
`LocalTranscriptionService` only closed a window on a fixed 10s boundary — so a
spoken command waited for wherever it happened to land in that window.

Measured live on a MacBook Air, real microphone, identical utterances:

  before   1.08s / 6.18s / 7.65s   (~5s expected, ~10s worst case)
  after    0.66s / 0.76s / 0.82s / 0.89s / 0.89s / 1.04s

Three changes, all in the on-device path:

- A window closes early once the buffer holds a second of voiced audio followed
  by 0.6s of quiet. The minimum is measured in voiced samples, not buffer
  length: a mostly-quiet window with one blip in it is what Parakeet answers
  with a hallucinated word (a 1.1s window at rms 0.0067 decoded to "Yeah.").
- Silence ahead of the first speech is dropped, advancing the emitted-seconds
  cursor over it so absolute timestamps stay exact. Otherwise quiet counted
  against the 10s cap and the window filled partway through the next sentence —
  observed cutting "Omi, what time is it now" down to "Now".
- The pump ticks at 0.5s rather than 1s, since it is now the floor on how soon
  a finished utterance can be transcribed rather than a poll for a full window.

The 10s cap is unchanged and still bounds continuous speech.

Verification:
- xcrun swift test --package-path Desktop --filter
  'LocalTranscriptionEndpointTests|LocalTranscriptionDuplicatePolicyTests|WakeWord'
  -> 42 tests, 0 failures
- Eight timed utterances through the real microphone on a named dev bundle,
  wake word enabled, on-device Parakeet: every one dispatched, full text, in
  0.66-1.04s from the end of speech.

Failure-Class: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@aryanorastar

Copy link
Copy Markdown
Contributor Author

The four red checks here are not this diff. This PR is main plus one commit touching three desktop files and no backend at all.

gcp-sa-key-ratchet (landed in #11125) declares two trigger paths that do not exist, and run_checks.py:187 fails the whole manifest resolution when a non-glob trigger is missing — before any check runs:

FAIL: could not resolve manifest checks: gcp-sa-key-ratchet: explicit trigger path does not exist: backend/agent-proxy/main.py; gcp-sa-key-ratchet: explicit trigger path does not exist: backend/agent-proxy/Dockerfile

Reproduces on a pristine checkout of main with no branch involved. Filed as #12182, along with a second defect underneath it — the ratchet has never actually passed its own baseline, which the resolution failure was hiding.

I have the one-line fix but am deliberately not sending it: on its own it turns a broken-manifest error into a red credential check on every PR, which is worse. #12182 has the detail and the two honest resolutions, both of which need someone who owns that area.

This PR's own content is verified independently of CI: swift build clean, LocalTranscriptionEndpointTests + LocalTranscriptionDuplicatePolicyTests 18/18, and scripts/pr-preflight 22/22 locally against 009132dbb3 before #11125 landed.

undivisible pushed a commit that referenced this pull request Aug 25, 2026
…12183)

Reverts #11125. The ratchet shipped with two defects that compound, and the
first hid the second (see #12182).

It declared backend/agent-proxy/main.py and backend/agent-proxy/Dockerfile as
trigger paths and neither exists. run_checks.py:187 existence-checks any
trigger without glob magic and fails the whole manifest resolution when one is
missing, before any check runs -- so this broke every consumer of the manifest,
not the ratchet alone. #12181 touches three desktop Swift files and no backend,
and lost Detect Desktop Swift Changes, Desktop Swift Build & Tests, Hygiene and
PR Metadata Preflight to it.

Making resolution succeed only moves the failure: the ratchet then runs for the
first time and does not pass its own baseline, on pristine main, with 13
findings. Twelve are stale-shrink entries -- several for backend/agent-proxy/*,
the same paths that do not exist. One is not:
backend/database/google_credentials.py:service-account-json-env reports found 6
against a cap of 1, on a file main has not modified, so the cap was miscounted
when written rather than something having regressed. --write-baseline never
raises caps by design, so that one cannot be corrected with the tool; it needs
either real key-path removal or a baseline edit under security review.

Reverting costs nothing that currently works: the check has never executed, so
no key path is being protected by it today. It can be re-landed with a baseline
generated against this tree and backend/agent-proxy/** as the trigger.

Deliberately kept: the Backend_Setup.mdx change, restored verbatim. It steers
local setup to ADC instead of copying application_default_credentials.json into
the repo as google-credentials.json. That improvement has no CI dependency, and
reverting it would put the copy-a-key-into-the-repo instructions back.

Failure-Class: none
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Verified the premise first: STTSessionState.resolveMode does select .local for mic/system audio on Apple Silicon, so this touches the default transcription path — worth the extra care, and it holds up.

desktop/macos/Desktop/Sources/LocalTranscriptionService.swift

  • isEndpointed(_:) is the right shape: a 0.6 s quiet tail under the shared 0.004 floor plus >=1 s of voiced audio, counted in 100 ms chunks by voicedSamples. Measuring the minimum in voiced samples rather than buffer length is what actually blocks the mostly-quiet-window hallucination case documented in the PR body.
  • leadingSilenceSamples trims only ahead of the first speech (the scan stops at the first chunk over the floor, keeping a 200 ms lead-in), and drain advances emittedSeconds over the trimmed amount so absolute timestamps stay exact. The pause-between-utterances case staying untrimmed is the important subtlety, and it's pinned by a test.
  • drain's (force || endpointed) ? available : windowSamples preserves the 10 s cap semantics for continuous speech while letting a pause consume the whole buffer — no arbitrary mid-utterance split.
  • Hoisting the inline 0.004 in drain's RMS guard into the shared speechFloor constant is a good cleanup.

desktop/macos/Desktop/Tests/LocalTranscriptionEndpointTests.swift

  • All 13 expectations are consistent with the implementation semantics — I re-derived the endpointing/trim arithmetic independently against each case (speech-then-pause closes; running speech, a pause shorter than the tail, pure silence, a sub-second blip, and a blip inside a mostly-quiet window don't close; voiced audio accumulates across short gaps; room tone under the floor reads as a pause; and the four leading-silence cases including the keep-two-chunks lead-in arithmetic). The blip-in-a-mostly-quiet-window test is the one that pins the real regression risk here.

desktop/macos/changelog/unreleased/20260823-local-transcription-pause-endpointing.json

  • Accurate and user-true ("about a second later" matches the measured 0.53-1.04 s) and matches the repo's desktop changelog schema.

The four red checks are not this diff. Confirmed independently: backend/agent-proxy/main.py and backend/agent-proxy/Dockerfile don't exist on main, gcp-sa-key-ratchet landed in #11125 at 09:04Z — three minutes before this PR opened — and Hygiene / Detect Desktop Swift Changes / PR Metadata Preflight / (cascading) Desktop Swift Build & Tests all die at the same could not resolve manifest checks line before any check body runs. That's the #12182 report, and declining to send the one-line fix from this branch was the right call.

Splitting this out of #11801 was also correct — pause endpointing applies to every on-device session, with or without the wake word.

What's left for a maintainer is judgment rather than correctness: 0.6 s tail / 1.0 s min-utterance are single-room, single-voice tuned constants on the default transcription path, and the disclosed trade-off — shorter windows surface Parakeet's quiet-window single-word hallucinations more often — is a product-behavior call. Merge will stay blocked until the #12182 manifest fix lands so CI can actually run these tests.


by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.

@Git-on-my-level Git-on-my-level added positive-signal Good PR — positive signal, not a formal approval ci-failing-pre-existing CI check failing for reasons pre-existing/unrelated to this PR (red main) needs-maintainer-review Needs a human maintainer to sign off before merge macOS labels Aug 25, 2026

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop bug-fix fast lane (owner-override) — well-verified pause-endpointing fix for on-device transcription. Holding merge: PR Metadata Preflight fails on a stale frozen merge-ref referencing a since-deleted backend/agent-proxy path (gcp-sa-key-ratchet check) — unrelated to this PR's Swift-only diff, matches the known frozen-merge-ref CI pattern. Reruns won't clear it; needs a branch update to pick up current main.

@Git-on-my-level Git-on-my-level removed needs-maintainer-review Needs a human maintainer to sign off before merge ci-failing-pre-existing CI check failing for reasons pre-existing/unrelated to this PR (red main) labels Aug 26, 2026

@kodjima33 kodjima33 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop bug fix: pause-based endpointing cuts on-device transcription latency from ~5s avg/10s worst-case to <1s, with 13 pinned tests. CLEAN, all CI green (PR Metadata Preflight now passes — prior frozen-ref block cleared). Confirmed unfixed on main. Owner-override merge.

@kodjima33
kodjima33 merged commit eb4146b into BasedHardware:main Aug 26, 2026
32 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macOS positive-signal Good PR — positive signal, not a formal approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants