M1-08: On-device transcription via SpeechAnalyzer #8
Labels
No labels
area/agent
area/capture
area/editor
area/geo
area/security
area/sync
area/transcription
area/ui
area/vault
area/voice-memos
blocked
milestone/M1
milestone/M2
milestone/M3
milestone/M4
needs-decision
needs-hardware
p0
p1
p2
type/bug
type/enhancement
type/feature
type/idea
type/infrastructure
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Stash/stash-ios#8
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Transcribe on-device, using Apple's speech engine, from the same audio tap that
feeds the recorder.
On-device is non-negotiable. Nothing about a Stash capture leaves the phone unencrypted,
ever — that constraint starts here and holds through M3, where the relay is blind by design.
Scope
SpeechAnalyzerfed from the sameAVAudioEnginetap used by issue 05, so the audio andthe transcript are guaranteed to describe the same signal.
relative to any chunk.
Two things to get right
Take the final transcription, not the live one. The live/partial hypotheses are lower
confidence and get revised. v2 has an open question about whether it kept the right one; do
not inherit the ambiguity.
Absolute timestamps from the start. Issue 09's whole design depends on it, and retrofitting
offsets later is the kind of thing that produces a transcript that is subtly wrong everywhere.
Not in scope
Custom vocabulary. On-device STT reliably mangles jargon — NIXL, CMX, Omnistation, DGX — and
an
SFSpeechLanguageModelcontextual-strings pass is the real fix. It is a known, wantedenhancement and it is not M1.
Done when
Speaking into the app produces logged cues with correct absolute timestamps.
Milestone 1 of the Stash v3 rebuild, scoped 2026-08-06. M1 is a functioning iOS app on Wes's
phone with no sync at all — the entire goal is recording voice memos reliably and getting
them off the device by hand. Sync arrives in M3.
Working agreement for every issue in this repo: feature branch, tested before the PR opens
(on-device where the label says so), then Wes reviews the PR and we walk the code together.
Docs update in the same commit. A PR that takes more than 20 minutes to review is too big —
say so and split it.
Validated on
wes-iphone, iOS 26.5.2. Speaking into the app produces cues.The bug worth recording
The first build on device transcribed nothing. The transcript section said "Listening…" and stayed there. The analyzer had rejected the stream on the first buffer:
Timestamps were taken from the audio file's 48 kHz frame clock — exact, already absolute, and the obvious source given what this issue asks for. But
SpeechTranscriber.availableCompatibleAudioFormatsis only 16 kHz and 8 kHz Int16, so the audio is resampled on the way in, and resampling does not preserve frame counts exactly. One tick of disagreement between a declared start time and the previous buffer's real duration, and the engine drops the entire stream rather than the one buffer.bufferStartTimeis now counted in frames actually handed to the analyzer, at the analyzer's own sample rate — monotonic by construction. Both clocks count the same audio, so an interruption is spliced out of both and they stay aligned.driftFromFilemeasures the gap and the debug view shows it in milliseconds, red past 250 ms.The second half of that failure was that none of it was visible.
LiveTranscriber.cueswas anAsyncStreamthat simply ended when the engine gave up, and an ended cue stream is indistinguishable from nobody speaking. It is anAsyncThrowingStreamnow and the reason reaches the screen.Verified off-device
A throwaway probe on macOS running the real
SpeechAnalyzerpath with Stash's exact formats and conversions, on synthesized speech. Not committed.Timestamps are absolute. Audio fed starting at a deliberate 10 s offset returned cues at 10.00→11.56 and 11.56→14.19, not 0.00→1.56.
AnalyzerInput.bufferStartTimeis the time base.Cues arrive during a recording, roughly three seconds behind speech, with contiguous ranges:
No volatile results are emitted at all. With
reportingOptionsempty: 2 results, 0 volatile, 2 final. "Take the final transcription, not the live one" is satisfied by not requesting the live ones rather than by filtering them out, which is the stronger guarantee. TheisFinalcheck that remains is a second line of defence.Timing.
supportedLocale(equivalentTo:)measured 65 ms against 3 ms for every other speech query, and it sat between the record button and the file being opened. Prewarmed at launch and memoised for the process.Not validated
finish()awaitsfinalizeAndFinishThroughEndOfInputspecifically so the last few seconds are not dropped, and that path has not been checked against a recording that ends mid-word.Out of scope, as filed
Custom vocabulary. On-device STT mangles jargon and
SFCustomLanguageModelDatais the fix; the API is present in the SDK and unused here.