Skip to content

fix: handle silent audio captures after stop - #178

Merged
jatinkrmalik merged 2 commits into
mainfrom
jmalik/fix-warm-audio-engine-release
Jul 17, 2026
Merged

fix: handle silent audio captures after stop#178
jatinkrmalik merged 2 commits into
mainfrom
jmalik/fix-warm-audio-engine-release

Conversation

@jatinkrmalik

@jatinkrmalik jatinkrmalik commented Jul 9, 2026

Copy link
Copy Markdown
Member

Summary

  • keep AVAudioEngine allocated for a 3 second idle window after normal stop so rapid push-to-talk recordings can reuse a warm input route
  • cancel any pending idle release when recording starts again
  • keep immediate engine release for force reset, configuration changes, and start failures
  • call engine.prepare() inside the exception-protected start path before start()
  • force-reset the audio engine when a capture is rejected as silent (guard from fix: reject silent microphone captures in clamshell mode #184) so a dead input route doesn't stay warm

Scope note

Rebased onto main after #184 ("reject silent microphone captures") landed. #184 covers the pure-silence case with a user-visible error, which is better UX than the near-silent RMS gate in the earlier revision of this PR, so the gate is gone. What remains is the warm-engine lifecycle plus the engine reset on rejected captures.

Validation

  • swift test

Refs #176

Copy link
Copy Markdown
Member Author

/build

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Build started for 2de5a76

Build signed & notarized DMG... this usually takes 10–20 minutes.

Watch the build →

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Build ready!

DMG VocaMac-0.7.0-pr.178+2de5a76-arm64.dmg
Size 6.1M
Branch jmalik/fix-warm-audio-engine-release
Commit 2de5a76
Signed ✅ Developer ID
Notarized ✅ Apple

📥 Install

  1. Click the DMG link above to download
  2. Open the DMG and drag VocaMac to Applications (replace existing)
  3. Open VocaMac — No Gatekeeper warnings, no permission resets
SHA-256 checksum
ccd4b9314f931e5967b9101278a44d1cfe8b1a8ff1b158e4778ba76a0da7c596  VocaMac-0.7.0-pr.178+2de5a76-arm64.dmg

💡 Comment /build or /build-quick to rebuild.

@flesler

flesler commented Jul 9, 2026

Copy link
Copy Markdown

Tested 0.7.0-pr.178+2de5a76 on the same M5 Max machine from #176 (push-to-talk F11, Large v3 Turbo Compact, built-in mic).

Results

Session slice n blank notes
Full PR session 27 10 (37%) includes intentional short stress
dur ≥ 1.5s 13 0 solid
dur < 1.5s 14 10 (71%) still broken

Warmup-specific test (long take, then rapid shorts within the 3s idle window):

# dur gap result
1 1.9s OK — “Okay, here's the longer one…”
2 0.5s 2.1s BLANK (should still be warm)
3 0.8s 2.1s BLANK
4 1.4s 2.5s OK
5 0.7s 2.2s BLANK
6–7 1.1–1.2s ~2.3s OK

Confirmed we were on the PR binary (pendingEngineRelease present; version 0.7.0-pr.178+2de5a76).

Takeaway

Keeping the engine warm helps longer / normal dictations (no ≥1.5s blanks in this session). It does not fix the silent-buffer case for very short takes — even immediately after a successful capture, inside the 3s window.

So #176 is only partly addressed. Remaining failure mode looks more like “short recording still gets near-silent PCM” than “cold reacquire after idle release.” Happy to try another build if you want to dig into the short-clip path next (tap readiness / minimum capture / early buffers).

@flesler

flesler commented Jul 9, 2026

Copy link
Copy Markdown

One more data point / suggestion from comparing with VocaLinux (same machine class of usage — I run both daily).

On Linux I basically never see this silent/blank short-dictation failure. The capture path there is different in ways that may be useful here:

  • keeps a normal open mic stream and reads fixed chunks (no create/destroy of the capture graph per keypress)
  • measures level / Silero VAD on chunks and drops buffers that never had speech instead of sending near-silence to the model
  • in push-to-talk, doesn’t auto-cut on silence — waits for key release

So a possible follow-up to the warm-engine change: don’t treat engine.start() as “mic is hot,” and/or gate transcription on measured energy/VAD so near-silent PCM never reaches Whisper. Happy to test another build.

@flesler

flesler commented Jul 9, 2026

Copy link
Copy Markdown

Product take (after testing the warm-engine build)

I appreciate #178 — keeping the engine warm for 3s is a reasonable mitigation for rapid re-records, and longer takes (≥~1.5s) looked solid on my machine. But after living with it for a bit, I don’t think this is the right long-term fix.

Most of my dictations are cold starts. I’m not usually hammering the hotkey again within 3 seconds. The warm window only helps a narrow usage pattern, and our stress tests still showed sub‑~1.5s takes going blank even inside that window (right after a successful longer take). So we’re still fighting AVAudioEngine lifecycle instead of telling the user the truth about when capture is actually live.

What I think the holistic fix is

Treat “ready to speak” as a real state, and drive the existing cursor indicator off it:

  1. Key down → show the overlay in the purple / processing-looking “arming” state (same visual language you already use while Whisper runs — IndicatorPhase.processing).
  2. Start the mic in the background.
  3. Flip to red / recording only when capture is actually hot — not merely when engine.start() returns success.
  4. If the user releases while still arming → drop the buffer / don’t send near-silence to Whisper.

That matches the real bug: the UI currently says “speak now” as soon as recording state flips, while early (or all) tap buffers can still be near-silent PCM. Whisper then “succeeds” on silence → empty text. Mic capture and Whisper are already separate; Whisper is fine. The lie is between UI and capture readiness.

Compared to warm-engine: this helps every dictation (cold or warm), teaches the user when to talk, and pairs cleanly with “don’t transcribe silent buffers” (something VocaLinux already does via level/VAD before sending audio to the model).

Warm-engine can still be a nice optimization so arming is usually instant — but it shouldn’t be the primary fix.

Code pointers (no PR from me — just a map)

Today the indicator goes red before/as the engine starts:

// AppState.startRecording()
appStatus = .recording
isRecording = true
if showCursorIndicator {
    cursorOverlay.show()          // → phase = .recording (red) immediately
}
let didStartRecording = await startAudioEngine(...)

Existing purple state is already there for post-stop transcription:

// CursorOverlayManager
enum IndicatorPhase { case idle, recording, processing }
// recording  = systemRed + mic.fill
// processing = purple #BF5AF2 + ellipsis.circle
func transitionToProcessing() { viewModel.phase = .processing }

So visually, arming → ready is mostly: show .processing first, then set .recording when ready. Reusing purple for “arming” is slightly overloaded with “transcribing,” but it’s the smallest UI change; a dedicated .arming phase would be clearer long-term.

Readiness signal already partially exists:

audioEngine.onAudioLevel = { level in
    self?.audioLevel = level
    self?.cursorOverlay.updateAudioLevel(level)
}

AudioEngine.processAudioBuffer already computes RMS and calls onAudioLevel. A practical gate: flip to red on first level above the existing silence threshold (or N consecutive non-silent buffers). Do not treat engine.start() == true alone as ready — that’s the optimistic path that caused the blank transcripts.

On stop while still arming: in stopRecordingAndTranscribe(), if never reached ready (or buffer energy ≈ 0), hide overlay and return idle without calling Whisper — same idea as VocaLinux dropping buffers with no speech detected.

Also worth aligning: menu bar recording icon, if it turns red on appStatus == .recording the same way.

Happy to retest a build that does arming→ready (+ silent-buffer drop). I think that’s the fix that matches how the app is actually used.

@flesler

flesler commented Jul 9, 2026

Copy link
Copy Markdown

New data — long takes can be fully blank (not just a slow start)

After switching back to stock 0.7.0, I hit two back-to-back failures that change the picture:

time (UTC) duration Whisper decode result
21:10:03 14.6s 0.90s OK
21:10:28 19.5s 0.35s BLANK
21:10:48 13.9s 0.35s BLANK

If this were only “mic needs ~300ms to wake up,” a 19s hold would still contain ~18s of speech and Whisper would return most of the sentence. Instead decode finishes at silence-speed (~0.35s) and text is empty — so the buffer looks like near-silence for the entire key-hold, not “quiet intro + speech.”

No errors around it

Literally nothing in the log except happy-path INFO:

Transcribing 19.5s of audio...
Transcription completed in 0.35s
Result: ...
Transcription produced no usable text (silence or blank audio)

No ERROR / WARNING, no AudioEngine lines, no config-change, no force-reset, no failed engine.start(). The take before and takes after look normal. From the app’s POV the pipeline “succeeded” — it just transcribed zeros.

Implication for #178 / readiness UI

  • Warm-engine still doesn’t explain full-length long blanks (these were cold-ish gaps, not rapid re-taps).
  • “Wait a beat before speaking” / arming icon alone also wouldn’t fix a zombie silent stream that stays dead for 19s.
  • Stronger signal that we need runtime detection that capture is actually producing energy (and abort/retry/drop if the whole buffer is near-silent), not only deferring engine teardown.

Happy to grab a debug export or try a build that logs RMS/peak per recording if that helps.

@jatinkrmalik jatinkrmalik changed the title fix: keep audio engine warm briefly after stop fix: handle silent audio captures after stop Jul 11, 2026
@jatinkrmalik
jatinkrmalik marked this pull request as ready for review July 11, 2026 00:28
@jatinkrmalik

Copy link
Copy Markdown
Member Author

/build

@github-actions

Copy link
Copy Markdown

PR Build started for 8ab6a77

Build signed & notarized DMG... this usually takes 10–20 minutes.

Watch the build →

@github-actions

Copy link
Copy Markdown

PR Build ready!

DMG VocaMac-0.7.0-pr.178+8ab6a77-arm64.dmg
Size 5.1M
Branch jmalik/fix-warm-audio-engine-release
Commit 8ab6a77
Signed ✅ Developer ID
Notarized ✅ Apple

📥 Install

  1. Click the DMG link above to download
  2. Open the DMG and drag VocaMac to Applications (replace existing)
  3. Open VocaMac — No Gatekeeper warnings, no permission resets
SHA-256 checksum
f47a5e71b803b14c28feaea2ff8c4e06a9ef47b4b1fd9b740583989bc5ff5f61  VocaMac-0.7.0-pr.178+8ab6a77-arm64.dmg

💡 Comment /build or /build-quick to rebuild.

@flesler

flesler commented Jul 13, 2026

Copy link
Copy Markdown

Retested 0.7.0-pr.178+8ab6a77 (warm engine + near-silent drop) on M5 Max, Large v3 Turbo Compact, vocab cleared so this is only the capture path.

Results

# dur outcome
medium 4.2s OK
medium 4.2s OK
short 0.7s blank via Whisper (no usable text)
short 0.7s blank via Whisper
short 0.6s blank via Whisper
short 1.4s blank via Whisper
long 8.6s OK
long 7.2s OK
short 0.8s blank via Whisper
medium 2.0s OK
medium 2.0s OK

Summary: ok=7 · whisper_blank=5 · Dropping near-silent capture count = 0

Takeaway

Normal/medium/long speech is fine on this build. The short blanks are unchanged from before: they still reach Whisper and come back empty at silence-speed. The new RMS gate never logged a drop, so those buffers are apparently above silentCaptureRMSThreshold (0.0005) — enough energy (noise?) to pass the check, not enough for Whisper to produce text.

So this helps the “don’t transcribe obvious zeros” case in theory, but on my machine the failing shorts aren’t pure digital silence; they’re low-energy junk that still clears 0.0005.

Possible next knobs:

  • raise the threshold / also require a minimum peak
  • log rms/peak on every capture (including ones that go to Whisper) so we can see what the failing shorts look like
  • or gate on “had speech-like level during recording” rather than whole-buffer RMS alone

Happy to grab another build that logs rms/peak for all stops.

@jatinkrmalik
jatinkrmalik force-pushed the jmalik/fix-warm-audio-engine-release branch from 8ab6a77 to c8774e5 Compare July 17, 2026 00:21
@jatinkrmalik
jatinkrmalik merged commit c1b0536 into main Jul 17, 2026
5 checks passed
@jatinkrmalik
jatinkrmalik deleted the jmalik/fix-warm-audio-engine-release branch August 22, 2026 05:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants