aboutsummaryrefslogtreecommitdiff
path: root/app/converter/clients/faster.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 01:43:41 -0400
committerhistoria <historiavg@proton.me>2026-08-26 01:43:41 -0400
commitacbd9ff2c91182d96c57ffb57bee6e9b3fcbcbd4 (patch)
treee336c11f2a57cff5566e249aa5d5477a3dc63c55 /app/converter/clients/faster.py
parent104a0d65c1ba37847c15b64212b7fec8ba371ccb (diff)
downloadtts-audiobook-generator-acbd9ff2c91182d96c57ffb57bee6e9b3fcbcbd4.tar.gz
refactor: split tts.py into per-backend packages
Diffstat (limited to 'app/converter/clients/faster.py')
-rw-r--r--app/converter/clients/faster.py123
1 files changed, 123 insertions, 0 deletions
diff --git a/app/converter/clients/faster.py b/app/converter/clients/faster.py
new file mode 100644
index 0000000..df98546
--- /dev/null
+++ b/app/converter/clients/faster.py
@@ -0,0 +1,123 @@
+"""Client for the faster-qwen3-tts OpenAI-compatible server."""
+
+import json
+import logging
+import urllib.error
+import urllib.request
+import wave
+from pathlib import Path
+from typing import List, Optional
+
+from .. import config
+from ..chunking import split_into_chunks
+from .base import BaseTTSClient, ConversionCancelled
+
+logger = logging.getLogger(__name__)
+
+# The 12Hz codec the faster server synthesizes with outputs 24 kHz audio.
+SAMPLE_RATE = 24000
+
+
+class FasterTTSClient(BaseTTSClient):
+ """Generates audio chunks through a faster-qwen3-tts server.
+
+ Talks to the OpenAI-compatible server shipped in the faster-qwen3-tts
+ repository (examples/openai_server.py). The reference voice (ref audio,
+ ref text) and language are configured on the server itself via
+ --ref-audio/--ref-text or a --voices JSON file; this client only sends
+ text. Unlike the Qwen demo, the server performs one generation per
+ request, so long chunks are sub-chunked client-side.
+ """
+
+ def __init__(self, chunks_dir: Path,
+ voice: Optional[str] = None, api_url: Optional[str] = None,
+ quiet: bool = False):
+ super().__init__(chunks_dir, quiet=quiet)
+ self.voice = voice or config.FASTER_VOICE
+ self.api_url = (api_url or config.FASTER_API_URL).rstrip("/")
+ self._check_health()
+
+ def _check_health(self) -> None:
+ """Verify the server is reachable and its model is loaded."""
+ url = f"{self.api_url}/health"
+ try:
+ with urllib.request.urlopen(url, timeout=10) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ except Exception as exc:
+ raise RuntimeError(
+ f"Faster TTS server not reachable at {url}: {exc}. "
+ "Start the faster-qwen3-tts OpenAI-compatible server first "
+ "(see the 'Faster backend' section of the README)."
+ ) from exc
+ if not payload.get("model_loaded"):
+ raise RuntimeError(
+ "The faster TTS server is running but its model is not loaded yet; "
+ "wait for model download and startup to finish, then retry."
+ )
+ self._report(f"[OK] Connected to faster TTS API at {self.api_url} (voice '{self.voice}')")
+ self._report(f"[INFO] The server silently falls back to its first configured voice if "
+ f"'{self.voice}' is not defined in its voice config (see README).")
+
+ # ------------------------------------------------------------------
+ # HTTP requests
+ # ------------------------------------------------------------------
+
+ def _request_pcm(self, text: str) -> bytes:
+ """POST one sub-chunk and return raw 16-bit mono PCM bytes."""
+ url = f"{self.api_url}/v1/audio/speech"
+ payload = json.dumps({
+ "model": "tts-1",
+ "input": text,
+ "voice": self.voice,
+ "response_format": "pcm",
+ }).encode("utf-8")
+ request = urllib.request.Request(
+ url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
+ try:
+ with urllib.request.urlopen(request, timeout=config.API_TIMEOUT) as response:
+ pcm = response.read()
+ except urllib.error.HTTPError as exc:
+ detail = ""
+ try:
+ detail = exc.read().decode("utf-8", errors="replace")[:200]
+ except Exception:
+ pass
+ raise RuntimeError(f"Faster TTS server returned HTTP {exc.code}: {detail}") from exc
+ except urllib.error.URLError as exc:
+ raise RuntimeError(f"Faster TTS request failed: {exc.reason}") from exc
+ if not pcm:
+ raise RuntimeError("Faster TTS server returned empty audio")
+ return pcm
+
+ # ------------------------------------------------------------------
+ # Chunk generation
+ # ------------------------------------------------------------------
+
+ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate one audio chunk; returns its path in the chunks folder."""
+ try:
+ sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ if not sub_chunks:
+ raise RuntimeError("No text to synthesize")
+
+ pcm_parts: List[bytes] = []
+ with self._chunk_heartbeat(chunk_num):
+ for sub_num, sub_text in enumerate(sub_chunks, 1):
+ pcm = self._request_pcm(sub_text)
+ pcm_parts.append(pcm)
+
+ output_path = self._chunk_path(chunk_num, ".wav")
+ with wave.open(str(output_path), "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(SAMPLE_RATE)
+ wav_file.writeframes(b"".join(pcm_parts))
+
+ logger.debug("Chunk %d generated (%d sub-chunks)", chunk_num, len(sub_chunks))
+ return str(output_path)
+
+ except ConversionCancelled:
+ raise
+ except Exception as exc:
+ logger.error("Faster chunk processing failed for chunk %d: %s", chunk_num, exc)
+ return None