Skip to content

fix(ui): cancel pending microphone acquisition on early release (#550)#580

Merged
pascalandr merged 1 commit into
NeuralNomadsAI:devfrom
heunghingwan:fix/550-stt-empty-audio-subsequent-recordings
Jul 13, 2026
Merged

fix(ui): cancel pending microphone acquisition on early release (#550)#580
pascalandr merged 1 commit into
NeuralNomadsAI:devfrom
heunghingwan:fix/550-stt-empty-audio-subsequent-recordings

Conversation

@heunghingwan

@heunghingwan heunghingwan commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Problem

The voice input button uses push-to-talk: press to start recording, release to stop. On the second and subsequent recordings, the button fails to enter the recording state (doesn't turn red) and jumps directly to "transcribing", producing empty or header-only audio (~110 bytes) that the STT provider rejects with HTTP 400.

This affects all platforms (Linux/Electron, Windows/Chrome), not just Linux as originally reported in #550.

Root Cause

startRecording() is async — it awaits getUserMedia(). But beginVoicePress() in prompt-input.tsx fires it with void (fire-and-forget):

void voiceInput.startRecording()   // not awaited

When the user releases the button before getUserMedia() resolves (common on second+ recordings where mic permission is already granted and acquisition is fast), stopRecording() finds no active recorder (mediaRecorder === null, state() === "idle") and silently returns. The pending getUserMedia() promise later resolves and starts an orphan recording with no matching stop.

The user perceives: button doesn't turn red → presses again → the orphan recorder is stopped → jumps to "transcribing". On fast taps, the orphan recording captures zero audio frames, producing a header-only WebM blob (~110 bytes).

Why it only manifests on second+ recordings

On the first recording, getUserMedia() may show a permission prompt or have slower initial device setup, giving the user time to hold the button. On subsequent recordings, the device is already warm and getUserMedia() resolves faster, but the user's tap duration remains the same — releasing before acquisition completes.

What this is NOT

  • Not a PipeWire/Linux-specific issue — reproduced on Windows Chrome (server on Linux, browser on Windows)
  • Not a MediaRecorder encoder bug — tested with Chrome's fake audio device: start() vs start(timeslice) produce identical results across consecutive recordings
  • Not a recorder reuse issue — the code already creates fresh MediaStream + MediaRecorder per recording

Fix

Add a requestGeneration counter (mirroring the pattern from use-transcription-test.ts in PR #579):

  • startRecording() increments the generation before awaiting getUserMedia()
  • After the await resolves, if the generation is stale, the stream is immediately stopped and discarded
  • stopRecording() / cancelRecording() increment the generation when called while state() === "idle" (pending acquisition), immediately invalidating any in-flight request
  • All event listeners (dataavailable, stop) and finalizeRecording() check the generation before mutating state
  • cleanupMedia() and onCleanup() increment the generation to cover unmount and error paths

Additionally, stopTracks(stream) is called before the transcription network round-trip (instead of after), releasing the audio device sooner.

Verification

  • tsc --noEmit passes (packages/ui)
  • Code review: no regressions, no stream leaks, no double-free paths found
  • Reproduction evidence: confirmed the 110-byte header-only blob signature matches the orphan-recording failure mode

Closes #550

@github-actions

Copy link
Copy Markdown

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/29146649660

Artifacts expire in 7 days.
Artifacts:

  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-tauri-macos
  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-tauri-linux
  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-tauri-windows
  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-electron-macos
  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-tauri-macos-arm64
  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-electron-windows
  • pr-580-df9172c1b75c8ea7d7e5d017291bf890202f769c-electron-linux

@pascalandr

Copy link
Copy Markdown
Contributor

Gatekeeper review of latest head df9172c1.

I reviewed this as a platform-specific MediaRecorder lifecycle fix, focusing on regressions, evidence for the proposed root cause, and integration with the other active speech PR.

Blocking findings

  1. P1: the fix has not been validated on the affected Linux/Electron path.

The primary behavioral change is mediaRecorder.start(TIMESLICE_MS) at packages/ui/src/components/prompt-input/usePromptVoiceInput.ts:123, but the PR does not demonstrate that current dev still reproduces the issue or that this patch fixes multiple consecutive recordings on Linux/Electron.

Issue #550 reports CodeNomad 0.15.0, while the current application and Electron version have changed. Green packaging checks do not execute MediaRecorder. Before merge, test the Linux/Electron artifact with at least three consecutive recordings and inspect the resulting payload/container contents, not only whether the request returns successfully.

  1. P1: the fixed 250-byte threshold can reject valid recordings and does not reliably detect empty audio.

packages/ui/src/components/prompt-input/usePromptVoiceInput.ts:8-9 and :150-161 classify every blob below 250 bytes as no audio. The recorder can select WebM, MP4, Ogg, or a browser-default format at :259-262.

A format-independent byte count cannot establish whether encoded audio frames exist:

  • a very short valid recording can be below the threshold;
  • an empty MP4/Ogg container can exceed it;
  • container metadata sizes vary by browser and codec.

Restrict the heuristic to the observed WebM failure signature, use format-aware validation, or remove this guard from the primary fix.

  1. P1: releasing the push-to-talk button while microphone acquisition is pending can start an orphan recording.

startRecording() remains effectively idle while awaiting Electron permission and getUserMedia() at usePromptVoiceInput.ts:84-106. The caller releases the button through packages/ui/src/components/prompt-input.tsx:784-787, but stopRecording() returns because no recorder exists yet (usePromptVoiceInput.ts:66-67). When the pending promise resolves, the continuation still starts recording at :106-123, with no matching future stop.

The same problem occurs if the component is unmounted while acquisition is pending: cleanup cannot invalidate a stream that has not resolved yet. This race predates the PR, but the new timeslice makes an orphan recorder periodically append chunks while keeping the microphone active.

Add a requesting state or request-generation token, invalidate it on release/unmount, and immediately stop streams returned for stale requests.

  1. P1 integration risk: PR feat(speech): separate STT/TTS provider configuration #579 introduces a second recorder path without this mitigation.

PR #579 adds packages/ui/src/lib/hooks/use-transcription-test.ts, which still starts MediaRecorder without a timeslice, stops without requestData(), and uploads without checking for the observed header-only payload. A synthetic merge does not surface this semantically: prompt dictation gets the workaround while Settings → Test input retains the same failure mode.

Put the recorder start/finalization policy in the shared audio utility introduced by #579, or apply the mitigation consistently to both paths before both PRs merge.

Important findings

  1. The claimed root cause is not established by the available evidence.

The observed 61 KB first payload followed by 110-byte payloads establishes a client capture/encoding failure. It does not establish that omitting a timeslice specifically causes Chromium's encoder initialization failure. The earlier #550 investigation described timeslicing as a defensive mitigation to test because no matching Electron/Chromium report was found.

Until the affected Linux/Electron scenario is reproduced and verified, this should be described as a workaround rather than a proven root-cause fix.

  1. There is no regression coverage for the changed lifecycle.

No test covers:

  • requestData() followed by stop();
  • collection of periodic and final dataavailable events;
  • the <250 branch;
  • release/unmount while permission is pending;
  • track release before upload.

A fake MediaRecorder test can cover event ordering and cleanup even though the Chromium-specific encoder issue remains a manual platform test.

What looks correct

  • requestData() followed by stop() has valid specified event ordering.
  • Collecting timeslice chunks and composing one Blob is valid MediaRecorder usage.
  • Recordings shorter than the timeslice should still receive the final stop flush.
  • Releasing microphone tracks before the network request is an improvement.
  • The new i18n key is present in all nine registered locales.

Validation

  • git diff --check origin/dev...HEAD: passed.
  • npm run typecheck --workspace @codenomad/ui: passed.
  • npm run build --workspace @codenomad/ui: passed.
  • Current GitHub build/package checks are green.
  • No automated MediaRecorder test exists.
  • No Linux/Electron verification of the reported repeated-recording scenario is included.

Verdict

Do not merge yet. The start(1000) workaround is plausible, but this platform-specific fix needs validation on the affected artifact, the byte-size heuristic needs to be removed or made format-aware, pending microphone acquisition must be cancellable, and the recorder path introduced by #579 must not retain the same failure mode.

@pascalandr pascalandr self-requested a review July 11, 2026 12:40
@heunghingwan heunghingwan force-pushed the fix/550-stt-empty-audio-subsequent-recordings branch from df9172c to 0d010eb Compare July 13, 2026 15:30
@heunghingwan heunghingwan changed the title fix(ui): resolve STT empty audio on subsequent recordings (#550) fix(ui): cancel pending microphone acquisition on early release (#550) Jul 13, 2026
@heunghingwan heunghingwan force-pushed the fix/550-stt-empty-audio-subsequent-recordings branch from 0d010eb to da64542 Compare July 13, 2026 15:39
@heunghingwan

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @pascalandr. The PR has been completely reworked based on your feedback and new evidence.

Revised root cause

The original hypothesis (timeslice encoder flush) was wrong. The actual root cause is a push-to-talk race condition in usePromptVoiceInput.ts:

beginVoicePress() fires startRecording() with void (not awaited). When the user releases the button before getUserMedia() resolves, stopRecording() finds no active recorder and silently returns. The pending getUserMedia() later resolves into an orphan recording with no matching stop.

This explains:

  • P1-1: Why it affects second+ recordings — on the first recording, getUserMedia() is slower (initial device setup), so the user holds long enough. On subsequent recordings, acquisition is faster but tap duration stays the same.
  • The original Speech-to-text fails on subsequent recordings — MediaRecorder sends near-empty audio chunks #550 report: Not Linux/PipeWire-specific. A maintainer confirmed the same symptom on Windows Chrome (server on Linux, browser on Windows, audio processing entirely client-side on Windows).
  • The 110-byte blobs: When the orphan recording is stopped almost immediately after starting, zero audio frames reach the encoder → header-only WebM container.

What was removed

Based on your feedback, all speculative changes were removed:

  • mediaRecorder.start(TIMESLICE_MS) — timeslice doesn't fix the race and produced 0-byte blobs for short recordings (worse than 110)
  • requestData() before stop() — was a speculative companion to timeslice
  • MIN_AUDIO_BYTES guard — format-blind (P1-2 was correct), and no longer needed since the race is prevented at the source

P1-3 (orphan recording race) — fixed

This was the core finding. Added a requestGeneration counter (mirroring the pattern from use-transcription-test.ts in #579):

  • startRecording() increments generation before await getUserMedia()
  • After the await, if generation is stale, the stream is immediately stopTracks'd and discarded
  • stopRecording() increments generation when state() === "idle" (pending acquisition), cancelling any in-flight request
  • cleanupMedia() and onCleanup() also increment it

P1-4 (PR #579 second recorder path)

use-transcription-test.ts from #579 already has the requestGeneration pattern and is unaffected. Since the timeslice approach was removed, there's nothing to share between the two paths. Both now use the same cancellation approach.

P1-2 (byte-size heuristic)

Removed entirely as you suggested.

Early stopTracks()

Kept the improvement of moving stopTracks(stream) before the transcription network round-trip.

Cleanup

  • Removed dead cancelRecording() function (zero callers)
  • Removed redundant disposed flag (cleanupMedia() bumping requestGeneration already covers disposal)

Tests (important-6)

Deferred for now. The project uses node:test with no jsdom/vitest infrastructure, and the hook imports .tsx modules (useI18n) that Node's native TypeScript stripper can't handle. All existing UI tests cover pure functions extracted from hooks, not the hooks themselves. Setting up hook testing would require either introducing vitest+jsdom or extracting the async state machine into a testable abstraction — both are larger efforts outside the scope of this fix.

@github-actions

Copy link
Copy Markdown

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/29262563146

Artifacts expire in 7 days.
Artifacts:

  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-tauri-linux
  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-tauri-macos-arm64
  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-electron-linux
  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-electron-windows

1 similar comment
@github-actions

Copy link
Copy Markdown

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/29262563146

Artifacts expire in 7 days.
Artifacts:

  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-tauri-linux
  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-tauri-macos-arm64
  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-electron-linux
  • pr-580-0d010ebc609e8d61140944bbd94e8188dd438b93-electron-windows

@github-actions

Copy link
Copy Markdown

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/29263156342

Artifacts expire in 7 days.
Artifacts:

  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-tauri-macos
  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-electron-macos
  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-tauri-windows
  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-tauri-macos-arm64
  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-tauri-linux
  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-electron-linux
  • pr-580-da645429c77bf20daed541777a8d2a280a17b788-electron-windows

@pascalandr pascalandr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Gatekeeper re-review of da64542

The rework is substantially better and the speculative recorder changes are gone. One merge gate remains.

Blocking finding

  1. P1: the replacement fix still has no behavioral validation.

The new generation guard in packages/ui/src/components/prompt-input/usePromptVoiceInput.ts is logically sound: an early release invalidates the pending request, stale streams are stopped, stale recorder events cannot mutate state, and tracks are released before transcription. It also resolves the integration concern with PR #579 because both recorder paths now use the same cancellation pattern.

However, this head replaces the original root-cause hypothesis and the response explicitly defers tests. The green build/package matrix, typecheck, and bundle do not execute getUserMedia or MediaRecorder, and there is no comment showing that the new artifact was exercised. That leaves the actual #550 workflow unverified.

Before merge, provide either:

  • an automated delayed-getUserMedia regression proving press -> release before resolution stops the returned stream, never creates/starts a recorder, never uploads audio, and allows the next recording; or
  • a short manual result from the current da64542 artifact (Linux/Electron or the confirmed Windows/Chrome environment) covering early release plus at least three consecutive normal recordings, with no orphan microphone activity and no header-only upload.

This does not require introducing Vitest/jsdom if manual artifact verification is the proportionate option.

Non-blocking observation

On Electron, release during requestMicrophoneAccess still proceeds to getUserMedia before the stale-generation check at lines 94-103. The resulting stream is stopped, so it cannot become an orphan recorder, but an additional generation check before getUserMedia would avoid acquiring the device after cancellation.

Resolved from the previous review

  • Removed the format-blind byte threshold.
  • Removed speculative timeslicing and requestData behavior.
  • Added stale request, event, finalization, cleanup, and unmount guards.
  • Confirmed the #579 settings recorder already has generation protection.
  • Moved track release ahead of the transcription round-trip.

Validation

  • npm run typecheck in packages/ui: passed.
  • npm run build in packages/ui: passed.
  • git diff --check upstream/dev...HEAD: passed.
  • Complete required GitHub build matrix: green.

Verdict

Changes requested. The code issue is addressed, but the behavioral gate from the previous review remains open until the current fix is exercised once on the affected workflow.

…alNomadsAI#550)

The voice input button uses a push-to-talk pattern: press to start,
release to stop. startRecording() is async (awaits getUserMedia) but
beginVoicePress() fires it with void — it does not await.

When the user releases the button before getUserMedia() resolves,
stopRecording() finds no active recorder and silently returns. The
pending getUserMedia() promise later resolves and starts an orphan
recording with no matching stop. The user perceives this as: button
doesn't turn red, then jumps directly to transcribing on the next
press. On fast taps the orphan recording captures zero audio frames,
producing a header-only WebM blob (~110 bytes) that the STT provider
rejects with HTTP 400.

This race also explains the original Linux/Electron report (NeuralNomadsAI#550):
subsequent recordings fail because the orphan from the previous tap
interferes with state. The issue is not platform-specific — it
reproduces on Windows Chrome too.

Fix: add a requestGeneration counter (mirroring the pattern already
used in use-transcription-test.ts from PR NeuralNomadsAI#579). Each startRecording()
call increments the generation; getUserMedia() checks it after
resolving and discards the stream if stale. stopRecording() also
increments the generation when called while state is still idle
(pending acquisition), immediately invalidating any in-flight request.
cleanupMedia() and onCleanup() increment it as well to cover unmount
and error paths.

The recording state machine is extracted into recording-controller.ts
(dependency-injected, no browser/SolidJS imports) with 10 unit tests
covering: normal start/stop, early-release cancellation, stream
release on cancel, no-chunk skip, next-recording-after-cancel,
transcribing lock, transcribe failure, cleanup-during-pending, and
stale-generation-during-transcription. usePromptVoiceInput.ts is now
a thin adapter wiring real browser APIs to the controller.

Additionally, stopTracks(stream) is called before the transcription
network round-trip instead of after, releasing the audio device
sooner.
@heunghingwan heunghingwan force-pushed the fix/550-stt-empty-audio-subsequent-recordings branch from da64542 to d1f5c5c Compare July 13, 2026 17:05

@pascalandr pascalandr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Final gatekeeper review of d1f5c5c

No blocking findings remain.

Verified resolution

  • The speculative timeslice, requestData, and format-blind byte-threshold changes remain removed.
  • The push-to-talk lifecycle is now isolated in a dependency-injected recording controller while the Solid/browser hook remains a thin adapter.
  • Releasing before getUserMedia resolves invalidates the request, prevents recorder creation/start, and stops the late stream.
  • Cleanup and stale transcription results are generation-guarded.
  • A subsequent recording works after a cancelled acquisition.
  • The Electron permission path now checks cancellation before calling getUserMedia, resolving the previous non-blocking observation.
  • Tracks are released before the transcription network round-trip.
  • The recorder path added by PR #579 remains compatible with the same generation-guard approach.

Regression coverage

The new 10-test controller suite covers normal start/stop, delayed acquisition cancellation, late stream release, no-chunk handling, recording after cancellation, transcribing lockout, transcription failure, cleanup during pending acquisition, and stale transcription completion.

Validation

  • npx tsx --test src/components/prompt-input/recording-controller.test.ts: 10/10 passed.
  • npm run typecheck in packages/ui: passed.
  • npm run build in packages/ui: passed.
  • git diff --check upstream/dev...HEAD: passed.
  • Electron Linux/macOS/Windows and Tauri Linux/macOS/macOS ARM64/Windows: passed.

Non-blocking residual

The UI package still has no test script and the current packaging workflow does not execute the controller test automatically. The focused suite passes and closes this review gate, but wiring existing UI tests into CI would make the regression protection persistent.

Verdict

Approved. The behavioral gate from the prior review is closed and the fix is ready to merge.

@github-actions

Copy link
Copy Markdown

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/29269028586

Artifacts expire in 7 days.
Artifacts:

  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-electron-macos
  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-tauri-macos
  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-tauri-windows
  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-tauri-macos-arm64
  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-tauri-linux
  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-electron-linux
  • pr-580-d1f5c5cd819301eb0c55dd4661a8cc416c353778-electron-windows

@pascalandr

Copy link
Copy Markdown
Contributor

Thanks @heunghingwan !

@pascalandr pascalandr merged commit f900af1 into NeuralNomadsAI:dev Jul 13, 2026
11 checks passed
@heunghingwan heunghingwan deleted the fix/550-stt-empty-audio-subsequent-recordings branch July 14, 2026 02:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Speech-to-text fails on subsequent recordings — MediaRecorder sends near-empty audio chunks

2 participants