Stop treating MediaRecorder.stop() as a finished file
Hermes Desktop voice mode broke because header-only WebM blobs reached speech-to-text. Five gates between a browser recording and backend input, with receipts.
Stop treating MediaRecorder.stop() as a finished file
Hermes Desktop voice mode broke in a specific, boring way. The browser produced WebM blobs that were structurally invalid. Speech-to-text received header-only or truncated containers, handed them to FFmpeg, and FFmpeg failed. The user saw a voice mode that sometimes worked and sometimes went silent with no error in the UI.
The root cause is a widespread misunderstanding of what MediaRecorder.stop() means. stop() is a request to finalize, not a completed file. The recording is not done when stop() returns. The recording is done when the onstop handler fires and the final dataavailable event has delivered the last chunk.
We repaired the recorder. Five gates now stand between a browser recording and backend speech-to-text input. The receipts: 7 of 7 focused tests passed. ESLint, TypeScript, diff-check, pack, and independent review all passed. 288 UI test files containing 2,468 tests passed and 1 skipped. Four live microphone-to-response turns completed. TTS playback completed. A deliberately generated 110-byte header-only WebM was rejected before reaching STT. The task is done.
Why MediaRecorder.stop() lies to you
The MediaRecorder API works asynchronously. You call stop(). The method returns immediately. The browser has not finished writing the file. Two events follow: dataavailable, which delivers the final chunk of data, and onstop, which fires when the recorder has fully stopped and flushed.
Code that treats stop() as a completion signal reads the blob before the final chunk arrives. The blob exists. It has bytes. It might even have a valid WebM header. What it does not have is the complete media payload. The Segment, Cluster, and block structures that carry the actual audio are missing or truncated.
The result is a file that looks like a WebM if you check the magic bytes, and fails in FFmpeg or any strict container parser because the structural elements are absent. The browser gave you a container with no cargo.
This is a protocol mismatch dressed up as a file bug. The code assumed stop() was a synchronous completion signal. The API treats it as a scheduling hint.
The five gates
Gate 1. stop() is a request, not a completed file. Resolve the recording only after onstop fires and the final dataavailable event has delivered the last chunk. Anything you do before both events arrive operates on an incomplete blob. The repair waits for both signals before constructing the final buffer.
Gate 2. Container branding is not structural validity. A file with a WebM header is not necessarily a valid WebM. Check the required container elements: EBML header, Segment, Cluster, and at least one media-bearing block. A header-only blob passes a magic-byte check and fails a structural check. The structural check is the one that matters, because STT and FFmpeg care about structure, not magic bytes.
Gate 3. Treat cancel, unmount, and replacement recordings as concurrency events. When a user cancels a recording, unmounts the component, or starts a new recording before the old one finishes finalizing, async completions from the old recording can still fire. A generation token prevents stale completions from corrupting the new state. Each recording gets a token. When the async completion arrives, the token is checked. If the token does not match the current generation, the completion is ignored. Without this gate, a canceled recording’s final chunk can overwrite or append to the wrong buffer.
Gate 4. Test the malformed case at the client and backend boundary, and prove the backend was not called. The repair rejects invalid WebM before it reaches the network. The test generates a 110-byte header-only blob, attempts to submit it, and asserts that the STT endpoint was never contacted. This is a negative test. It proves the gate works by proving the failure path is blocked. Without this test, you have a validator that might work. With this test, you have a validator that does work, because you tried to break it and it held.
Gate 5. Verify the full path separately. Microphone to recorder to STT to response, then TTS to playback. Each leg is a distinct failure surface. A recording that finalizes correctly can still fail at STT. A response that generates correctly can still fail at TTS playback. Four live microphone-to-response turns and completed TTS playback prove the full chain, not only the recorder fix.
What the receipts say
The focused test suite returned 7 of 7. The broader UI suite returned 2,468 passed and 1 skipped across 288 test files. ESLint, TypeScript, diff-check, pack, and independent review all passed.
A fresh readback on 2026-07-28 confirmed source, test, and installed bundle hashes still match the accepted SHA-256 values. The Desktop process is live. A post-install log scan found zero lines containing FFmpeg, EBML, or invalid-input signatures in the relevant logs.
An older EBML/EOF failure appears in the logs at 16:13 on the install day. That failure predates the install and is the triggering incident, not a regression. The install happened at 16:36. Nothing after the install matches the failure signature.
A deliberately generated 110-byte header-only WebM was rejected before reaching STT. That is the proof the gate works under adversarial input, not only under normal input.
The concurrency trap
Gate 3 is the one most teams miss. Async completion handlers fire whenever the browser gets around to firing them. If a user cancels a recording and starts a new one, the old recording’s dataavailable and onstop events are still in flight. Without a generation token, the old events write into the new recording’s buffer. The new recording arrives at STT corrupted, and the failure looks random because it depends on timing.
The generation token is a version number. Start a recording, increment the token. When an async completion arrives, compare its token to the current token. If they differ, discard the completion. The old recording’s events arrive, check the token, see a mismatch, and drop themselves. The new recording’s buffer stays clean.
This pattern generalizes beyond MediaRecorder. Any async browser API where a user can cancel and restart before the old operation completes has the same race. WebRTC, fetch streams, IndexedDB transactions. The token is the fix.
What this does and does not prove
The repair is installed, tested, and live-verified. Source, test, and bundle hashes match. The Desktop process is running. Post-install logs are clean. A deliberately malformed input was rejected before STT.
This proves the accepted build remains installed and no matching error is logged after install. It does not prove every future browser, device, or media codec case. Browser recording behavior varies across vendors, versions, and hardware. A codec that produces valid WebM today might produce a variant the structural check does not recognize tomorrow.
The public claim is repaired, tested, and live-verified. The claim is not universally solved. The five gates close the specific failure modes we encountered. New failure modes require new gates.
The general lesson
MediaRecorder.stop() is a protocol, not a function call. The browser tells you when it is done. Listen for both events. Check the structure, not the branding. Handle concurrency with a generation token. Test the malformed case and prove the backend was not called. Verify the full path, not only the piece you fixed.
The same discipline applies to any async boundary where a “complete” signal can fire before the work is actually complete. Trust the terminal event. Check the payload. Discard stale completions. Test the failure path. The gates are cheap. The data loss they prevent is not.