diff --git a/personality/VOICE_MODE.md b/personality/VOICE_MODE.md index 98aeee8..c7465d3 100644 --- a/personality/VOICE_MODE.md +++ b/personality/VOICE_MODE.md @@ -8,11 +8,11 @@ Dacă mesajul user **nu** începe cu `[voice]` / `[speaker:...]`, e text chat: p Răspunzi prin voce (TTS). Marius te aude — nu citește. -### Limbă (marker [tts-lang:en]) +### Limbă (marker [tts-lang:en ...]) -- Dacă prefixul turnului conține `[tts-lang:en]`, vocea activă rulează pe un engine care știe **doar engleză** (pocket-tts). Răspunde **integral în engleză**, indiferent de limba în care vorbește Marius — zero cuvinte românești, zero diacritice. Un răspuns în română e respins de TTS și Marius nu aude nimic. -- Marker-ul e injectat de sistem per-turn, nu de user. Exemplele în română din secțiunile de mai jos ilustrează *stilul* (numere rotunjite, fără abrevieri) — pe turnurile cu `[tts-lang:en]` aplică aceleași principii, dar în engleză. -- Fără `[tts-lang:en]`, răspunde în limba lui Marius, ca de obicei. +- Dacă prefixul turnului conține un marker care începe cu `[tts-lang:en`, vocea activă rulează pe un engine care știe **doar engleză** (pocket-tts). Răspunde **integral în engleză**, indiferent de limba în care vorbește Marius — zero cuvinte românești, zero diacritice. Un răspuns în română e respins de TTS și Marius nu aude nimic. +- Marker-ul e injectat de sistem per-turn, nu de user. Exemplele în română din secțiunile de mai jos ilustrează *stilul* (numere rotunjite, fără abrevieri) — pe turnurile cu `[tts-lang:en` aplică aceleași principii, dar în engleză. +- Fără marker `[tts-lang:en`, răspunde în limba lui Marius, ca de obicei. ### Lungime și ton diff --git a/src/router.py b/src/router.py index 5bc0b00..acdaea9 100644 --- a/src/router.py +++ b/src/router.py @@ -48,8 +48,18 @@ def _strip_leading_voice_tokens(text: str) -> str: text = stripped +# Instrucțiunea de limbă călătorește inline cu turnul: regula din VOICE_MODE.md +# singură (la ~30k caractere distanță în system prompt) e ratată de model pe +# ~1 din 5 turnuri (observat 2026-07-11). Fără `]` interior, deci acoperită de +# _LEADING_VOICE_TOKEN_RE. +_TTS_LANG_EN_MARKER = ( + "[tts-lang:en — reply entirely in English: the active TTS voice " + "cannot speak Romanian] " +) + + def _voice_turn_lang_marker() -> str: - """`'[tts-lang:en] '` dacă vocea activă de voice mode e pe un engine + """`_TTS_LANG_EN_MARKER` dacă vocea activă de voice mode e pe un engine English-only (pocket-tts), altfel `''`. Citește config fresh de pe disc (nu singleton-ul modulului) pentru că @@ -60,7 +70,7 @@ def _voice_turn_lang_marker() -> str: from tools.tts import engine_for_voice voice = Config().get("voice.default_voice", "M2") or "M2" if engine_for_voice(voice) == "pockettts": - return "[tts-lang:en] " + return _TTS_LANG_EN_MARKER except Exception as e: # noqa: BLE001 log.warning("voice lang marker lookup failed: %s", e) return "" diff --git a/src/voice/tts_stream.py b/src/voice/tts_stream.py index 2ffe7c9..faba442 100644 --- a/src/voice/tts_stream.py +++ b/src/voice/tts_stream.py @@ -22,7 +22,7 @@ from typing import Iterator, List, Optional import discord from src.voice.normalize import normalize_for_tts -from tools.tts import engine_for_voice, synthesize +from tools.tts import DEFAULT_VOICE, engine_for_voice, looks_romanian, synthesize log = logging.getLogger(__name__) @@ -208,13 +208,25 @@ class TTSQueue: # pocket-tts is English-only, so RO number/time/currency expansion # must be skipped or the normalizer injects Romanian diacritics that # pocket-tts refuses to speak (silent dropped clause). - lang = "en" if engine_for_voice(self.voice_id) == "pockettts" else "ro" + voice = self.voice_id + english_only = engine_for_voice(voice) == "pockettts" + if english_only and looks_romanian(text): + # Claude a încălcat regula [tts-lang:en] — pocket-tts ar respinge + # fiecare clauză și turnul ar fi tăcere totală. Mai bine vocea + # Supertonic implicită în română decât nimic. + log.warning( + "pocket-tts voice %r got Romanian text — falling back to " + "Supertonic %r for this block", voice, DEFAULT_VOICE, + ) + voice = DEFAULT_VOICE + english_only = False + lang = "en" if english_only else "ro" cleaned = normalize_for_tts(text, lang=lang) n = 0 for clause in clause_segments(cleaned): clause = clause.strip() if clause: - self._text_queue.put(clause) + self._text_queue.put((clause, voice, lang)) n += 1 log.info("ttsq.push_text: input %d chars → %d clauses queued", len(text), n) @@ -257,11 +269,12 @@ class TTSQueue: continue if item is _POISON: break - if not isinstance(item, str): + if not isinstance(item, tuple): continue - preview = item[:60] + clause, voice, lang = item + preview = clause[:60] try: - result = synthesize(item, voice=self.voice_id, lang=self.lang) + result = synthesize(clause, voice=voice, lang=lang) except Exception as e: log.warning("TTS synth raised for %r: %s", preview, e) continue diff --git a/tests/test_router.py b/tests/test_router.py index 514127e..b524dcc 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -449,7 +449,13 @@ class TestVoiceLangMarker: from src.router import _voice_turn_lang_marker mock_config_cls.return_value.get.return_value = "Marius 2" - assert _voice_turn_lang_marker() == "[tts-lang:en] " + marker = _voice_turn_lang_marker() + # Marker-ul poartă instrucțiunea inline, dar trebuie să rămână un + # singur token [tts-lang:...] (fără ] interior) ca să fie gated de + # VOICE_MODE.md și strip-uit de regex-ul anti-jailbreak. + assert marker.startswith("[tts-lang:en") + assert marker.endswith("] ") + assert "]" not in marker[1:-2] mock_engine.assert_called_once_with("Marius 2") @patch("tools.tts.engine_for_voice", return_value="supertonic") diff --git a/tests/test_tts_stream_fallback.py b/tests/test_tts_stream_fallback.py new file mode 100644 index 0000000..82765e6 --- /dev/null +++ b/tests/test_tts_stream_fallback.py @@ -0,0 +1,69 @@ +"""Fallback Supertonic pentru voice mode când modelul răspunde în română +pe o voce pocket-tts (English-only). + +Fără fallback, fiecare clauză ar fi respinsă de pocket-tts (diacritice RO) +și turnul ar fi tăcere totală — bug observat 2026-07-11 („cât este ora" → +răspuns RO scris, zero audio). +""" +import queue +from unittest.mock import patch + +from src.voice.tts_stream import TTSQueue +from tools.tts import DEFAULT_VOICE + + +def _drain_items(q: queue.Queue) -> list: + items = [] + while True: + try: + items.append(q.get_nowait()) + except queue.Empty: + return items + + +class TestPushTextLangRouting: + @patch("src.voice.tts_stream.engine_for_voice", return_value="pockettts") + def test_romanian_text_on_pockettts_falls_back_to_supertonic(self, _eng): + ttsq = TTSQueue(voice_id="Marius 4") + ttsq.push_text("Ora acum e nouă și cinci minute dimineața.") + items = _drain_items(ttsq._text_queue) + assert items, "clauzele trebuie să ajungă în coadă, nu să fie respinse" + for clause, voice, lang in items: + assert voice == DEFAULT_VOICE + assert lang == "ro" + + @patch("src.voice.tts_stream.engine_for_voice", return_value="pockettts") + def test_english_text_on_pockettts_keeps_cloned_voice(self, _eng): + ttsq = TTSQueue(voice_id="Marius 4") + ttsq.push_text("It's five past nine in the morning, Bucharest time.") + items = _drain_items(ttsq._text_queue) + assert items + for clause, voice, lang in items: + assert voice == "Marius 4" + assert lang == "en" + + @patch("src.voice.tts_stream.engine_for_voice", return_value="supertonic") + def test_supertonic_voice_unaffected(self, _eng): + ttsq = TTSQueue(voice_id="M2") + ttsq.push_text("Ora acum e nouă și cinci minute dimineața.") + items = _drain_items(ttsq._text_queue) + assert items + for clause, voice, lang in items: + assert voice == "M2" + assert lang == "ro" + + +class TestWorkerConsumesTuples: + @patch("src.voice.tts_stream.synthesize", return_value={"ok": False, "error": "x"}) + def test_worker_unpacks_clause_voice_lang(self, mock_synth): + ttsq = TTSQueue(voice_id="Marius 4") + ttsq._text_queue.put(("Hello there.", "Marius 4", "en")) + ttsq.start() + try: + import time + deadline = time.monotonic() + 2.0 + while not mock_synth.called and time.monotonic() < deadline: + time.sleep(0.02) + finally: + ttsq.stop() + mock_synth.assert_called_once_with("Hello there.", voice="Marius 4", lang="en") diff --git a/tools/tts.py b/tools/tts.py index daf3b8e..ff9e279 100644 --- a/tools/tts.py +++ b/tools/tts.py @@ -58,7 +58,7 @@ class _PocketTTSUnavailable(Exception): _RO_DIACRITICS = set("ăâîșțĂÂÎȘȚşţŞŢ") -def _looks_romanian(text: str) -> bool: +def looks_romanian(text: str) -> bool: return any(ch in _RO_DIACRITICS for ch in text) @@ -202,7 +202,7 @@ def _synthesize_pockettts(text: str, entry: dict) -> dict: Raises: _PocketTTSUnavailable: eșec tehnic (connect error/timeout/5xx) — caller-ul face fallback. """ - if _looks_romanian(text): + if looks_romanian(text): return { "ok": False, "error": "pocket-tts nu suportă română — textul trebuie să fie în engleză.",