Skip to content

Keep the wake gate open while the microphone is still hearing speech#217

Merged
RobVanProd merged 1 commit into
mainfrom
fix/wake-gate-capture-race
Jul 26, 2026
Merged

Keep the wake gate open while the microphone is still hearing speech#217
RobVanProd merged 1 commit into
mainfrom
fix/wake-gate-capture-race

Conversation

@RobVanProd

Copy link
Copy Markdown
Owner

Reported by the bridge side as a 12 second wake-gate race. Two separate collisions, both closed.

1. The gate closed mid-utterance

BridgeWakeGate closes gateOpenMs after the last speech it was told about, and closing force-completes the active turn — it sends utterance_end:

if (telemetry_.gateOpen && nowMs >= telemetry_.closeAtMs) {
  completeTurn(nowMs, "bridge_wake_gate_timeout");
  expireGate(nowMs);
}

It 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. This is also the source of the audio_uplink_not_active errors that had been counting up (bridge_uplink_errors: 80 against 2 turns on the last boot) — I'd previously guessed at trailing chunks after utterance_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 UserSpeaking contract rather than inventing a new one. Renewing only on real speech preserves the privacy semantics exactly: the gate still closes gateOpenMs after the speaker actually stops.

gate lifetime vs a 10 s utterance result
before closed at 6000 ms — cut off four seconds
after tracked the speech, closed 6 s after it ended

2. The privacy guard raced the capture ceiling

kBridgeWakeGateMaxTurnMs was 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_assert in main.cpp tying them together so they can't drift apart again:

static_assert(kWakeMwwDedicatedCaptureCeilingMs < kBridgeWakeGateMaxTurnMs,
              "wake gate max turn must exceed the dedicated capture ceiling");

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.

  • New 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 at maxTurnMs
  • New test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling: pins the ordering
  • Existing test_bridge_wake_gate_renews_on_speech_and_expires unchanged and passing — the fix uses the contract that test documents rather than changing it
  • Builds for ESP32-S3: RAM 56.8%, Flash 43.2%

🤖 Generated with Claude Code

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>
@RobVanProd
RobVanProd merged commit 10b0cc5 into main Jul 26, 2026
15 of 16 checks passed
@RobVanProd
RobVanProd deleted the fix/wake-gate-capture-race branch July 26, 2026 16:15
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.

1 participant