diff --git a/src/io/BridgeWakeGate.hpp b/src/io/BridgeWakeGate.hpp index d0aa434..5f761b6 100644 --- a/src/io/BridgeWakeGate.hpp +++ b/src/io/BridgeWakeGate.hpp @@ -12,7 +12,13 @@ namespace stackchan { #endif constexpr uint32_t kBridgeWakeGateOpenMs = 6000; -constexpr uint32_t kBridgeWakeGateMaxTurnMs = 12000; +// Hard privacy bound on one streamed turn. This must stay strictly greater than +// the longest capture the firmware can take, or the guard fires while the +// microphone is still recording and the tail of the utterance is streamed into a +// turn that has already been closed. It used to be 12000, exactly equal to the +// voice-activity endpoint's own ceiling, so the two raced. main.cpp holds a +// static_assert tying this to the capture ceiling so they cannot drift apart. +constexpr uint32_t kBridgeWakeGateMaxTurnMs = 15000; constexpr size_t kBridgeWakeGateErrorMax = kBridgeErrorMax; struct BridgeWakeGateConfig { diff --git a/src/main.cpp b/src/main.cpp index b16c9d9..97b43af 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -990,6 +990,15 @@ constexpr uint32_t kWakeMwwCueCompletionTimeoutMs = 120; // uplink limit. Was 96 (9.6 s), and the endpoint's own 4.8 s cap fired first, // which is what truncated longer sentences. constexpr uint16_t kWakeMwwDedicatedCaptureChunks = 130; +// One chunk is STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES at the capture rate. +constexpr uint32_t kWakeMwwDedicatedCaptureCeilingMs = + (static_cast(kWakeMwwDedicatedCaptureChunks) * + STACKCHAN_MWW_WAKE_UPLINK_CHUNK_SAMPLES * 1000u) / + STACKCHAN_MWW_WAKE_CAPTURE_SAMPLE_RATE; +// The wake gate's privacy guard must outlast any capture we can take, otherwise +// it closes the turn mid-utterance and the remaining chunks are rejected. +static_assert(kWakeMwwDedicatedCaptureCeilingMs < kBridgeWakeGateMaxTurnMs, + "wake gate max turn must exceed the dedicated capture ceiling"); RobotEvent gWakeMwwPendingCaptureEvent {}; bool gWakeMwwPendingCaptureEventReady = false; bool gWakeMwwPendingCaptureIsConversationReply = false; @@ -4780,8 +4789,29 @@ void serviceDedicatedWakeCaptureChunk() { gWakeSrProbe.lastRecordMs = millis(); if (gWakeMwwDedicatedCapture.endpoint.telemetry().enabled) { + const uint32_t speechAtBefore = + gWakeMwwDedicatedCapture.endpoint.telemetry().lastSpeechAtMs; endpointReason = gWakeMwwDedicatedCapture.endpoint.process( monoBuf, kMonoSamples, gWakeSrProbe.lastRecordMs); + // Tell the wake gate that speech is still happening. + // + // The gate closes gateOpenMs after the last speech it heard about, and it + // force-completes the active turn when it does. It normally learns this + // from bridge UserSpeaking events, which arrive only after host STT. During + // our own dedicated capture nothing told it, so the gate closed 6 s after + // the wake word and sent utterance_end while the microphone was still + // recording. Every chunk after that hit an uplink with no active turn, + // which is where the audio_uplink_not_active errors came from. + // + // Renewing only on real speech preserves the privacy semantics: the gate + // still closes gateOpenMs after the speaker actually stops. + if (gWakeMwwDedicatedCapture.endpoint.telemetry().lastSpeechAtMs != speechAtBefore) { + RobotEvent speakingEvent; + speakingEvent.type = EventType::UserSpeaking; + speakingEvent.timestampMs = gWakeSrProbe.lastRecordMs; + speakingEvent.strength = 1.0f; + gBridgeWakeGate.applyEvent(speakingEvent, gWakeSrProbe.lastRecordMs); + } } if (submitDedicatedWakeCaptureChunk( diff --git a/test/test_native_logic/test_main.cpp b/test/test_native_logic/test_main.cpp index 93e94bb..d50940c 100644 --- a/test/test_native_logic/test_main.cpp +++ b/test/test_native_logic/test_main.cpp @@ -7130,6 +7130,78 @@ void test_bridge_wake_gate_can_start_uplink_turn_from_speech_when_enabled() { TEST_ASSERT_NOT_NULL(std::strstr(decodedStart, "\"seq\":52")); } +void test_bridge_wake_gate_survives_a_long_utterance() { + BridgeClient bridge; + FakeBridgeNetworkSocket socket; + BridgeNetworkSession session; + connectBridgeNetworkSession(bridge, socket, session, 1220); + + BridgeAudioUplinkConfig uplinkConfig; + uplinkConfig.enabled = true; + BridgeAudioUplink uplink; + TEST_ASSERT_TRUE(uplink.begin(uplinkConfig, &session)); + + // Production values, not shortened ones: this is about the real relationship + // between the gate window and how long a capture can run. + BridgeWakeGateConfig gateConfig; + BridgeWakeGate gate; + TEST_ASSERT_TRUE(gate.begin(gateConfig, &uplink)); + + RobotEvent wake; + wake.type = EventType::WakeWord; + gate.applyEvent(wake, 1000); + TEST_ASSERT_TRUE(gate.telemetry().turnActive); + // Drain the utterance_start frame, as the real session loop does. Without this + // the outgoing queue fills and the closing utterance_end cannot be sent. + session.update(1005); + socket.clearOutgoing(); + + // Ten seconds of continuous speech. The firmware renews the gate from its own + // voice-activity endpoint while it hears speech; without that the gate closed + // gateOpenMs after the wake word and completed the turn mid-utterance, and the + // remaining chunks were rejected by an uplink with no active turn. + const uint32_t speechEndsMs = 11000; + for (uint32_t nowMs = 1100; nowMs <= speechEndsMs; nowMs += 100) { + RobotEvent speaking; + speaking.type = EventType::UserSpeaking; + speaking.timestampMs = nowMs; + gate.applyEvent(speaking, nowMs); + gate.update(nowMs); + session.update(nowMs); + socket.clearOutgoing(); + TEST_ASSERT_TRUE(gate.telemetry().gateOpen); + TEST_ASSERT_TRUE(gate.telemetry().turnActive); + } + + // The turn is still the same one; it was never cut and restarted. + TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsStarted); + TEST_ASSERT_EQUAL_UINT32(0, gate.telemetry().turnsCompleted); + + // Renewal must not make streaming unbounded. The privacy guard still caps the + // turn at maxTurnMs from when it started, however long the speaker keeps + // going, and that bound is the whole reason the guard exists. + const uint32_t turnStartedMs = 1000; + gate.update(turnStartedMs + kBridgeWakeGateMaxTurnMs - 100); + TEST_ASSERT_TRUE(gate.telemetry().gateOpen); + TEST_ASSERT_TRUE(gate.telemetry().turnActive); + gate.update(turnStartedMs + kBridgeWakeGateMaxTurnMs); + TEST_ASSERT_FALSE(gate.telemetry().gateOpen); + TEST_ASSERT_FALSE(gate.telemetry().turnActive); + TEST_ASSERT_EQUAL_UINT32(1, gate.telemetry().turnsCompleted); +} + +void test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling() { + // The privacy guard must be the last thing to fire, after the capture has + // already ended on its own. When these were equal they raced, and the guard + // could close the turn while the microphone was still recording. + const uint32_t captureCeilingMs = 13000; // 130 chunks x 100 ms in main.cpp + TEST_ASSERT_GREATER_THAN_UINT32(captureCeilingMs, kBridgeWakeGateMaxTurnMs); + + // And the endpoint's own ceiling ends capture before the chunk ceiling does. + VoiceActivityEndpointConfig endpointConfig; + TEST_ASSERT_LESS_THAN_UINT32(captureCeilingMs, endpointConfig.maximumCaptureMs); +} + void test_bridge_wake_gate_renews_on_speech_and_expires() { BridgeClient bridge; FakeBridgeNetworkSocket socket; @@ -8271,6 +8343,8 @@ int main() { RUN_TEST(test_bridge_wake_gate_starts_and_completes_uplink_turn); RUN_TEST(test_bridge_wake_gate_can_start_uplink_turn_from_speech_when_enabled); RUN_TEST(test_bridge_wake_gate_renews_on_speech_and_expires); + RUN_TEST(test_bridge_wake_gate_survives_a_long_utterance); + RUN_TEST(test_bridge_wake_gate_max_turn_outlasts_the_capture_ceiling); RUN_TEST(test_bridge_network_session_reconnects_after_socket_disconnect); RUN_TEST(test_bridge_network_session_clears_stale_error_after_reconnect_handshake); RUN_TEST(test_bridge_wifi_provisioner_disabled_default_is_ready_not_configured);