diff --git a/.env.example b/.env.example index 73880fe..87490aa 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,8 @@ AI_VOICE_V2_QUEUE_CODES=voice_lab_ai AI_VOICE_V2_ACK_MODE=immediate_short AI_VOICE_V2_STREAMING_TTS=1 AI_VOICE_V2_PARTIAL_ASR=1 +AI_VOICE_V2_EMOTIVE_ACK_ENABLED=1 +AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY=1 AI_VOICE_AUDIOSOCKET_ENABLED=0 AI_VOICE_AUDIOSOCKET_HOST=0.0.0.0 AI_VOICE_AUDIOSOCKET_PORT=9019 diff --git a/.env.production.template b/.env.production.template index 64642aa..d1705fb 100644 --- a/.env.production.template +++ b/.env.production.template @@ -54,6 +54,8 @@ AI_VOICE_V2_QUEUE_CODES=voice_lab_ai AI_VOICE_V2_ACK_MODE=immediate_short AI_VOICE_V2_STREAMING_TTS=1 AI_VOICE_V2_PARTIAL_ASR=1 +AI_VOICE_V2_EMOTIVE_ACK_ENABLED=1 +AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY=1 AI_VOICE_AUDIOSOCKET_ENABLED=1 AI_VOICE_AUDIOSOCKET_HOST=0.0.0.0 AI_VOICE_AUDIOSOCKET_PORT=9019 diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 373c751..2b49d3a 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -36,6 +36,8 @@ x-app-env: &app_env AI_VOICE_V2_ACK_MODE: immediate_short AI_VOICE_V2_STREAMING_TTS: "1" AI_VOICE_V2_PARTIAL_ASR: "1" + AI_VOICE_V2_EMOTIVE_ACK_ENABLED: "1" + AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY: "1" AI_VOICE_TURN_MAX_MS: "10000" AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS: "15" AI_VOICE_MAX_CONTEXT_SEGMENTS: "8" diff --git a/scripts/local_stack.py b/scripts/local_stack.py index de0f8e8..c8271b5 100644 --- a/scripts/local_stack.py +++ b/scripts/local_stack.py @@ -316,6 +316,8 @@ def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_ env["AI_VOICE_V2_ACK_MODE"] = env.get("AI_VOICE_V2_ACK_MODE", "immediate_short") env["AI_VOICE_V2_STREAMING_TTS"] = env.get("AI_VOICE_V2_STREAMING_TTS", "1") env["AI_VOICE_V2_PARTIAL_ASR"] = env.get("AI_VOICE_V2_PARTIAL_ASR", "1") + env["AI_VOICE_V2_EMOTIVE_ACK_ENABLED"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_ENABLED", "1") + env["AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY"] = env.get("AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY", "1") env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "8") env["AI_VOICE_HANDOFF_TIMEOUT_SECONDS"] = env.get("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", "8") env["AI_VOICE_VAD_TRAILING_SILENCE_MS"] = env.get("AI_VOICE_VAD_TRAILING_SILENCE_MS", "400") diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py index 594c184..10e848c 100644 --- a/services/ai_voice_runtime_service/app.py +++ b/services/ai_voice_runtime_service/app.py @@ -153,6 +153,14 @@ def _voice_v2_partial_asr_enabled() -> bool: return _bool_env("AI_VOICE_V2_PARTIAL_ASR", True) +def _voice_v2_emotive_ack_enabled() -> bool: + return _bool_env("AI_VOICE_V2_EMOTIVE_ACK_ENABLED", True) + + +def _voice_v2_emotive_ack_ru_only() -> bool: + return _bool_env("AI_VOICE_V2_EMOTIVE_ACK_RU_ONLY", True) + + def _service_headers() -> dict[str, str]: token = issue_app_token( subject="svc:ai-voice-runtime", @@ -497,6 +505,8 @@ def _media_registration_from_row(row: VoiceAISessionRow, *, queue_code: str | No voice_v2_ack_mode=_voice_v2_ack_mode() if voice_v2_for_session else "disabled", voice_v2_streaming_tts=bool(voice_v2_for_session and _voice_v2_streaming_tts_enabled()), voice_v2_partial_asr=bool(voice_v2_for_session and _voice_v2_partial_asr_enabled()), + voice_v2_emotive_ack=bool(voice_v2_for_session and _voice_v2_emotive_ack_enabled()), + voice_v2_emotive_ack_ru_only=bool(_voice_v2_emotive_ack_ru_only()), ) diff --git a/services/ai_voice_runtime_service/media_runtime.py b/services/ai_voice_runtime_service/media_runtime.py index b911f16..75c55ba 100644 --- a/services/ai_voice_runtime_service/media_runtime.py +++ b/services/ai_voice_runtime_service/media_runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import contextlib +import hashlib import logging import threading import time @@ -45,6 +46,8 @@ class MediaRegistration: voice_v2_ack_mode: str = "disabled" voice_v2_streaming_tts: bool = False voice_v2_partial_asr: bool = False + voice_v2_emotive_ack: bool = False + voice_v2_emotive_ack_ru_only: bool = True @dataclass(slots=True) @@ -77,6 +80,7 @@ class MediaActor: finalized_utterance_generation: int = 0 partial_asr_task: asyncio.Task | None = None partial_asr_attempted: bool = False + last_ack_text: str | None = None class AudioSocketMediaRuntime: @@ -182,6 +186,74 @@ class AudioSocketMediaRuntime: return "Сейчас сориентирую." return "Сейчас подскажу." + @staticmethod + def _should_use_emotive_ack(registration: MediaRegistration, language: str | None) -> bool: + if not registration.voice_v2_emotive_ack: + return False + if not registration.voice_v2_emotive_ack_ru_only: + return True + normalized = str(language or registration.language or "").strip().lower() + return normalized.startswith("ru") + + @staticmethod + def _base_ack_variants(language: str | None, ack_kind: str) -> tuple[str, ...]: + normalized = str(language or "").strip().lower() + if normalized == "kz": + return (AudioSocketMediaRuntime._ack_text(language, ack_kind),) + if ack_kind == "handoff": + return ( + "Угу, секунду.", + "Понял вас, секунду.", + "Мхм, соединяю.", + "Хорошо, сейчас соединю.", + ) + if ack_kind == "understanding": + return ( + "Угу, сейчас подскажу.", + "Мхм, сориентирую.", + "Ага, понял вас.", + "Хм, сейчас уточню.", + "Хорошо, сейчас подскажу.", + "Понял вас, секунду.", + ) + return ( + "Угу, сейчас подскажу.", + "Мхм, я в контексте.", + "Ага, понял.", + "Хм, секунду.", + "Хорошо, сейчас подскажу.", + ) + + def _select_ack_payload( + self, + actor: MediaActor, + *, + language: str | None, + ack_kind: str, + ) -> tuple[str, dict[str, object] | None, str]: + effective_language = language or actor.registration.language + emotive_ack = self._should_use_emotive_ack(actor.registration, effective_language) + if emotive_ack: + variants = self._base_ack_variants(effective_language, ack_kind) + else: + variants = (self._ack_text(effective_language, ack_kind),) + seed = ( + f"{actor.registration.voice_session_id}:" + f"{actor.response_plan_id or actor.utterance_generation}:" + f"{effective_language or ''}:" + f"{ack_kind}" + ).encode("utf-8") + index = int.from_bytes(hashlib.sha256(seed).digest()[:4], "big") % len(variants) + ack_text = variants[index] + if actor.last_ack_text == ack_text and len(variants) > 1: + index = (index + 1) % len(variants) + ack_text = variants[index] + actor.last_ack_text = ack_text + style_hints: dict[str, object] | None = None + if emotive_ack: + style_hints = {"role": "good"} + return ack_text, style_hints, f"{ack_kind}:{index}" + def _should_use_voice_v2(self, registration: MediaRegistration) -> bool: return bool(registration.voice_v2_enabled and str(registration.voice_v2_ack_mode or "").strip() == "immediate_short") @@ -284,7 +356,11 @@ class AudioSocketMediaRuntime: if actor.closed or actor.early_ack_started: return ack_kind = self._ack_kind_for_intent(actor.partial_intent or "unknown") - ack_text = self._ack_text(language or actor.registration.language, ack_kind) + ack_text, style_hints, ack_variant = self._select_ack_payload( + actor, + language=language, + ack_kind=ack_kind, + ) actor.early_ack_started = True await self._plan_reply_segment( actor, @@ -295,11 +371,13 @@ class AudioSocketMediaRuntime: "partial_transcript": actor.partial_transcript, "early_intent": actor.partial_intent, "ack_kind": ack_kind, + "ack_variant": ack_variant, + "voice_style": "emotive_ack" if style_hints else "neutral_ack", "phase": "ack", "partial_ack_source": ack_source, }, ) - await self._speak_text(actor, ack_text, is_greeting=False) + await self._speak_text(actor, ack_text, is_greeting=False, style_hints=style_hints) if not actor.closed: await self._set_actor_state(actor, "thinking") @@ -680,12 +758,19 @@ class AudioSocketMediaRuntime: actor.handoff_task = asyncio.create_task(_run()) return actor.handoff_task - async def _stream_tts_chunks(self, actor: MediaActor, text: str): + async def _stream_tts_chunks( + self, + actor: MediaActor, + text: str, + *, + style_hints: dict[str, object] | None = None, + ): if not actor.registration.voice_v2_streaming_tts: synthesis = await asyncio.to_thread( lambda: self._tts_provider.synthesize( text, language=actor.registration.language, + style_hints=style_hints, ) ) if synthesis.audio_bytes: @@ -698,7 +783,11 @@ class AudioSocketMediaRuntime: def _producer() -> None: try: - for synthesis in self._tts_provider.synthesize_chunks(text, language=actor.registration.language): + for synthesis in self._tts_provider.synthesize_chunks( + text, + language=actor.registration.language, + style_hints=style_hints, + ): asyncio.run_coroutine_threadsafe(queue.put(synthesis), loop).result() except Exception as exc: # pragma: no cover - defensive bridge from thread to loop asyncio.run_coroutine_threadsafe(queue.put(exc), loop).result() @@ -718,14 +807,21 @@ class AudioSocketMediaRuntime: raise item yield item - async def _speak_text(self, actor: MediaActor, text: str, *, is_greeting: bool) -> None: + async def _speak_text( + self, + actor: MediaActor, + text: str, + *, + is_greeting: bool, + style_hints: dict[str, object] | None = None, + ) -> None: if actor.closed or not text: return await self._set_actor_state(actor, "speaking") synth_started_at = time.monotonic() first_frame_sent = False total_audio_bytes = 0 - async for synthesis in self._stream_tts_chunks(actor, text): + async for synthesis in self._stream_tts_chunks(actor, text, style_hints=style_hints): if not synthesis.audio_bytes: continue total_audio_bytes += len(synthesis.audio_bytes) diff --git a/services/ai_voice_runtime_service/providers/tts.py b/services/ai_voice_runtime_service/providers/tts.py index e3c16e1..ef2c5bf 100644 --- a/services/ai_voice_runtime_service/providers/tts.py +++ b/services/ai_voice_runtime_service/providers/tts.py @@ -150,12 +150,24 @@ class TTSSynthesis: class TTSProvider: name = "stub" - def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: - del language + def synthesize( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis: + del language, style_hints return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=8000) - def synthesize_chunks(self, text: str, *, language: str | None = None): - synthesis = self.synthesize(text, language=language) + def synthesize_chunks( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ): + synthesis = self.synthesize(text, language=language, style_hints=style_hints) if synthesis.audio_bytes: yield synthesis @@ -245,7 +257,14 @@ class OpenAITTSProvider(TTSProvider): except OSError: pass - def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + def synthesize( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis: + del style_hints if not text: return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=24000) cached = self._load_cached_synthesis(text, language=language) @@ -301,12 +320,29 @@ class YandexTTSProvider(TTSProvider): def _lang(self, language: str | None) -> str: return _normalize_yandex_language(language) - def _cache_key(self, text: str, *, language: str | None) -> str: + @staticmethod + def _normalize_style_hints(style_hints: dict[str, object] | None) -> dict[str, object]: + if not isinstance(style_hints, dict): + return {} + normalized: dict[str, object] = {} + role = str(style_hints.get("role") or "").strip() + if role: + normalized["role"] = role + return normalized + + def _effective_role(self, *, style_hints: dict[str, object] | None = None) -> str: + normalized_hints = self._normalize_style_hints(style_hints) + explicit_role = str(normalized_hints.get("role") or "").strip() + if explicit_role: + return explicit_role + return self._role + + def _cache_key(self, text: str, *, language: str | None, style_hints: dict[str, object] | None = None) -> str: payload = { "provider": self.name, "voice": self._voice(language), "language": self._lang(language), - "role": self._role or None, + "role": self._effective_role(style_hints=style_hints) or None, "speed": self._speed, "sample_rate_hz": self._sample_rate_hz, "text": text, @@ -318,10 +354,16 @@ class YandexTTSProvider(TTSProvider): prefix = self._cache_dir / cache_key[:2] / cache_key[2:4] return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json" - def _load_cached_synthesis(self, text: str, *, language: str | None) -> TTSSynthesis | None: + def _load_cached_synthesis( + self, + text: str, + *, + language: str | None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis | None: if not self._cache_enabled: return None - pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language)) + pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language, style_hints=style_hints)) if not pcm_path.exists(): return None @@ -336,17 +378,25 @@ class YandexTTSProvider(TTSProvider): return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz) - def _write_cached_synthesis(self, synthesis: TTSSynthesis, *, language: str | None) -> None: + def _write_cached_synthesis( + self, + synthesis: TTSSynthesis, + *, + language: str | None, + style_hints: dict[str, object] | None = None, + ) -> None: if not self._cache_enabled or not synthesis.audio_bytes: return - pcm_path, meta_path = self._cache_paths(self._cache_key(synthesis.text, language=language)) + pcm_path, meta_path = self._cache_paths( + self._cache_key(synthesis.text, language=language, style_hints=style_hints) + ) pcm_path.parent.mkdir(parents=True, exist_ok=True) metadata = { "provider": self.name, "voice": self._voice(language), "language": self._lang(language), - "role": self._role or None, + "role": self._effective_role(style_hints=style_hints) or None, "speed": self._speed, "sample_rate_hz": synthesis.sample_rate_hz, "text": synthesis.text, @@ -373,10 +423,16 @@ class YandexTTSProvider(TTSProvider): except OSError: pass - def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + def synthesize( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis: if not text: return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=self._sample_rate_hz) - cached = self._load_cached_synthesis(text, language=language) + cached = self._load_cached_synthesis(text, language=language, style_hints=style_hints) if cached is not None: return cached if not self._api_key and not self._iam_token: @@ -396,8 +452,9 @@ class YandexTTSProvider(TTSProvider): {"voice": self._voice(language)}, {"speed": float(self._speed)}, ] - if self._role: - hints.append({"role": self._role}) + effective_role = self._effective_role(style_hints=style_hints) + if effective_role: + hints.append({"role": effective_role}) payload = { "text": text, @@ -434,10 +491,10 @@ class YandexTTSProvider(TTSProvider): raise RuntimeError("Yandex TTS returned invalid base64 audio data") from exc synthesis = TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=self._sample_rate_hz) with self._cache_lock: - cached = self._load_cached_synthesis(text, language=language) + cached = self._load_cached_synthesis(text, language=language, style_hints=style_hints) if cached is not None: return cached - self._write_cached_synthesis(synthesis, language=language) + self._write_cached_synthesis(synthesis, language=language, style_hints=style_hints) return synthesis diff --git a/tests/test_ai_voice_media_runtime.py b/tests/test_ai_voice_media_runtime.py index 4a828c9..138ac8a 100644 --- a/tests/test_ai_voice_media_runtime.py +++ b/tests/test_ai_voice_media_runtime.py @@ -30,8 +30,14 @@ class _StubASRProvider(ASRProvider): class _StubTTSProvider(TTSProvider): name = "stub-tts" - def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: - del language + def synthesize( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis: + del language, style_hints assert text return TTSSynthesis( text=text, @@ -276,8 +282,14 @@ def test_media_runtime_sends_keepalive_while_tts_is_slow(): class _SlowTTSProvider(TTSProvider): name = "slow-stub-tts" - def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: - del language + def synthesize( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis: + del language, style_hints assert text time.sleep(1.2) return TTSSynthesis( @@ -501,8 +513,14 @@ def test_media_runtime_starts_handoff_before_handoff_tts_finishes(): frame_bytes=320, ) - async def _fake_speak_text(current_actor, text: str, *, is_greeting: bool) -> None: - del current_actor, text, is_greeting + async def _fake_speak_text( + current_actor, + text: str, + *, + is_greeting: bool, + style_hints: dict[str, object] | None = None, + ) -> None: + del current_actor, text, is_greeting, style_hints events.append("speak_start") await asyncio.sleep(0) assert handoff_started.wait(timeout=0.5) @@ -671,8 +689,14 @@ def test_media_runtime_voice_v2_uses_partial_asr_to_start_ack_before_full_asr(): handle_media_error=lambda session_id, message, metadata: None, ) - async def _fake_speak_text(current_actor, text: str, *, is_greeting: bool) -> None: - del current_actor, is_greeting + 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, time.monotonic())) await asyncio.sleep(0) @@ -776,8 +800,14 @@ def test_media_runtime_voice_v2_emits_generic_ack_before_full_asr_without_partia handle_media_error=lambda session_id, message, metadata: None, ) - async def _fake_speak_text(current_actor, text: str, *, is_greeting: bool) -> None: - del current_actor, is_greeting + 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, time.monotonic())) await asyncio.sleep(0) @@ -814,3 +844,187 @@ def test_media_runtime_voice_v2_emits_generic_ack_before_full_asr_without_partia assert speak_events assert speak_events[0][0] == runtime._ack_text("ru", "generic") assert speak_events[0][1] < timings["full_finished"] + + +def test_media_runtime_voice_v2_emotive_ack_uses_ru_variants_and_style_hints_only_for_ack(): + synth_calls: list[tuple[str, dict[str, object] | None]] = [] + + class _RecordingTTSProvider(TTSProvider): + name = "recording-tts" + + def synthesize( + self, + text: str, + *, + language: str | None = None, + style_hints: dict[str, object] | None = None, + ) -> TTSSynthesis: + del language + synth_calls.append((text, style_hints)) + return TTSSynthesis(text=text, audio_bytes=(b"\x10\x00" * 960), sample_rate_hz=24000) + + class _ScheduleASRProvider(ASRProvider): + name = "schedule-asr" + + def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: + del 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=_ScheduleASRProvider(), + tts_provider=_RecordingTTSProvider(), + 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: ( + 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", + metadata={"early_intent": "schedule", "ack_kind": "understanding"}, + ) + ), + request_handoff=lambda session_id, customer_request_text, decision: None, + handle_media_error=lambda session_id, message, metadata: None, + ) + + async def _fake_write_audio_packet(current_actor, pcm_frame: bytes) -> None: + del current_actor, pcm_frame + + runtime._write_audio_packet = _fake_write_audio_packet # type: ignore[method-assign] + + async def _scenario() -> None: + actor = MediaActor( + registration=MediaRegistration( + voice_session_id="avs_media_runtime_v2_emotive_ack", + call_id="call_media_runtime_v2_emotive_ack", + interaction_id="int_media_runtime_v2_emotive_ack", + ai_session_id="ais_media_runtime_v2_emotive_ack", + 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=False, + voice_v2_partial_asr=False, + voice_v2_emotive_ack=True, + voice_v2_emotive_ack_ru_only=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=400), + frame_ms=20, + frame_bytes=320, + ) + pcm_frame = (1000).to_bytes(2, "little", signed=True) * 160 + await runtime._process_utterance(actor, pcm_frame, False) + + asyncio.run(_scenario()) + + assert len(synth_calls) == 2 + assert synth_calls[0][0] in runtime._base_ack_variants("ru", "understanding") + assert synth_calls[0][1] == {"role": "good"} + assert synth_calls[1][0] == "Подскажите, пожалуйста, какой город вас интересует?" + assert synth_calls[1][1] is None + + +def test_media_runtime_voice_v2_emotive_ack_avoids_same_variant_back_to_back(): + async def _scenario() -> tuple[str, str, AudioSocketMediaRuntime]: + 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=_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="reply", + 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, + ) + + actor = MediaActor( + registration=MediaRegistration( + voice_session_id="avs_media_runtime_v2_repeat_guard", + call_id="call_media_runtime_v2_repeat_guard", + interaction_id="int_media_runtime_v2_repeat_guard", + ai_session_id="ais_media_runtime_v2_repeat_guard", + 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_emotive_ack=True, + voice_v2_emotive_ack_ru_only=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=400), + frame_ms=20, + frame_bytes=320, + ) + actor.response_plan_id = "rsp_same_seed" + + first_text, _, _ = runtime._select_ack_payload(actor, language="ru", ack_kind="understanding") + actor.response_plan_id = "rsp_same_seed" + second_text, _, _ = runtime._select_ack_payload(actor, language="ru", ack_kind="understanding") + return first_text, second_text, runtime + + first_text, second_text, runtime = asyncio.run(_scenario()) + + assert first_text in runtime._base_ack_variants("ru", "understanding") + assert second_text in runtime._base_ack_variants("ru", "understanding") + assert first_text != second_text diff --git a/tests/test_ai_voice_tts_provider.py b/tests/test_ai_voice_tts_provider.py index 7b02cd4..4d231c2 100644 --- a/tests/test_ai_voice_tts_provider.py +++ b/tests/test_ai_voice_tts_provider.py @@ -138,6 +138,40 @@ def test_yandex_tts_provider_posts_lpcm_with_api_key(tmp_path, monkeypatch): assert list(Path(tmp_path).rglob("*.json")) +def test_yandex_tts_provider_prefers_per_utterance_role_hint_over_global_role(tmp_path, monkeypatch): + calls: list[dict] = [] + encoded_audio = base64.b64encode(b"\x12\x00\x34\x00").decode("ascii") + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) + return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}}) + + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.example.test") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_VOICE", "jane") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_ROLE", "neutral") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0") + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.YandexTTSProvider() + synthesis = provider.synthesize("Эмоциональный быстрый отклик", language="ru", style_hints={"role": "good"}) + + assert synthesis.audio_bytes == b"\x12\x00\x34\x00" + assert len(calls) == 1 + assert calls[0]["json"]["hints"][0]["voice"] == "jane" + assert calls[0]["json"]["hints"][2]["role"] == "good" + + def test_yandex_tts_provider_uses_iam_token_with_folder_id(tmp_path, monkeypatch): calls: list[dict] = [] encoded_audio = base64.b64encode(b"\x30\x00\x40\x00").decode("ascii")