Keep the wake gate open while the microphone is still hearing speech#217
Merged
Conversation
Reported by the bridge side as a 12 second wake-gate race. Two separate collisions, both now closed. The gate closed mid-utterance. BridgeWakeGate closes gateOpenMs after the last speech it was told about, and closing force-completes the active turn by sending utterance_end. It normally learns about speech from bridge UserSpeaking events, which only arrive after host STT. During the firmware's own dedicated capture nothing told it anything, so the gate closed 6 s after the wake word while the microphone kept recording for up to 13 s. Every chunk after that hit an uplink with no active turn. That is also the source of the audio_uplink_not_active errors that had been counting up all along. The capture loop now reports speech to the gate from its own voice-activity endpoint, using the existing UserSpeaking contract rather than a new one. Renewing only on real speech preserves the privacy semantics exactly: the gate still closes gateOpenMs after the speaker actually stops. Measured, gate lifetime against a 10 s utterance: before gate closed at 6000 ms, cutting off four seconds after gate tracked the speech and closed 6 s after it ended The privacy guard raced the capture ceiling. kBridgeWakeGateMaxTurnMs was 12000, exactly equal to the voice-activity endpoint's own ceiling, so which fired first was undefined. It is now 15000, above the 13000 ms dedicated capture ceiling, and main.cpp holds a static_assert tying the two together so they cannot drift apart again. The guard still bounds streaming. A turn is capped 15 s after it starts no matter how long the speaker continues, and the new test asserts that as well as the long-utterance case, so renewal cannot quietly become unbounded. The full native suite now runs locally against the ArduinoJson copy PlatformIO already fetches, rather than only the extracted subsets used earlier: 289 of 289 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reported by the bridge side as a 12 second wake-gate race. Two separate collisions, both closed.
1. The gate closed mid-utterance
BridgeWakeGateclosesgateOpenMsafter the last speech it was told about, and closing force-completes the active turn — it sendsutterance_end:It learns about speech from bridge
UserSpeakingevents, which only arrive after host STT. During the firmware's own dedicated capture nothing told it anything, so the gate closed 6 s after the wake word while the microphone kept recording for up to 13 s.Every chunk after that hit an uplink with no active turn. This is also the source of the
audio_uplink_not_activeerrors that had been counting up (bridge_uplink_errors: 80against 2 turns on the last boot) — I'd previously guessed at trailing chunks afterutterance_end, which was right about the mechanism but not the cause.The capture loop now reports speech to the gate from its own voice-activity endpoint, using the existing
UserSpeakingcontract rather than inventing a new one. Renewing only on real speech preserves the privacy semantics exactly: the gate still closesgateOpenMsafter the speaker actually stops.2. The privacy guard raced the capture ceiling
kBridgeWakeGateMaxTurnMswas 12000, exactly equal to the voice-activity endpoint's own ceiling. Which fired first was undefined ordering.Now 15000, above the 13000 ms dedicated capture ceiling, with a
static_assertinmain.cpptying them together so they can't drift apart again:Cascade is now: endpoint ends on trailing silence → endpoint ceiling 12 s → chunk ceiling 13 s → privacy guard 15 s.
The guard still bounds streaming
Renewal must not make capture unbounded. A turn is still capped 15 s after it starts however long the speaker continues, and the new test asserts that alongside the long-utterance case.
Worth recording: my first version of this test asserted the gate would stay open to 15.9 s, which would have meant streaming past the privacy bound. The test failed and caught it. The second version failed too — the turn aborted rather than completed because the test wasn't draining the socket the way the real session loop does.
Verification
The full native suite now runs locally against the ArduinoJson copy PlatformIO already fetches, rather than only the extracted subsets I'd been using: 289 of 289 pass.
test_bridge_wake_gate_survives_a_long_utterance: gate stays open across 10 s of speech, one turn start, no premature completion, and the privacy guard still caps atmaxTurnMstest_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling: pins the orderingtest_bridge_wake_gate_renews_on_speech_and_expiresunchanged and passing — the fix uses the contract that test documents rather than changing it🤖 Generated with Claude Code