aboutsummaryrefslogtreecommitdiff
path: root/converter
diff options
context:
space:
mode:
Diffstat (limited to 'converter')
-rw-r--r--converter/config.py12
-rw-r--r--converter/converter.py40
-rw-r--r--converter/tts.py57
3 files changed, 85 insertions, 24 deletions
diff --git a/converter/config.py b/converter/config.py
index ed49f12..83778ab 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -7,14 +7,10 @@ API_TIMEOUT = 600 # Timeout per chunk request in seconds
MAX_RETRIES = 3 # Attempts per chunk request
HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N seconds
-# Words per TTS generation request.
-# Note that qwen-tts-demo does no chunking at all, but faster-qwen3-tts and
-# audio.cpp may do chunking as well, so you may be needlessly double-chunking.
-# This is the only size limit: there is no hard ceiling. However, servers
-# silently truncate audio when a single generation runs too long (roughly
-# ~2.5 min on the faster backend's static KV cache, ~11 min on the Gradio
-# demo) without reporting an error, so raising this is at your own risk.
-# The client-side truncation check still catches and retries gross cases.
+# Words per TTS generation request (client-side chunking).
+# The gradio and faster backends always chunk with this size
+# The audio.cpp backend chunks long text itself, so this is ignored
+# by default with that backend. Force chunking with --chunk
CHUNK_SIZE = 250
# Default TTS backend.
diff --git a/converter/converter.py b/converter/converter.py
index 6994a31..f1064f1 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -134,7 +134,8 @@ class AudiobookConverter:
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
language: Optional[str] = None, backend: str = BACKEND_GRADIO,
- voice: Optional[str] = None, debug: bool = False):
+ voice: Optional[str] = None, debug: bool = False,
+ chunk: bool = False):
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if output_format not in AUDIO_FORMATS:
@@ -154,6 +155,12 @@ class AudiobookConverter:
self.backend = backend
self.voice = voice
self.debug = bool(debug)
+ # Client-side chunking: the gradio and faster backends always chunk
+ # (their servers do one generation per request and silently truncate
+ # long text). The audio.cpp server chunks long text itself, so it
+ # defaults to one request per chapter; --chunk forces client-side
+ # chunking on top (possible needless double-chunking).
+ self.client_chunks = bool(chunk) or backend != BACKEND_AUDIOCPP
self._validate_configuration()
if backend == BACKEND_FASTER:
# The faster backend always voice-clones using a reference voice
@@ -162,7 +169,8 @@ class AudiobookConverter:
elif backend == BACKEND_AUDIOCPP:
# Speaker mode (no voice) uses a built-in CustomVoice speaker;
# an explicit voice selects a server-side preset (cloning).
- self.tts = AudioCppTTSClient(voice=voice, language=self.language)
+ self.tts = AudioCppTTSClient(voice=voice, language=self.language,
+ chunk_text=self.client_chunks)
else:
self.tts = QwenTTSClient(
voice_mode=voice_mode,
@@ -426,6 +434,18 @@ class AudiobookConverter:
logger.info("Chunk processing completed: %d/%d chunks", successful_chunks, total_chunks)
return results
+ def _chapter_chunks(self, text: str) -> List[str]:
+ """Split chapter text into TTS requests.
+
+ Client-side chunking splits into CHUNK_SIZE-word chunks (gradio and
+ faster always; audio.cpp only with --chunk). Otherwise (audio.cpp
+ default) the whole text is one request and the server does its own
+ long-form chunking.
+ """
+ if self.client_chunks:
+ return chunking.split_into_chunks(text)
+ return [text] if text.strip() else []
+
def _convert_text(self, text: str, output_path: Path, start_time: float,
speed: Optional[float] = None,
output_format: Optional[str] = None,
@@ -452,7 +472,7 @@ class AudiobookConverter:
logger.info("Extracted %d characters (%d words)", len(text), len(text.split()))
- chunks = chunking.split_into_chunks(text)
+ chunks = self._chapter_chunks(text)
total_chunks = len(chunks)
if total_chunks == 0:
logger.error("No chunks created")
@@ -460,7 +480,13 @@ class AudiobookConverter:
chunk_sizes = [len(chunk.split()) for chunk in chunks]
avg_chunk_size = sum(chunk_sizes) / len(chunk_sizes)
- logger.info("Split into %d chunks (avg %.0f words per chunk)", total_chunks, avg_chunk_size)
+ if len(chunks) == 1:
+ logger.info("Sending the whole text as one request (%d words; "
+ "the server chunks long text itself)",
+ chunk_sizes[0])
+ else:
+ logger.info("Split into %d chunks (avg %.0f words per chunk)",
+ total_chunks, avg_chunk_size)
backend_labels = {
BACKEND_FASTER: "faster TTS API",
BACKEND_AUDIOCPP: "audio.cpp server",
@@ -526,6 +552,12 @@ class AudiobookConverter:
else:
print("Backend: audio.cpp (custom voice, built-in speaker)")
print(f"Speaker: {config.SPEAKER}")
+ if self.client_chunks:
+ print("Chunking: client-side (--chunk; the server also chunks "
+ "long text itself, so this may double-chunk)")
+ else:
+ print("Chunking: server-side (one request per chapter; "
+ "--chunk forces client-side chunking)")
print(f"Language: {self.language}")
else:
api_url = (config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE
diff --git a/converter/tts.py b/converter/tts.py
index 1a5b1a0..afb0655 100644
--- a/converter/tts.py
+++ b/converter/tts.py
@@ -724,22 +724,35 @@ class AudioCppTTSClient(_BaseTTSClient):
server works for --voice runs, while speaker mode on such a server
fails with a hint to pass --voice.
+ Chunking: the server does its own long-form text chunking (its
+ ``text_chunk_size`` option, 8192 chars by default for qwen3_tts), so by
+ default each chapter is sent as a single request and the audio comes
+ back already stitched. With ``chunk_text=True`` (the --chunk CLI flag),
+ text is instead split client-side into CHUNK_SIZE-word sub-requests,
+ which may needlessly double-chunk — the warning is printed by the CLI.
+
Each response is a complete WAV file, so sub-request audio is
concatenated with the same lossless path used for the Gradio client.
"""
def __init__(self, voice: Optional[str] = None, language: Optional[str] = None,
- api_url: Optional[str] = None):
+ api_url: Optional[str] = None, chunk_text: bool = False):
self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/")
self.model_id = config.AUDIOCPP_MODEL_ID
# Validate before connecting so bad values fail fast without a server.
self.language = normalize_language(
language if language is not None else config.LANGUAGE)
- # Same seed convention as the Gradio client: one value per run,
- # reused for every request (see _resolve_request_seed).
+ # One seed value per run, reused for every request (see
+ # _resolve_request_seed). Unlike the Gradio demo, audio.cpp has no
+ # negative "randomize" seed, so a negative value means "send no seed
+ # at all" (see _request_wav) and the server randomizes.
self._seed = _resolve_request_seed()
self.preset_mode = bool(voice)
self.voice = voice or speaker_display_name()
+ # When False (default), each chapter is sent as one request and the
+ # server does its own long-form chunking (text_chunk_size); when True,
+ # text is split client-side into CHUNK_SIZE-word sub-requests first.
+ self.chunk_text = bool(chunk_text)
self._check_health()
model_ids = self._list_model_ids()
if self.preset_mode:
@@ -887,15 +900,23 @@ class AudioCppTTSClient(_BaseTTSClient):
# ------------------------------------------------------------------
def _request_wav(self, text: str) -> bytes:
- """POST one sub-chunk and return the raw WAV bytes."""
+ """POST one sub-chunk and return the raw WAV bytes.
+
+ The request timeout scales with the text length when a whole
+ chapter is sent in one request (no client-side chunking), since a
+ long chapter means many minutes of audio generated in one go.
+ """
url = f"{self.api_url}/v1/audio/speech"
payload: Dict[str, Any] = {
"model": self.model_id,
"input": text,
"voice": self.voice,
"language": self.language,
- "seed": self._seed,
}
+ if self._seed >= 0:
+ # audio.cpp has no negative "randomize" seed; a negative seed
+ # means "let the server randomize", so the field is omitted.
+ payload["seed"] = self._seed
if not self.preset_mode and config.INSTRUCT:
# Style instruction for the CustomVoice speakers; ignored by
# the Base (cloning) model.
@@ -903,8 +924,14 @@ class AudioCppTTSClient(_BaseTTSClient):
request = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
+ timeout = config.API_TIMEOUT
+ if not self.chunk_text:
+ # Estimated audio duration at 150 wpm, doubled plus a minute of
+ # slack, bounded below by the configured per-request timeout.
+ estimated_seconds = 60.0 * len(text.split()) / _ESTIMATED_WORDS_PER_MINUTE
+ timeout = max(timeout, int(estimated_seconds * 2) + 60)
try:
- with urllib.request.urlopen(request, timeout=config.API_TIMEOUT) as response:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
wav = response.read()
except urllib.error.HTTPError as exc:
detail = ""
@@ -940,14 +967,20 @@ class AudioCppTTSClient(_BaseTTSClient):
def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
"""Generate one audio chunk; returns its path in the chunks folder.
- The text is split into sub-requests of at most
- ``config.CHUNK_SIZE`` words each (defense in depth against
- pathological input, matching the Gradio client), each sub-request
- returns a complete WAV file, and the parts are concatenated into
- one chunk file.
+ By default the whole text goes out as a single request and the
+ server does its own long-form chunking (see the class docstring).
+ With ``chunk_text=True`` (--chunk), the text is split into
+ sub-requests of at most ``config.CHUNK_SIZE`` words each; each
+ sub-request returns a complete WAV file and the parts are
+ concatenated into one chunk file.
"""
try:
- sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ if self.chunk_text:
+ sub_texts = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ elif text.strip():
+ sub_texts = [text]
+ else:
+ sub_texts = []
if not sub_texts:
raise RuntimeError("No text to synthesize")