diff --git a/services/ai_voice_runtime_service/media_runtime.py b/services/ai_voice_runtime_service/media_runtime.py index 6934062..84e73e3 100644 --- a/services/ai_voice_runtime_service/media_runtime.py +++ b/services/ai_voice_runtime_service/media_runtime.py @@ -563,12 +563,20 @@ class AudioSocketMediaRuntime: def _buffer_thinking_continuation(self, actor: MediaActor, pcm_frame: bytes, *, is_speech: bool, now: float) -> None: if not is_speech and not actor.thinking_continuation_pcm: return + if ( + not is_speech + and actor.thinking_continuation_deadline_monotonic > 0 + and now > actor.thinking_continuation_deadline_monotonic + ): + return remaining = self._thinking_continuation_max_bytes - len(actor.thinking_continuation_pcm) if remaining <= 0: - actor.thinking_continuation_deadline_monotonic = now + self._thinking_continuation_grace_seconds + if is_speech: + actor.thinking_continuation_deadline_monotonic = now + self._thinking_continuation_grace_seconds return actor.thinking_continuation_pcm.extend(pcm_frame[:remaining]) - actor.thinking_continuation_deadline_monotonic = now + self._thinking_continuation_grace_seconds + if is_speech: + actor.thinking_continuation_deadline_monotonic = now + self._thinking_continuation_grace_seconds async def _extend_with_thinking_continuation(self, actor: MediaActor, pcm_bytes: bytes) -> bytes: deadline = time.monotonic() + self._thinking_continuation_grace_seconds diff --git a/tests/test_ai_voice_media_runtime.py b/tests/test_ai_voice_media_runtime.py index fb5d19c..ad51643 100644 --- a/tests/test_ai_voice_media_runtime.py +++ b/tests/test_ai_voice_media_runtime.py @@ -1694,6 +1694,81 @@ def test_media_runtime_merges_thinking_continuation_into_current_utterance(): assert merged.endswith(continuation_pcm + continuation_pcm) +def test_media_runtime_thinking_continuation_is_not_kept_alive_by_silence(): + runtime = AudioSocketMediaRuntime( + enabled=True, + host="127.0.0.1", + port=0, + frame_ms=20, + idle_timeout_seconds=2.0, + registration_wait_timeout_seconds=0.5, + min_speech_ms=40, + trailing_silence_ms=40, + max_turn_ms=2000, + asr_provider=_StubASRProvider(), + streaming_asr_provider=StreamingASRProvider(), + tts_provider=_StubTTSProvider(), + load_registration_by_media_uuid=lambda value: None, + mark_media_connected=lambda session_id, value: None, + mark_media_ended=lambda session_id, reason: None, + touch_media_frame=lambda session_id: None, + set_state=lambda session_id, state, handoff_reason, metadata: None, + get_pending_greeting=lambda session_id: None, + mark_reply_delivered=lambda session_id, text, is_greeting: None, + plan_reply=lambda session_id, text, metadata, kind: None, + process_turn=lambda session_id, transcript_text, language, barge_in, metadata: VoiceAITurnDecisionOut( + language=language or "ru", + intent="clarification", + reply_text="Подскажите подробнее, пожалуйста.", + confidence=0.8, + needs_handoff=False, + handoff_reason=None, + case_action="keep_open", + kb_refs=[], + summary_text="reply ready", + model="stub-voice", + latency_ms=1, + status="active", + ), + request_handoff=lambda session_id, customer_request_text, decision: None, + handle_media_error=lambda session_id, message, metadata: None, + ) + base_pcm = (1000).to_bytes(2, "little", signed=True) * 160 + continuation_pcm = (900).to_bytes(2, "little", signed=True) * 160 + silence_frame = b"\x00\x00" * 160 + + async def _scenario() -> bytes: + actor = MediaActor( + registration=MediaRegistration( + voice_session_id="avs_media_runtime_thinking_silence_tail", + call_id="call_media_runtime_thinking_silence_tail", + interaction_id="int_media_runtime_thinking_silence_tail", + ai_session_id="ais_media_runtime_thinking_silence_tail", + language="ru", + media_uuid=str(uuid.uuid4()), + ), + reader=asyncio.StreamReader(), + writer=None, # type: ignore[arg-type] + vad=EnergyVAD(frame_ms=20, min_speech_ms=40, trailing_silence_ms=40, max_turn_ms=2000), + frame_ms=20, + frame_bytes=320, + state="thinking", + ) + merge_task = asyncio.create_task(runtime._extend_with_thinking_continuation(actor, base_pcm)) + await runtime._handle_pcm(actor, continuation_pcm) + for _ in range(40): + await runtime._handle_pcm(actor, silence_frame) + await asyncio.sleep(0.02) + merged = await asyncio.wait_for(merge_task, timeout=0.2) + assert actor.thinking_continuation_pcm == bytearray() + return merged + + merged = asyncio.run(_scenario()) + + assert len(merged) > len(base_pcm) + assert continuation_pcm in merged + + def _legacy_test_media_runtime_voice_v2_uses_streaming_sidecar_for_partial_and_final_asr(): registrations: dict[str, MediaRegistration] = {} reply_starts: list[tuple[str, str | None, float]] = []