Skip to content

fix(voice): playback-aware muting when \--speak\ and \--voice\ run to… - #349

Open
Adityakk9031 wants to merge 2 commits into
robocurve:mainfrom
Adityakk9031:feature/332-playback-aware-muting
Open

fix(voice): playback-aware muting when \--speak\ and \--voice\ run to…#349
Adityakk9031 wants to merge 2 commits into
robocurve:mainfrom
Adityakk9031:feature/332-playback-aware-muting

Conversation

@Adityakk9031

Copy link
Copy Markdown
Contributor

close #332

Description

With both --speak and --voice enabled, the microphone can capture the TTS playback and feed it back into the operator-message channel. Since the energy gate and Whisper hallucination filters are tuned to accept clean speech, they will not filter out synthesized narration.

This PR implements a lightweight, thread-safe synchronization mechanism inside the inspect-robots-voice plugin to mute input audio chunks captured during active TTS playback.

Changes

  • inspect-robots-voice (version 0.5.2):
    • Added a thread-safe registry (_active_speakers set guarded by _speakers_lock) in _capture.py to track active audio synthesis / playback sinks.
    • Modified SpeakerSink._worker in _speaker.py to register itself to _active_speakers immediately before chunked audio output (playback.write) and reliably discard/unregister itself when done via a try...finally block.
    • Updated MicrophoneCapture._callback in _capture.py to check _active_speakers. If playback is active, the block is zero-filled (muted) before entering the queue, preventing speaker echo while keeping the adaptive energy gate statistics stable.
    • Bumped the plugin version to 0.5.2 and updated related factory test constraints.

Verification & Testing

  • Added test_playback_aware_muting_callback in tests/test_capture.py to verify that input blocks are zero-filled when a speaker registry entry is active and preserved when empty.
  • Added test_speaker_active_playback_registration in tests/test_speaker.py to verify that the sink registration correctly brackets chunked writes.
  • Verified that all 198 voice plugin tests and all 1391 core tests pass successfully.
  • Added changelog details to CHANGELOG.md under ### Fixed.

…gether

Introduce a thread-safe registry (_active_speakers guarded by _speakers_lock)
in _capture.py to track active audio synthesis / playback sinks.
Modify SpeakerSink._worker in _speaker.py to register itself to _active_speakers
during active playback chunk writes.
Update MicrophoneCapture._callback in _capture.py to zero-fill captured blocks
if a speaker is active, preventing TTS feedback echo.
Bump plugin version to 0.5.2 and add unit tests.

Closes robocurve#332
@Adityakk9031

Copy link
Copy Markdown
Contributor Author

@jeqcho have a look

@jeqcho jeqcho left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @Adityakk9031 for taking on #332 — the mechanism you built is the right shape (a small shared signal between SpeakerSink and MicrophoneCapture, exactly what the issue asked for), the try/finally bracketing is careful, and the tests for registration and muting are clear. Two behavioral issues need addressing before this fixes the bug end to end, though:

1. Zero-filling collapses the adaptive noise floor (blocking)

The muted blocks still flow into EnergyGate.push, and a zero block has RMS 0. While the gate is closed, _push_closed in plugins/inspect-robots-voice/src/inspect_robots_voice/_segmenter.py runs

self._noise_floor = (1.0 - self.ema_alpha) * self._noise_floor + self.ema_alpha * rms

so each 0.1s zero block multiplies the noise floor by 0.95. A 10-second narration is ~100 blocks, shrinking the floor ~170x. When the mute lifts, ordinary room noise is far above the near-zero threshold, so the gate opens immediately — and since the floor only re-adapts while the gate is closed, it can stay open until the 30s max_utterance_s cap and ship a long noise "utterance" to the transcriber. That's the very hallucination path this PR is meant to close, so the description's claim that zeroing "keeps the adaptive energy gate statistics stable" is unfortunately inverted. Options, roughly in order of preference:

  • Drop muted blocks in MicrophoneCapture._callback (_capture.py ~line 138) instead of enqueueing zeros — this matches the issue's "discard captured segments" wording and freezes the gate state during playback; or
  • keep zero-filling but have the segmenter skip noise adaptation for muted blocks; or
  • reset() the gate when playback ends.

2. Echo tail leaks after unmute (blocking, smaller)

_active_speakers.discard(self) in _speaker.py fires as soon as the last blocking playback.write returns, but output-stream latency plus room acoustics plus the 0.1s input blocksize mean the tail of the narration is still arriving at the mic for a few hundred milliseconds afterward — and there are brief unmuted gaps between queued utterances (register/discard is per queue item). A short mute hangover would cover both: e.g. record last_playback_end = time.monotonic() on discard and treat capture as muted while a speaker is active or within ~0.3–0.5s of the last one. That would also let you add the test the issue asks for (a segment overlapping a playback window is dropped, including its tail).

Smaller points

  • _speaker.py (~line 312): the from inspect_robots_voice._capture import ... sits inside the worker loop and re-executes every iteration. There's no import cycle (_capture doesn't import _speaker), so please hoist it to the module top.
  • uv.lock: ~100 lines of environment-marker churn unrelated to the version bump — looks like it was regenerated with a different uv version. Could you re-lock so only the inspect-robots-voice version entry changes?
  • tests/test_tts.py: the re.escape fix is sensible (Windows paths, I'd guess) but unrelated to this PR — worth a mention in the description or splitting out so the changelog story stays clean.
  • Taking a threading.Lock inside the PortAudio callback is fine here given both critical sections are tiny, so no change needed — just noting it was considered.

Really appreciate the thorough write-up and the passing CI matrix — with the drop-instead-of-zero change and a small unmute hangover this will land nicely.

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.

voice: playback-aware muting when --speak and --voice run together

2 participants