fix(voice): normalizare TTS lang-aware + cap 400 chars mutat per-request Supertonic

Două cauze pentru audio mut pe pocket-tts după fix-ul [tts-lang:en]:

1. normalize_for_tts expanda numerele/orele în cuvinte românești chiar și
   în text englezesc ("It's 9:46 PM" → "nouă și patruzeci și șase de
   minute") → diacritice → pocket-tts respingea clauzele. push_text rezolvă
   acum lang din engine-ul vocii curente (engine_for_voice) și sare
   expansiunile RO când lang != 'ro'. [scris de Echo în sesiunea Discord]

2. sanitize_for_supertonic (cap 400 chars — limită ONNX per request) rula
   pe textul întreg în normalize, înainte de segmentarea în clauze —
   răspunsurile lungi erau retezate mid-word și trunchierea la 200 cuvinte
   cu sufixul "Restul l-am scris în chat" nu se mai aplica. Separat
   map_tts_punctuation (fără cap, folosit de normalize) de
   sanitize_for_supertonic (cap, aplicat în _synthesize_supertonic).

Include vocea clonată "Marius 4" (tts_voices.json + default în config).

Fixează test_truncate_exactly_200_words_unchanged și
test_truncate_over_200_words_appends_suffix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 18:57:30 +00:00
parent 00e0d1ad8c
commit 61d667ec65
5 changed files with 61 additions and 23 deletions

View File

@@ -267,17 +267,25 @@ def expand_currency(text: str) -> str:
# ---------- Symbols ----------
def expand_symbols(text: str) -> str:
"""Replace common symbols with their Romanian spoken form."""
text = text.replace('%', ' la sută')
text = text.replace('&', ' și ')
text = text.replace('@', ' la ')
text = text.replace('°', ' grade')
_SYMBOL_WORDS = {
'ro': {'%': ' la sută', '&': ' și ', '@': ' la ', '°': ' grade'},
'en': {'%': ' percent', '&': ' and ', '@': ' at ', '°': ' degrees'},
}
def expand_symbols(text: str, lang: str = 'ro') -> str:
"""Replace common symbols with their spoken form for the given language."""
words = _SYMBOL_WORDS.get(lang, _SYMBOL_WORDS['ro'])
for symbol, word in words.items():
text = text.replace(symbol, word)
text = re.sub(r'\s+', ' ', text).strip()
return text
from tools.tts import sanitize_for_supertonic as sanitize_punctuation
# Punctuation-only mapping (fără cap de lungime) — cap-ul de 400 chars e
# limita per-request Supertonic și se aplică în _synthesize_supertonic,
# după segmentarea în clauze, nu aici pe textul întreg.
from tools.tts import map_tts_punctuation as sanitize_punctuation
# ---------- Abbreviations ----------
@@ -306,34 +314,41 @@ _MAX_WORDS = 200
_TRUNCATE_SUFFIX = "Restul l-am scris în chat."
def expand_for_tts(text: str) -> str:
def expand_for_tts(text: str, lang: str = 'ro') -> str:
"""Apply the full normalization pipeline (markdown strip, abbreviations,
numbers, currency, units, symbols) WITHOUT the 200-word truncation.
Use this for one-shot TTS generation (e.g. /audio command) where the
"Restul l-am scris în chat." suffix from normalize_for_tts() would be
misleading (no live chat mirror exists for that flow).
The RO-specific expansions (abbreviations, thousands, time, currency,
units, numbers-to-words) only make sense for Romanian text — for other
languages (e.g. English text routed to pocket-tts, which is
English-only) they'd inject Romanian words/diacritics into text the
target engine can't speak. Skip them when lang != 'ro'.
"""
text = strip_markdown(text)
text = sanitize_punctuation(text)
text = expand_abbreviations(text)
text = normalize_thousands(text)
text = expand_time(text)
text = expand_currency(text)
text = expand_units(text)
text = expand_numbers_ro(text)
text = expand_symbols(text)
if lang == 'ro':
text = expand_abbreviations(text)
text = normalize_thousands(text)
text = expand_time(text)
text = expand_currency(text)
text = expand_units(text)
text = expand_numbers_ro(text)
text = expand_symbols(text, lang=lang)
return text.strip()
def normalize_for_tts(text: str) -> str:
def normalize_for_tts(text: str, lang: str = 'ro') -> str:
"""Apply the full normalization pipeline and truncate to 200 words.
If the text exceeds 200 words, the first 200 are kept and the suffix
"Restul l-am scris în chat." is appended so the listener knows the
response continues in the text channel mirror.
"""
text = expand_for_tts(text)
text = expand_for_tts(text, lang=lang)
words = text.split()
if len(words) > _MAX_WORDS:
text = ' '.join(words[:_MAX_WORDS]) + f" {_TRUNCATE_SUFFIX}"

View File

@@ -22,7 +22,7 @@ from typing import Iterator, List, Optional
import discord
from src.voice.normalize import normalize_for_tts
from tools.tts import synthesize
from tools.tts import engine_for_voice, synthesize
log = logging.getLogger(__name__)
@@ -202,7 +202,14 @@ class TTSQueue:
"""Normalize, segment into clauses, enqueue each clause for synthesis."""
if not text:
return
cleaned = normalize_for_tts(text)
# Resolve lang from the *current* voice_id (not self.lang, which is
# set once at construction and goes stale on in-band voice swap —
# see session.ttsq.voice_id mutation in discord_voice.py / pipeline.py).
# 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"
cleaned = normalize_for_tts(text, lang=lang)
n = 0
for clause in clause_segments(cleaned):
clause = clause.strip()