diff options
| author | historia <historiavg@proton.me> | 2026-08-28 14:57:48 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-28 14:57:48 -0400 |
| commit | db38085d07ce75f8961eecdc1919e98748254c53 (patch) | |
| tree | beaea2ba84de05bfc2fa90e9b92a24568d3b4cb9 /app/converter | |
| parent | afb2c2d5b297c5aa28bcced0e3f90e207d799c2a (diff) | |
| download | tts-audiobook-generator-db38085d07ce75f8961eecdc1919e98748254c53.tar.gz | |
refactor: overhaul config.py, remove cli default options
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/clients/__init__.py | 4 | ||||
| -rw-r--r-- | app/converter/clients/audiocpp.py | 166 | ||||
| -rw-r--r-- | app/converter/clients/faster.py | 8 | ||||
| -rw-r--r-- | app/converter/clients/qwen.py | 29 | ||||
| -rw-r--r-- | app/converter/clients/speakers.py | 15 | ||||
| -rw-r--r-- | app/converter/config.py | 104 | ||||
| -rw-r--r-- | app/converter/converter.py | 50 |
7 files changed, 120 insertions, 256 deletions
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index d02fd9f..16fc99c 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -19,7 +19,7 @@ from .base import BaseTTSClient, ConversionCancelled, VOICE_MODE_CLONE, \ from .languages import LANGUAGE_CHOICES, LANGUAGE_ISO_CODES, TTS_LANGUAGES, \ TTS_LANGUAGE_ALIASES, normalize_language from .speakers import QWEN3_TTS_SPEAKERS, SPEAKER_DISPLAY_NAMES, \ - is_builtin_speaker, speaker_display_name, speaker_display_name_for + is_builtin_speaker, speaker_display_name_for from .transcribe import (transcribe_reference_audio, transcribe_reference_audio_detailed, whisper_backend_available, whisper_backend_problem) @@ -57,7 +57,7 @@ __all__ = [ "LANGUAGE_CHOICES", "normalize_language", # speakers "QWEN3_TTS_SPEAKERS", "SPEAKER_DISPLAY_NAMES", - "speaker_display_name", "speaker_display_name_for", "is_builtin_speaker", + "speaker_display_name_for", "is_builtin_speaker", # transcription "transcribe_reference_audio", "transcribe_reference_audio_detailed", "whisper_backend_available", "whisper_backend_problem", diff --git a/app/converter/clients/audiocpp.py b/app/converter/clients/audiocpp.py index 8c446d3..da8d364 100644 --- a/app/converter/clients/audiocpp.py +++ b/app/converter/clients/audiocpp.py @@ -16,8 +16,7 @@ from ..chunking import split_into_chunks from .base import (BaseTTSClient, ConversionCancelled, NonRetryableTTSError, resolve_request_seed) from .languages import LANGUAGE_ISO_CODES, normalize_language -from .speakers import (is_builtin_speaker, speaker_display_name, - speaker_display_name_for) +from .speakers import is_builtin_speaker, speaker_display_name_for logger = logging.getLogger(__name__) @@ -215,23 +214,18 @@ class AudioCppTTSClient(BaseTTSClient): its voice is supplied; all three are resolved server-side from the request's "voice"/"instructions" fields: - - Speaker mode (--voice with a built-in speaker name, or no flag on a - CustomVoice entry): Qwen3-TTS CustomVoice only. A built-in speaker - name (e.g. "Vivian") is passed through, plus the INSTRUCT style - prompt. The selected entry must be the CustomVoice model (capability + - Speaker mode (--voice with a built-in speaker name): Qwen3-TTS + CustomVoice only. A built-in speaker name (e.g. "Vivian") is passed + through. The selected entry must be the CustomVoice model (capability == speaker); a speaker name on a non-speaker entry is treated as a server-side preset instead. - Preset mode (--voice NAME): a voice configured on the server (``voice_presets`` or ``voice_dir`` in its config, e.g. a cloning reference). The name is validated against GET /v1/audio/voices at startup because an unresolvable name would silently fall back to - plain TTS on a clone-based model instead of failing. When - AUDIOCPP_CLONE_MODEL_ID names a second server entry of the same - family (typically the Qwen Base model), preset requests are routed - to it. Selecting a non-speaker --voice on a CustomVoice primary with - a clone id configured is the documented way to switch a speaker setup - to cloning; without a clone id the voice is validated against the - server's voice library. + plain TTS on a clone-based model instead of failing. To clone on a + Qwen3-TTS setup, select the Base model entry with --model and pass + a preset voice. - Voice design (task "vdes" entries, e.g. Qwen3-TTS VoiceDesign): the voice is described in natural language through ``instructions``, which is required and sent with every request (no ``voice`` field). @@ -240,12 +234,13 @@ class AudioCppTTSClient(BaseTTSClient): The entry's capability decides how an explicit --voice is read: on a speaker-capable entry a name that matches a built-in speaker selects - speaker mode, and every other name is a server-side preset. With no - --voice the entry's capability picks the mode: design entries require - --instructions; speaker entries use the built-in CustomVoice speaker - in config.SPEAKER; clone entries (the Base model, and every other - family) fail fast with a hint to pass --voice, instead of silently - synthesizing with a random default voice. + speaker mode, and every other name is a server-side preset. The + entry's capability picks the mode: design entries require + --instructions; speaker entries require --voice naming a built-in + CustomVoice speaker; clone entries (the Base model, and every other + family) require --voice with a server-side preset — all fail fast + with a hint instead of silently synthesizing with a random default + voice. ``instructions`` also works on non-design entries, where it acts as a generic style/delivery instruction (voice control): families that read @@ -272,11 +267,11 @@ class AudioCppTTSClient(BaseTTSClient): quiet: bool = False): super().__init__(chunks_dir, quiet=quiet) self.api_url = (api_url or config.AUDIOCPP_API_URL).rstrip("/") - # Per-run model selection: the --model CLI flag overrides config; an - # empty value is resolved at connect time when the server hosts exactly - # one entry, so multi-model servers don't require editing config.py. - self.model_id = (model_id if model_id is not None - else config.AUDIOCPP_MODEL_ID) or "" + # Per-run model selection: the --model CLI flag (or the Generate + # form's Model pick). An empty value is resolved at connect time + # when the server hosts exactly one entry, so single-model servers + # don't require --model. + self.model_id = (model_id or "").strip() self._model_id_explicit = bool(self.model_id) # Validate before connecting so bad values fail fast without a server. self.language = normalize_language( @@ -290,26 +285,23 @@ class AudioCppTTSClient(BaseTTSClient): # resolved in _connect: a --voice that names a built-in CustomVoice # speaker on a speaker-capable entry selects speaker mode; every # other name (and any name on a clone-capable entry) is a server-side - # preset. preset_mode gates _select_model's reroute to - # AUDIOCPP_CLONE_MODEL_ID and the INSTRUCT style-prompt logic. The - # request's "voice" field (self.voice) is filled in _connect per the - # mode. + # preset. The request's "voice" field (self.voice) is filled in + # _connect per the mode. self.preset_mode = False self.speaker_mode = False self.voice = voice or None # Style/voice-design instruction sent with every request (the CLI - # --instructions flag overrides AUDIOCPP_INSTRUCTIONS in config.py). + # --instructions flag / the Generate form's Instructions field). # For task "vdes" entries it describes the voice to design; for other # families it is a generic style instruction when the model reads one. - self.instructions = (instructions if instructions is not None - else config.AUDIOCPP_INSTRUCTIONS or "").strip() + self.instructions = (instructions or "").strip() # Free-form per-request options (--option KEY=VALUE) forwarded in the # request's "options" object; models ignore keys they don't know. self.request_options: Dict[str, str] = dict(request_options or {}) # Set during _connect: design_mode for "vdes" entries, instruction_voice # when a family without built-in speakers gets its voice from the # instruction alone (no voice field). self.voice is also finalized - # there (the speaker/preset name, or config.SPEAKER for the default). + # there (the speaker/preset name). self.design_mode = False self.instruction_voice = False # Family and task of the selected model entry and the family's request @@ -337,10 +329,9 @@ class AudioCppTTSClient(BaseTTSClient): mode. An explicit --voice on a speaker-capable (CustomVoice) entry that names a built-in speaker selects speaker mode; every other --voice is a server-side preset, validated against the server's - voice library (and rerouted to AUDIOCPP_CLONE_MODEL_ID when set). - With no --voice, design entries require --instructions, speaker- - capable entries use the built-in config.SPEAKER, and clone entries - fail fast with a hint instead of silently synthesizing with a + voice library. Without a --voice, design entries require + --instructions and every other capability requires --voice — the + run fails fast with a hint instead of silently synthesizing with a random default voice. """ self._check_health() @@ -351,8 +342,7 @@ class AudioCppTTSClient(BaseTTSClient): # preset. A name matching a built-in CustomVoice speaker on a # speaker-capable primary selects speaker mode; every other name # (and any name when the primary entry is absent) is a preset, - # validated against the server's voice library and rerouted to - # AUDIOCPP_CLONE_MODEL_ID when configured. + # validated against the server's voice library. primary = next((m for m in models if m["id"] == self.model_id), None) if primary is not None: @@ -367,10 +357,8 @@ class AudioCppTTSClient(BaseTTSClient): self.speaker_mode = True self._connected(f"speaker '{self.voice}'") if not self.speaker_mode: - # Server-side preset (--voice): validate it and route to - # the clone model entry when AUDIOCPP_CLONE_MODEL_ID is set. + # Server-side preset (--voice): validate it. self.preset_mode = True - self._select_model(models) self._require_model_id(models) self._resolve_family(models) self._resolve_task(models) @@ -401,10 +389,13 @@ class AudioCppTTSClient(BaseTTSClient): self._connected("voice design") self._report(f"[INFO] Designing the voice from: {self.instructions}") elif capability == AUDIOCPP_VOICE_SPEAKER: - # No flag on a CustomVoice entry: the built-in config.SPEAKER. - self.voice = speaker_display_name() - self.speaker_mode = True - self._connected(f"speaker '{self.voice}'") + # No --voice on a CustomVoice entry: refuse instead of + # guessing a built-in speaker. + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' (family " + f"'{self.family}') serves built-in speakers: pass " + "--voice NAME with one of them (e.g. Vivian, Ryan, " + "Uncle Fu) to synthesize with it (see README).") elif self.instructions: # Families without built-in speakers can still get their voice # from the instruction alone (e.g. OmniVoice voice design). @@ -523,11 +514,8 @@ class AudioCppTTSClient(BaseTTSClient): def _auto_pick_model_id(self, models: List[Dict[str, str]]) -> None: """Resolve an empty model id when the server hosts exactly one entry. - Multi-model servers generated with several lazily-loaded entries can - be used without editing app/converter/config.py: leave AUDIOCPP_MODEL_ID - (and ``--model``) unset, and the single hosted entry is chosen - automatically. With more than one entry an explicit choice is required - (via ``--model`` or AUDIOCPP_MODEL_ID), since guessing would risk + Multi-model servers generated with several lazily-loaded entries + need an explicit ``--model``, since guessing would risk synthesizing a whole book with the wrong family. """ if self.model_id: @@ -535,22 +523,16 @@ class AudioCppTTSClient(BaseTTSClient): if len(models) == 1: self.model_id = models[0]["id"] logger.info( - "AUDIOCPP_MODEL_ID is unset; using the only server entry '%s'", + "No --model given; using the only server entry '%s'", self.model_id) else: logger.debug( - "AUDIOCPP_MODEL_ID is unset and the server hosts %d entries; " - "an explicit --model or config id is required", + "No --model given and the server hosts %d entries; " + "an explicit --model is required", len(models)) def _require_model_id(self, models: List[Dict[str, str]]) -> None: - """Verify the model id chosen for this run exists on the server. - - Speaker mode needs AUDIOCPP_MODEL_ID (the CustomVoice entry). - Preset mode validates whichever id _select_model resolved, so a - server hosting only a cloning model works for --voice. The default - error distinguishes the two so the fix is obvious. - """ + """Verify the model id chosen for this run exists on the server.""" model_ids = [model["id"] for model in models] if self.model_id and self.model_id in model_ids: return @@ -559,19 +541,14 @@ class AudioCppTTSClient(BaseTTSClient): raise RuntimeError( f"The audio.cpp server at {self.api_url} hosts {len(model_ids)} " f"model entries ({configured}); audiobook.py needs to know which " - "one to use. Pass --model <id> when converting, or set " - "AUDIOCPP_MODEL_ID in app/converter/config.py to one of them " - "(see README)." + "one to use. Pass --model <id> when converting (see README)." ) if self.preset_mode: raise RuntimeError( f"The audio.cpp server at {self.api_url} has no model id " - f"'{self.model_id}' or clone model id " - f"'{config.AUDIOCPP_CLONE_MODEL_ID}' (configured: {configured}). " - "Add a TTS model entry for the family you want to the server " - "config and match AUDIOCPP_MODEL_ID / AUDIOCPP_CLONE_MODEL_ID " - "in app/converter/config.py to its id, or select it per run with " - "--model (see README)." + f"'{self.model_id}' (configured: {configured}). Pass " + "--model <id> naming one of the hosted TTS model entries " + "(see README)." ) raise RuntimeError( f"The audio.cpp server at {self.api_url} has no model id " @@ -581,52 +558,6 @@ class AudioCppTTSClient(BaseTTSClient): "on any TTS model (see README)." ) - def _select_model(self, models: List[Dict[str, str]]) -> None: - """Pick the model for preset (cloning) requests. - - Defaults to the primary model id. When AUDIOCPP_CLONE_MODEL_ID is - configured and present on the server, preset requests are routed - to it instead, so one server can host the CustomVoice model for - speaker mode and the Base model for cloning (Qwen3-TTS setups). - A clone id that names a model of a different family is ignored - with a warning, since preset requests must synthesize with the - family the run is configured for. - """ - clone_model_id = config.AUDIOCPP_CLONE_MODEL_ID - if not clone_model_id or clone_model_id == self.model_id: - return - families = {model["id"]: model["family"] for model in models} - if clone_model_id not in families: - # A qwen3_tts primary without its clone entry silently degrades - # (presets are ignored on the CustomVoice model), so that case - # keeps the warning; single-model servers of other families are - # the normal configuration and only get a debug note. - primary_family = families.get(self.model_id) or "" - if primary_family == AUDIOCPP_FAMILY_QWEN3_TTS: - logger.warning( - "AUDIOCPP_CLONE_MODEL_ID %r is not configured on the audio.cpp " - "server; preset requests use '%s' instead", - clone_model_id, self.model_id) - else: - logger.debug( - "AUDIOCPP_CLONE_MODEL_ID %r is not configured on the audio.cpp " - "server; preset requests use '%s' instead", - clone_model_id, self.model_id) - return - primary_family = families.get(self.model_id) - clone_family = families[clone_model_id] - if primary_family and clone_family and primary_family != clone_family: - logger.warning( - "AUDIOCPP_CLONE_MODEL_ID %r hosts family %r, but " - "AUDIOCPP_MODEL_ID %r hosts %r; preset requests stay on " - "'%s'. Point both ids at the same model entry in " - "app/converter/config.py (single-model servers use the same id " - "for both)", - clone_model_id, clone_family, self.model_id, primary_family, - self.model_id) - return - self.model_id = clone_model_id - def _resolve_family(self, models: List[Dict[str, str]]) -> None: """Resolve the selected model's family and its request profile. @@ -730,11 +661,6 @@ class AudioCppTTSClient(BaseTTSClient): # Explicit voice-design or style instruction (required for task # "vdes" entries; a Ctrl/style control on families that read it). payload["instructions"] = self.instructions - elif not self.preset_mode and config.INSTRUCT \ - and self.profile.sends_instructions: - # Style instruction for the Qwen3-TTS CustomVoice speakers; - # ignored by the Base (cloning) model and other families. - payload["instructions"] = config.INSTRUCT if self.request_options: # Generic per-model controls (--option KEY=VALUE): forwarded # verbatim; the model ignores keys it does not know. diff --git a/app/converter/clients/faster.py b/app/converter/clients/faster.py index f0874e0..48c26ab 100644 --- a/app/converter/clients/faster.py +++ b/app/converter/clients/faster.py @@ -33,7 +33,13 @@ class FasterTTSClient(BaseTTSClient): 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 + # The voice is per-run (--voice / the Generate form's Voice pick); + # there is no configured default. + self.voice = (voice or "").strip() + if not self.voice: + raise RuntimeError( + "The faster backend requires a voice: pass --voice NAME " + "naming a key in the server's voices.json (see README).") self.api_url = (api_url or config.FASTER_API_URL).rstrip("/") self._check_health() diff --git a/app/converter/clients/qwen.py b/app/converter/clients/qwen.py index ed3149b..17f14c5 100644 --- a/app/converter/clients/qwen.py +++ b/app/converter/clients/qwen.py @@ -15,7 +15,7 @@ from .base import (BaseTTSClient, ConversionCancelled, resolve_request_seed, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN, VOICE_MODES) from .languages import normalize_language -from .speakers import speaker_display_name +from .speakers import QWEN3_TTS_SPEAKERS, speaker_display_name_for logger = logging.getLogger(__name__) @@ -32,7 +32,8 @@ class QwenTTSClient(BaseTTSClient): voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, language: Optional[str] = None, api_url: Optional[str] = None, - instructions: Optional[str] = None, quiet: bool = False): + instructions: Optional[str] = None, quiet: bool = False, + voice: Optional[str] = None): super().__init__(chunks_dir, quiet=quiet) if voice_mode not in VOICE_MODES: raise ValueError( @@ -42,11 +43,17 @@ class QwenTTSClient(BaseTTSClient): self.voice_clone_ref_audio = voice_clone_ref_audio self.voice_clone_ref_text = (voice_clone_ref_text or "").strip() self.skip_transcription = skip_transcription + # Built-in CustomVoice speaker (VOICE_MODE_CUSTOM): the --voice + # value / the Generate form's Speaker pick. Required there — there + # is no configured default speaker. + self.speaker = (voice or "").strip() or None + if voice_mode == VOICE_MODE_CUSTOM and not self.speaker: + raise ValueError( + "CustomVoice mode requires a speaker: pass --voice SPEAKER " + f"(one of {', '.join(QWEN3_TTS_SPEAKERS)})") # Voice design / style instruction (VoiceDesign mode): describes the - # voice to design. Defaults to the configured CustomVoice INSTRUCT so - # a run never sends an empty design prompt. - self.instructions = (instructions if instructions is not None - else config.INSTRUCT).strip() + # voice to design. Required there (validated by the converter). + self.instructions = (instructions or "").strip() # api_url overrides the configured endpoint for the active voice mode # (used by the hub's "[remote]" backend entries and --api-url). self.api_url = (api_url or "").strip() or None @@ -261,21 +268,19 @@ class QwenTTSClient(BaseTTSClient): # ------------------------------------------------------------------ def _generate_custom_voice(self, text: str) -> Tuple: - """Generate audio using CustomVoice mode.""" + """Generate audio using CustomVoice mode with the run's speaker.""" custom_api = self._resolve_api_name("/run_instruct", "/run_custom_voice", "/generate_custom_voice") if custom_api == "/run_instruct": payload = dict( text=text, lang_disp=self.language, - spk_disp=speaker_display_name(), - instruct=config.INSTRUCT, + spk_disp=speaker_display_name_for(self.speaker), ) else: payload = dict( text=text, language=self.language, - speaker=config.SPEAKER, - instruct=config.INSTRUCT, + speaker=self.speaker, ) if self._endpoint_accepts_param(custom_api, "model_id_cv"): payload["model_id_cv"] = CUSTOM_VOICE_MODEL_ID @@ -323,7 +328,7 @@ class QwenTTSClient(BaseTTSClient): clone_api = self._resolve_api_name("/run_voice_clone", "/generate_voice_clone", api_info=self.clone_api_info) - use_xvector = config.XVECTOR_ONLY or not self.voice_clone_ref_text + use_xvector = not self.voice_clone_ref_text if clone_api == "/run_voice_clone": payload = dict( diff --git a/app/converter/clients/speakers.py b/app/converter/clients/speakers.py index eecd52a..a68af68 100644 --- a/app/converter/clients/speakers.py +++ b/app/converter/clients/speakers.py @@ -2,13 +2,11 @@ from typing import Optional -from .. import config - # Built-in CustomVoice speaker names for the Qwen3-TTS family. Shared by the -# qwen-tts demo backend (config.SPEAKER, the qwen setup/form) and the -# audio.cpp audiocpp backend's CustomVoice entry (the Convert form's Speaker -# picker). Entries are the canonical/config form; speaker_display_name() -# maps them to the wire (display) form via SPEAKER_DISPLAY_NAMES below. +# qwen-tts demo backend (the qwen setup/form) and the audio.cpp audiocpp +# backend's CustomVoice entry (the Convert form's Speaker picker). Entries +# are the canonical form; speaker_display_name_for() maps them to the wire +# (display) form via SPEAKER_DISPLAY_NAMES below. QWEN3_TTS_SPEAKERS = ("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", "Ryan", "Aiden", "Ono_Anna", "Sohee") @@ -50,8 +48,3 @@ def is_builtin_speaker(name: Optional[str]) -> bool: norm = name.lower().replace("_", " ").replace("-", " ") return any(norm == speaker.lower().replace("_", " ") for speaker in QWEN3_TTS_SPEAKERS) - - -def speaker_display_name() -> str: - """Return the display name for the configured custom speaker.""" - return speaker_display_name_for(config.SPEAKER) diff --git a/app/converter/config.py b/app/converter/config.py index e37269b..3511033 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -3,119 +3,43 @@ AUDIO_FORMAT = "m4b" AUDIO_BITRATE = "128k" LANGUAGE = "English" -API_TIMEOUT = 600 # Timeout per chunk request in seconds -MAX_RETRIES = 3 # Attempts per chunk request +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 (client-side chunking). CHUNK_SIZE = 250 # Where books are read from and where finished audiobooks are written. -# Relative paths resolve against the project root (the folder containing -# audiobook.py). The --input/--output CLI flags override these per run. +# Relative paths resolve against the project root. INPUT_DIR = "./input" OUTPUT_DIR = "./output" -# Playback speed factor for the final audiobook (1.0 = normal). -# Pitch-preserving. The --speed CLI flag overrides this per run. +# Output audiobook file at a different tempo. SPEED = 1.0 -# Dump each chunk's raw audio and the exact text sent for it under the -# debug/ folder (organized per book and chapter), and log every TTS -# request and response to the console and log file. The --debug CLI flag -# forces this on for a single run. +# Dump each chunk's raw audio and the text sent for it into debug/ DEBUG = False -# Default for "Stop server and exit" (TUI Settings menu: "Stop server -# and exit"): automatically stop the TTS server and exit the TUI after -# generating audiobooks. +# Default option for stop and exit TTS server after generating with TUI STOP_SERVER_AND_EXIT = True -# Default TTS backend. -# audiocpp: audiocpp_server -# qwen: qwen-tts-demo -# faster: faster-qwen-tts -# The --backend CLI flag overrides this -BACKEND = "audiocpp" +# The IP/port that locally-managed TTS server instances run on +QWEN_API_URL = "http://127.0.0.1:7860" +FASTER_API_URL = "http://127.0.0.1:8000" +AUDIOCPP_API_URL = "http://127.0.0.1:8080" -############################################################################### -# BACKEND 1: qwen-tts-demo (qwen) options # -############################################################################### - -# The qwen backend runs ONE demo server at a time, on this port. Which model -# the server hosts is chosen per run on the Generate Audiobooks screen and -# persisted below (see QWEN_MODEL); switching models restarts the server. -QWEN_API_URL = "http://127.0.0.1:7860" # single qwen-tts-demo server - -# Remote (externally-run) server URL. The hub probes it and offers a -# "[remote]" backend entry when it answers with a known qwen-tts demo (any -# of the three models), so an externally-started server can be used alongside -# a locally-managed one. Leave empty to disable remote probing. The default -# matches the local port so an external server squatting the local port is -# found without any configuration. +# The URI used to discover externally-run instances QWEN_REMOTE_URL = "http://127.0.0.1:7860" - -# Which model the managed demo server runs (one server hosts one model): -# CustomVoice - built-in speakers (see SPEAKER) -# Base - voice cloning from a reference .wav -# VoiceDesign - voice described by an instruction -# Chosen per run in the Generate-audiobooks form; edited here only as the -# default for the next run. -QWEN_MODEL = "CustomVoice" - -# Custom voice options -SPEAKER = "Vivian" #Vivian, Serena, Uncle_Fu, Dylan, Eric, Ryan, Aiden, Ono_Anna, Sohee -# Style/delivery instruction for CustomVoice runs; also the default design -# instruction when a VoiceDesign run does not override it. -INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience." - -# Don't clone with transcription, only use x-vector-only cloning. Generally "worse" -XVECTOR_ONLY = False +FASTER_REMOTE_URL = "http://127.0.0.1:8000" +AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080" # Randomization seed. -1 means randomize with every generation # With SEED = -1 and CONSTANT_SEED = True, one random seed will be used for the entire audiobook. -# This may keep the voice slightly more consistent across chunk boundaries +# This MAY keep the voice slightly more consistent across chunk boundaries SEED = -1 CONSTANT_SEED = False -############################################################################### -# BACKEND 2: faster-qwen-tts options # -############################################################################### -FASTER_API_URL = "http://127.0.0.1:8000" # faster-qwen3-tts server (Base model only) -FASTER_REMOTE_URL = "http://127.0.0.1:8000" # externally-run faster-qwen3-tts server ("" disables probing) - -# Default voice if no --voice is passed -FASTER_VOICE = "narrator" - -############################################################################### -# BACKEND 3: audio.cpp options # -############################################################################### -AUDIOCPP_API_URL = "http://127.0.0.1:8082" # audio.cpp audiocpp_server -AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080" # externally-run audiocpp_server ("" disables probing) - -# Model ids in the audio.cpp server.json config. AUDIOCPP_MODEL_ID may point -# at any TTS model entry the server hosts; the family is detected from the -# server at startup and adapts the request automatically. Only qwen3_tts has -# built-in speakers (speaker mode); every other family needs --voice with a -# server-side voice preset. The server entry id is the model package's -# target_directory name (e.g. "Qwen3-TTS-12Hz-1.7B-Base-GGUF"). For -# single-model servers, set AUDIOCPP_CLONE_MODEL_ID to the same id as -# AUDIOCPP_MODEL_ID (or leave it empty); for Qwen3-TTS it typically names a -# second entry with the Base (cloning) model. Both default to empty so a -# single-entry server is auto-selected; a multi-model server (one server.json -# hosting several lazily-loaded entries) needs no editing here either: leave -# AUDIOCPP_MODEL_ID unset to auto-select when only one entry is hosted, or -# pick the entry per run with the --model CLI flag. -AUDIOCPP_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-Base-GGUF" -AUDIOCPP_CLONE_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-Base-GGUF" - -# Voice design / style instruction sent with every audio.cpp request when -# the --instructions CLI flag is not given. Required for server entries -# hosted with task "vdes" (voice design models such as Qwen3-TTS -# VoiceDesign); on other families it acts as a style/delivery instruction -# when the model supports one and is ignored otherwise. Empty by default. -AUDIOCPP_INSTRUCTIONS = "" - # Ask the audio.cpp server to unload all currently loaded models before # converting, so models left resident by earlier runs free their memory # (e.g. VRAM on GPU backends) and only the selected entry loads. Set to diff --git a/app/converter/converter.py b/app/converter/converter.py index 5235991..a9ea7eb 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -31,7 +31,7 @@ from .clients import ( FasterTTSClient, QwenTTSClient, normalize_language, - speaker_display_name, + speaker_display_name_for, ) logger = logging.getLogger(__name__) @@ -207,7 +207,7 @@ class AudiobookConverter: def __init__(self, voice_mode: str = VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None, 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 = config.BACKEND, + language: Optional[str] = None, backend: str = None, voice: Optional[str] = None, debug: bool = False, model_id: Optional[str] = None, instructions: Optional[str] = None, @@ -219,6 +219,8 @@ class AudiobookConverter: raise ValueError(f"Speed must be a positive number, got {speed}") if output_format not in AUDIO_FORMATS: raise ValueError(f"Unsupported output format: {output_format}") + if backend is None: + raise ValueError("backend is required (pass --backend)") if backend not in BACKENDS: raise ValueError( f"Unknown backend: {backend!r} (expected one of {BACKENDS})" @@ -257,10 +259,10 @@ class AudiobookConverter: elif backend == BACKEND_AUDIOCPP: # --voice picks the voice: a built-in speaker name on the # CustomVoice entry, or a server-side preset (cloning) - # elsewhere. model_id overrides AUDIOCPP_MODEL_ID for - # multi-model servers; instructions describe or style the - # voice, request_options pass per-model controls through to - # the server. + # elsewhere. model_id picks the server entry per run + # (auto-selected on single-entry servers); instructions + # describe or style the voice, request_options pass + # per-model controls through to the server. self.tts = AudioCppTTSClient(chunks_dir=CHUNKS_FOLDER, voice=voice, language=self.language, model_id=model_id, @@ -270,6 +272,7 @@ class AudiobookConverter: else: # Qwen: the voice mode picks the request shape (built-in # speaker, clone from a reference .wav, or a designed voice); + # --voice names the built-in speaker in speaker mode and # instructions describe the voice in design mode. self.tts = QwenTTSClient( chunks_dir=CHUNKS_FOLDER, @@ -281,6 +284,7 @@ class AudiobookConverter: instructions=self.instructions, api_url=api_url, quiet=quiet, + voice=voice, ) self._progress = progress self.tts.cancel = cancel @@ -314,6 +318,13 @@ class AudiobookConverter: "Voice Design mode requires a voice description. " "Use --instructions \"...\" to describe the voice to synthesize with." ) + if self.backend == BACKEND_QWEN and self.voice_mode == VOICE_MODE_CUSTOM \ + and not (self.voice or "").strip(): + raise ValueError( + "CustomVoice mode requires a speaker. Use --voice SPEAKER " + "(e.g. Vivian) to pick one, or --clone / --instructions " + "for the other voice modes." + ) if self.voice_mode == VOICE_MODE_CLONE and self.backend == BACKEND_QWEN: if not self.voice_clone_ref_audio: raise ValueError( @@ -348,19 +359,18 @@ class AudiobookConverter: Custom voice mode uses the built-in speaker's display name; voice clone mode uses the reference audio file's stem; the faster and - audiocpp backends use the server-side voice name (or, for audiocpp's - speaker mode, the selected built-in CustomVoice speaker, falling back - to the configured one). An instruction without a voice (voice design, - or instruction-defined voices on families without built-in speakers) - uses "designed". Spaces become underscores (e.g. "Uncle Fu" -> - "Uncle_Fu"). + audiocpp backends use the server-side voice name (for audiocpp's + speaker mode, the selected built-in CustomVoice speaker). An + instruction without a voice (voice design, or instruction-defined + voices on families without built-in speakers) uses "designed". + Spaces become underscores (e.g. "Uncle Fu" -> "Uncle_Fu"). Pure (no I/O, no server) so the pre-flight overwrite check can compute the exact output names a run would produce before spending time connecting to a TTS server. """ if backend == BACKEND_FASTER: - narrator = voice or config.FASTER_VOICE + narrator = voice or "default" elif backend == BACKEND_AUDIOCPP: if voice: narrator = voice @@ -368,7 +378,10 @@ class AudiobookConverter: # The voice comes from the instruction, not a speaker name. narrator = "designed" else: - narrator = speaker_display_name() + # Unreachable in a valid run (the audiocpp client refuses a + # speaker-capable entry without --voice); keep a stable tag + # for the pre-flight of runs that will fail at connect time. + narrator = "narrator" elif voice_mode == VOICE_MODE_DESIGN: # Qwen's VoiceDesign model: the voice is described by an # instruction and has no speaker name. @@ -376,7 +389,7 @@ class AudiobookConverter: elif voice_mode == VOICE_MODE_CLONE: narrator = Path(voice_clone_ref_audio).stem else: - narrator = speaker_display_name() + narrator = speaker_display_name_for(voice or "") return AudiobookConverter._sanitize_filename( narrator, fallback="narrator").replace(" ", "_") @@ -736,7 +749,7 @@ class AudiobookConverter: if self.backend == BACKEND_FASTER: self._say(f"Faster TTS endpoint: {config.FASTER_API_URL}") self._say("Backend: faster (voice cloning, reference configured on server)") - self._say(f"Voice: {self.voice or config.FASTER_VOICE}") + self._say(f"Voice: {self.voice}") elif self.backend == BACKEND_AUDIOCPP: self._say(f"audio.cpp endpoint: {config.AUDIOCPP_API_URL}") self._say(f"Model id: {self.tts.model_id}") @@ -750,9 +763,6 @@ class AudiobookConverter: elif self.instructions: self._say("Backend: audio.cpp (voice from --instructions description)") self._say(f"Instruction: {self.instructions}") - else: - self._say("Backend: audio.cpp (custom voice, built-in speaker)") - self._say(f"Speaker: {config.SPEAKER}") if self.request_options: self._say(f"Request options: {self.request_options}") self._say(f"Language: {self.language}") @@ -764,7 +774,7 @@ class AudiobookConverter: self._say(f"Voice mode: {self.voice_mode}") self._say(f"Model size: {MODEL_SIZE} (always)") if self.voice_mode == VOICE_MODE_CUSTOM: - self._say(f"Speaker: {config.SPEAKER}") + self._say(f"Speaker: {self.voice}") self._say(f"Language: {self.language}") elif self.voice_mode == VOICE_MODE_CLONE: self._say(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") |
