.
This commit is contained in:
+6
-2
@@ -33,10 +33,14 @@ OLLAMA_LLM_NUM_CTX=1024
|
|||||||
|
|
||||||
STT_PROVIDER=openai
|
STT_PROVIDER=openai
|
||||||
STT_FALLBACK_PROVIDER=elevenlabs
|
STT_FALLBACK_PROVIDER=elevenlabs
|
||||||
STT_NAME_CAPTURE_LANGUAGE_CODE=kz
|
# Empty means auto-detect the first answer, because the greeting asks for name and preferred language.
|
||||||
|
STT_NAME_CAPTURE_LANGUAGE_CODE=
|
||||||
STT_PROMPT=Это русская речь по телефону. Точно распознавай короткие ответы, имена, фамилии, слова благодарности и прощания. Особое внимание именам: Айнур, Айгерим, Алия, Данияр, Азамат, Арман, Мадина, Нурсултан, Руслан, Асель. Если человек представился одним словом, сохраняй его как имя. Номера телефонов записывай цифрами.
|
STT_PROMPT=Это русская речь по телефону. Точно распознавай короткие ответы, имена, фамилии, слова благодарности и прощания. Особое внимание именам: Айнур, Айгерим, Алия, Данияр, Азамат, Арман, Мадина, Нурсултан, Руслан, Асель. Если человек представился одним словом, сохраняй его как имя. Номера телефонов записывай цифрами.
|
||||||
TTS_PROVIDER=elevenlabs
|
TTS_PROVIDER=elevenlabs
|
||||||
|
|
||||||
|
REALTIME_VOICE_INITIAL_GREETING_KK_TEXT=Сәлеметсіз бе! Менің атым Айнұр, мен ДиджиОпс компаниясының эй-ай ассистентімін. Сізге қалай жүгінсем болады және қай тілде сөйлескен ыңғайлы: қазақша ма, орысша ма?
|
||||||
|
REALTIME_VOICE_INITIAL_GREETING_RU_TEXT=Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. Как я могу к вам обращаться и на каком языке вам удобнее продолжить: на казахском или на русском?
|
||||||
|
|
||||||
ENABLE_AUDIO_DUMP=false
|
ENABLE_AUDIO_DUMP=false
|
||||||
AUDIO_DUMP_DIR=debug_audio
|
AUDIO_DUMP_DIR=debug_audio
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ VAD_SILENCE_TIMEOUT_MS=420
|
|||||||
VAD_SPEECH_PAD_MS=180
|
VAD_SPEECH_PAD_MS=180
|
||||||
VAD_MIN_SPEECH_DURATION_MS=0
|
VAD_MIN_SPEECH_DURATION_MS=0
|
||||||
VAD_USE_ONNX=false
|
VAD_USE_ONNX=false
|
||||||
REALTIME_VOICE_INITIAL_GREETING_TEXT=Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. Как я могу к вам обращаться?
|
REALTIME_VOICE_INITIAL_GREETING_TEXT=
|
||||||
SEMANTIC_ENDPOINTING_HOLD_MS=600
|
SEMANTIC_ENDPOINTING_HOLD_MS=600
|
||||||
REALTIME_VOICE_FILLER_DELAY_MS=300
|
REALTIME_VOICE_FILLER_DELAY_MS=300
|
||||||
REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_CHARS=10
|
REALTIME_VOICE_TTS_CHUNK_SOFT_MIN_CHARS=10
|
||||||
|
|||||||
+15
-2
@@ -8,6 +8,7 @@ import os
|
|||||||
import random
|
import random
|
||||||
import wave
|
import wave
|
||||||
from collections.abc import AsyncIterable
|
from collections.abc import AsyncIterable
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from realtime_voice_service.providers.base import BaseTTS
|
from realtime_voice_service.providers.base import BaseTTS
|
||||||
|
|
||||||
@@ -98,6 +99,18 @@ class FillerAudioLibrary:
|
|||||||
async def synthesize_text(self, tts: BaseTTS, text: str) -> bytes:
|
async def synthesize_text(self, tts: BaseTTS, text: str) -> bytes:
|
||||||
return await self._synthesize_clip(tts, text)
|
return await self._synthesize_clip(tts, text)
|
||||||
|
|
||||||
|
async def synthesize_segments(
|
||||||
|
self,
|
||||||
|
tts: BaseTTS,
|
||||||
|
segments: Iterable[tuple[str, str | None]],
|
||||||
|
) -> bytes:
|
||||||
|
clip = bytearray()
|
||||||
|
for text, language_code in segments:
|
||||||
|
segment = await self._synthesize_clip(tts, text, language_code=language_code)
|
||||||
|
if segment:
|
||||||
|
clip.extend(segment)
|
||||||
|
return bytes(clip)
|
||||||
|
|
||||||
def _load_from_files(self) -> None:
|
def _load_from_files(self) -> None:
|
||||||
raw_config = str(os.getenv(self._config_env, "")).strip()
|
raw_config = str(os.getenv(self._config_env, "")).strip()
|
||||||
if not raw_config:
|
if not raw_config:
|
||||||
@@ -149,12 +162,12 @@ class FillerAudioLibrary:
|
|||||||
if not self._clips.get(key):
|
if not self._clips.get(key):
|
||||||
LOGGER.warning("no filler clips available for key=%s", key)
|
LOGGER.warning("no filler clips available for key=%s", key)
|
||||||
|
|
||||||
async def _synthesize_clip(self, tts: BaseTTS, text: str) -> bytes:
|
async def _synthesize_clip(self, tts: BaseTTS, text: str, *, language_code: str | None = None) -> bytes:
|
||||||
async def one_shot_text_stream() -> AsyncIterable[str]:
|
async def one_shot_text_stream() -> AsyncIterable[str]:
|
||||||
yield text
|
yield text
|
||||||
|
|
||||||
clip = bytearray()
|
clip = bytearray()
|
||||||
async for audio_chunk in tts.synthesize_stream(one_shot_text_stream()):
|
async for audio_chunk in tts.synthesize_stream(one_shot_text_stream(), language_code=language_code):
|
||||||
if audio_chunk:
|
if audio_chunk:
|
||||||
clip.extend(audio_chunk)
|
clip.extend(audio_chunk)
|
||||||
return bytes(clip)
|
return bytes(clip)
|
||||||
|
|||||||
+131
-25
@@ -247,12 +247,49 @@ def _preview_text(text: str, *, limit: int = 160) -> str:
|
|||||||
|
|
||||||
_KAZAKH_SPECIFIC_LETTERS = set("әғқңөұүһі")
|
_KAZAKH_SPECIFIC_LETTERS = set("әғқңөұүһі")
|
||||||
_RUSSIAN_SPECIFIC_LETTERS = set("ыэъё")
|
_RUSSIAN_SPECIFIC_LETTERS = set("ыэъё")
|
||||||
|
_KAZAKH_LANGUAGE_MARKERS = frozenset({
|
||||||
|
"казахский",
|
||||||
|
"казахском",
|
||||||
|
"казахски",
|
||||||
|
"казакша",
|
||||||
|
"казак",
|
||||||
|
"қазақ",
|
||||||
|
"қазақша",
|
||||||
|
})
|
||||||
|
_RUSSIAN_LANGUAGE_MARKERS = frozenset({
|
||||||
|
"русский",
|
||||||
|
"русском",
|
||||||
|
"русски",
|
||||||
|
"орыс",
|
||||||
|
"орысша",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_preferred_session_language(text: str) -> str | None:
|
||||||
|
normalized = _voice_text_key(text)
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
words = set(normalized.split())
|
||||||
|
if words & _KAZAKH_LANGUAGE_MARKERS:
|
||||||
|
return "kk"
|
||||||
|
if words & _RUSSIAN_LANGUAGE_MARKERS:
|
||||||
|
return "ru"
|
||||||
|
if re.search(r"\bпо\s+русски\b", normalized):
|
||||||
|
return "ru"
|
||||||
|
if re.search(r"\bна\s+казахском\b", normalized):
|
||||||
|
return "kk"
|
||||||
|
if re.search(r"\bна\s+русском\b", normalized):
|
||||||
|
return "ru"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _detect_session_language(text: str) -> str | None:
|
def _detect_session_language(text: str) -> str | None:
|
||||||
normalized = str(text or "").strip().lower()
|
normalized = str(text or "").strip().lower()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
|
preferred_language = _detect_preferred_session_language(normalized)
|
||||||
|
if preferred_language:
|
||||||
|
return preferred_language
|
||||||
has_kazakh = any(ch in _KAZAKH_SPECIFIC_LETTERS for ch in normalized)
|
has_kazakh = any(ch in _KAZAKH_SPECIFIC_LETTERS for ch in normalized)
|
||||||
if has_kazakh:
|
if has_kazakh:
|
||||||
return "kk"
|
return "kk"
|
||||||
@@ -605,6 +642,8 @@ def _sanitize_voice_text(text: str) -> str:
|
|||||||
|
|
||||||
DEFAULT_NAME_RETRY_TEXT = "Подскажите, пожалуйста, как я могу к вам обращаться?"
|
DEFAULT_NAME_RETRY_TEXT = "Подскажите, пожалуйста, как я могу к вам обращаться?"
|
||||||
DEFAULT_PERSONALIZED_GREETING_TEMPLATE = '{name}, я работаю на основе искусственного интеллекта и постараюсь максимально внимательно разобраться с вашим вопросом. Чем я могу помочь?'
|
DEFAULT_PERSONALIZED_GREETING_TEMPLATE = '{name}, я работаю на основе искусственного интеллекта и постараюсь максимально внимательно разобраться с вашим вопросом. Чем я могу помочь?'
|
||||||
|
KAZAKH_NAME_RETRY_TEXT = "Өтінемін, сізге қалай жүгінсем болады?"
|
||||||
|
KAZAKH_PERSONALIZED_GREETING_TEMPLATE = "{name}, мен жасанды интеллект негізінде жұмыс істеймін және сұрағыңызды мұқият түсінуге тырысамын. Қалай көмектесе аламын?"
|
||||||
_NAME_CAPTURE_CONFUSION_ALIASES = {
|
_NAME_CAPTURE_CONFUSION_ALIASES = {
|
||||||
"парень": "Арнур",
|
"парень": "Арнур",
|
||||||
"арнер": "Арнур",
|
"арнер": "Арнур",
|
||||||
@@ -693,7 +732,11 @@ def _normalize_name_candidate(text: str | None) -> str | None:
|
|||||||
"атым",
|
"атым",
|
||||||
"аты",
|
"аты",
|
||||||
"болады",
|
"болады",
|
||||||
|
"язык",
|
||||||
|
"тіл",
|
||||||
}
|
}
|
||||||
|
stop_words.update(_KAZAKH_LANGUAGE_MARKERS)
|
||||||
|
stop_words.update(_RUSSIAN_LANGUAGE_MARKERS)
|
||||||
filtered = [word for word, lowered_word in zip(words, lowered) if lowered_word not in stop_words]
|
filtered = [word for word, lowered_word in zip(words, lowered) if lowered_word not in stop_words]
|
||||||
if not filtered:
|
if not filtered:
|
||||||
return None
|
return None
|
||||||
@@ -756,6 +799,8 @@ def _normalize_name_candidate(text: str | None) -> str | None:
|
|||||||
"сұрақ",
|
"сұрақ",
|
||||||
"көмек",
|
"көмек",
|
||||||
}
|
}
|
||||||
|
invalid_tokens.update(_KAZAKH_LANGUAGE_MARKERS)
|
||||||
|
invalid_tokens.update(_RUSSIAN_LANGUAGE_MARKERS)
|
||||||
invalid_letter_tokens = {_voice_letters_key(token) for token in invalid_tokens}
|
invalid_letter_tokens = {_voice_letters_key(token) for token in invalid_tokens}
|
||||||
filler_letters = {"а", "э", "е", "ё", "у", "о", "ы", "м", "m", "h"}
|
filler_letters = {"а", "э", "е", "ё", "у", "о", "ы", "м", "m", "h"}
|
||||||
for word in filtered:
|
for word in filtered:
|
||||||
@@ -838,6 +883,8 @@ def _extract_name_candidate(text: str | None) -> tuple[str | None, bool]:
|
|||||||
"сұра",
|
"сұра",
|
||||||
"көмек",
|
"көмек",
|
||||||
}
|
}
|
||||||
|
cutoff_tokens.update(_KAZAKH_LANGUAGE_MARKERS)
|
||||||
|
cutoff_tokens.update(_RUSSIAN_LANGUAGE_MARKERS)
|
||||||
for pattern in explicit_patterns:
|
for pattern in explicit_patterns:
|
||||||
match = re.search(pattern, normalized, flags=re.IGNORECASE)
|
match = re.search(pattern, normalized, flags=re.IGNORECASE)
|
||||||
if not match:
|
if not match:
|
||||||
@@ -1008,6 +1055,7 @@ class CallSession:
|
|||||||
tts: BaseTTS | None = None,
|
tts: BaseTTS | None = None,
|
||||||
filler_audio: FillerAudioLibrary | None = None,
|
filler_audio: FillerAudioLibrary | None = None,
|
||||||
initial_greeting_text: str | None = None,
|
initial_greeting_text: str | None = None,
|
||||||
|
initial_greeting_segments: Iterable[tuple[str, str | None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.transport = transport
|
self.transport = transport
|
||||||
@@ -1033,9 +1081,13 @@ class CallSession:
|
|||||||
self._sentence_queue: asyncio.Queue[str | None] | None = None
|
self._sentence_queue: asyncio.Queue[str | None] | None = None
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._filler_audio = filler_audio
|
self._filler_audio = filler_audio
|
||||||
self._initial_greeting_text = str(initial_greeting_text or "").strip()
|
self._initial_greeting_segments = self._normalize_initial_greeting_segments(
|
||||||
|
initial_greeting_segments,
|
||||||
|
fallback_text=initial_greeting_text,
|
||||||
|
)
|
||||||
|
self._initial_greeting_text = _combine_user_transcripts(text for text, _ in self._initial_greeting_segments)
|
||||||
self._initial_greeting_discarded_bytes = 0
|
self._initial_greeting_discarded_bytes = 0
|
||||||
self._awaiting_customer_name = bool(self._initial_greeting_text)
|
self._awaiting_customer_name = bool(self._initial_greeting_segments)
|
||||||
self._customer_name: str | None = None
|
self._customer_name: str | None = None
|
||||||
self._name_retry_text = DEFAULT_NAME_RETRY_TEXT
|
self._name_retry_text = DEFAULT_NAME_RETRY_TEXT
|
||||||
self._personalized_greeting_template = DEFAULT_PERSONALIZED_GREETING_TEMPLATE
|
self._personalized_greeting_template = DEFAULT_PERSONALIZED_GREETING_TEMPLATE
|
||||||
@@ -1057,6 +1109,26 @@ class CallSession:
|
|||||||
self._semantic_hold_silence_timeout_ms,
|
self._semantic_hold_silence_timeout_ms,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_initial_greeting_segments(
|
||||||
|
segments: Iterable[tuple[str, str | None]] | None,
|
||||||
|
*,
|
||||||
|
fallback_text: str | None,
|
||||||
|
) -> tuple[tuple[str, str | None], ...]:
|
||||||
|
normalized_segments: list[tuple[str, str | None]] = []
|
||||||
|
for text, language_code in segments or ():
|
||||||
|
normalized_text = str(text or "").strip()
|
||||||
|
if not normalized_text:
|
||||||
|
continue
|
||||||
|
normalized_language = str(language_code or "").strip() or None
|
||||||
|
normalized_segments.append((normalized_text, normalized_language))
|
||||||
|
if normalized_segments:
|
||||||
|
return tuple(normalized_segments)
|
||||||
|
normalized_fallback = str(fallback_text or "").strip()
|
||||||
|
if normalized_fallback:
|
||||||
|
return ((normalized_fallback, None),)
|
||||||
|
return ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def conversation(self) -> tuple[tuple[str, str], ...]:
|
def conversation(self) -> tuple[tuple[str, str], ...]:
|
||||||
return tuple(self._conversation)
|
return tuple(self._conversation)
|
||||||
@@ -1641,13 +1713,22 @@ class CallSession:
|
|||||||
raw = (
|
raw = (
|
||||||
os.getenv("STT_NAME_CAPTURE_LANGUAGE_CODE", "").strip()
|
os.getenv("STT_NAME_CAPTURE_LANGUAGE_CODE", "").strip()
|
||||||
or os.getenv("STT_NAME_CAPTURE_LANGUAGE", "").strip()
|
or os.getenv("STT_NAME_CAPTURE_LANGUAGE", "").strip()
|
||||||
or "kz"
|
|
||||||
)
|
)
|
||||||
return raw or None
|
return raw or None
|
||||||
|
|
||||||
def _build_name_collection_response(self, transcript: str) -> str | None:
|
def _build_name_collection_response(self, transcript: str) -> str | None:
|
||||||
if not self._awaiting_customer_name:
|
if not self._awaiting_customer_name:
|
||||||
return None
|
return None
|
||||||
|
if self._session_language is None:
|
||||||
|
detected_language = _detect_session_language(transcript)
|
||||||
|
if detected_language:
|
||||||
|
self._session_language = detected_language
|
||||||
|
LOGGER.info(
|
||||||
|
"realtime session %s language locked during name collection: language=%s transcript=%r",
|
||||||
|
self.session_id,
|
||||||
|
detected_language,
|
||||||
|
_preview_text(transcript),
|
||||||
|
)
|
||||||
status, name_value = _voice_start_name_outcome(transcript)
|
status, name_value = _voice_start_name_outcome(transcript)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"realtime session %s name collection outcome: status=%s name=%r transcript=%r",
|
"realtime session %s name collection outcome: status=%s name=%r transcript=%r",
|
||||||
@@ -1661,9 +1742,19 @@ class CallSession:
|
|||||||
if short_name:
|
if short_name:
|
||||||
self._customer_name = short_name
|
self._customer_name = short_name
|
||||||
self._awaiting_customer_name = False
|
self._awaiting_customer_name = False
|
||||||
return self._personalized_greeting_template.format(name=short_name)
|
return self._personalized_greeting_text(short_name)
|
||||||
|
return self._name_retry_text_for_current_language()
|
||||||
|
|
||||||
|
def _name_retry_text_for_current_language(self) -> str:
|
||||||
|
if self._session_language == "kk":
|
||||||
|
return KAZAKH_NAME_RETRY_TEXT
|
||||||
return self._name_retry_text
|
return self._name_retry_text
|
||||||
|
|
||||||
|
def _personalized_greeting_text(self, name: str) -> str:
|
||||||
|
if self._session_language == "kk":
|
||||||
|
return KAZAKH_PERSONALIZED_GREETING_TEMPLATE.format(name=name)
|
||||||
|
return self._personalized_greeting_template.format(name=name)
|
||||||
|
|
||||||
def _build_llm_context(self) -> list[object]:
|
def _build_llm_context(self) -> list[object]:
|
||||||
context: list[object] = list(self._conversation)
|
context: list[object] = list(self._conversation)
|
||||||
if self._customer_name:
|
if self._customer_name:
|
||||||
@@ -1719,7 +1810,7 @@ class CallSession:
|
|||||||
await playback_task
|
await playback_task
|
||||||
|
|
||||||
def _start_initial_greeting(self) -> None:
|
def _start_initial_greeting(self) -> None:
|
||||||
if not self._initial_greeting_text or self._closed:
|
if not self._initial_greeting_segments or self._closed:
|
||||||
return
|
return
|
||||||
if self._greeting_task is not None and not self._greeting_task.done():
|
if self._greeting_task is not None and not self._greeting_task.done():
|
||||||
return
|
return
|
||||||
@@ -1727,13 +1818,15 @@ class CallSession:
|
|||||||
self._play_initial_greeting(
|
self._play_initial_greeting(
|
||||||
epoch=self.generation_epoch,
|
epoch=self.generation_epoch,
|
||||||
greeting_text=self._initial_greeting_text,
|
greeting_text=self._initial_greeting_text,
|
||||||
|
greeting_segments=self._initial_greeting_segments,
|
||||||
),
|
),
|
||||||
name=f"{self.session_id}-greeting-{self.generation_epoch}",
|
name=f"{self.session_id}-greeting-{self.generation_epoch}",
|
||||||
)
|
)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"realtime session %s initial greeting scheduled: epoch=%s chars=%s text=%r",
|
"realtime session %s initial greeting scheduled: epoch=%s segments=%s chars=%s text=%r",
|
||||||
self.session_id,
|
self.session_id,
|
||||||
self.generation_epoch,
|
self.generation_epoch,
|
||||||
|
[(language, len(text)) for text, language in self._initial_greeting_segments],
|
||||||
len(self._initial_greeting_text),
|
len(self._initial_greeting_text),
|
||||||
_preview_text(self._initial_greeting_text),
|
_preview_text(self._initial_greeting_text),
|
||||||
)
|
)
|
||||||
@@ -1743,10 +1836,8 @@ class CallSession:
|
|||||||
*,
|
*,
|
||||||
epoch: int,
|
epoch: int,
|
||||||
greeting_text: str,
|
greeting_text: str,
|
||||||
|
greeting_segments: Iterable[tuple[str, str | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
async def one_shot_text_stream() -> AsyncGenerator[str, None]:
|
|
||||||
yield greeting_text
|
|
||||||
|
|
||||||
first_audio_seen = False
|
first_audio_seen = False
|
||||||
started_monotonic = time.perf_counter()
|
started_monotonic = time.perf_counter()
|
||||||
cached_greeting = self._filler_audio.pick("initial_greeting") if self._filler_audio is not None else None
|
cached_greeting = self._filler_audio.pick("initial_greeting") if self._filler_audio is not None else None
|
||||||
@@ -1761,22 +1852,37 @@ class CallSession:
|
|||||||
try:
|
try:
|
||||||
audio_chunk_count = 0
|
audio_chunk_count = 0
|
||||||
audio_byte_count = 0
|
audio_byte_count = 0
|
||||||
async for audio_chunk in self._tts.synthesize_stream(one_shot_text_stream()):
|
for segment_text, language_code in greeting_segments:
|
||||||
self._ensure_generation(epoch)
|
async def one_shot_text_stream() -> AsyncGenerator[str, None]:
|
||||||
if not audio_chunk:
|
yield segment_text
|
||||||
continue
|
|
||||||
audio_chunk_count += 1
|
LOGGER.info(
|
||||||
audio_byte_count += len(audio_chunk)
|
"realtime session %s initial greeting segment start: epoch=%s language=%s chars=%s text=%r",
|
||||||
if not first_audio_seen:
|
self.session_id,
|
||||||
first_audio_seen = True
|
epoch,
|
||||||
self._log_latency(
|
language_code,
|
||||||
"initial_greeting_ttfa",
|
len(segment_text),
|
||||||
started_monotonic,
|
_preview_text(segment_text),
|
||||||
epoch=epoch,
|
)
|
||||||
message="session start -> initial greeting first audio",
|
async for audio_chunk in self._tts.synthesize_stream(
|
||||||
)
|
one_shot_text_stream(),
|
||||||
self._set_state(SessionState.ASSISTANT_SPEAKING, reason="initial greeting started")
|
language_code=language_code,
|
||||||
await self.transport.send_audio(audio_chunk)
|
):
|
||||||
|
self._ensure_generation(epoch)
|
||||||
|
if not audio_chunk:
|
||||||
|
continue
|
||||||
|
audio_chunk_count += 1
|
||||||
|
audio_byte_count += len(audio_chunk)
|
||||||
|
if not first_audio_seen:
|
||||||
|
first_audio_seen = True
|
||||||
|
self._log_latency(
|
||||||
|
"initial_greeting_ttfa",
|
||||||
|
started_monotonic,
|
||||||
|
epoch=epoch,
|
||||||
|
message="session start -> initial greeting first audio",
|
||||||
|
)
|
||||||
|
self._set_state(SessionState.ASSISTANT_SPEAKING, reason="initial greeting started")
|
||||||
|
await self.transport.send_audio(audio_chunk)
|
||||||
await self.transport.flush_audio()
|
await self.transport.flush_audio()
|
||||||
self._ensure_generation(epoch)
|
self._ensure_generation(epoch)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
|
|||||||
@@ -87,9 +87,43 @@ def _http_port() -> int:
|
|||||||
|
|
||||||
def _initial_greeting_text() -> str:
|
def _initial_greeting_text() -> str:
|
||||||
raw = os.getenv("REALTIME_VOICE_INITIAL_GREETING_TEXT")
|
raw = os.getenv("REALTIME_VOICE_INITIAL_GREETING_TEXT")
|
||||||
if raw is None:
|
if raw is not None and str(raw).strip():
|
||||||
return "Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. Как я могу к вам обращаться? Я работаю на основе искусственного интеллекта и постараюсь максимально внимательно разобраться с вашим вопросом. Расскажите, пожалуйста, чем могу помочь?"
|
return str(raw).strip()
|
||||||
return str(raw).strip()
|
return " ".join(text for text, _ in _initial_greeting_segments())
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_greeting_segments() -> tuple[tuple[str, str | None], ...]:
|
||||||
|
raw = os.getenv("REALTIME_VOICE_INITIAL_GREETING_TEXT")
|
||||||
|
if raw is not None and str(raw).strip():
|
||||||
|
language = str(os.getenv("REALTIME_VOICE_INITIAL_GREETING_LANGUAGE_CODE", "")).strip() or None
|
||||||
|
text = str(raw).strip()
|
||||||
|
return ((text, language),)
|
||||||
|
|
||||||
|
kk_text = str(
|
||||||
|
os.getenv(
|
||||||
|
"REALTIME_VOICE_INITIAL_GREETING_KK_TEXT",
|
||||||
|
(
|
||||||
|
"Сәлеметсіз бе! Менің атым Айнұр, мен ДиджиОпс компаниясының эй-ай ассистентімін. "
|
||||||
|
"Сізге қалай жүгінсем болады және қай тілде сөйлескен ыңғайлы: қазақша ма, орысша ма?"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).strip()
|
||||||
|
ru_text = str(
|
||||||
|
os.getenv(
|
||||||
|
"REALTIME_VOICE_INITIAL_GREETING_RU_TEXT",
|
||||||
|
(
|
||||||
|
"Здравствуйте! Меня зовут Айнур, я эй-ай-ассистент компании ДиджиОпс. "
|
||||||
|
"Как я могу к вам обращаться и на каком языке вам удобнее продолжить: на казахском или на русском?"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).strip()
|
||||||
|
segments: list[tuple[str, str | None]] = []
|
||||||
|
if kk_text:
|
||||||
|
segments.append((kk_text, "kk"))
|
||||||
|
if ru_text:
|
||||||
|
segments.append((ru_text, "ru"))
|
||||||
|
return tuple(segments)
|
||||||
|
|
||||||
|
|
||||||
def _llm_provider_name() -> str:
|
def _llm_provider_name() -> str:
|
||||||
return str(os.getenv("LLM_PROVIDER") or os.getenv("REALTIME_VOICE_LLM_PROVIDER") or "openai").strip().lower()
|
return str(os.getenv("LLM_PROVIDER") or os.getenv("REALTIME_VOICE_LLM_PROVIDER") or "openai").strip().lower()
|
||||||
@@ -167,17 +201,19 @@ class RealtimeVoiceService:
|
|||||||
LOGGER.exception("failed to preload filler audio clips")
|
LOGGER.exception("failed to preload filler audio clips")
|
||||||
|
|
||||||
async def _preload_initial_greeting_audio(self) -> None:
|
async def _preload_initial_greeting_audio(self) -> None:
|
||||||
greeting_text = _initial_greeting_text()
|
greeting_segments = _initial_greeting_segments()
|
||||||
if not greeting_text:
|
greeting_text = " ".join(text for text, _ in greeting_segments).strip()
|
||||||
|
if not greeting_segments or not greeting_text:
|
||||||
LOGGER.info("initial greeting audio preload skipped: empty text")
|
LOGGER.info("initial greeting audio preload skipped: empty text")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"initial greeting audio preload start: chars=%s text=%r",
|
"initial greeting audio preload start: segments=%s chars=%s text=%r",
|
||||||
|
[(language, len(text)) for text, language in greeting_segments],
|
||||||
len(greeting_text),
|
len(greeting_text),
|
||||||
greeting_text[:120],
|
greeting_text[:120],
|
||||||
)
|
)
|
||||||
clip = await self._filler_audio.synthesize_text(self._tts, greeting_text)
|
clip = await self._filler_audio.synthesize_segments(self._tts, greeting_segments)
|
||||||
if not clip:
|
if not clip:
|
||||||
LOGGER.warning("initial greeting audio preload produced empty clip")
|
LOGGER.warning("initial greeting audio preload produced empty clip")
|
||||||
return
|
return
|
||||||
@@ -240,6 +276,7 @@ class RealtimeVoiceService:
|
|||||||
tts=self._tts,
|
tts=self._tts,
|
||||||
filler_audio=self._filler_audio,
|
filler_audio=self._filler_audio,
|
||||||
initial_greeting_text=_initial_greeting_text(),
|
initial_greeting_text=_initial_greeting_text(),
|
||||||
|
initial_greeting_segments=_initial_greeting_segments(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user