Skip to content

fix(desktop): remove the voice mic re-arm delay - #54067

Closed
laurinaitis wants to merge 3 commits into
NousResearch:mainfrom
laurinaitis:fix/desktop-voice-latency
Closed

fix(desktop): remove the voice mic re-arm delay#54067
laurinaitis wants to merge 3 commits into
NousResearch:mainfrom
laurinaitis:fix/desktop-voice-latency

Conversation

@laurinaitis

@laurinaitis laurinaitis commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

What

Three fixes to the desktop voice loop, all on the microphone side.

  • The mic re-arms as soon as the assistant finishes speaking, instead of waiting for an unrelated re-render.
  • The MediaStream stays open between turns, so a turn no longer re-runs getUserMedia.
  • The sentence splitter stops handing TTS a clipped fragment or half a number.

Why

Two separate delays after the assistant stopped talking, both measured on macOS with a local
Kokoro + Whisper backend.

The effect never re-ran. Once the reply was fully spoken, the loop set pendingStartRef, called
setStatus('idle') and returned. status is already 'idle' at that point, because the last speak() set
it in its finally, so the setState is a no-op, React bails out, and the re-render that would run the effect
again never arrives. The restart guard is three lines below the return. Nothing reaches it until some other
component happens to render, which is where the 4-8s wait and its variance came from.

Every turn re-acquired the microphone. Cleanup stopped the stream tracks on every normal stop, so the
next turn paid requestMicrophoneAccess + getUserMedia again.

The splitter mishandled short fragments and numbers. A bare list marker like 1. was voiced as its own
synth call, which sounds clipped and pauses before the item it introduces. And because the splitter runs on a
buffer that is still growing, a . at the end of the buffer doesn't mean the sentence ended: Revenue grew to 1. matched, so the reply was spoken as "Revenue grew to one" followed by "2 billion."

Notes

The AudioContext is still rebuilt every turn on purpose. The browser suspends an idle context, after which
the analyser reads silence and speech detection stops without any error.

Keeping the stream open has two visible consequences: the OS microphone indicator stays lit for the whole
conversation rather than flickering off between turns, and a microphone device change takes effect on the
next release instead of the next turn. Cancel, unmount, mute and recorder errors all still release the
tracks.

#38411 diagnosed the re-arm first and independently. Its fix removes the same early return, and also removes
the setStatus('idle') calls; the sibling paths rely on those, so removing them strands the hook in
'thinking' with a dead microphone. This keeps both and drops only the one return that can't be reached
usefully. Evidence and a correction are on that PR. If it merges first, the re-arm commit here comes out.

voice-playback.ts is deliberately untouched. #44308 has been rewriting playSpeechText for the same
latency goal since 11 June; the prefetch work that used to be in this PR is offered there instead.

Testing

19 contracts. use-voice-conversation.test.tsx (3) and use-mic-recorder.test.tsx (5) run against current
main unchanged: 2 fail there (the already-idle re-arm, and stream retention across a normal stop) and 6
pass, holding the existing teardown and sibling behaviour still. speech-chunking.test.ts (11) imports
extractChunk, which this PR adds, and streams the reply in a character at a time, since passing a finished
string hides the decimal case.

Desktop UI suite green apart from markdown-text.test.ts, which fails the same way on unmodified main
here. eslint clean on the touched files. tsc shows the same errors as main, none in these files. Verified
on macOS with local Kokoro + Whisper. Not tested on Windows; @wen0531 tested an earlier revision on Linux
(comment below).

Fixes #51265.

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) tool/tts Text-to-speech and transcription P3 Low — cosmetic, nice to have labels Jun 28, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: LGTM

Removes voice mic re-arm delay and gap between TTS sentences in the desktop voice conversation. Key changes: (1) keeps mic stream alive between turns instead of releasing/re-requesting, (2) pre-synthesizes upcoming TTS chunks for gapless playback, (3) re-arms mic immediately after speaking instead of waiting for re-render.

Changes

  • apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts: Soft cleanup (keep stream) vs full release, AudioContext rebuilt per turn
  • apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts: peekUpcomingChunks lookahead, prefetchSpeechText pre-synth, immediate mic re-arm
  • apps/desktop/src/lib/voice-playback.ts: Speech prefetch cache (Map with size bound)

Looks Good

  • Keeping stream alive eliminates the costly getUserMedia round-trip
  • AudioContext intentionally rebuilt per turn (browser suspends idle contexts)
  • Prefetch cache bounded at 6 entries to prevent accumulation
  • 600ms timeout on canplaythrough prevents blocking on flaky codec decode
  • Immediate startListening() eliminates re-render lag

Reviewed by Hermes Agent

@wen0531

wen0531 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Linux 测试结果

之前 Cherry-pick 了 1e60c5133 到 main,在 Linux (Ubuntu 24.04, X11) 上重新打包后做了 5 轮语音对话测试:

