fix(voice): instrucțiune de limbă inline + fallback Supertonic pe română
Markerul opac [tts-lang:en] depindea de regula din VOICE_MODE.md, aflată la ~30k caractere distanță în system prompt — modelul o rata pe ~1 din 5 turnuri. Răspunsul în română era apoi respins integral de pocket-tts (English-only) și turnul rămânea fără audio. - src/router.py: markerul poartă instrucțiunea inline (un singur token, acoperit în continuare de strip-ul anti-jailbreak); 5/5 EN la repro - src/voice/tts_stream.py: dacă modelul tot scapă română pe o voce pockettts, blocul cade pe Supertonic (M2, ro) în loc de tăcere; coada TTS transportă (clause, voice, lang) per clauză - tools/tts.py: looks_romanian public (folosit de tts_stream) - personality/VOICE_MODE.md: regula gated pe prefixul [tts-lang:en - tests: 4 teste noi fallback + expectații marker actualizate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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.
|
- 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ă.
|
- 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.
|
- Fără marker `[tts-lang:en`, răspunde în limba lui Marius, ca de obicei.
|
||||||
|
|
||||||
### Lungime și ton
|
### Lungime și ton
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,18 @@ def _strip_leading_voice_tokens(text: str) -> str:
|
|||||||
text = stripped
|
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:
|
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 `''`.
|
English-only (pocket-tts), altfel `''`.
|
||||||
|
|
||||||
Citește config fresh de pe disc (nu singleton-ul modulului) pentru că
|
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
|
from tools.tts import engine_for_voice
|
||||||
voice = Config().get("voice.default_voice", "M2") or "M2"
|
voice = Config().get("voice.default_voice", "M2") or "M2"
|
||||||
if engine_for_voice(voice) == "pockettts":
|
if engine_for_voice(voice) == "pockettts":
|
||||||
return "[tts-lang:en] "
|
return _TTS_LANG_EN_MARKER
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.warning("voice lang marker lookup failed: %s", e)
|
log.warning("voice lang marker lookup failed: %s", e)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from typing import Iterator, List, Optional
|
|||||||
import discord
|
import discord
|
||||||
|
|
||||||
from src.voice.normalize import normalize_for_tts
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -208,13 +208,25 @@ class TTSQueue:
|
|||||||
# pocket-tts is English-only, so RO number/time/currency expansion
|
# pocket-tts is English-only, so RO number/time/currency expansion
|
||||||
# must be skipped or the normalizer injects Romanian diacritics that
|
# must be skipped or the normalizer injects Romanian diacritics that
|
||||||
# pocket-tts refuses to speak (silent dropped clause).
|
# 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)
|
cleaned = normalize_for_tts(text, lang=lang)
|
||||||
n = 0
|
n = 0
|
||||||
for clause in clause_segments(cleaned):
|
for clause in clause_segments(cleaned):
|
||||||
clause = clause.strip()
|
clause = clause.strip()
|
||||||
if clause:
|
if clause:
|
||||||
self._text_queue.put(clause)
|
self._text_queue.put((clause, voice, lang))
|
||||||
n += 1
|
n += 1
|
||||||
log.info("ttsq.push_text: input %d chars → %d clauses queued", len(text), n)
|
log.info("ttsq.push_text: input %d chars → %d clauses queued", len(text), n)
|
||||||
|
|
||||||
@@ -257,11 +269,12 @@ class TTSQueue:
|
|||||||
continue
|
continue
|
||||||
if item is _POISON:
|
if item is _POISON:
|
||||||
break
|
break
|
||||||
if not isinstance(item, str):
|
if not isinstance(item, tuple):
|
||||||
continue
|
continue
|
||||||
preview = item[:60]
|
clause, voice, lang = item
|
||||||
|
preview = clause[:60]
|
||||||
try:
|
try:
|
||||||
result = synthesize(item, voice=self.voice_id, lang=self.lang)
|
result = synthesize(clause, voice=voice, lang=lang)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("TTS synth raised for %r: %s", preview, e)
|
log.warning("TTS synth raised for %r: %s", preview, e)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -449,7 +449,13 @@ class TestVoiceLangMarker:
|
|||||||
from src.router import _voice_turn_lang_marker
|
from src.router import _voice_turn_lang_marker
|
||||||
|
|
||||||
mock_config_cls.return_value.get.return_value = "Marius 2"
|
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")
|
mock_engine.assert_called_once_with("Marius 2")
|
||||||
|
|
||||||
@patch("tools.tts.engine_for_voice", return_value="supertonic")
|
@patch("tools.tts.engine_for_voice", return_value="supertonic")
|
||||||
|
|||||||
69
tests/test_tts_stream_fallback.py
Normal file
69
tests/test_tts_stream_fallback.py
Normal file
@@ -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")
|
||||||
@@ -58,7 +58,7 @@ class _PocketTTSUnavailable(Exception):
|
|||||||
_RO_DIACRITICS = set("ăâîșțĂÂÎȘȚşţŞŢ")
|
_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)
|
return any(ch in _RO_DIACRITICS for ch in text)
|
||||||
|
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ def _synthesize_pockettts(text: str, entry: dict) -> dict:
|
|||||||
Raises:
|
Raises:
|
||||||
_PocketTTSUnavailable: eșec tehnic (connect error/timeout/5xx) — caller-ul face fallback.
|
_PocketTTSUnavailable: eșec tehnic (connect error/timeout/5xx) — caller-ul face fallback.
|
||||||
"""
|
"""
|
||||||
if _looks_romanian(text):
|
if looks_romanian(text):
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": "pocket-tts nu suportă română — textul trebuie să fie în engleză.",
|
"error": "pocket-tts nu suportă română — textul trebuie să fie în engleză.",
|
||||||
|
|||||||
Reference in New Issue
Block a user