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

@@ -104,7 +104,7 @@
"949388626146517022" "949388626146517022"
], ],
"user_name": "Marius", "user_name": "Marius",
"default_voice": "Marius 3", "default_voice": "Marius 4",
"auto_leave_minutes": 5, "auto_leave_minutes": 5,
"stt_model": "/home/moltbot/echo-core/models/whisper-small-ro-cv11-int8" "stt_model": "/home/moltbot/echo-core/models/whisper-small-ro-cv11-int8"
}, },

View File

@@ -267,17 +267,25 @@ def expand_currency(text: str) -> str:
# ---------- Symbols ---------- # ---------- Symbols ----------
def expand_symbols(text: str) -> str: _SYMBOL_WORDS = {
"""Replace common symbols with their Romanian spoken form.""" 'ro': {'%': ' la sută', '&': ' și ', '@': ' la ', '°': ' grade'},
text = text.replace('%', ' la sută') 'en': {'%': ' percent', '&': ' and ', '@': ' at ', '°': ' degrees'},
text = text.replace('&', ' și ') }
text = text.replace('@', ' la ')
text = text.replace('°', ' grade')
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() text = re.sub(r'\s+', ' ', text).strip()
return text 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 ---------- # ---------- Abbreviations ----------
@@ -306,34 +314,41 @@ _MAX_WORDS = 200
_TRUNCATE_SUFFIX = "Restul l-am scris în chat." _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, """Apply the full normalization pipeline (markdown strip, abbreviations,
numbers, currency, units, symbols) WITHOUT the 200-word truncation. numbers, currency, units, symbols) WITHOUT the 200-word truncation.
Use this for one-shot TTS generation (e.g. /audio command) where the 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 "Restul l-am scris în chat." suffix from normalize_for_tts() would be
misleading (no live chat mirror exists for that flow). 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 = strip_markdown(text)
text = sanitize_punctuation(text) text = sanitize_punctuation(text)
text = expand_abbreviations(text) if lang == 'ro':
text = normalize_thousands(text) text = expand_abbreviations(text)
text = expand_time(text) text = normalize_thousands(text)
text = expand_currency(text) text = expand_time(text)
text = expand_units(text) text = expand_currency(text)
text = expand_numbers_ro(text) text = expand_units(text)
text = expand_symbols(text) text = expand_numbers_ro(text)
text = expand_symbols(text, lang=lang)
return text.strip() 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. """Apply the full normalization pipeline and truncate to 200 words.
If the text exceeds 200 words, the first 200 are kept and the suffix 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 "Restul l-am scris în chat." is appended so the listener knows the
response continues in the text channel mirror. response continues in the text channel mirror.
""" """
text = expand_for_tts(text) text = expand_for_tts(text, lang=lang)
words = text.split() words = text.split()
if len(words) > _MAX_WORDS: if len(words) > _MAX_WORDS:
text = ' '.join(words[:_MAX_WORDS]) + f" {_TRUNCATE_SUFFIX}" text = ' '.join(words[:_MAX_WORDS]) + f" {_TRUNCATE_SUFFIX}"

View File

@@ -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 synthesize from tools.tts import engine_for_voice, synthesize
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -202,7 +202,14 @@ class TTSQueue:
"""Normalize, segment into clauses, enqueue each clause for synthesis.""" """Normalize, segment into clauses, enqueue each clause for synthesis."""
if not text: if not text:
return 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 n = 0
for clause in clause_segments(cleaned): for clause in clause_segments(cleaned):
clause = clause.strip() clause = clause.strip()

View File

@@ -62,8 +62,14 @@ 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)
def sanitize_for_supertonic(text: str) -> str: def map_tts_punctuation(text: str) -> str:
"""Replace Unicode punctuation and strip chars that crash Supertonic's ONNX model.""" """Replace Unicode punctuation with ASCII and strip emoji — NO length cap.
Folosit și de pipeline-ul de normalizare voice (src/voice/normalize.py),
care rulează pe textul întreg ÎNAINTE de segmentarea în clauze — un cap de
lungime aici ar reteza răspunsurile lungi în mijlocul cuvântului și ar
face moartă trunchierea la 200 de cuvinte din normalize_for_tts.
"""
for src, dst in _TTS_PUNCT_MAP.items(): for src, dst in _TTS_PUNCT_MAP.items():
text = text.replace(src, dst) text = text.replace(src, dst)
# Strip emoji and high-codepoint chars (keep ASCII printable + Latin/Romanian diacritice) # Strip emoji and high-codepoint chars (keep ASCII printable + Latin/Romanian diacritice)
@@ -74,7 +80,12 @@ def sanitize_for_supertonic(text: str) -> str:
cleaned.append(ch) cleaned.append(ch)
else: else:
cleaned.append(' ') cleaned.append(' ')
text = ' '.join(''.join(cleaned).split()) return ' '.join(''.join(cleaned).split())
def sanitize_for_supertonic(text: str) -> str:
"""map_tts_punctuation + hard cap la limita ONNX Supertonic (per request)."""
text = map_tts_punctuation(text)
if len(text) > _MAX_TTS_CHARS: if len(text) > _MAX_TTS_CHARS:
text = text[:_MAX_TTS_CHARS] text = text[:_MAX_TTS_CHARS]
return text return text

View File

@@ -62,5 +62,10 @@
"alba": { "alba": {
"engine": "pockettts", "engine": "pockettts",
"voice_url": "alba" "voice_url": "alba"
},
"Marius 4": {
"engine": "pockettts",
"state_path": "models/voices/marius-4.safetensors",
"owner": "Marius"
} }
} }