Stabilize German XTTS speech output
This commit is contained in:
@@ -680,6 +680,9 @@ services:
|
|||||||
TTS_VOICE_ALIAS: alloy
|
TTS_VOICE_ALIAS: alloy
|
||||||
XTTS_SPEAKER: Annmarie Nele
|
XTTS_SPEAKER: Annmarie Nele
|
||||||
TTS_DEFAULT_LANGUAGE: de
|
TTS_DEFAULT_LANGUAGE: de
|
||||||
|
# Mixed-language clip stitching caused long pauses and unintelligible
|
||||||
|
# transitions. Keep full sentences in one stable German voice.
|
||||||
|
TTS_CODE_SWITCH_ENABLED: "false"
|
||||||
XTTS_QUEUE_TIMEOUT: "15"
|
XTTS_QUEUE_TIMEOUT: "15"
|
||||||
XTTS_TIMEOUT: "120"
|
XTTS_TIMEOUT: "120"
|
||||||
PIPER_TIMEOUT: "120"
|
PIPER_TIMEOUT: "120"
|
||||||
|
|||||||
@@ -105,9 +105,12 @@ Ultra bleibt für maximalen Kontext bewusst text-only.
|
|||||||
|
|
||||||
Open WebUI spricht ausschließlich den Router an. Dieser reicht TTS intern an
|
Open WebUI spricht ausschließlich den Router an. Dieser reicht TTS intern an
|
||||||
das TTS-Gateway weiter. Das Gateway nutzt primär XTTS-v2 mit der Stimme
|
das TTS-Gateway weiter. Das Gateway nutzt primär XTTS-v2 mit der Stimme
|
||||||
`Annmarie Nele` auf der RTX 3060. Deutsche Texte werden an bekannten
|
`Annmarie Nele` auf der RTX 3060. Gemischte deutsch-englische Antworten laufen
|
||||||
englischen IT-Begriffen segmentiert; reine englische Texte laufen vollständig
|
bewusst als vollständige deutsche Satzblöcke: Das vermeidet die langen Pausen,
|
||||||
mit `language=en`. Da der offizielle XTTS-Streamingserver nur einen Auftrag
|
Tonhöhensprünge und unverständlichen Übergänge, die beim Zusammensetzen vieler
|
||||||
|
kurzer Sprachsegmente entstanden. Vollständig englische Texte werden weiterhin
|
||||||
|
automatisch mit `language=en` gesprochen. Da der offizielle
|
||||||
|
XTTS-Streamingserver nur einen Auftrag
|
||||||
gleichzeitig unterstützt, serialisiert das Gateway die Aufträge. Bei Fehler,
|
gleichzeitig unterstützt, serialisiert das Gateway die Aufträge. Bei Fehler,
|
||||||
Timeout oder belegter Queue übernimmt automatisch Piper auf der CPU. Kein
|
Timeout oder belegter Queue übernimmt automatisch Piper auf der CPU. Kein
|
||||||
TTS-Port wird veröffentlicht. Der äußere Kompatibilitätsname bleibt bewusst
|
TTS-Port wird veröffentlicht. Der äußere Kompatibilitätsname bleibt bewusst
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
| Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
|
| Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
|
||||||
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
|
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
|
||||||
| XTTS-v2 | Coqui, offizielles CUDA-12.1-Image per Digest | RTX-3060-Container, Stimme `Annmarie Nele`, CPML | Kern |
|
| XTTS-v2 | Coqui, offizielles CUDA-12.1-Image per Digest | RTX-3060-Container, Stimme `Annmarie Nele`, CPML | Kern |
|
||||||
| TTS-Gateway | `platform/docker/tts-gateway/` | interne Queue, Deutsch/Englisch-Segmentierung und Piper-Fallback | Kern |
|
| TTS-Gateway | `platform/docker/tts-gateway/` | interne Queue, stabile deutsche Satzblöcke, automatische Erkennung rein englischer Texte und Piper-Fallback | Kern |
|
||||||
| Piper TTS | Open Home Foundation, `piper-tts` 1.6.0 | interner CPU-Fallback, Stimme `de_DE-thorsten-high` | Kern |
|
| Piper TTS | Open Home Foundation, `piper-tts` 1.6.0 | interner CPU-Fallback, Stimme `de_DE-thorsten-high` | Kern |
|
||||||
| FLUX.2 klein | Black Forest Labs | Worker und Modellmanifest | optional |
|
| FLUX.2 klein | Black Forest Labs | Worker und Modellmanifest | optional |
|
||||||
| LLama-GUI | separates Upstream-Projekt | nur Betriebsrolle dokumentiert | optional |
|
| LLama-GUI | separates Upstream-Projekt | nur Betriebsrolle dokumentiert | optional |
|
||||||
|
|||||||
@@ -88,8 +88,12 @@ intern an das TTS-Gateway weiter. Primär spricht XTTS-v2 mit `Annmarie Nele`
|
|||||||
auf der RTX 3060; bei Fehlern oder Queue-Timeout übernimmt Piper auf der CPU.
|
auf der RTX 3060; bei Fehlern oder Queue-Timeout übernimmt Piper auf der CPU.
|
||||||
Der Port 8085 wird nicht am Host veröffentlicht. Ein
|
Der Port 8085 wird nicht am Host veröffentlicht. Ein
|
||||||
Restore setzt zusätzlich die vier persistenten Audiofelder gezielt neu, damit
|
Restore setzt zusätzlich die vier persistenten Audiofelder gezielt neu, damit
|
||||||
alte Werte wie `tts-1` oder `coral` die Compose-Vorgaben nicht überstimmen. Ein
|
alte Werte wie `tts-1` oder `coral` die Compose-Vorgaben nicht überstimmen.
|
||||||
Ende-zu-Ende-Test ohne Ausgabe des API-Schlüssels:
|
`TTS_CODE_SWITCH_ENABLED=false` hält gemischte Antworten als zusammenhängende
|
||||||
|
deutsche Satzblöcke. Einzelne englische Fachbegriffe werden damit zwar deutsch
|
||||||
|
ausgesprochen, die Ausgabe bleibt jedoch flüssig und verständlich. Reine
|
||||||
|
englische Texte erkennt das Gateway weiterhin automatisch. Ein Ende-zu-Ende-Test
|
||||||
|
ohne Ausgabe des API-Schlüssels:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
set -a; source /etc/mike-ai/stack.env; set +a
|
set -a; source /etc/mike-ai/stack.env; set +a
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import pathlib
|
import pathlib
|
||||||
import unittest
|
import unittest
|
||||||
|
from array import array
|
||||||
|
|
||||||
|
|
||||||
MODULE_PATH = pathlib.Path(__file__).with_name("tts_gateway.py")
|
MODULE_PATH = pathlib.Path(__file__).with_name("tts_gateway.py")
|
||||||
@@ -10,6 +11,12 @@ SPEC.loader.exec_module(gateway)
|
|||||||
|
|
||||||
|
|
||||||
class LanguageSegmentationTests(unittest.TestCase):
|
class LanguageSegmentationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.original_code_switch = gateway.CODE_SWITCH_ENABLED
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
gateway.CODE_SWITCH_ENABLED = self.original_code_switch
|
||||||
|
|
||||||
def test_german_only(self):
|
def test_german_only(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
gateway.segment_languages("Guten Abend, wie warm ist es heute?"),
|
gateway.segment_languages("Guten Abend, wie warm ist es heute?"),
|
||||||
@@ -21,6 +28,7 @@ class LanguageSegmentationTests(unittest.TestCase):
|
|||||||
self.assertEqual(gateway.segment_languages(text), [("en", text)])
|
self.assertEqual(gateway.segment_languages(text), [("en", text)])
|
||||||
|
|
||||||
def test_mixed_compounds(self):
|
def test_mixed_compounds(self):
|
||||||
|
gateway.CODE_SWITCH_ENABLED = True
|
||||||
text = "Ich öffne das Unraid-Dashboard und prüfe die Docker-Container."
|
text = "Ich öffne das Unraid-Dashboard und prüfe die Docker-Container."
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
gateway.segment_languages(text),
|
gateway.segment_languages(text),
|
||||||
@@ -33,6 +41,43 @@ class LanguageSegmentationTests(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_operational_vocabulary_is_not_read_as_german(self):
|
||||||
|
gateway.CODE_SWITCH_ENABLED = True
|
||||||
|
text = (
|
||||||
|
"31 von 60 Containern laufen, alle sind up und healthy. "
|
||||||
|
"Die Health-Checks zeigen keine Crashes oder Restart-Loops. "
|
||||||
|
"Immich meldet unsupported image format und premature end of JPEG."
|
||||||
|
)
|
||||||
|
segments = gateway.segment_languages(text)
|
||||||
|
english = [part.lower() for language, part in segments if language == "en"]
|
||||||
|
self.assertIn("containern", english)
|
||||||
|
self.assertIn("up", english)
|
||||||
|
self.assertIn("healthy", english)
|
||||||
|
self.assertIn("health-checks", english)
|
||||||
|
self.assertIn("crashes", english)
|
||||||
|
self.assertIn("restart-loops", english)
|
||||||
|
self.assertIn("unsupported image format", english)
|
||||||
|
self.assertIn("premature end of jpeg", english)
|
||||||
|
prepared = gateway.prepare_segments(text)
|
||||||
|
self.assertTrue(all(any(char.isalnum() for char in part) for _, part in prepared))
|
||||||
|
|
||||||
|
def test_long_text_is_cleaned_and_chunked(self):
|
||||||
|
text = "✅ **Status**\n" + ("Ein langer deutscher Diagnosesatz. " * 30)
|
||||||
|
segments = gateway.prepare_segments(text)
|
||||||
|
self.assertTrue(segments)
|
||||||
|
self.assertTrue(all(len(part) <= gateway.CHUNK_CHARS for _, part in segments))
|
||||||
|
self.assertNotIn("✅", "".join(part for _, part in segments))
|
||||||
|
|
||||||
|
def test_inflected_container_has_english_pronunciation(self):
|
||||||
|
gateway.CODE_SWITCH_ENABLED = True
|
||||||
|
prepared = gateway.prepare_segments("Ich prüfe die Containern.")
|
||||||
|
self.assertIn(("en", "containers."), prepared)
|
||||||
|
|
||||||
|
def test_code_switch_is_disabled_by_default(self):
|
||||||
|
gateway.CODE_SWITCH_ENABLED = False
|
||||||
|
text = "Docker-Container laufen, die Health-Checks melden healthy."
|
||||||
|
self.assertEqual(gateway.segment_languages(text), [("de", text)])
|
||||||
|
|
||||||
|
|
||||||
class FallbackTests(unittest.TestCase):
|
class FallbackTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -55,5 +100,24 @@ class FallbackTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioJoinTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def pcm(values):
|
||||||
|
samples = array("h", values)
|
||||||
|
return samples.tobytes()
|
||||||
|
|
||||||
|
def test_trim_removes_long_silent_edges(self):
|
||||||
|
pcm = self.pcm(([0] * 1000) + ([500] * 100) + ([0] * 1000))
|
||||||
|
trimmed = gateway._trim_pcm(pcm)
|
||||||
|
self.assertLess(len(trimmed), len(pcm))
|
||||||
|
self.assertGreater(len(trimmed), 100 * 2)
|
||||||
|
|
||||||
|
def test_join_inserts_pause_only_after_sentence(self):
|
||||||
|
spoken = self.pcm([500] * 1000)
|
||||||
|
inline = gateway._join_pcm([("Docker", spoken), ("Container", spoken)])
|
||||||
|
sentence = gateway._join_pcm([("Fertig.", spoken), ("Weiter", spoken)])
|
||||||
|
self.assertGreater(len(sentence), len(inline))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import wave
|
import wave
|
||||||
|
from array import array
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
@@ -28,13 +30,19 @@ PIPER_URL = os.getenv("PIPER_URL", "http://piper:8085").rstrip("/")
|
|||||||
VOICE_ALIAS = os.getenv("TTS_VOICE_ALIAS", "alloy")
|
VOICE_ALIAS = os.getenv("TTS_VOICE_ALIAS", "alloy")
|
||||||
XTTS_SPEAKER = os.getenv("XTTS_SPEAKER", "Annmarie Nele")
|
XTTS_SPEAKER = os.getenv("XTTS_SPEAKER", "Annmarie Nele")
|
||||||
DEFAULT_LANGUAGE = os.getenv("TTS_DEFAULT_LANGUAGE", "de")
|
DEFAULT_LANGUAGE = os.getenv("TTS_DEFAULT_LANGUAGE", "de")
|
||||||
|
CODE_SWITCH_ENABLED = os.getenv("TTS_CODE_SWITCH_ENABLED", "false").lower() \
|
||||||
|
in {"1", "true", "yes", "on"}
|
||||||
MAX_TEXT_CHARS = int(os.getenv("TTS_MAX_TEXT_CHARS", "8000"))
|
MAX_TEXT_CHARS = int(os.getenv("TTS_MAX_TEXT_CHARS", "8000"))
|
||||||
MAX_REQUEST_BYTES = int(os.getenv("TTS_MAX_REQUEST_BYTES", "65536"))
|
MAX_REQUEST_BYTES = int(os.getenv("TTS_MAX_REQUEST_BYTES", "65536"))
|
||||||
MAX_AUDIO_BYTES = int(os.getenv("TTS_MAX_AUDIO_BYTES", str(64 * 1024 * 1024)))
|
MAX_AUDIO_BYTES = int(os.getenv("TTS_MAX_AUDIO_BYTES", str(64 * 1024 * 1024)))
|
||||||
XTTS_TIMEOUT = float(os.getenv("XTTS_TIMEOUT", "120"))
|
XTTS_TIMEOUT = float(os.getenv("XTTS_TIMEOUT", "120"))
|
||||||
PIPER_TIMEOUT = float(os.getenv("PIPER_TIMEOUT", "120"))
|
PIPER_TIMEOUT = float(os.getenv("PIPER_TIMEOUT", "120"))
|
||||||
QUEUE_TIMEOUT = float(os.getenv("XTTS_QUEUE_TIMEOUT", "15"))
|
QUEUE_TIMEOUT = float(os.getenv("XTTS_QUEUE_TIMEOUT", "15"))
|
||||||
SILENCE_MS = int(os.getenv("XTTS_SEGMENT_SILENCE_MS", "20"))
|
CHUNK_CHARS = int(os.getenv("XTTS_CHUNK_CHARS", "220"))
|
||||||
|
TRIM_THRESHOLD = int(os.getenv("XTTS_TRIM_THRESHOLD", "90"))
|
||||||
|
TRIM_PADDING_MS = int(os.getenv("XTTS_TRIM_PADDING_MS", "18"))
|
||||||
|
CROSSFADE_MS = int(os.getenv("XTTS_CROSSFADE_MS", "8"))
|
||||||
|
SENTENCE_PAUSE_MS = int(os.getenv("XTTS_SENTENCE_PAUSE_MS", "65"))
|
||||||
|
|
||||||
SYNTHESIS_LOCK = threading.Lock()
|
SYNTHESIS_LOCK = threading.Lock()
|
||||||
STATE_LOCK = threading.Lock()
|
STATE_LOCK = threading.Lock()
|
||||||
@@ -50,16 +58,27 @@ STATE = {
|
|||||||
# Prefer full compounds to isolated terms. This keeps switches infrequent and
|
# Prefer full compounds to isolated terms. This keeps switches infrequent and
|
||||||
# avoids making mixed-language speech sound like a sequence of separate clips.
|
# avoids making mixed-language speech sound like a sequence of separate clips.
|
||||||
ENGLISH_TERMS = (
|
ENGLISH_TERMS = (
|
||||||
|
"unsupported image format", "premature end of JPEG", "incomplete scan",
|
||||||
|
"Docker Containers", "Docker-Containers", "Docker Containern",
|
||||||
|
"Docker-Containern", "Health Checks", "Health-Checks",
|
||||||
|
"Restart Loops", "Restart-Loops", "False Positive", "Delivery Errors",
|
||||||
|
"DeliveryErrors", "Ack Problem", "Ack-Problem", "I/O timeout",
|
||||||
|
"Parity Check", "Parity-Check", "Disk disabled", "Disk invalid",
|
||||||
"Home Assistant", "Open WebUI", "OpenWebUI", "Unraid Dashboard",
|
"Home Assistant", "Open WebUI", "OpenWebUI", "Unraid Dashboard",
|
||||||
"Unraid-Dashboard", "Docker Container", "Docker-Container",
|
"Unraid-Dashboard", "Docker Container", "Docker-Container",
|
||||||
"Server Log", "Server-Log", "GitHub Repository", "GitHub Repo",
|
"Server Log", "Server-Log", "GitHub Repository", "GitHub Repo",
|
||||||
"WireGuard Tunnel", "Cron Job", "Cronjob", "Home Server",
|
"WireGuard Tunnel", "Cron Job", "Cronjob", "Home Server",
|
||||||
"API Key", "Tool Calling", "Context Window", "Prompt Injection",
|
"API Key", "Tool Calling", "Context Window", "Prompt Injection",
|
||||||
"Unraid", "Docker", "Container", "Dashboard", "Server", "Log",
|
"Unraid", "Docker", "Containern", "Containers", "Container",
|
||||||
|
"Crashes", "healthy", "disabled", "invalid", "Dashboard", "Server",
|
||||||
|
"Logs", "Log", "Matches", "up",
|
||||||
"OpenAI", "GitHub", "WireGuard", "Linux", "Debian", "Frontend",
|
"OpenAI", "GitHub", "WireGuard", "Linux", "Debian", "Frontend",
|
||||||
"Backend", "Router", "Browser", "Web", "Token", "Prompt", "Context",
|
"Backend", "Router", "Browser", "Web", "Token", "Prompt", "Context",
|
||||||
"Model", "Image", "Tool", "Workflow", "Benchmark", "Streaming",
|
"Model", "Image", "Tool", "Workflow", "Benchmark", "Streaming",
|
||||||
"SSH", "MCP", "API", "CPU", "GPU", "VRAM", "RAM", "HTTP", "HTTPS",
|
"SSH", "MCP", "API", "CPU", "GPU", "VRAM", "RAM", "HTTP", "HTTPS",
|
||||||
|
"HomeAssistant", "ESPHome", "iGotify", "Immich", "Vaultwarden",
|
||||||
|
"UniFi", "UptimeKuma", "go2rtc", "Zigbee", "SONOFF", "eWeLink",
|
||||||
|
"RTSP", "JPEG", "NVMe", "GiB",
|
||||||
)
|
)
|
||||||
TERM_PATTERN = re.compile(
|
TERM_PATTERN = re.compile(
|
||||||
r"(?<![\w])(" + "|".join(
|
r"(?<![\w])(" + "|".join(
|
||||||
@@ -77,6 +96,26 @@ ENGLISH_MARKERS = {
|
|||||||
"of", "on", "or", "please", "the", "this", "to", "with", "you",
|
"of", "on", "or", "please", "the", "this", "to", "with", "you",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ENGLISH_PRONUNCIATIONS = {
|
||||||
|
"containern": "containers",
|
||||||
|
"docker containern": "Docker containers",
|
||||||
|
"docker-containern": "Docker containers",
|
||||||
|
"docker-container": "Docker container",
|
||||||
|
"docker-containers": "Docker containers",
|
||||||
|
"health-checks": "health checks",
|
||||||
|
"restart-loops": "restart loops",
|
||||||
|
"deliveryerrors": "delivery errors",
|
||||||
|
"ack-problem": "ack problem",
|
||||||
|
"i/o timeout": "I O timeout",
|
||||||
|
"parity-check": "parity check",
|
||||||
|
"homeassistant": "Home Assistant",
|
||||||
|
"openwebui": "Open Web U I",
|
||||||
|
"rtsp": "R T S P",
|
||||||
|
"jpeg": "J peg",
|
||||||
|
"nvme": "N V M E",
|
||||||
|
"gib": "gigabytes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _request(url: str, *, payload: dict | None = None,
|
def _request(url: str, *, payload: dict | None = None,
|
||||||
timeout: float = 10) -> tuple[bytes, str]:
|
timeout: float = 10) -> tuple[bytes, str]:
|
||||||
@@ -124,6 +163,8 @@ def segment_languages(text: str) -> list[tuple[str, str]]:
|
|||||||
return [("en", text)]
|
return [("en", text)]
|
||||||
if DEFAULT_LANGUAGE != "de":
|
if DEFAULT_LANGUAGE != "de":
|
||||||
return [(DEFAULT_LANGUAGE, text)]
|
return [(DEFAULT_LANGUAGE, text)]
|
||||||
|
if not CODE_SWITCH_ENABLED:
|
||||||
|
return [("de", text)]
|
||||||
|
|
||||||
segments: list[tuple[str, str]] = []
|
segments: list[tuple[str, str]] = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
@@ -149,6 +190,79 @@ def segment_languages(text: str) -> list[tuple[str, str]]:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def clean_for_speech(text: str) -> str:
|
||||||
|
"""Remove visual markup that makes long TTS output unstable or noisy."""
|
||||||
|
text = re.sub(r"```.*?```", " Code block. ", text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||||
|
text = re.sub(r"!\[([^]]*)\]\([^)]+\)", r"\1", text)
|
||||||
|
text = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", text)
|
||||||
|
text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text)
|
||||||
|
text = re.sub(r"(?m)^\s*[-*+]\s+", "", text)
|
||||||
|
text = text.replace("→", ". ").replace("←", ". ")
|
||||||
|
text = text.replace("–", " - ").replace("—", " - ")
|
||||||
|
text = "".join(
|
||||||
|
char for char in text
|
||||||
|
if unicodedata.category(char) not in {"So", "Cs"}
|
||||||
|
)
|
||||||
|
text = re.sub(r"[ \t]+", " ", text)
|
||||||
|
text = re.sub(r"\s*\n+\s*", ". ", text)
|
||||||
|
text = re.sub(r"(?:\.\s*){2,}", ". ", text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _split_chunk(text: str, limit: int = CHUNK_CHARS) -> list[str]:
|
||||||
|
"""Split at natural pauses and keep every XTTS request comfortably short."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
if len(text) <= limit:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
pieces = re.split(r"(?<=[.!?;:])\s+|\s+(?=\d+[.)]\s)", text)
|
||||||
|
chunks: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for piece in pieces:
|
||||||
|
piece = piece.strip()
|
||||||
|
if not piece:
|
||||||
|
continue
|
||||||
|
if len(piece) > limit:
|
||||||
|
words = piece.split()
|
||||||
|
for word in words:
|
||||||
|
candidate = f"{current} {word}".strip()
|
||||||
|
if current and len(candidate) > limit:
|
||||||
|
chunks.append(current)
|
||||||
|
current = word
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
continue
|
||||||
|
candidate = f"{current} {piece}".strip()
|
||||||
|
if current and len(candidate) > limit:
|
||||||
|
chunks.append(current)
|
||||||
|
current = piece
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_segments(text: str) -> list[tuple[str, str]]:
|
||||||
|
"""Prepare short, deterministic German/English XTTS requests."""
|
||||||
|
prepared: list[tuple[str, str]] = []
|
||||||
|
for language, segment in segment_languages(clean_for_speech(text)):
|
||||||
|
if not re.search(r"\w", segment, flags=re.UNICODE):
|
||||||
|
if prepared:
|
||||||
|
previous_language, previous_text = prepared[-1]
|
||||||
|
prepared[-1] = (previous_language, previous_text + segment.strip())
|
||||||
|
continue
|
||||||
|
spoken = segment
|
||||||
|
if language == "en":
|
||||||
|
spoken = ENGLISH_PRONUNCIATIONS.get(segment.strip().lower(), segment)
|
||||||
|
for chunk in _split_chunk(spoken):
|
||||||
|
prepared.append((language, chunk))
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
|
||||||
def _speaker_conditioning() -> dict:
|
def _speaker_conditioning() -> dict:
|
||||||
global SPEAKER_CONDITIONING
|
global SPEAKER_CONDITIONING
|
||||||
with SPEAKER_LOCK:
|
with SPEAKER_LOCK:
|
||||||
@@ -180,6 +294,70 @@ def _xtts_pcm(text: str, language: str, conditioning: dict) -> bytes:
|
|||||||
return audio[44:]
|
return audio[44:]
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_pcm(pcm: bytes) -> bytes:
|
||||||
|
"""Trim generated edge silence while retaining a small safety padding."""
|
||||||
|
samples = array("h")
|
||||||
|
samples.frombytes(pcm)
|
||||||
|
if not samples:
|
||||||
|
return pcm
|
||||||
|
first = next((i for i, value in enumerate(samples)
|
||||||
|
if abs(value) >= TRIM_THRESHOLD), 0)
|
||||||
|
last = next((i for i in range(len(samples) - 1, -1, -1)
|
||||||
|
if abs(samples[i]) >= TRIM_THRESHOLD), len(samples) - 1)
|
||||||
|
padding = int(24000 * max(0, TRIM_PADDING_MS) / 1000)
|
||||||
|
first = max(0, first - padding)
|
||||||
|
last = min(len(samples) - 1, last + padding)
|
||||||
|
return samples[first:last + 1].tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _fade_edge(pcm: bytes, *, fade_in: bool = False,
|
||||||
|
fade_out: bool = False) -> bytes:
|
||||||
|
samples = array("h")
|
||||||
|
samples.frombytes(pcm)
|
||||||
|
count = min(len(samples), int(24000 * max(0, CROSSFADE_MS) / 1000))
|
||||||
|
if count <= 1:
|
||||||
|
return pcm
|
||||||
|
if fade_in:
|
||||||
|
for index in range(count):
|
||||||
|
samples[index] = int(samples[index] * index / (count - 1))
|
||||||
|
if fade_out:
|
||||||
|
start = len(samples) - count
|
||||||
|
for index in range(count):
|
||||||
|
samples[start + index] = int(
|
||||||
|
samples[start + index] * (count - 1 - index) / (count - 1))
|
||||||
|
return samples.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _join_pcm(parts: list[tuple[str, bytes]]) -> bytes:
|
||||||
|
"""Join clips without clicks; pause only at real sentence boundaries."""
|
||||||
|
if not parts:
|
||||||
|
return b""
|
||||||
|
output = bytearray()
|
||||||
|
sentence_silence = b"\x00\x00" * int(
|
||||||
|
24000 * max(0, SENTENCE_PAUSE_MS) / 1000)
|
||||||
|
for index, (text, pcm) in enumerate(parts):
|
||||||
|
pcm = _trim_pcm(pcm)
|
||||||
|
previous_ends_sentence = index > 0 and bool(
|
||||||
|
re.search(r"[.!?;:]\s*$", parts[index - 1][0]))
|
||||||
|
if index == 0:
|
||||||
|
output.extend(_fade_edge(pcm, fade_in=True))
|
||||||
|
elif previous_ends_sentence:
|
||||||
|
if output:
|
||||||
|
faded = _fade_edge(bytes(output), fade_out=True)
|
||||||
|
output[:] = faded
|
||||||
|
output.extend(sentence_silence)
|
||||||
|
output.extend(_fade_edge(pcm, fade_in=True))
|
||||||
|
else:
|
||||||
|
# Language switches inside a sentence get no artificial pause.
|
||||||
|
# Small fades remove the discontinuity that otherwise sounds like
|
||||||
|
# a high click or beep between independently generated clips.
|
||||||
|
if output:
|
||||||
|
faded = _fade_edge(bytes(output), fade_out=True)
|
||||||
|
output[:] = faded
|
||||||
|
output.extend(_fade_edge(pcm, fade_in=True))
|
||||||
|
return bytes(output)
|
||||||
|
|
||||||
|
|
||||||
def _wav(pcm: bytes) -> bytes:
|
def _wav(pcm: bytes) -> bytes:
|
||||||
output = io.BytesIO()
|
output = io.BytesIO()
|
||||||
with wave.open(output, "wb") as wav_file:
|
with wave.open(output, "wb") as wav_file:
|
||||||
@@ -211,19 +389,14 @@ def _convert(wav_bytes: bytes, output_format: str, speed: float) -> tuple[bytes,
|
|||||||
def synthesize_xtts(text: str, output_format: str,
|
def synthesize_xtts(text: str, output_format: str,
|
||||||
speed: float) -> tuple[bytes, str]:
|
speed: float) -> tuple[bytes, str]:
|
||||||
conditioning = _speaker_conditioning()
|
conditioning = _speaker_conditioning()
|
||||||
pcm_parts: list[bytes] = []
|
pcm_parts: list[tuple[str, bytes]] = []
|
||||||
silence = b"\x00\x00" * int(24000 * max(0, SILENCE_MS) / 1000)
|
for language, segment in prepare_segments(text):
|
||||||
for language, segment in segment_languages(text):
|
|
||||||
if not segment.strip():
|
if not segment.strip():
|
||||||
continue
|
continue
|
||||||
pcm_parts.append(_xtts_pcm(segment, language, conditioning))
|
pcm_parts.append((segment, _xtts_pcm(segment, language, conditioning)))
|
||||||
if silence:
|
|
||||||
pcm_parts.append(silence)
|
|
||||||
if pcm_parts and silence:
|
|
||||||
pcm_parts.pop()
|
|
||||||
if not pcm_parts:
|
if not pcm_parts:
|
||||||
raise RuntimeError("no speech segments generated")
|
raise RuntimeError("no speech segments generated")
|
||||||
return _convert(_wav(b"".join(pcm_parts)), output_format, speed)
|
return _convert(_wav(_join_pcm(pcm_parts)), output_format, speed)
|
||||||
|
|
||||||
|
|
||||||
def synthesize_piper(text: str, output_format: str,
|
def synthesize_piper(text: str, output_format: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user