Author SHA1 Message Date
arys fe8598e09f fix: TTS ack-bank served stale voice after voice-config changes; add website/English pronunciation rules
RuntimeConfiguredTTSProvider (the live 'dynamic' TTS provider, resolves
voice from DB-backed config) never overrode cache_fingerprint(), so it
fell back to the base class's empty string. PrebakedAckBank keys its
on-disk cache on that fingerprint, so short filler phrases like
'Секунду' kept serving audio baked with the previous ElevenLabs voice
even after a voice change, while full LLM replies (cached inside the
resolved provider itself, keyed on its own voice id) already used the
new voice — explaining why callers heard two different voices in the
same call. Fix: delegate cache_fingerprint() to the resolved provider.

Also extend the voice delivery_hint so the model transliterates website
addresses and English words/abbreviations into spoken Cyrillic instead
of leaving raw Latin text for the TTS engine to mangle (egov.kz was
coming out as 'эговкз').
2026-08-30 11:39:14 +05:00
didar 9e4c47eddd feat: enhance AudioSocketMediaRuntime to skip filler acks for closing intents and throttle repeated filler acks
deploy / deploy (push) Successful in 30s
2026-08-30 11:30:55 +05:00
arystanbek a013ac95e8 Merge pull request 'fix: voice AI spells out numbers/dates for TTS' (#5) from fix/voice-number-pronunciation into main
deploy / deploy (push) Successful in 31s
2026-08-30 06:22:05 +00:00
4 changed files with 346 additions and 4 deletions
@@ -143,7 +143,12 @@ def operator_system_prompt(*, language: str, channel_label: str, is_voice: bool,
"Short hotline or service numbers (e.g. 1414, 109) must be spelled out the way people say them as a code, "
"grouped and read naturally (\"1414\" as \"четырнадцать четырнадцать\", not \"тысяча четыреста четырнадцать\"). "
"Calendar dates must use the correct spoken grammatical case (\"25 числа\" as \"двадцать пятого числа\", "
"not \"двадцать пять число\"; \"14 марта\" as \"четырнадцатого марта\")."
"not \"двадцать пять число\"; \"14 марта\" as \"четырнадцатого марта\"). "
"Never leave a website address, domain, or English word/abbreviation in raw Latin script — the TTS engine "
"slurs it into gibberish (e.g. \"egov.kz\" comes out as \"эговкз\"). Transliterate it into how a person "
"actually pronounces it aloud, with an explicit pause word for punctuation: write \"egov.kz\" as "
"\"игов точка кэ-зэт\", write \".kz\"/\".com\" as \"точка кэ-зэт\"/\"точка ком\", spell out an acronym or "
"English word phonetically in Cyrillic (\"IT\" as \"ай-ти\", \"email\" as \"имейл\")."
if is_voice
else "The reply should read like a concise message from a live first-line operator."
)
@@ -217,6 +217,10 @@ class AudioSocketMediaRuntime:
self._immediate_ack_min_ms = 700
self._v2_ack_post_gap_seconds = 0.10
self._v1_ack_wait_seconds = 0.6
# Skip a would-be filler if the previous one finished too recently, so rapid
# back-and-forth turns (e.g. a caller spelling out a phone number field by
# field) don't get a filler read before every single fragment.
self._ack_min_repeat_gap_seconds = 2.5
self._partial_poll_interval_seconds = 0.20
# Small startup cushion for streamed TTS playback: absorb ElevenLabs
# network delivery jitter before we start pacing frames out to the
@@ -314,10 +318,32 @@ class AudioSocketMediaRuntime:
return True
return normalized in cls._FINAL_LOW_SIGNAL_PHRASES
_CLOSING_INTENT_PHRASES = (
"до свидания",
"всего доброго",
"хорошего дня",
"хорошего вечера",
"прощайте",
"созвонимся",
"это все спасибо",
"это всё спасибо",
"у меня все спасибо",
"у меня всё спасибо",
"больше вопросов нет",
"вопросов больше нет",
"спасибо за помощь",
"спасибо большое до свидания",
"сау болыңыз",
"келесіге дейін",
"рахмет көп",
)
def _detect_early_intent(self, text: str) -> str:
normalized = self._normalize_intent_text(text)
if not normalized:
return "unknown"
if any(token in normalized for token in self._CLOSING_INTENT_PHRASES):
return "closing"
if any(token in normalized for token in ("оператор", "оператором", "человеком", "менеджер", "сотрудник")):
return "operator_request"
if any(token in normalized for token in ("график", "распис", "время работы", "work schedule", "жұмыс")):
@@ -347,6 +373,8 @@ class AudioSocketMediaRuntime:
@staticmethod
def _ack_kind_for_intent(intent: str) -> str:
if intent == "closing":
return "closing"
if intent == "operator_request":
return "handoff"
if intent in {"schedule", "address", "price", "status", "problem"}:
@@ -406,8 +434,18 @@ class AudioSocketMediaRuntime:
return True
return normalized_intent != "unknown"
def _should_emit_blind_ack(self, actor: MediaActor, pcm_bytes: bytes) -> bool:
def _should_emit_blind_ack(self, actor: MediaActor, pcm_bytes: bytes, partial_transcript: str) -> bool:
"""Duration-only fallback for when no usable partial transcript exists yet.
Must defer to the transcript when one *is* available: otherwise a caller
who already said a recognized filler-answer ("да"/"нет"/"хорошо") still
gets a blind ack just because the audio happened to cross the length
threshold, even though `_should_emit_partial_ack` correctly said no.
"""
del actor
transcript_text = str(partial_transcript or "").strip()
if transcript_text and self._is_low_signal_partial_transcript(transcript_text):
return False
return len(pcm_bytes) >= self._immediate_ack_min_bytes
@staticmethod
@@ -1116,10 +1154,22 @@ class AudioSocketMediaRuntime:
metadata: dict[str, Any],
ack_source: str,
ack_kind: str | None = None,
intent: str | None = None,
) -> None:
if actor.closed or actor.early_ack_started:
return
ack_kind = ack_kind or self._ack_kind_for_intent(actor.partial_intent or "unknown")
effective_intent = str(
intent or actor.stable_partial_intent or actor.partial_intent or "unknown"
).strip() or "unknown"
if effective_intent == "closing":
# The caller is wrapping up; a "thinking" filler right before the
# closing reply reads as robotic, so skip it and go straight to the reply.
return
if actor.last_ack_completed_monotonic and (
time.monotonic() - actor.last_ack_completed_monotonic
) < self._ack_min_repeat_gap_seconds:
return
ack_kind = ack_kind or self._ack_kind_for_intent(effective_intent)
ack_text, style_hints, ack_variant = self._select_ack_payload(
actor,
language=language,
@@ -1620,8 +1670,9 @@ class AudioSocketMediaRuntime:
language=actor.registration.language,
metadata=base_metadata,
ack_source="streaming_partial" if actor.asr_streaming_enabled else "precomputed_partial_asr",
intent=partial_intent,
)
elif self._should_emit_blind_ack(actor, pcm_bytes):
elif self._should_emit_blind_ack(actor, pcm_bytes, partial_transcript):
await self._emit_early_ack(
actor,
language=actor.registration.language,
@@ -114,6 +114,10 @@ class RuntimeConfiguredTTSProvider(TTSProvider):
self._provider_cache[cache_key] = provider
return provider
def cache_fingerprint(self, language: str | None, *, style_hints: dict[str, object] | None = None) -> str:
provider = self._provider_for_language(language)
return f"{provider.name}:{provider.cache_fingerprint(language, style_hints=style_hints)}"
def synthesize(self, text: str, *, language: str | None = None, style_hints: dict[str, object] | None = None):
provider = self._provider_for_language(language)
return provider.synthesize(text, language=language, style_hints=style_hints)
+282
View File
@@ -1249,6 +1249,288 @@ def test_media_runtime_voice_v2_emits_blind_ack_on_first_turn_without_partial_si
assert speak_events[1][0] == "Подскажите подробнее, пожалуйста."
def test_media_runtime_voice_v2_skips_filler_ack_when_caller_says_goodbye():
planned: list[tuple[str, str, str, dict | None]] = []
class _GoodbyeASRProvider(ASRProvider):
name = "goodbye-asr"
def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
assert audio_bytes
return ASRTranscription(text="Спасибо, до свидания", language=language_hint or "ru", confidence=0.9)
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=400,
asr_provider=_GoodbyeASRProvider(),
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: planned.append((session_id, text, kind, metadata)),
process_turn=lambda session_id, transcript_text, language, barge_in, metadata: (
time.sleep(0.25)
or VoiceAITurnDecisionOut(
language=language or "ru",
intent="closing",
reply_text="Хорошо, всего доброго!",
confidence=0.9,
needs_handoff=False,
handoff_reason=None,
case_action="close",
kb_refs=[],
summary_text="call wrapped up",
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,
)
async def _fake_speak_text(
current_actor,
text: str,
*,
is_greeting: bool,
style_hints: dict[str, object] | None = None,
) -> None:
del current_actor, is_greeting, style_hints
await asyncio.sleep(0)
runtime._speak_text = _fake_speak_text # type: ignore[method-assign]
pcm_frame = (1000).to_bytes(2, "little", signed=True) * 160
async def _scenario() -> None:
actor = MediaActor(
registration=MediaRegistration(
voice_session_id="avs_media_runtime_v2_goodbye",
call_id="call_media_runtime_v2_goodbye",
interaction_id="int_media_runtime_v2_goodbye",
ai_session_id="ais_media_runtime_v2_goodbye",
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=False,
),
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=400),
frame_ms=20,
frame_bytes=320,
)
actor.finalized_caller_turn_count = 1
await runtime._process_utterance(actor, pcm_frame, False)
asyncio.run(_scenario())
assert [item[2] for item in planned] == ["reply"]
assert planned[0][1] == "Хорошо, всего доброго!"
def test_media_runtime_voice_v2_throttles_repeated_filler_ack_within_gap():
planned: list[tuple[str, str, str, dict | None]] = []
class _SlowASRProvider(ASRProvider):
name = "slow-asr"
def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription:
assert audio_bytes
return ASRTranscription(text="Хочу узнать график работы", language=language_hint or "ru", confidence=0.9)
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=400,
asr_provider=_SlowASRProvider(),
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: planned.append((session_id, text, kind, metadata)),
process_turn=lambda session_id, transcript_text, language, barge_in, metadata: (
time.sleep(0.25)
or VoiceAITurnDecisionOut(
language=language or "ru",
intent="clarification",
reply_text="Подскажите, пожалуйста, какой город вас интересует?",
confidence=0.9,
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,
)
async def _fake_speak_text(
current_actor,
text: str,
*,
is_greeting: bool,
style_hints: dict[str, object] | None = None,
) -> None:
del current_actor, is_greeting, style_hints
await asyncio.sleep(0)
runtime._speak_text = _fake_speak_text # type: ignore[method-assign]
pcm_frame = (1000).to_bytes(2, "little", signed=True) * 160
async def _scenario() -> None:
actor = MediaActor(
registration=MediaRegistration(
voice_session_id="avs_media_runtime_v2_throttle",
call_id="call_media_runtime_v2_throttle",
interaction_id="int_media_runtime_v2_throttle",
ai_session_id="ais_media_runtime_v2_throttle",
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=False,
),
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=400),
frame_ms=20,
frame_bytes=320,
)
actor.finalized_caller_turn_count = 1
await runtime._process_utterance(actor, pcm_frame, False)
runtime._reset_live_turn_state(actor)
await runtime._process_utterance(actor, pcm_frame, False)
asyncio.run(_scenario())
assert [item[2] for item in planned] == ["ack", "reply", "reply"]
def test_media_runtime_voice_v2_blind_ack_defers_to_known_low_signal_partial_transcript():
speak_events: list[str] = []
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(),
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.9,
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,
)
async def _fake_speak_text(
current_actor,
text: str,
*,
is_greeting: bool,
style_hints: dict[str, object] | None = None,
) -> None:
del current_actor, is_greeting, style_hints
speak_events.append(text)
await asyncio.sleep(0)
runtime._speak_text = _fake_speak_text # type: ignore[method-assign]
pcm_frame = (1000).to_bytes(2, "little", signed=True) * 160
async def _scenario() -> None:
actor = MediaActor(
registration=MediaRegistration(
voice_session_id="avs_media_runtime_v2_low_signal_blind",
call_id="call_media_runtime_v2_low_signal_blind",
interaction_id="int_media_runtime_v2_low_signal_blind",
ai_session_id="ais_media_runtime_v2_low_signal_blind",
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,
),
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,
)
actor.finalized_caller_turn_count = 1
# A caller who already said a recognized filler-answer ("да") should not
# get a blind ack just because the audio clip crossed the length threshold.
actor.stable_partial_transcript = "да"
await runtime._process_utterance(actor, pcm_frame * 40, False)
asyncio.run(_scenario())
assert speak_events == ["Подскажите подробнее, пожалуйста."]
def test_media_runtime_low_signal_filter_catches_short_asr_noise():
assert AudioSocketMediaRuntime._is_low_signal_partial_transcript("Давай")
assert AudioSocketMediaRuntime._is_low_signal_partial_transcript("твой")