测试环境

  • 系统: Ubuntu 24.04.4 LTS, X11
  • 硬件: RTX 3070, 32GB RAM
  • 后端: MiMo ASR (mimo-v2.5-asr) + MiMo TTS (mimo-v2.5-tts),command provider 走脚本
  • 桌面版: 重新 electron-builder 打包

结果

轮次 麦克风响应 备注
第 1 轮 秒开
第 2 轮 秒开
第 3 轮 秒开
第 4 轮 秒开
第 5 轮 秒开

说明

修复前的 mic re-arm 延迟(3-4 秒甚至 8-9 秒)在 5 轮中均未复现。两句之间的间隙也正常,无停顿感。

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for tracing this through the desktop voice loop. The reported behavior is still present on current main: use-mic-recorder.ts:81-90 stops the stream on every normal cleanup, and use-voice-conversation.ts:357-364 returns after setting pendingStartRef instead of reaching the re-arm at :382-384.

Problems

  • The PR changes three stateful media paths but adds no regression tests. In particular, the idle-to-idle completion path and the soft-vs-full stream-release lifecycle need coverage.

Suggested changes

  • Add focused hook/media mocks covering direct final-response re-arm plus stream retention on normal stop and release on cancel/error/unmount.
  • Integrate the separate playback-state guard from open fix(desktop): guard voice loop during playback #55581 when salvaging; it changes the same hook and includes a regression test for starting the microphone only after shared playback is idle.

This is an automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 15, 2026
Every normal cleanup stopped the MediaStream tracks, so each turn re-ran
`requestMicrophoneAccess` + `getUserMedia`. That round-trip is most of the pause
after the assistant stops speaking.

`stop()` now drops the MediaRecorder and the audio graph but keeps the stream
open, and the next turn arms off it. Cancel, unmount and a recorder error still
release the tracks, and a retained stream whose track has ended is re-requested
rather than reused. The AudioContext is still rebuilt per turn on purpose: the
browser suspends an idle context, after which the analyser reads silence and
speech detection stops without a word.

Five contracts cover the split. The retention one fails on main; the four release
ones pass there, so they hold the existing teardown still.

Consequence worth knowing: the OS microphone indicator now stays lit for the
whole conversation instead of flickering off between turns, and a device change
takes effect on the next release rather than the next turn.
Once the whole reply had been spoken the effect set `pendingStartRef`, called
`setStatus('idle')` and returned. `status` is already 'idle' at that point,
because the last speak() set it in its finally, so the setState is a no-op, React
bails out, and the re-render that would run this effect again never happens. The
restart guard at the bottom is only three lines below, and nothing reaches it
until some unrelated render arrives. That was the 4-8s wait before recording
resumed, and the variance is other components re-rendering.

Dropping the early return lets the same pass fall through to the guard, which
already checks `busy` and `status` before starting.

NousResearch#38411 diagnosed this first, and independently. Its fix removes the same return
but also removes the `setStatus('idle')` calls, which the sibling paths rely on:
where `status` is still 'thinking' the setState is a real change, and the
re-render it triggers is what reaches the guard. Removing it there strands the
hook in 'thinking' with a dead mic, so this keeps both calls and only drops the
one return that is unreachable-by-design.

Three contracts, driven through the real path rather than by setting refs. The
first fails on main. The two covering the sibling paths pass on main and are here
to stop a fix trading one stall for another.
Two things the splitter got wrong, both audible on a local backend.

A short leading fragment was voiced on its own. A list marker like "1." became
its own synth call, which sounds clipped and pauses before the item it
introduces. Chunks now accumulate to MIN_SPEAK_CHARS, so a marker rides along
with its text.

Sentences ending in a number were split mid-number. The buffer here is still
growing, so a `.` at its end doesn't mean the sentence ended: "Revenue grew to
1." was spoken as "Revenue grew to one", with "2 billion." following as its own
chunk. `!?。!?` and a `.` after a non-digit may still end at the buffer end,
while a `.` after a digit waits for whitespace to prove the number is finished.
That costs one character of latency on "grew 12." and keeps "1.2" whole.

The splitter is pure, so it moves to module scope, out of the hook body where it
made the loop effect depend on two functions rebuilt every render.

The contracts stream the reply in a character at a time and collect what would be
spoken, because passing a finished string hides both bugs: a decimal is only at
risk while the text is still arriving.
@laurinaitis
laurinaitis force-pushed the fix/desktop-voice-latency branch from 94d2e59 to f58f229 Compare July 17, 2026 09:18
@laurinaitis laurinaitis changed the title fix(desktop): remove voice mic re-arm delay and gap between TTS sentences fix(desktop): remove the voice mic re-arm delay Jul 17, 2026
@laurinaitis

Copy link
Copy Markdown
Contributor Author

