From acbd9ff2c91182d96c57ffb57bee6e9b3fcbcbd4 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 01:43:41 -0400 Subject: refactor: split tts.py into per-backend packages --- app/converter/clients/faster.py | 123 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 app/converter/clients/faster.py (limited to 'app/converter/clients/faster.py') 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 -- cgit v1.2.3