Spaces:
Paused
Paused
Commit ·
6392afd
1
Parent(s): 20fd4f6
perf: sentence-splitting para síntese por sentença dentro de uma GPU call
Browse filesDivide o texto em sentenças antes de sintetizar, processando cada uma
individualmente em loop dentro do mesmo @spaces.GPU. Benefícios:
- RTF melhor: sequências curtas geram tokens mais rápido (atenção quadrática)
- Cache reutilizado: use_memory_cache="on" carrega a voz 1x para todas
- Sem ruído de cauda: cada sentença para limpa em seu ponto final
- max_new_tokens ajustado por sentença (não pelo texto inteiro)
_split_sentences(): quebra em pontuação forte, funde sentençs curtas
(<60 chars) e subdivide longas (>220 chars) na vírgula mais próxima.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
app.py
CHANGED
|
@@ -4,31 +4,83 @@ from typing import Optional
|
|
| 4 |
|
| 5 |
|
| 6 |
def _clean_text(text: str) -> str:
|
| 7 |
-
"""Remove emojis, formatação markdown e normaliza espaços.
|
| 8 |
-
|
| 9 |
-
Emojis e caracteres fora do Latin Extended causam tokens inesperados no
|
| 10 |
-
Fish Speech, aumentando o tempo de geração sem contribuir para o áudio.
|
| 11 |
-
"""
|
| 12 |
# Remove markdown: **bold**, *italic*, __underline__, _italic_, `code`
|
| 13 |
text = re.sub(r'\*{1,3}([^*\n]*)\*{1,3}', r'\1', text)
|
| 14 |
text = re.sub(r'_{1,2}([^_\n]*)_{1,2}', r'\1', text)
|
| 15 |
text = re.sub(r'`[^`]*`', '', text)
|
| 16 |
# Remove emojis e símbolos fora do BMP / Latin Extended
|
| 17 |
text = re.sub(
|
| 18 |
-
u"[\U0001F600-\U0001F64F"
|
| 19 |
-
u"\U0001F300-\U0001F5FF"
|
| 20 |
-
u"\U0001F680-\U0001F6FF"
|
| 21 |
-
u"\U0001F900-\U0001FA9F"
|
| 22 |
-
u"\U00002702-\U000027B0"
|
| 23 |
-
u"\U0001F1E0-\U0001F1FF"
|
| 24 |
-
u"\U0000FE0F"
|
| 25 |
u"]+", '', text
|
| 26 |
)
|
| 27 |
-
# Colapsa espaços/linhas excessivos
|
| 28 |
text = re.sub(r'[ \t]+', ' ', text)
|
| 29 |
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 30 |
return text.strip()
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# Instala fish-speech@v1.5.1 sem suas deps (evita conflito com gradio 6.x).
|
| 33 |
# Os deps necessários já estão em requirements.txt.
|
| 34 |
print("Instalando fish-speech@v1.5.1 (--no-deps)...")
|
|
@@ -334,33 +386,35 @@ def synthesize(text: str, speaker_wav: str, speaker_text: str, language: str = "
|
|
| 334 |
ref_bytes = f.read()
|
| 335 |
|
| 336 |
text = _clean_text(text)
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
text=text,
|
| 342 |
-
references=[ServeReferenceAudio(audio=ref_bytes, text=speaker_text or "")],
|
| 343 |
-
max_new_tokens=max_tokens,
|
| 344 |
-
top_p=0.7,
|
| 345 |
-
repetition_penalty=1.2,
|
| 346 |
-
temperature=0.7,
|
| 347 |
-
format="wav",
|
| 348 |
-
normalize=True, # Fish Speech normaliza números/abreviações
|
| 349 |
-
use_memory_cache="on", # cacheia embedding de voz entre chamadas
|
| 350 |
-
)
|
| 351 |
|
| 352 |
-
# Em modo não-streaming (padrão), fish-speech emite code="final" com o
|
| 353 |
-
# áudio completo concatenado. Em streaming, emite code="segment" por chunk.
|
| 354 |
-
# Coletamos de ambos para cobrir os dois casos.
|
| 355 |
audio_chunks = []
|
| 356 |
out_sr = 44100
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
if not audio_chunks:
|
| 366 |
raise RuntimeError("Nenhum áudio gerado pela inferência.")
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
def _clean_text(text: str) -> str:
|
| 7 |
+
"""Remove emojis, formatação markdown e normaliza espaços."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
# Remove markdown: **bold**, *italic*, __underline__, _italic_, `code`
|
| 9 |
text = re.sub(r'\*{1,3}([^*\n]*)\*{1,3}', r'\1', text)
|
| 10 |
text = re.sub(r'_{1,2}([^_\n]*)_{1,2}', r'\1', text)
|
| 11 |
text = re.sub(r'`[^`]*`', '', text)
|
| 12 |
# Remove emojis e símbolos fora do BMP / Latin Extended
|
| 13 |
text = re.sub(
|
| 14 |
+
u"[\U0001F600-\U0001F64F"
|
| 15 |
+
u"\U0001F300-\U0001F5FF"
|
| 16 |
+
u"\U0001F680-\U0001F6FF"
|
| 17 |
+
u"\U0001F900-\U0001FA9F"
|
| 18 |
+
u"\U00002702-\U000027B0"
|
| 19 |
+
u"\U0001F1E0-\U0001F1FF"
|
| 20 |
+
u"\U0000FE0F"
|
| 21 |
u"]+", '', text
|
| 22 |
)
|
|
|
|
| 23 |
text = re.sub(r'[ \t]+', ' ', text)
|
| 24 |
text = re.sub(r'\n{3,}', '\n\n', text)
|
| 25 |
return text.strip()
|
| 26 |
|
| 27 |
+
|
| 28 |
+
def _split_sentences(text: str, min_len: int = 60, max_len: int = 220) -> list:
|
| 29 |
+
"""Divide texto em sentenças para síntese individual.
|
| 30 |
+
|
| 31 |
+
Estratégia:
|
| 32 |
+
- Quebra em pontuação de fim de frase (. ! ? : ) seguida de espaço/newline
|
| 33 |
+
- Itens de lista numerados/marcados viram sentenças separadas
|
| 34 |
+
- Sentenças muito curtas são fundidas com a próxima (até min_len chars)
|
| 35 |
+
- Sentenças muito longas são subdivididas na vírgula mais próxima do meio
|
| 36 |
+
|
| 37 |
+
Cada sentença é sintetizada individualmente dentro de uma única chamada GPU,
|
| 38 |
+
aproveitando o cache de voz e a melhor RTF de sequências curtas.
|
| 39 |
+
"""
|
| 40 |
+
# Normaliza quebras de linha
|
| 41 |
+
text = re.sub(r'\r\n', '\n', text)
|
| 42 |
+
|
| 43 |
+
# Divide em partes brutas por pontuação forte ou quebra de parágrafo
|
| 44 |
+
parts = re.split(r'(?<=[.!?:])\s+|\n{2,}', text)
|
| 45 |
+
|
| 46 |
+
# Também quebra itens de lista (1. / 2. / - / •)
|
| 47 |
+
expanded = []
|
| 48 |
+
for part in parts:
|
| 49 |
+
items = re.split(r'(?m)(?=^\s*(?:\d+[.)]\s+|-\s+))', part)
|
| 50 |
+
expanded.extend(i.strip() for i in items if i.strip())
|
| 51 |
+
|
| 52 |
+
# Funde sentenças muito curtas com a seguinte
|
| 53 |
+
merged = []
|
| 54 |
+
buf = ""
|
| 55 |
+
for s in expanded:
|
| 56 |
+
buf = (buf + " " + s).strip() if buf else s
|
| 57 |
+
if len(buf) >= min_len:
|
| 58 |
+
merged.append(buf)
|
| 59 |
+
buf = ""
|
| 60 |
+
if buf:
|
| 61 |
+
if merged:
|
| 62 |
+
merged[-1] = merged[-1] + " " + buf
|
| 63 |
+
else:
|
| 64 |
+
merged.append(buf)
|
| 65 |
+
|
| 66 |
+
# Subdivide sentenças muito longas na vírgula mais próxima do meio
|
| 67 |
+
result = []
|
| 68 |
+
for s in merged:
|
| 69 |
+
if len(s) <= max_len:
|
| 70 |
+
result.append(s)
|
| 71 |
+
continue
|
| 72 |
+
# Tenta quebrar na vírgula mais próxima do meio
|
| 73 |
+
mid = len(s) // 2
|
| 74 |
+
commas = [m.start() for m in re.finditer(r',\s', s)]
|
| 75 |
+
if commas:
|
| 76 |
+
cut = min(commas, key=lambda i: abs(i - mid))
|
| 77 |
+
result.append(s[:cut + 1].strip())
|
| 78 |
+
result.append(s[cut + 1:].strip())
|
| 79 |
+
else:
|
| 80 |
+
result.append(s)
|
| 81 |
+
|
| 82 |
+
return [s for s in result if s]
|
| 83 |
+
|
| 84 |
# Instala fish-speech@v1.5.1 sem suas deps (evita conflito com gradio 6.x).
|
| 85 |
# Os deps necessários já estão em requirements.txt.
|
| 86 |
print("Instalando fish-speech@v1.5.1 (--no-deps)...")
|
|
|
|
| 386 |
ref_bytes = f.read()
|
| 387 |
|
| 388 |
text = _clean_text(text)
|
| 389 |
+
sentences = _split_sentences(text)
|
| 390 |
+
print(f"=== Sintetizando {len(sentences)} sentença(s) ===", flush=True)
|
| 391 |
+
for i, s in enumerate(sentences, 1):
|
| 392 |
+
print(f" [{i}] ({len(s)} chars): {s[:80]}...", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
|
|
|
|
|
|
|
|
|
|
| 394 |
audio_chunks = []
|
| 395 |
out_sr = 44100
|
| 396 |
+
|
| 397 |
+
for sent in sentences:
|
| 398 |
+
# max_new_tokens proporcional à sentença: ~8 tok/char, min 128, max 1024
|
| 399 |
+
max_tokens = min(1024, max(128, len(sent) * 8))
|
| 400 |
+
req = ServeTTSRequest(
|
| 401 |
+
text=sent,
|
| 402 |
+
references=[ServeReferenceAudio(audio=ref_bytes, text=speaker_text or "")],
|
| 403 |
+
max_new_tokens=max_tokens,
|
| 404 |
+
top_p=0.7,
|
| 405 |
+
repetition_penalty=1.2,
|
| 406 |
+
temperature=0.7,
|
| 407 |
+
format="wav",
|
| 408 |
+
normalize=True,
|
| 409 |
+
use_memory_cache="on", # cache de voz reutilizado em todas as sentenças
|
| 410 |
+
)
|
| 411 |
+
for result in _engine.inference(req):
|
| 412 |
+
if result.code == "error":
|
| 413 |
+
raise RuntimeError(f"Fish Speech error: {result.error}")
|
| 414 |
+
if result.code in ("segment", "final") and result.audio is not None:
|
| 415 |
+
sr, chunk = result.audio
|
| 416 |
+
out_sr = sr
|
| 417 |
+
audio_chunks.append(chunk.astype(np.float32))
|
| 418 |
|
| 419 |
if not audio_chunks:
|
| 420 |
raise RuntimeError("Nenhum áudio gerado pela inferência.")
|