Tests are in, and the reconciliation went further than expected: this PR now stops at the microphone.
voice-playback.ts is untouched and the prefetch cache has gone to #44308, which was already doing that
work.

#44308. I should have found it before filing and didn't. It's been open since 11 June rewriting
playSpeechText for the same latency goal, and its chunk/prefetch direction has your endorsement from the
07-14 review. Rather than land a second rewrite of that function, I've dropped mine and offered the two
pieces that looked additive over there: their prepared map is created inside playSpeechText, so the
voice loop (which calls it once per sentence as the reply streams) can never reach it, and caching the synth
promise leaves the decode at play time. Both are their call to take or leave.

What's left here is the part nobody else touches: the mic. Three commits.

The re-arm. Once the reply is fully spoken the effect set pendingStartRef, called setStatus('idle') and
returned. status is already 'idle' there, since the last speak() set it, so React bails out and the
re-render that would run the effect again never arrives. The restart guard sits three lines below and
nothing reaches it until an unrelated render happens, which is the 4-8s wait and its variance. Dropping the
early return lets the same pass fall through to the guard.

The stream. Every cleanup stopped the MediaStream tracks, so each turn re-ran requestMicrophoneAccess and
getUserMedia. A normal stop now keeps the stream and the next turn arms off it; cancel, unmount and
recorder errors still release it, and a retained stream whose track has ended is re-requested.

The splitter, which is where the audible pauses came from: a bare list marker was voiced as its own synth
call, and a sentence ending in a number was split mid-number, so "Revenue grew to 1.2 billion" came out as
"Revenue grew to one" and then "2 billion."

#55581, and the answer isn't the one I expected. Its startListening() reordering fixes a real thing:
clearing pendingStartRef before the guards means a call that bails swallows the re-arm request. I took it,
then found my own mechanism was the only reason I needed it. I had been calling startListening() directly
from the completion path, which consumed the flag while status hadn't committed yet. Falling through to
the existing guard instead makes the direct call unnecessary, and with it gone the ordering isn't
load-bearing for this PR. So it stays @enjoylife1243's, along with the isVoicePlaybackActive() guards and
the rejected-submit propagation. It's still worth landing there: the flag swallow it fixes is reachable on
main through start() while busy. One practical note if both move: #55581 adds
use-voice-conversation.test.tsx at the same path as mine.

#38411 got to the re-arm three weeks before me and diagnosed it correctly. Its fix removes the same
early return, and also removes the setStatus('idle') calls, which the sibling paths need: where status
is still 'thinking' that setState is a real change, and the re-render is what reaches the guard. Removing
it there strands the hook in 'thinking' with a dead mic. I've put the evidence and a two-line correction
on that PR, and offered it my tests. If it lands first I'll drop my re-arm commit and rebase; if it stays
quiet, this carries the fix with credit to @rdnot.

Tests. 19 contracts: the re-arm paths (3), the mic stream lifecycle (5), and the splitter (11). The
first two files run against current main unchanged and 2 fail there: the already-idle re-arm, and stream
retention across a normal stop. The other 6 pass on main and are there to hold the existing teardown and
sibling behaviour still. The splitter's 11 import extractChunk, which this PR adds, so they can't be
pointed at main as-is; they stream the reply in a character at a time, because passing a finished string
hides the decimal bug entirely.

Two things worth a sanity check. Keeping the stream open means the OS microphone indicator stays lit for the
whole conversation instead of flickering off between turns, and a device change takes effect on the next
release rather than the next turn. Every exit path still releases, with a contract on each.

Rebased onto 0f102fa4; it was 683 behind, and the old head predates enough of this file that I've rebuilt
the branch rather than rebased it. eslint clean on the touched files. Desktop UI suite passes except
markdown-text.test.ts, which fails identically on stock main here. I haven't run a clean npm install
against this rebase, so treat the build as unverified. Behaviour was checked on macOS with local Kokoro and
Whisper; @wen0531's Linux pass in the thread above was on the pre-rebase revision.

@alt-glitch alt-glitch removed the sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades label Jul 17, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #38411: both address the mic re-arm path, while this PR also adds separate MediaStream reuse and streamed speech chunking. It is therefore a broader competing repair rather than a duplicate. Playback-specific latency work is tracked separately in #44308.

@teknium1

Copy link
Copy Markdown
Contributor

Closing as superseded by the merged voice playback/barge-in rework #69511 (93e9061f15, merged 2026-07-22) plus #69602: the desktop voice stack was rewritten around speech-stream sessions with barge-in capture and native resume-listening, and the code this PR patches no longer exists. The re-arm delay this PR removes no longer exists after the rewrite (and blanket removal would now defeat the feedback-loop guard) — thanks for flagging the latency.

If anything in this area still misbehaves on current main, please open a fresh PR against the new stack.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users tool/tts Text-to-speech and transcription type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TTS playback completion delays microphone activation by 7-8 seconds

5 participants