1033 lines
35 KiB
Python
1033 lines
35 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import importlib.util
|
|||
|
|
import queue
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import List, Optional, Tuple
|
|||
|
|
|
|||
|
|
import torch
|
|||
|
|
import torchaudio as ta
|
|||
|
|
|
|||
|
|
# SDPA does not support output_attentions=True (required by AlignmentStreamAnalyzer hook);
|
|||
|
|
# fall back to eager attention so attention weights are returned as tensors, not None.
|
|||
|
|
import chatterbox.models.t3.llama_configs as _llama_cfg
|
|||
|
|
_llama_cfg.LLAMA_520M_CONFIG_DICT["attn_implementation"] = "eager"
|
|||
|
|
|
|||
|
|
from chatterbox.tts import ChatterboxTTS
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
|
|||
|
|
HAS_MULTILINGUAL = True
|
|||
|
|
except Exception:
|
|||
|
|
ChatterboxMultilingualTTS = None
|
|||
|
|
HAS_MULTILINGUAL = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
SUPPORTED_LANGS = {
|
|||
|
|
"ar", "da", "de", "el", "en", "es", "fi", "fr", "he", "hi", "it",
|
|||
|
|
"ja", "ko", "ms", "nl", "no", "pl", "pt", "ru", "sv", "sw", "tr", "zh"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
SENTENCE_END_RE = re.compile(
|
|||
|
|
r'.+?(?:'
|
|||
|
|
r'\.\.\.|…|'
|
|||
|
|
r'[!?¡¿]+|'
|
|||
|
|
r'[!?。]+|'
|
|||
|
|
r'‽|'
|
|||
|
|
r'(?<!\d)\.' # Punkt, aber NICHT nach einer Ziffer (kein Ordinalzahl-Split)
|
|||
|
|
r')(?=\s+|$)',
|
|||
|
|
re.DOTALL
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
NON_SPELLED_ACRONYMS = {
|
|||
|
|
"NATO",
|
|||
|
|
"NASA",
|
|||
|
|
"UNESCO",
|
|||
|
|
"OPEC",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
GERMAN_LETTER_NAMES = {
|
|||
|
|
'A': 'Ah', 'B': 'Be', 'C': 'Tse', 'D': 'De', 'E': 'E',
|
|||
|
|
'F': 'Ef', 'G': 'Ge', 'H': 'Ha', 'I': 'I', 'J': 'Jot',
|
|||
|
|
'K': 'Ka', 'L': 'El', 'M': 'Em', 'N': 'En', 'O': 'O',
|
|||
|
|
'P': 'Pe', 'Q': 'Ku', 'R': 'Er', 'S': 'Es', 'T': 'Te',
|
|||
|
|
'U': 'U', 'V': 'Fau', 'W': 'We', 'X': 'Iks', 'Y': 'Ypsilon',
|
|||
|
|
'Z': 'Tset',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Trennlinien wie "--- Ende ---", "===", "---" filtern
|
|||
|
|
# Matcht: reine Strichlinien ODER "---Wort---"-Muster mit kurzem Inhalt (<= 20 Zeichen)
|
|||
|
|
SEPARATOR_LINE_RE = re.compile(r'^\s*-{2,}\s*[\w\s]{0,20}\s*-{2,}\s*$|^\s*[=_-]{3,}\s*$')
|
|||
|
|
|
|||
|
|
UPPER_ACRONYM_RE = re.compile(r'\b[A-ZÄÖÜ]{2,}(?:[A-ZÄÖÜ0-9]*[A-ZÄÖÜ])?\b')
|
|||
|
|
# Akronym direkt vor Bindestrich + Wort: "US-Präsident", "NATO-Mitglied"
|
|||
|
|
ACRONYM_COMPOUND_RE = re.compile(r'\b([A-ZÄÖÜ]{2,}(?:[A-ZÄÖÜ0-9]*[A-ZÄÖÜ])?)-(?=[A-ZÄÖÜa-zäöü])')
|
|||
|
|
|
|||
|
|
# Unterstützt:
|
|||
|
|
# - 14:58
|
|||
|
|
# - 14.58
|
|||
|
|
# - 14:58 Uhr
|
|||
|
|
# - 14.58 Uhr
|
|||
|
|
TIME_RE = re.compile(r'\b([01]?\d|2[0-3])([:.])([0-5]\d)(?:\s*Uhr)?\b', re.IGNORECASE)
|
|||
|
|
|
|||
|
|
# Vierstellige Jahreszahlen
|
|||
|
|
YEAR_RE = re.compile(r'\b(19\d{2}|20\d{2}|21\d{2})\b')
|
|||
|
|
|
|||
|
|
# Einfache deutsche Einheiten
|
|||
|
|
UNIT_REPLACEMENTS = {
|
|||
|
|
"km/h": "Kilometer pro Stunde",
|
|||
|
|
"km": "Kilometer",
|
|||
|
|
"m": "Meter",
|
|||
|
|
"cm": "Zentimeter",
|
|||
|
|
"mm": "Millimeter",
|
|||
|
|
"kg": "Kilogramm",
|
|||
|
|
"g": "Gramm",
|
|||
|
|
"mg": "Milligramm",
|
|||
|
|
"Hz": "Hertz",
|
|||
|
|
"kHz": "Kilohertz",
|
|||
|
|
"MHz": "Megahertz",
|
|||
|
|
"GHz": "Gigahertz",
|
|||
|
|
"€": "Euro",
|
|||
|
|
"$": "Dollar",
|
|||
|
|
"%": "Prozent",
|
|||
|
|
"Kb": "Kilobyte",
|
|||
|
|
"Mb": "Megabyte",
|
|||
|
|
"GB": "Gigabyte",
|
|||
|
|
"TB": "Terabyte",
|
|||
|
|
"PB": "Petabyte",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Eingebaute phonetische Annäherungen für häufige Fremdnamen (Deutsch)
|
|||
|
|
DEFAULT_PRONUNCIATION_DE: dict[str, str] = {
|
|||
|
|
"Xi Jinping": "Schi Jinping",
|
|||
|
|
"Xi": "Schi",
|
|||
|
|
"Jinping": "Jinping",
|
|||
|
|
"Peking": "Peking", # bleibt — deutsches TTS kennt es
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_pronunciation_dict(text: str, pron_dict: dict[str, str]) -> str:
|
|||
|
|
for phrase, replacement in sorted(pron_dict.items(), key=lambda x: len(x[0]), reverse=True):
|
|||
|
|
text = text.replace(phrase, replacement)
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def clean_raw_text(text: str) -> str:
|
|||
|
|
"""Unsichtbare Steuerzeichen entfernen, die Splitting oder TTS stoeren."""
|
|||
|
|
for ch in ('', '', '', ''):
|
|||
|
|
text = text.replace(ch, '')
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def has_module(name: str) -> bool:
|
|||
|
|
return importlib.util.find_spec(name) is not None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_device(explicit_device: Optional[str] = None) -> str:
|
|||
|
|
if explicit_device:
|
|||
|
|
if explicit_device.startswith("cuda") and not torch.cuda.is_available():
|
|||
|
|
raise RuntimeError("CUDA angefordert, aber keine CUDA-GPU verfügbar.")
|
|||
|
|
if explicit_device.startswith("cuda"):
|
|||
|
|
try:
|
|||
|
|
idx = int(explicit_device.split(":")[1]) if ":" in explicit_device else 0
|
|||
|
|
except (IndexError, ValueError):
|
|||
|
|
idx = 0
|
|||
|
|
torch.cuda.set_device(idx)
|
|||
|
|
return explicit_device
|
|||
|
|
|
|||
|
|
if torch.cuda.is_available():
|
|||
|
|
torch.cuda.set_device(0)
|
|||
|
|
return "cuda:0"
|
|||
|
|
|
|||
|
|
return "cpu"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def number_to_words_de(n: int) -> str:
|
|||
|
|
ones = {
|
|||
|
|
0: "null", 1: "eins", 2: "zwei", 3: "drei", 4: "vier", 5: "fünf",
|
|||
|
|
6: "sechs", 7: "sieben", 8: "acht", 9: "neun", 10: "zehn",
|
|||
|
|
11: "elf", 12: "zwölf", 13: "dreizehn", 14: "vierzehn",
|
|||
|
|
15: "fünfzehn", 16: "sechzehn", 17: "siebzehn", 18: "achtzehn",
|
|||
|
|
19: "neunzehn"
|
|||
|
|
}
|
|||
|
|
tens = {
|
|||
|
|
20: "zwanzig", 30: "dreißig", 40: "vierzig", 50: "fünfzig",
|
|||
|
|
60: "sechzig", 70: "siebzig", 80: "achtzig", 90: "neunzig"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if n < 20:
|
|||
|
|
return ones[n]
|
|||
|
|
|
|||
|
|
if n < 100:
|
|||
|
|
t = (n // 10) * 10
|
|||
|
|
o = n % 10
|
|||
|
|
if o == 0:
|
|||
|
|
return tens[t]
|
|||
|
|
one_prefix = "ein" if o == 1 else ones[o]
|
|||
|
|
return f"{one_prefix}und{tens[t]}"
|
|||
|
|
|
|||
|
|
if n < 1000:
|
|||
|
|
h = n // 100
|
|||
|
|
r = n % 100
|
|||
|
|
prefix = "einhundert" if h == 1 else f"{ones[h]}hundert"
|
|||
|
|
return prefix if r == 0 else f"{prefix}{number_to_words_de(r)}"
|
|||
|
|
|
|||
|
|
if n < 1000000:
|
|||
|
|
th = n // 1000
|
|||
|
|
r = n % 1000
|
|||
|
|
prefix = "eintausend" if th == 1 else f"{number_to_words_de(th)}tausend"
|
|||
|
|
return prefix if r == 0 else f"{prefix}{number_to_words_de(r)}"
|
|||
|
|
|
|||
|
|
return str(n)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def number_to_words_en(n: int) -> str:
|
|||
|
|
ones = {
|
|||
|
|
0: "zero", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
|
|||
|
|
6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten",
|
|||
|
|
11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen",
|
|||
|
|
15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen",
|
|||
|
|
19: "nineteen"
|
|||
|
|
}
|
|||
|
|
tens = {
|
|||
|
|
20: "twenty", 30: "thirty", 40: "forty", 50: "fifty",
|
|||
|
|
60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if n < 20:
|
|||
|
|
return ones[n]
|
|||
|
|
|
|||
|
|
if n < 100:
|
|||
|
|
t = (n // 10) * 10
|
|||
|
|
o = n % 10
|
|||
|
|
return tens[t] if o == 0 else f"{tens[t]}-{ones[o]}"
|
|||
|
|
|
|||
|
|
if n < 1000:
|
|||
|
|
h = n // 100
|
|||
|
|
r = n % 100
|
|||
|
|
prefix = f"{ones[h]} hundred"
|
|||
|
|
return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}"
|
|||
|
|
|
|||
|
|
if n < 1000000:
|
|||
|
|
th = n // 1000
|
|||
|
|
r = n % 1000
|
|||
|
|
prefix = f"{number_to_words_en(th)} thousand"
|
|||
|
|
return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}"
|
|||
|
|
|
|||
|
|
return str(n)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def year_to_words_de(year: int) -> str:
|
|||
|
|
if year < 1000 or year > 9999:
|
|||
|
|
return str(year)
|
|||
|
|
|
|||
|
|
if year == 2000:
|
|||
|
|
return "zweitausend"
|
|||
|
|
|
|||
|
|
if 2001 <= year <= 2099:
|
|||
|
|
return f"zweitausend{number_to_words_de(year - 2000)}"
|
|||
|
|
|
|||
|
|
return number_to_words_de(year)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def year_to_words_en(year: int) -> str:
|
|||
|
|
if year < 1000 or year > 9999:
|
|||
|
|
return str(year)
|
|||
|
|
|
|||
|
|
if 2000 <= year <= 2009:
|
|||
|
|
if year == 2000:
|
|||
|
|
return "two thousand"
|
|||
|
|
return f"two thousand {number_to_words_en(year - 2000)}"
|
|||
|
|
|
|||
|
|
if 2010 <= year <= 2099:
|
|||
|
|
last_two = year % 100
|
|||
|
|
return f"twenty {number_to_words_en(last_two)}"
|
|||
|
|
|
|||
|
|
first_two = year // 100
|
|||
|
|
last_two = year % 100
|
|||
|
|
if last_two == 0:
|
|||
|
|
return f"{number_to_words_en(first_two)} hundred"
|
|||
|
|
return f"{number_to_words_en(first_two)} {number_to_words_en(last_two)}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def spell_out_acronym(token: str, mode: str = "period_space") -> str:
|
|||
|
|
chars = list(token)
|
|||
|
|
|
|||
|
|
if mode == "german":
|
|||
|
|
return " ".join(GERMAN_LETTER_NAMES.get(c, c) for c in chars)
|
|||
|
|
|
|||
|
|
if mode == "space":
|
|||
|
|
return " ".join(chars)
|
|||
|
|
|
|||
|
|
if mode == "period":
|
|||
|
|
return ".".join(chars) + "."
|
|||
|
|
|
|||
|
|
if mode == "comma":
|
|||
|
|
return ", ".join(chars)
|
|||
|
|
|
|||
|
|
if mode == "period_space":
|
|||
|
|
return ". ".join(chars) + "."
|
|||
|
|
|
|||
|
|
raise ValueError(f"Unbekannter mode: {mode}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_units(text: str, lang: str) -> str:
|
|||
|
|
if lang != "de":
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
for unit, expanded in sorted(UNIT_REPLACEMENTS.items(), key=lambda x: len(x[0]), reverse=True):
|
|||
|
|
text = re.sub(rf'(?<=\d)\s*{re.escape(unit)}\b', f" {expanded}", text)
|
|||
|
|
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_times(text: str, lang: str) -> str:
|
|||
|
|
def repl(match: re.Match) -> str:
|
|||
|
|
hh = int(match.group(1))
|
|||
|
|
mm = int(match.group(3))
|
|||
|
|
|
|||
|
|
if lang == "de":
|
|||
|
|
if mm == 0:
|
|||
|
|
return f"{number_to_words_de(hh)} Uhr"
|
|||
|
|
return f"{number_to_words_de(hh)} Uhr {number_to_words_de(mm)}"
|
|||
|
|
|
|||
|
|
if lang == "en":
|
|||
|
|
if hh == 0 and mm == 0:
|
|||
|
|
return "twelve midnight"
|
|||
|
|
if hh == 12 and mm == 0:
|
|||
|
|
return "twelve noon"
|
|||
|
|
|
|||
|
|
hour12 = hh % 12
|
|||
|
|
if hour12 == 0:
|
|||
|
|
hour12 = 12
|
|||
|
|
suffix = "a m" if hh < 12 else "p m"
|
|||
|
|
|
|||
|
|
if mm == 0:
|
|||
|
|
return f"{number_to_words_en(hour12)} {suffix}"
|
|||
|
|
if mm < 10:
|
|||
|
|
return f"{number_to_words_en(hour12)} oh {number_to_words_en(mm)} {suffix}"
|
|||
|
|
return f"{number_to_words_en(hour12)} {number_to_words_en(mm)} {suffix}"
|
|||
|
|
|
|||
|
|
return match.group(0)
|
|||
|
|
|
|||
|
|
return TIME_RE.sub(repl, text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_years(text: str, lang: str) -> str:
|
|||
|
|
def repl(match: re.Match) -> str:
|
|||
|
|
year = int(match.group(1))
|
|||
|
|
|
|||
|
|
if lang == "de":
|
|||
|
|
return year_to_words_de(year)
|
|||
|
|
|
|||
|
|
if lang == "en":
|
|||
|
|
return year_to_words_en(year)
|
|||
|
|
|
|||
|
|
return match.group(0)
|
|||
|
|
|
|||
|
|
return YEAR_RE.sub(repl, text)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def preprocess_tts_text(
|
|||
|
|
text: str,
|
|||
|
|
lang: str,
|
|||
|
|
spell_uppercase_acronyms: bool = True,
|
|||
|
|
acronym_mode: Optional[str] = None, # None = auto: 'german' bei de, sonst 'period_space'
|
|||
|
|
normalize_time_values: bool = True,
|
|||
|
|
normalize_year_values: bool = True,
|
|||
|
|
normalize_units_values: bool = True,
|
|||
|
|
pronunciation_dict: Optional[dict] = None,
|
|||
|
|
) -> str:
|
|||
|
|
if acronym_mode is None:
|
|||
|
|
acronym_mode = "german" if lang == "de" else "period_space"
|
|||
|
|
|
|||
|
|
# 1. Aussprache-Wörterbuch zuerst (vor Akronym-Expansion, damit Eigennamen greifen)
|
|||
|
|
if lang == "de":
|
|||
|
|
text = apply_pronunciation_dict(text, DEFAULT_PRONUNCIATION_DE)
|
|||
|
|
if pronunciation_dict:
|
|||
|
|
text = apply_pronunciation_dict(text, pronunciation_dict)
|
|||
|
|
|
|||
|
|
if normalize_units_values:
|
|||
|
|
text = normalize_units(text, lang)
|
|||
|
|
|
|||
|
|
if normalize_time_values:
|
|||
|
|
text = normalize_times(text, lang)
|
|||
|
|
|
|||
|
|
if normalize_year_values:
|
|||
|
|
text = normalize_years(text, lang)
|
|||
|
|
|
|||
|
|
if spell_uppercase_acronyms:
|
|||
|
|
def repl_compound(match: re.Match) -> str:
|
|||
|
|
acr = match.group(1)
|
|||
|
|
if acr in NON_SPELLED_ACRONYMS:
|
|||
|
|
return acr + " "
|
|||
|
|
return spell_out_acronym(acr, mode=acronym_mode) + " "
|
|||
|
|
|
|||
|
|
def repl(match: re.Match) -> str:
|
|||
|
|
token = match.group(0)
|
|||
|
|
if token in NON_SPELLED_ACRONYMS:
|
|||
|
|
return token
|
|||
|
|
return spell_out_acronym(token, mode=acronym_mode)
|
|||
|
|
|
|||
|
|
# Compound zuerst: "US-Präsident" → "U Es Präsident" (Bindestrich weg)
|
|||
|
|
text = ACRONYM_COMPOUND_RE.sub(repl_compound, text)
|
|||
|
|
# Dann verbleibende Akronyme buchstabieren
|
|||
|
|
text = UPPER_ACRONYM_RE.sub(repl, text)
|
|||
|
|
|
|||
|
|
text = re.sub(r'\s+', ' ', text).strip()
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def split_long_text(text: str, max_len: int = 400) -> List[str]:
|
|||
|
|
chunks = []
|
|||
|
|
current = ""
|
|||
|
|
|
|||
|
|
for part in text.split("\n\n"):
|
|||
|
|
part = part.strip()
|
|||
|
|
if not part:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
sentences = SENTENCE_END_RE.findall(part)
|
|||
|
|
|
|||
|
|
consumed = "".join(sentences).strip()
|
|||
|
|
rest = part[len(consumed):].strip()
|
|||
|
|
if rest:
|
|||
|
|
sentences.append(rest)
|
|||
|
|
|
|||
|
|
for sentence in sentences:
|
|||
|
|
sentence = sentence.strip()
|
|||
|
|
if not sentence:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if len(sentence) > max_len:
|
|||
|
|
if current:
|
|||
|
|
chunks.append(current.strip())
|
|||
|
|
current = ""
|
|||
|
|
|
|||
|
|
chunks.extend(force_split_sentence(sentence, max_len))
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if current and len(current) + 1 + len(sentence) > max_len:
|
|||
|
|
chunks.append(current.strip())
|
|||
|
|
current = sentence
|
|||
|
|
else:
|
|||
|
|
current = f"{current} {sentence}".strip() if current else sentence
|
|||
|
|
|
|||
|
|
if current:
|
|||
|
|
chunks.append(current.strip())
|
|||
|
|
current = ""
|
|||
|
|
|
|||
|
|
return chunks
|
|||
|
|
|
|||
|
|
|
|||
|
|
def split_for_conversation(text: str, first_chunk_len: int = 120, max_len: int = 400) -> List[str]:
|
|||
|
|
base_chunks = split_long_text(text, max_len=max_len)
|
|||
|
|
if not base_chunks:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
first = base_chunks[0]
|
|||
|
|
if len(first) <= first_chunk_len:
|
|||
|
|
return base_chunks
|
|||
|
|
|
|||
|
|
early = force_split_sentence(first, first_chunk_len)
|
|||
|
|
return early + base_chunks[1:]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def force_split_sentence(text: str, max_len: int) -> List[str]:
|
|||
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|||
|
|
if len(text) <= max_len:
|
|||
|
|
return [text]
|
|||
|
|
|
|||
|
|
parts = []
|
|||
|
|
remaining = text
|
|||
|
|
|
|||
|
|
while len(remaining) > max_len:
|
|||
|
|
split_pos = remaining.rfind(" ", 0, max_len + 1)
|
|||
|
|
if split_pos <= 0:
|
|||
|
|
split_pos = max_len
|
|||
|
|
parts.append(remaining[:split_pos].strip())
|
|||
|
|
remaining = remaining[split_pos:].strip()
|
|||
|
|
|
|||
|
|
if remaining:
|
|||
|
|
parts.append(remaining)
|
|||
|
|
|
|||
|
|
return parts
|
|||
|
|
|
|||
|
|
|
|||
|
|
def split_into_sentences(text: str, max_len: int = 200) -> List[str]:
|
|||
|
|
result = []
|
|||
|
|
for part in text.split("\n\n"):
|
|||
|
|
part = part.strip()
|
|||
|
|
if not part:
|
|||
|
|
continue
|
|||
|
|
if SEPARATOR_LINE_RE.match(part):
|
|||
|
|
continue
|
|||
|
|
sentences = SENTENCE_END_RE.findall(part)
|
|||
|
|
consumed = "".join(sentences).strip()
|
|||
|
|
rest = part[len(consumed):].strip()
|
|||
|
|
if rest:
|
|||
|
|
sentences.append(rest)
|
|||
|
|
for sentence in sentences:
|
|||
|
|
sentence = sentence.strip()
|
|||
|
|
if not sentence:
|
|||
|
|
continue
|
|||
|
|
if len(sentence) > max_len:
|
|||
|
|
result.extend(force_split_sentence(sentence, max_len))
|
|||
|
|
else:
|
|||
|
|
result.append(sentence)
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def read_input_text(text_arg: Optional[str], input_path: Optional[str]) -> str:
|
|||
|
|
if text_arg and input_path:
|
|||
|
|
raise ValueError("Bitte entweder --text oder --input angeben, nicht beides.")
|
|||
|
|
if not text_arg and not input_path:
|
|||
|
|
raise ValueError("Bitte --text oder --input angeben.")
|
|||
|
|
|
|||
|
|
if text_arg:
|
|||
|
|
return text_arg.strip()
|
|||
|
|
|
|||
|
|
path = Path(input_path)
|
|||
|
|
if not path.exists():
|
|||
|
|
raise FileNotFoundError(f"Input-Datei nicht gefunden: {path}")
|
|||
|
|
|
|||
|
|
return path.read_text(encoding="utf-8").strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def default_output_path(input_path: Optional[str], lang: str) -> Path:
|
|||
|
|
if input_path:
|
|||
|
|
src = Path(input_path)
|
|||
|
|
return src.with_suffix(f".{lang}.wav")
|
|||
|
|
return Path(f"tts_output.{lang}.wav")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_model(lang: str, device: str, t3_model: Optional[str] = None):
|
|||
|
|
if lang == "en":
|
|||
|
|
model = ChatterboxTTS.from_pretrained(device=device)
|
|||
|
|
return model, "mono", model.sr
|
|||
|
|
|
|||
|
|
if not HAS_MULTILINGUAL:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"Multilingual-Modell nicht verfügbar. Installiere ein Chatterbox-Paket mit chatterbox.mtl_tts."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
model = ChatterboxMultilingualTTS.from_pretrained(device=device, t3_model=t3_model)
|
|||
|
|
return model, "multi", model.sr
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_chunk(model, model_kind: str, text: str, lang: str, voice_path: Optional[str]):
|
|||
|
|
kwargs = {}
|
|||
|
|
if voice_path:
|
|||
|
|
kwargs["audio_prompt_path"] = voice_path
|
|||
|
|
|
|||
|
|
if model_kind == "mono":
|
|||
|
|
return model.generate(text, **kwargs)
|
|||
|
|
|
|||
|
|
return model.generate(text, language_id=lang, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def generate_stream_chunk(
|
|||
|
|
model,
|
|||
|
|
model_kind: str,
|
|||
|
|
text: str,
|
|||
|
|
lang: str,
|
|||
|
|
voice_path: Optional[str],
|
|||
|
|
stream_chunk_size: int,
|
|||
|
|
):
|
|||
|
|
kwargs = {
|
|||
|
|
"chunk_size": stream_chunk_size,
|
|||
|
|
"print_metrics": False,
|
|||
|
|
}
|
|||
|
|
if voice_path:
|
|||
|
|
kwargs["audio_prompt_path"] = voice_path
|
|||
|
|
|
|||
|
|
if model_kind == "mono":
|
|||
|
|
return model.generate_stream(text, **kwargs)
|
|||
|
|
|
|||
|
|
return model.generate_stream(text, language_id=lang, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PlaybackWorker:
|
|||
|
|
PLAYBACK_RATE = 48000 # PipeWire/PulseAudio standard
|
|||
|
|
CALLBACK_BLOCK = 2048 # ~43 ms pro Callback-Block bei 48 kHz
|
|||
|
|
|
|||
|
|
def __init__(self, sample_rate: int, device: Optional[str] = "pulse", speed: float = 1.0):
|
|||
|
|
self.sample_rate = sample_rate
|
|||
|
|
self.device = device
|
|||
|
|
self.speed = speed
|
|||
|
|
# Eingang: Torch-Tensoren vom TTS-Modell
|
|||
|
|
self.audio_queue: "queue.Queue[Optional[torch.Tensor]]" = queue.Queue()
|
|||
|
|
# Intern: fertig vorbereitete numpy-Blöcke für den Callback
|
|||
|
|
self._block_queue: "queue.Queue" = queue.Queue(maxsize=500)
|
|||
|
|
self._blocks_produced = 0
|
|||
|
|
self._blocks_consumed = 0
|
|||
|
|
self.thread = None
|
|||
|
|
self.error = None
|
|||
|
|
|
|||
|
|
def start(self):
|
|||
|
|
if not has_module("sounddevice"):
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"Für Live-Wiedergabe ist das Modul 'sounddevice' nötig. Installiere z. B. 'pip install sounddevice'."
|
|||
|
|
)
|
|||
|
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
|||
|
|
self.thread.start()
|
|||
|
|
|
|||
|
|
def _callback(self, outdata, frames, time_info, status):
|
|||
|
|
# Läuft im Audio-Thread: so schnell wie möglich, kein Lock nötig.
|
|||
|
|
try:
|
|||
|
|
data = self._block_queue.get_nowait()
|
|||
|
|
outdata[:, 0] = data
|
|||
|
|
self._blocks_consumed += 1
|
|||
|
|
except queue.Empty:
|
|||
|
|
outdata[:] = 0.0 # Stille statt Underrun-Klick
|
|||
|
|
|
|||
|
|
def _produce(self):
|
|||
|
|
"""Wandelt Torch-Tensoren in CALLBACK_BLOCK-große numpy-Arrays um."""
|
|||
|
|
import numpy as np
|
|||
|
|
|
|||
|
|
remainder = np.zeros(0, dtype="float32")
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
item = self.audio_queue.get()
|
|||
|
|
if item is None:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
chunk = item.detach().cpu()
|
|||
|
|
if chunk.ndim == 2:
|
|||
|
|
chunk = chunk.squeeze(0)
|
|||
|
|
|
|||
|
|
if self.speed != 1.0:
|
|||
|
|
import pyrubberband as pyrb
|
|||
|
|
# R3-Engine (--fine): deutlich weniger Phasiness als R2, besser für Sprache.
|
|||
|
|
# rate < 1.0 = langsamer, rate > 1.0 = schneller; Pitch bleibt gleich.
|
|||
|
|
stretched = pyrb.time_stretch(
|
|||
|
|
chunk.numpy().astype("float64"), self.sample_rate, self.speed,
|
|||
|
|
rbargs={"--fine": ""},
|
|||
|
|
)
|
|||
|
|
chunk = torch.from_numpy(stretched.astype("float32"))
|
|||
|
|
|
|||
|
|
chunk = ta.functional.resample(chunk, self.sample_rate, self.PLAYBACK_RATE)
|
|||
|
|
|
|||
|
|
samples = np.concatenate([remainder, chunk.numpy().astype("float32")])
|
|||
|
|
|
|||
|
|
i = 0
|
|||
|
|
while i + self.CALLBACK_BLOCK <= len(samples):
|
|||
|
|
self._block_queue.put(samples[i : i + self.CALLBACK_BLOCK])
|
|||
|
|
self._blocks_produced += 1
|
|||
|
|
i += self.CALLBACK_BLOCK
|
|||
|
|
remainder = samples[i:]
|
|||
|
|
|
|||
|
|
# Restliche Samples (< CALLBACK_BLOCK) mit Stille auffüllen
|
|||
|
|
if len(remainder) > 0:
|
|||
|
|
block = np.zeros(self.CALLBACK_BLOCK, dtype="float32")
|
|||
|
|
block[: len(remainder)] = remainder
|
|||
|
|
self._block_queue.put(block)
|
|||
|
|
self._blocks_produced += 1
|
|||
|
|
|
|||
|
|
def _run(self):
|
|||
|
|
try:
|
|||
|
|
import sounddevice as sd
|
|||
|
|
|
|||
|
|
producer = threading.Thread(target=self._produce, daemon=True)
|
|||
|
|
producer.start()
|
|||
|
|
|
|||
|
|
with sd.OutputStream(
|
|||
|
|
samplerate=self.PLAYBACK_RATE,
|
|||
|
|
channels=1,
|
|||
|
|
dtype="float32",
|
|||
|
|
device=self.device,
|
|||
|
|
blocksize=self.CALLBACK_BLOCK,
|
|||
|
|
callback=self._callback,
|
|||
|
|
):
|
|||
|
|
producer.join() # alle Tensoren sind zu Blöcken konvertiert
|
|||
|
|
|
|||
|
|
# Warten bis der Callback alle Blöcke abgespielt hat
|
|||
|
|
while self._blocks_consumed < self._blocks_produced:
|
|||
|
|
time.sleep(0.02)
|
|||
|
|
|
|||
|
|
# Letzten Block aus Hardware-Buffer ausspielen lassen
|
|||
|
|
time.sleep(self.CALLBACK_BLOCK / self.PLAYBACK_RATE + 0.1)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
self.error = e
|
|||
|
|
|
|||
|
|
def put(self, chunk: torch.Tensor):
|
|||
|
|
self.audio_queue.put(chunk)
|
|||
|
|
|
|||
|
|
def stop(self):
|
|||
|
|
self.audio_queue.put(None)
|
|||
|
|
if self.thread:
|
|||
|
|
self.thread.join()
|
|||
|
|
if self.error:
|
|||
|
|
raise RuntimeError(f"Fehler bei Live-Wiedergabe: {self.error}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def synthesize_non_streaming(
|
|||
|
|
text: str,
|
|||
|
|
lang: str,
|
|||
|
|
output_path: Optional[Path],
|
|||
|
|
max_len: int,
|
|||
|
|
first_chunk_len: int,
|
|||
|
|
voice_path: Optional[str],
|
|||
|
|
device: str,
|
|||
|
|
show_progress: bool = True,
|
|||
|
|
spell_uppercase_acronyms: bool = True,
|
|||
|
|
acronym_mode: Optional[str] = None,
|
|||
|
|
normalize_time_values: bool = True,
|
|||
|
|
normalize_year_values: bool = True,
|
|||
|
|
normalize_units_values: bool = True,
|
|||
|
|
conversation_mode: bool = True,
|
|||
|
|
play_audio: bool = False,
|
|||
|
|
save_wav: bool = True,
|
|||
|
|
audio_device: Optional[str] = "pulse",
|
|||
|
|
sentence_mode: bool = True,
|
|||
|
|
speed: float = 1.0,
|
|||
|
|
debug_delay: float = 0.0,
|
|||
|
|
t3_model: Optional[str] = None,
|
|||
|
|
pronunciation_dict: Optional[dict] = None,
|
|||
|
|
) -> Optional[Path]:
|
|||
|
|
if lang not in SUPPORTED_LANGS:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"Nicht unterstützte Sprache '{lang}'. Unterstützt: {', '.join(sorted(SUPPORTED_LANGS))}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if voice_path and not Path(voice_path).exists():
|
|||
|
|
raise FileNotFoundError(f"Voice-Referenz nicht gefunden: {voice_path}")
|
|||
|
|
|
|||
|
|
# Erst unsichtbare Zeichen entfernen, dann Sätze splitten (Paragraphen-Struktur erhalten),
|
|||
|
|
# danach erst Akronym-Expansion — sonst erzeugen "A. R. D."-Punkte falsche Satzgrenzen.
|
|||
|
|
text = clean_raw_text(text)
|
|||
|
|
|
|||
|
|
model, model_kind, sr = load_model(lang, device, t3_model=t3_model)
|
|||
|
|
|
|||
|
|
if sentence_mode:
|
|||
|
|
raw_chunks = split_into_sentences(text, max_len=max_len)
|
|||
|
|
elif conversation_mode:
|
|||
|
|
raw_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len)
|
|||
|
|
else:
|
|||
|
|
raw_chunks = split_long_text(text, max_len=max_len)
|
|||
|
|
|
|||
|
|
preprocess_kw = dict(
|
|||
|
|
lang=lang,
|
|||
|
|
spell_uppercase_acronyms=spell_uppercase_acronyms,
|
|||
|
|
acronym_mode=acronym_mode,
|
|||
|
|
normalize_time_values=normalize_time_values,
|
|||
|
|
normalize_year_values=normalize_year_values,
|
|||
|
|
normalize_units_values=normalize_units_values,
|
|||
|
|
pronunciation_dict=pronunciation_dict,
|
|||
|
|
)
|
|||
|
|
chunks = [preprocess_tts_text(c, **preprocess_kw) for c in raw_chunks]
|
|||
|
|
chunks = [c for c in chunks if c.strip()]
|
|||
|
|
|
|||
|
|
if not chunks:
|
|||
|
|
raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.")
|
|||
|
|
|
|||
|
|
if show_progress:
|
|||
|
|
print(f"Sprache: {lang}")
|
|||
|
|
print(f"Gerät: {device}")
|
|||
|
|
print(f"Modell: {'ChatterboxTTS (monolingual)' if model_kind == 'mono' else 'ChatterboxMultilingualTTS'}")
|
|||
|
|
print(f"Sätze: {len(chunks)}")
|
|||
|
|
print(f"Modus: {'Satz-für-Satz' if sentence_mode else 'non-streaming'} + Playback")
|
|||
|
|
print(f"Live-Wiedergabe: {'ja' if play_audio else 'nein'}")
|
|||
|
|
print(f"WAV speichern: {'ja' if save_wav and output_path else 'nein'}")
|
|||
|
|
if output_path and save_wav:
|
|||
|
|
print(f"Ausgabe: {output_path}")
|
|||
|
|
|
|||
|
|
if play_audio:
|
|||
|
|
playback = PlaybackWorker(sample_rate=sr, device=audio_device, speed=speed)
|
|||
|
|
playback.start()
|
|||
|
|
else:
|
|||
|
|
playback = None
|
|||
|
|
|
|||
|
|
wavs = []
|
|||
|
|
try:
|
|||
|
|
for i, chunk in enumerate(chunks, start=1):
|
|||
|
|
if debug_delay > 0:
|
|||
|
|
if show_progress:
|
|||
|
|
print(f"[{i}/{len(chunks)}] Warte {debug_delay:.0f}s (debug_delay) ...")
|
|||
|
|
time.sleep(debug_delay)
|
|||
|
|
if show_progress:
|
|||
|
|
print(f"[{i}/{len(chunks)}] Generiere ({len(chunk)} Zeichen) ...")
|
|||
|
|
wav = generate_chunk(model, model_kind, chunk, lang, voice_path)
|
|||
|
|
wavs.append(wav)
|
|||
|
|
if playback is not None:
|
|||
|
|
playback.put(wav)
|
|||
|
|
finally:
|
|||
|
|
if playback is not None:
|
|||
|
|
playback.stop()
|
|||
|
|
|
|||
|
|
if not wavs:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
final_wav = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1)
|
|||
|
|
|
|||
|
|
if save_wav and output_path:
|
|||
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
ta.save(str(output_path), final_wav, sr)
|
|||
|
|
return output_path
|
|||
|
|
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def synthesize_streaming(
|
|||
|
|
text: str,
|
|||
|
|
lang: str,
|
|||
|
|
output_path: Optional[Path],
|
|||
|
|
max_len: int,
|
|||
|
|
first_chunk_len: int,
|
|||
|
|
voice_path: Optional[str],
|
|||
|
|
device: str,
|
|||
|
|
show_progress: bool = True,
|
|||
|
|
spell_uppercase_acronyms: bool = True,
|
|||
|
|
acronym_mode: str = "period_space",
|
|||
|
|
normalize_time_values: bool = True,
|
|||
|
|
normalize_year_values: bool = True,
|
|||
|
|
normalize_units_values: bool = True,
|
|||
|
|
conversation_mode: bool = True,
|
|||
|
|
play_audio: bool = True,
|
|||
|
|
save_wav: bool = True,
|
|||
|
|
stream_chunk_size: int = 25,
|
|||
|
|
audio_device: Optional[str] = None,
|
|||
|
|
) -> Optional[Path]:
|
|||
|
|
if lang not in SUPPORTED_LANGS:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"Nicht unterstützte Sprache '{lang}'. Unterstützt: {', '.join(sorted(SUPPORTED_LANGS))}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if voice_path and not Path(voice_path).exists():
|
|||
|
|
raise FileNotFoundError(f"Voice-Referenz nicht gefunden: {voice_path}")
|
|||
|
|
|
|||
|
|
text = preprocess_tts_text(
|
|||
|
|
text=text,
|
|||
|
|
lang=lang,
|
|||
|
|
spell_uppercase_acronyms=spell_uppercase_acronyms,
|
|||
|
|
acronym_mode=acronym_mode,
|
|||
|
|
normalize_time_values=normalize_time_values,
|
|||
|
|
normalize_year_values=normalize_year_values,
|
|||
|
|
normalize_units_values=normalize_units_values,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
model, model_kind, sr = load_model(lang, device)
|
|||
|
|
|
|||
|
|
if not hasattr(model, "generate_stream"):
|
|||
|
|
raise RuntimeError(
|
|||
|
|
"Dieses Chatterbox-Paket bietet kein generate_stream(). "
|
|||
|
|
"Installiere z. B. 'chatterbox-streaming'."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if conversation_mode:
|
|||
|
|
text_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len)
|
|||
|
|
else:
|
|||
|
|
text_chunks = split_long_text(text, max_len=max_len)
|
|||
|
|
|
|||
|
|
if not text_chunks:
|
|||
|
|
raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.")
|
|||
|
|
|
|||
|
|
if play_audio:
|
|||
|
|
playback = PlaybackWorker(sample_rate=sr, device=audio_device)
|
|||
|
|
playback.start()
|
|||
|
|
else:
|
|||
|
|
playback = None
|
|||
|
|
|
|||
|
|
all_audio_chunks: List[torch.Tensor] = []
|
|||
|
|
t0 = time.perf_counter()
|
|||
|
|
first_audio_started = False
|
|||
|
|
|
|||
|
|
if show_progress:
|
|||
|
|
print(f"Sprache: {lang}")
|
|||
|
|
print(f"Gerät: {device}")
|
|||
|
|
print(f"Modell: {'ChatterboxTTS (monolingual)' if model_kind == 'mono' else 'ChatterboxMultilingualTTS'}")
|
|||
|
|
print(f"Text-Chunks: {len(text_chunks)}")
|
|||
|
|
print(f"Modus: streaming")
|
|||
|
|
print(f"Gesprächsmodus: {'ja' if conversation_mode else 'nein'}")
|
|||
|
|
print(f"Live-Wiedergabe: {'ja' if play_audio else 'nein'}")
|
|||
|
|
print(f"WAV speichern: {'ja' if save_wav and output_path else 'nein'}")
|
|||
|
|
print(f"Streaming chunk_size: {stream_chunk_size}")
|
|||
|
|
if output_path:
|
|||
|
|
print(f"Ausgabe: {output_path}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
for text_idx, text_chunk in enumerate(text_chunks, start=1):
|
|||
|
|
if show_progress:
|
|||
|
|
print(f"[Text {text_idx}/{len(text_chunks)}] Starte Streaming für {len(text_chunk)} Zeichen ...")
|
|||
|
|
|
|||
|
|
stream_iter = generate_stream_chunk(
|
|||
|
|
model=model,
|
|||
|
|
model_kind=model_kind,
|
|||
|
|
text=text_chunk,
|
|||
|
|
lang=lang,
|
|||
|
|
voice_path=voice_path,
|
|||
|
|
stream_chunk_size=stream_chunk_size,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for audio_idx, item in enumerate(stream_iter, start=1):
|
|||
|
|
if isinstance(item, tuple) and len(item) == 2:
|
|||
|
|
audio_chunk, metrics = item
|
|||
|
|
else:
|
|||
|
|
audio_chunk, metrics = item, None
|
|||
|
|
|
|||
|
|
all_audio_chunks.append(audio_chunk)
|
|||
|
|
|
|||
|
|
if playback is not None:
|
|||
|
|
playback.put(audio_chunk)
|
|||
|
|
if not first_audio_started:
|
|||
|
|
first_audio_started = True
|
|||
|
|
if show_progress:
|
|||
|
|
dt = time.perf_counter() - t0
|
|||
|
|
print(f"Audio-Wiedergabe gestartet nach {dt:.3f}s")
|
|||
|
|
|
|||
|
|
if show_progress:
|
|||
|
|
msg = f" -> Audio-Chunk {audio_idx}"
|
|||
|
|
if metrics is not None:
|
|||
|
|
latency = getattr(metrics, "latency_to_first_chunk", None)
|
|||
|
|
rtf = getattr(metrics, "rtf", None)
|
|||
|
|
chunk_count = getattr(metrics, "chunk_count", None)
|
|||
|
|
if chunk_count is not None:
|
|||
|
|
msg += f", model_chunk={chunk_count}"
|
|||
|
|
if latency:
|
|||
|
|
msg += f", first_latency={latency:.3f}s"
|
|||
|
|
if rtf:
|
|||
|
|
msg += f", rtf={rtf:.3f}"
|
|||
|
|
print(msg)
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
if playback is not None:
|
|||
|
|
playback.stop()
|
|||
|
|
|
|||
|
|
final_output = None
|
|||
|
|
if save_wav and output_path:
|
|||
|
|
final_audio = all_audio_chunks[0] if len(all_audio_chunks) == 1 else torch.cat(all_audio_chunks, dim=-1)
|
|||
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
ta.save(str(output_path), final_audio, sr)
|
|||
|
|
final_output = output_path
|
|||
|
|
|
|||
|
|
return final_output
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_argparser() -> argparse.ArgumentParser:
|
|||
|
|
p = argparse.ArgumentParser(
|
|||
|
|
description="Low-Latency Chatterbox TTS CLI mit deutscher Text-Normalisierung und optionalem Streaming."
|
|||
|
|
)
|
|||
|
|
p.add_argument("--text", type=str, help="Direkter Eingabetext.")
|
|||
|
|
p.add_argument("--input", type=str, help="Pfad zu UTF-8-Textdatei.")
|
|||
|
|
p.add_argument("--lang", type=str, default="de", help="Sprachcode, default: de.")
|
|||
|
|
p.add_argument("--len", dest="max_len", type=int, default=400, help="Maximale Chunk-Länge, default: 400.")
|
|||
|
|
p.add_argument("--first-chunk-len", type=int, default=80, help="Kleinere Zielgröße für den ersten Chunk im Gesprächsmodus. Default: 80.")
|
|||
|
|
p.add_argument("--output", type=str, help="Ausgabedatei .wav")
|
|||
|
|
p.add_argument("--voice", type=str, help="Optionale Referenz-WAV für Voice-Cloning.")
|
|||
|
|
p.add_argument("--device", type=str, default=None, help="z. B. cuda:0 oder cpu.")
|
|||
|
|
p.add_argument("--no-progress", action="store_true", help="Weniger Konsolen-Output.")
|
|||
|
|
p.add_argument("--no-spell-acronyms", action="store_true", help="Großgeschriebene Akronyme nicht buchstabieren.")
|
|||
|
|
p.add_argument(
|
|||
|
|
"--acronym-mode",
|
|||
|
|
type=str,
|
|||
|
|
default=None, # None = automatisch: 'german' bei de, 'period_space' sonst
|
|||
|
|
choices=["space", "period", "comma", "period_space", "german"],
|
|||
|
|
help="Ausgabeformat für buchstabierte Akronyme. Default: 'german' bei --lang de, sonst 'period_space'."
|
|||
|
|
)
|
|||
|
|
p.add_argument("--pronunciation-dict", type=str, default=None, help="Pfad zu einer JSON-Datei mit Aussprache-Substitutionen (Eigenname → Lautschrift).")
|
|||
|
|
p.add_argument("--no-normalize-times", action="store_true", help="Uhrzeiten nicht in sprechbaren Text umwandeln.")
|
|||
|
|
p.add_argument("--no-normalize-years", action="store_true", help="Jahreszahlen nicht in sprechbaren Text umwandeln.")
|
|||
|
|
p.add_argument("--no-normalize-units", action="store_true", help="Einheiten nicht in sprechbaren Text umwandeln.")
|
|||
|
|
p.add_argument("--stream", action="store_true", help="Streaming-TTS-Modus (experimentell, kann abgehackt klingen).")
|
|||
|
|
p.add_argument("--no-play", action="store_true", help="Nicht live abspielen.")
|
|||
|
|
p.add_argument("--audio-device", type=str, default="pulse", help="Sounddevice-Ausgabegerät, z. B. 'pulse' oder 'M2: USB Audio'. Standard: pulse.")
|
|||
|
|
p.add_argument("--save", action="store_true", help="WAV-Datei speichern (Standard: nein).")
|
|||
|
|
p.add_argument("--stream-chunk-size", type=int, default=12, help="Streaming chunk_size (nur mit --stream). Default: 12.")
|
|||
|
|
p.add_argument("--no-sentence-mode", action="store_true", help="Sätze zu größeren Chunks gruppieren statt einzeln ausgeben.")
|
|||
|
|
p.add_argument("--speed", type=float, default=1.0, help="Wiedergabegeschwindigkeit: 0.8 = 20%% langsamer, 1.2 = 20%% schneller. Default: 1.0.")
|
|||
|
|
p.add_argument("--debug-delay", type=float, default=0.0, help="Sekunden Pause vor jedem Satz (simuliert langsame KI). Nur zum Testen.")
|
|||
|
|
p.add_argument("--t3-model", type=str, default="v3", help="Multilingual T3-Modell: 'v3' (default), 'v2' oder Dateiname.")
|
|||
|
|
p.add_argument("--no-conversation-mode", action="store_true", help="Ersten Chunk nicht künstlich kleiner machen (nur ohne --no-sentence-mode).")
|
|||
|
|
return p
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
parser = build_argparser()
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
text = read_input_text(args.text, args.input)
|
|||
|
|
device = get_device(args.device)
|
|||
|
|
output_path = Path(args.output) if args.output else default_output_path(args.input, args.lang)
|
|||
|
|
|
|||
|
|
save_wav = args.save or bool(args.output)
|
|||
|
|
|
|||
|
|
# Acronym-Mode-Default: 'german' bei Deutsch, 'period_space' sonst
|
|||
|
|
acronym_mode = args.acronym_mode or ("german" if args.lang == "de" else "period_space")
|
|||
|
|
|
|||
|
|
# Optionales Aussprache-Wörterbuch laden
|
|||
|
|
pronunciation_dict: Optional[dict] = None
|
|||
|
|
if args.pronunciation_dict:
|
|||
|
|
import json
|
|||
|
|
pron_path = Path(args.pronunciation_dict)
|
|||
|
|
if not pron_path.exists():
|
|||
|
|
raise FileNotFoundError(f"Aussprache-Dict nicht gefunden: {pron_path}")
|
|||
|
|
pronunciation_dict = json.loads(pron_path.read_text(encoding="utf-8"))
|
|||
|
|
|
|||
|
|
if args.stream:
|
|||
|
|
out = synthesize_streaming(
|
|||
|
|
text=text,
|
|||
|
|
lang=args.lang,
|
|||
|
|
output_path=output_path if save_wav else None,
|
|||
|
|
max_len=args.max_len,
|
|||
|
|
first_chunk_len=args.first_chunk_len,
|
|||
|
|
voice_path=args.voice,
|
|||
|
|
device=device,
|
|||
|
|
show_progress=not args.no_progress,
|
|||
|
|
spell_uppercase_acronyms=not args.no_spell_acronyms,
|
|||
|
|
acronym_mode=acronym_mode,
|
|||
|
|
normalize_time_values=not args.no_normalize_times,
|
|||
|
|
normalize_year_values=not args.no_normalize_years,
|
|||
|
|
normalize_units_values=not args.no_normalize_units,
|
|||
|
|
conversation_mode=not args.no_conversation_mode,
|
|||
|
|
play_audio=not args.no_play,
|
|||
|
|
save_wav=save_wav,
|
|||
|
|
stream_chunk_size=args.stream_chunk_size,
|
|||
|
|
audio_device=args.audio_device,
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
out = synthesize_non_streaming(
|
|||
|
|
text=text,
|
|||
|
|
lang=args.lang,
|
|||
|
|
output_path=output_path if save_wav else None,
|
|||
|
|
max_len=args.max_len,
|
|||
|
|
first_chunk_len=args.first_chunk_len,
|
|||
|
|
voice_path=args.voice,
|
|||
|
|
device=device,
|
|||
|
|
show_progress=not args.no_progress,
|
|||
|
|
spell_uppercase_acronyms=not args.no_spell_acronyms,
|
|||
|
|
acronym_mode=acronym_mode,
|
|||
|
|
normalize_time_values=not args.no_normalize_times,
|
|||
|
|
normalize_year_values=not args.no_normalize_years,
|
|||
|
|
normalize_units_values=not args.no_normalize_units,
|
|||
|
|
conversation_mode=not args.no_conversation_mode,
|
|||
|
|
play_audio=not args.no_play,
|
|||
|
|
save_wav=save_wav,
|
|||
|
|
audio_device=args.audio_device,
|
|||
|
|
sentence_mode=not args.no_sentence_mode,
|
|||
|
|
speed=args.speed,
|
|||
|
|
debug_delay=args.debug_delay,
|
|||
|
|
t3_model=args.t3_model,
|
|||
|
|
pronunciation_dict=pronunciation_dict,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if out is not None:
|
|||
|
|
print(f"Fertig: {out}")
|
|||
|
|
else:
|
|||
|
|
print("Fertig.")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Fehler: {e}", file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|
|||
|
|
|