fix(voice): race slow streaming asr finalize

This commit is contained in:
Yera All
2026-04-19 01:06:03 +05:00
parent a44a1b97a1
commit 77df41bce0
4 changed files with 317 additions and 34 deletions
+13
View File
@@ -1546,6 +1546,19 @@ def test_voice_postprocess_reply_rewrites_midcall_greeting_with_followup_questio
assert "о чем именно" not in normalized
def test_voice_reply_with_name_strips_midcall_greeting_and_does_not_double_prefix():
reply_text = voice_module._voice_reply_with_name(
"ru",
"Здравствуйте, Ернур! Чтобы уточнить график работы, назовите город или филиал.",
"Ернур",
)
normalized = reply_text.lower()
assert reply_text.startswith("Ернур, Чтобы уточнить")
assert normalized.count("ернур") == 1
assert "здравствуйте" not in normalized
def test_voice_postprocess_reply_reuses_active_topic_after_frustration_turn():
reply_text = voice_module._voice_postprocess_reply_text(
language="ru",
+121
View File
@@ -2240,6 +2240,127 @@ def test_media_runtime_voice_v2_uses_partial_as_final_when_streaming_finalize_fa
assert final_turns[0][1]["transcript_source"] == "streaming_partial_after_finalize_failure"
def test_media_runtime_races_batch_asr_when_streaming_finalize_is_slow():
class _CountingASRProvider(ASRProvider):
name = "counting-asr"
def __init__(self) -> None:
self.transcribe_count = 0
def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
assert audio_bytes
self.transcribe_count += 1
return ASRTranscription(text="batch schedule", language=language_hint or "ru", confidence=0.9)
class _SlowStreamingProvider(StreamingASRProvider):
name = "slow-streaming"
supports_streaming = True
def __init__(self) -> None:
self.finalize_started = False
self.finalize_done = False
def finalize(self, stream_id: str) -> ASRTranscription:
assert stream_id == "stream-1"
self.finalize_started = True
time.sleep(0.35)
self.finalize_done = True
return ASRTranscription(text="streaming schedule", language="ru", confidence=0.9)
def close_stream(self, stream_id: str) -> None:
assert stream_id == "stream-1"
asr_provider = _CountingASRProvider()
streaming_provider = _SlowStreamingProvider()
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=asr_provider,
streaming_asr_provider=streaming_provider,
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="schedule",
reply_text="reply",
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,
)
runtime._streaming_finalize_race_grace_seconds = 0.01
async def _scenario() -> tuple[ASRTranscription, str, float]:
actor = MediaActor(
registration=MediaRegistration(
voice_session_id="avs_media_runtime_asr_race",
call_id="call_media_runtime_asr_race",
interaction_id="int_media_runtime_asr_race",
ai_session_id="ais_media_runtime_asr_race",
language="ru",
media_uuid=str(uuid.uuid4()),
queue_code="voice_lab_ai",
queue_id="que_voice_lab_ai",
agent_profile="voice_support",
voice_v2_enabled=True,
voice_v2_ack_mode="immediate_short",
voice_v2_streaming_tts=True,
voice_v2_partial_asr=True,
voice_v2_duplex=True,
voice_v2_streaming_asr_backend="local_sidecar",
),
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,
)
pcm_frame = (1000).to_bytes(2, "little", signed=True) * 160
started = time.monotonic()
result = await runtime._finalize_turn_transcription(
actor,
pcm_bytes=pcm_frame * 40,
partial_transcript="",
detached_stream=("stream-1", None, None, False),
)
elapsed = time.monotonic() - started
for _ in range(50):
if streaming_provider.finalize_done:
break
await asyncio.sleep(0.01)
return result[0], result[1], elapsed
transcription, source, elapsed = asyncio.run(_scenario())
assert streaming_provider.finalize_started is True
assert asr_provider.transcribe_count == 1
assert transcription.text == "batch schedule"
assert source == "batch_race_before_streaming_finalize"
assert elapsed < 0.2
def test_media_runtime_merges_thinking_continuation_into_current_utterance():
runtime = AudioSocketMediaRuntime(
enabled=True,