mathiasvinicius Claude Sonnet 4.6 commited on
Commit
6392afd
·
1 Parent(s): 20fd4f6

perf: sentence-splitting para síntese por sentença dentro de uma GPU call

Browse files

Divide 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>

Files changed (1) hide show
  1. app.py +91 -37
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" # emoticons
19
- u"\U0001F300-\U0001F5FF" # símbolos & pictogramas
20
- u"\U0001F680-\U0001F6FF" # transporte
21
- u"\U0001F900-\U0001FA9F" # símbolos extras
22
- u"\U00002702-\U000027B0" # dingbats
23
- u"\U0001F1E0-\U0001F1FF" # bandeiras
24
- u"\U0000FE0F" # variation selector
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
- # max_new_tokens proporcional ao texto: ~8 tokens/char, mínimo 512, máximo 4096
338
- max_tokens = min(4096, max(512, len(text) * 8))
339
-
340
- req = ServeTTSRequest(
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
- for result in _engine.inference(req):
358
- if result.code == "error":
359
- raise RuntimeError(f"Fish Speech error: {result.error}")
360
- if result.code in ("segment", "final") and result.audio is not None:
361
- sr, chunk = result.audio
362
- out_sr = sr
363
- audio_chunks.append(chunk.astype(np.float32))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.")