diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 6 | ||||
| -rw-r--r-- | app/backends/audiocpp/configsync.py | 42 | ||||
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 35 | ||||
| -rwxr-xr-x | app/backends/faster.py | 18 | ||||
| -rw-r--r-- | app/backends/qwen.py | 44 | ||||
| -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 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 2 | ||||
| -rw-r--r-- | app/docs/backend-faster.md | 5 | ||||
| -rw-r--r-- | app/docs/backend-qwen.md | 2 | ||||
| -rw-r--r-- | app/tests/test_audiobook_cli.py | 74 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 40 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 98 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 57 | ||||
| -rw-r--r-- | app/tests/test_converter_progress.py | 1 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 323 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 372 | ||||
| -rw-r--r-- | app/ui/hub.py | 72 |
23 files changed, 618 insertions, 949 deletions
diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py index f55cf35..02fe967 100644 --- a/app/backends/audiocpp/__init__.py +++ b/app/backends/audiocpp/__init__.py @@ -9,7 +9,7 @@ Modules: catalog the model_specs catalog + server.json building/selections models install state on disk, missing-model guidance, downloads voices reference-.wav transcription planning and execution - configsync app/converter/config.py + server.json port/id/backend sync + configsync app/converter/config.py + server.json port/backend sync build checkout lifecycle: ggml patches, binary build, update, uninstall remote querying a running server for its models/voices @@ -56,7 +56,6 @@ from .voices import ( from .configsync import ( config_port, update_config_api_url_port, - update_config_model_ids, update_server_backend, update_server_config_port, ) @@ -97,8 +96,7 @@ __all__ = [ "transcribe_wav_dir", "print_empty_transcript_warning", # configsync "config_port", "update_config_api_url_port", - "update_config_model_ids", "update_server_config_port", - "update_server_backend", + "update_server_config_port", "update_server_backend", # build "find_local_checkout", "find_audiocpp_server_bin", "find_build_script", "apply_ggml_patches", "build_audiocpp", "uninstall", "update", diff --git a/app/backends/audiocpp/configsync.py b/app/backends/audiocpp/configsync.py index 0a161c0..bce8142 100644 --- a/app/backends/audiocpp/configsync.py +++ b/app/backends/audiocpp/configsync.py @@ -75,25 +75,6 @@ def update_server_config_port(port: int) -> bool: return True -def update_config_model_ids(model_id: str, - clone_model_id: Optional[str] = None, - config_path: Optional[Path] = None) -> bool: - """Rewrite AUDIOCPP_MODEL_ID (and AUDIOCPP_CLONE_MODEL_ID when given). - - Goes through ``common.update_config_value`` so the imported config - module mirrors the change immediately. Returns True when every named - key now holds its value in the file. - """ - path = Path(config_path) if config_path is not None else CONFIG_PATH - ok = common.update_config_value("AUDIOCPP_MODEL_ID", model_id, - config_path=path) - if clone_model_id is not None: - ok = common.update_config_value("AUDIOCPP_CLONE_MODEL_ID", - clone_model_id, - config_path=path) and ok - return ok - - def _apply_port_sync(port: int, accepted: bool) -> None: """Write the port into app/converter/config.py, or report when declined.""" if accepted: @@ -106,29 +87,6 @@ def _apply_port_sync(port: int, accepted: bool) -> None: f"will still use port {config_port()}") -def _offer_config_model_id_sync(model_id: str, accepted: Optional[bool]) -> None: - """Point app/converter/config.py at a single hosted model entry. - - The converter requests the model id configured in AUDIOCPP_MODEL_ID, - and single-model servers use the same id for the clone entry, so both - ids are rewritten together. ACCEPTED is True/False (apply/skip the - rewrite) or None when no single-entry sync applies (nothing to do). - """ - if config.AUDIOCPP_MODEL_ID == model_id \ - and config.AUDIOCPP_CLONE_MODEL_ID == model_id: - return - if accepted is None: - return - if accepted: - if not update_config_model_ids(model_id, model_id): - print(f"[WARNING] Could not update {CONFIG_PATH}; edit " - "AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID by hand so " - "audiobook.py uses this model") - else: - print("[WARNING] Left the model ids unchanged; audiobook.py will " - f"still request model '{config.AUDIOCPP_MODEL_ID}'") - - def update_server_backend(backend: str) -> bool: """Rewrite the 'backend' in the checkout's server.json, or True when none. diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py index d85076a..6d2d33a 100644 --- a/app/backends/audiocpp/wizard.py +++ b/app/backends/audiocpp/wizard.py @@ -20,7 +20,6 @@ from backends.common import ( resolve_wav_dir_arg, write_prompt_text, ) -from converter import config from ui import taskview, tui from . import build as _build from . import configsync as _configsync @@ -288,7 +287,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser "build": build, "lazy_load": True, "sync_port": None, - "sync_model_ids": s["sync_model_ids"], "wav_dir": s["wav_dir"], "plan": s["plan"], "download": s["download"], @@ -385,7 +383,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser s["download"] = bool(result.get("download")) and ( _models.download_applicable(s["audiocpp_dir"], s["model_entries"])) - s["sync_model_ids"] = result.get("sync_model_ids") s["delete_unused"] = bool(result.get("delete_unused")) \ and bool(s["unused_entries"]) return _finalize() @@ -395,9 +392,9 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser The Generate-audiobooks-style form replaces the old one-question- per-screen chain (host, port, port sync, backend, build offer, - wav directory, transcription plan, model-id sync, delete unused, - download). Rows whose question does not apply are hidden rather - than skipped silently. Esc or Cancel pops back to the model tree. + wav directory, transcription plan, delete unused, download). Rows + whose question does not apply are hidden rather than skipped + silently. Esc or Cancel pops back to the model tree. """ try: _compute_entries() @@ -487,16 +484,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser "kind": "bool", "value": True, }) - model_sync_relevant = len(s["entry_ids"]) == 1 and not ( - config.AUDIOCPP_MODEL_ID == s["entry_ids"][0] - and config.AUDIOCPP_CLONE_MODEL_ID == s["entry_ids"][0]) - if model_sync_relevant: - fields.append({ - "key": "sync_model_ids", - "label": f"Make '{s['entry_ids'][0]}' the default model?", - "kind": "bool", "value": True, - }) - new_paths = {entry["path"] for entry in s["model_entries"]} s["unused_entries"] = _models.unused_installed_entries( s["output_path"], new_paths) \ @@ -636,9 +623,6 @@ def _execute_lanes(settings: dict, print(f"[OK] Deleted {removed} unused model " f"{'entry' if removed == 1 else 'entries'} from disk.") - if len(settings["entry_ids"]) == 1: - _configsync._offer_config_model_id_sync(settings["entry_ids"][0], - settings["sync_model_ids"]) _voices.print_empty_transcript_warning(state["transcripts"]) return 0 @@ -927,13 +911,6 @@ def _collect_from_flags(args: argparse.Namespace, print("[INFO] Aborted; existing server.json kept") return None - # Config sync decisions (auto-apply unless explicitly declined). - sync_model_ids: Optional[bool] = None - if len(entry_ids) == 1 and not ( - config.AUDIOCPP_MODEL_ID == entry_ids[0] - and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]): - sync_model_ids = not args.no_sync_model_ids - # Wav dir + transcription plan (defaults to the project's voices/ dir). wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR plan: Optional[dict] = None @@ -961,7 +938,6 @@ def _collect_from_flags(args: argparse.Namespace, "build": build, "lazy_load": lazy_load, "sync_port": None, - "sync_model_ids": sync_model_ids, "wav_dir": wav_dir, "plan": plan, "download": args.download, @@ -972,7 +948,7 @@ def build_parser() -> argparse.ArgumentParser: """The audio.cpp setup CLI (also used to build a default namespace).""" parser = argparse.ArgumentParser( description="Set up the audio.cpp TTS backend: clone/build, pick " - "models, write server.json, and sync app/converter/config.py.") + "models, write server.json, and sync the configured port.") parser.add_argument("--wavs", type=resolve_wav_dir_arg, default=None, dest="input_dir", metavar="WAV_DIR", help="Directory with .wav reference files to publish as " @@ -1017,9 +993,6 @@ def build_parser() -> argparse.ArgumentParser: help="Run model_manager_v2.py install for each hosted " "model automatically (default: print the commands " "only)") - parser.add_argument("--no-sync-model-ids", action="store_true", - help="Do not rewrite AUDIOCPP_MODEL_ID/" - "AUDIOCPP_CLONE_MODEL_ID for a single-entry server") return parser diff --git a/app/backends/faster.py b/app/backends/faster.py index 285d865..2d0cad3 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -337,27 +337,19 @@ def _execute_steps(settings: dict) -> List[taskview.TaskStep]: if voices is None: return 1 - # Sync app/converter/config.py default voice. (The server port is - # not touched here: it lives in FASTER_API_URL, edited in the - # Settings screen.) - default_voice = next(iter(voices)) - if default_voice != config.FASTER_VOICE: - if common.update_config_value("FASTER_VOICE", default_voice): - print(f"[OK] Updated FASTER_VOICE to {default_voice}") - else: - print("[WARNING] Could not update FASTER_VOICE; edit " - "app/converter/config.py by hand") - + # (The server port is not touched here: it lives in + # FASTER_API_URL, edited in the Settings screen. Voices are picked + # per run with --voice / on the Generate-audiobooks screen.) _print_launch_hint(settings["output_path"]) return 0 steps.append(taskview.TaskStep( - "Write voices.json & sync config", write)) + "Write voices.json", write)) return steps def _execute(settings: dict) -> int: - """Console tail: install, clone, write voices.json, sync, advise.""" + """Console tail: install, clone, write voices.json, advise.""" return taskview.run_steps_inline(_execute_steps(settings)) diff --git a/app/backends/qwen.py b/app/backends/qwen.py index d6dd2e0..a4cddb5 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -6,9 +6,10 @@ ONE Qwen3-TTS model per process — CustomVoice (built-in speakers), Base (voice cloning) or VoiceDesign (described voice). This module sets it up end-to-end: pip-install the package into its own managed venv (``app/envs/qwen`` — the app venv and the faster backend's never receive -it). There are no questions to ask — the port and which model to run live in -``app/converter/config.py`` (the model is chosen per run on the hub's -Generate-audiobooks screen), and only one server runs at a time. +it). There are no questions to ask — the port lives in +``app/converter/config.py`` (the hub's Settings screen) and the model is +chosen per run on the hub's Generate-audiobooks screen; only one server +runs at a time. Model weights are not part of the install: each demo lazily fetches its ~4GB snapshot from HuggingFace into the standard hub cache the first time a @@ -52,9 +53,9 @@ QWEN_PIP_PKG = "qwen-tts" QWEN_ENV = envs.QWEN_ENV_DIR DEFAULT_PORT = 7860 -# The models a single demo server can host, by config.QWEN_MODEL name. -# A running server identifies itself via its probe identity (backends.probe), -# so "which model is up" is always read off the server, never assumed. +# The models a single demo server can host. A running server identifies +# itself via its probe identity (backends.probe), so "which model is up" is +# always read off the server, never assumed. MODEL_REPOS = { "CustomVoice": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", "Base": "Qwen/Qwen3-TTS-12Hz-1.7B-Base", @@ -66,11 +67,13 @@ IDENTITY_TO_MODEL = { probe.IDENTITY_QWEN_CLONE: "Base", probe.IDENTITY_QWEN_DESIGN: "VoiceDesign", } +# The model a fresh managed start (Start/Stop Backend Servers menu) hosts; +# Generate-audiobooks runs needing another model restart the server. DEFAULT_MODEL = "CustomVoice" -# Built-in CustomVoice speakers (see app/converter/config.py SPEAKER). The -# canonical list lives in converter.clients.speakers (shared with the -# audio.cpp backend's Convert-form Speaker picker). +# Built-in CustomVoice speakers. The canonical list lives in +# converter.clients.speakers (shared with the audio.cpp backend's +# Convert-form Speaker picker); a run's speaker is picked per run. QWEN_SPEAKERS = QWEN3_TTS_SPEAKERS @@ -179,8 +182,8 @@ def _config_port(url: str, fallback: int) -> int: def current_model() -> str: - """The configured model to host (a MODEL_REPOS key; DEFAULT_MODEL on typos).""" - return config.QWEN_MODEL if config.QWEN_MODEL in MODEL_REPOS else DEFAULT_MODEL + """The model a fresh managed start hosts (DEFAULT_MODEL).""" + return DEFAULT_MODEL def model_for_identity(identity: Optional[str]) -> Optional[str]: @@ -202,8 +205,9 @@ def _wizard(stdscr, args: argparse.Namespace) -> dict: The qwen backend has no per-install choices: install happens when the package is missing (and not skipped by flag), and every other value — - port, speaker, which model runs — lives in app/converter/config.py / - the hub's Settings and Generate-audiobooks screens. + port — lives in app/converter/config.py (the hub's Settings screen); + the model and voice are picked per run on the Generate-audiobooks + screen. """ return { "do_install": (not _is_installed()) and not args.skip_install, @@ -306,11 +310,12 @@ def _managed_running_model() -> Optional[str]: def detect() -> BackendStatus: """Detect whether qwen-tts is installed, plus the launch command. - One managed spec exists, hosting ``config.QWEN_MODEL`` on the single - configured port. Which model currently answers there is read via the - probe (local pid alive => check our own URL; otherwise the remote URL) - so the status names the *running* model even when it differs from the - configured one. + One managed spec exists, hosting the default model on the single + configured port (Generate-audiobooks runs needing another model boot + it via their own spec). Which model currently answers there is read + via the probe (local pid alive => check our own URL; otherwise the + remote URL) so the status names the *running* model even when it + differs from the default one. """ installed = _is_installed() model = current_model() @@ -319,8 +324,7 @@ def detect() -> BackendStatus: details.append("pip: installed" if installed else "not installed — run setup to pip install qwen-tts") details.append(f"port: {_config_port(url, DEFAULT_PORT)}") - details.append(f"model: {model}") - details.append(f"speaker: {config.SPEAKER}") + details.append(f"default model: {model}") specs = [_build_spec(model)] managed = servers.manages(specs) # A locally-managed server names its running model via the probe of the 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}") diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 353fe1b..03e781e 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -83,7 +83,7 @@ In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice # Higgs Audio (clone-only) python audiobook.py --backend audiocpp --model Higgs-Audio-v3-TTS-4B-GGUF --voice narrator -# Qwen3-TTS built-in speaker (pick one with --voice, or omit it for config.SPEAKER) +# Qwen3-TTS built-in speaker (pick one with --voice) python audiobook.py --backend audiocpp --model Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF --voice Vivian # Qwen3-TTS voice cloning diff --git a/app/docs/backend-faster.md b/app/docs/backend-faster.md index f5b728f..5de337d 100644 --- a/app/docs/backend-faster.md +++ b/app/docs/backend-faster.md @@ -44,5 +44,8 @@ python examples/openai_server.py --voices voices.json --port 8000 Then from another terminal, run audiobook.py with `--backend faster` ```bash -python audiobook.py --backend faster [--voice NAME] +python audiobook.py --backend faster --voice NAME ``` + +`--voice` is required: NAME must be a key in the server's `voices.json` +(the server silently falls back to its first configured voice if it is not). diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index b1e1773..cb01561 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -1,6 +1,6 @@ # Backend Option 2: Qwen3-TTS -The easiest way is to run `python audiobook.py` → **Configure Backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (the choice is remembered in `QWEN_MODEL` and re-used by the next autostart; switching models while a managed server is up restarts it with the newly-selected model). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure Backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (switching models while a managed server is up restarts it with the newly-selected model; an autostart boots exactly the model the run picked). You can also start the server from the hub's **Start/Stop Backend Servers** menu (a fresh start runs CustomVoice), or let a conversion start it automatically. If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI. diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index b06a715..d947c5d 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -43,13 +43,17 @@ class MainTestCase(unittest.TestCase): self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_cli_")) self.addCleanup(shutil.rmtree, self.tmp, True) - def run_main(self, argv): + def run_main(self, argv, backend="audiocpp"): """Run main() with the given argv; returns (code, stderr, convert mock). The envs bootstrap (which re-execs into the managed venv via os.execv when active) and convert() are stubbed, so no TTS work - happens and the process survives. + happens and the process survives. BACKEND (a --backend value, or + None to omit the required flag) is prepended unless the argv + already carries --backend. """ + if backend is not None and "--backend" not in argv: + argv = ["--backend", backend, *argv] err = io.StringIO() convert = MagicMock(return_value=0) with patch.object(sys, "argv", ["audiobook.py", *argv]), \ @@ -64,6 +68,71 @@ class MainTestCase(unittest.TestCase): return code, err.getvalue(), convert +class MainBackendTests(MainTestCase): + """The backend/model/voice are per-run choices with no config defaults.""" + + def test_backend_is_required(self): + code, err, convert = self.run_main(["--debug"], backend=None) + self.assertEqual(code, 2) + self.assertIn("--backend", err) + self.assertIn("required", err) + convert.assert_not_called() + + def test_backend_reaches_convert(self): + code, _, convert = self.run_main([]) + self.assertEqual(code, 0) + self.assertEqual(convert.call_args.kwargs["backend"], "audiocpp") + + def test_qwen_accepts_a_builtin_speaker_voice(self): + code, _, convert = self.run_main( + ["--backend", "qwen", "--voice", "Vivian"], backend=None) + self.assertEqual(code, 0) + self.assertEqual(convert.call_args.kwargs["backend"], "qwen") + self.assertEqual(convert.call_args.kwargs["voice"], "Vivian") + + def test_qwen_rejects_a_non_speaker_voice(self): + code, err, convert = self.run_main( + ["--backend", "qwen", "--voice", "narrator"], backend=None) + self.assertEqual(code, 2) + self.assertIn("not a built-in speaker", err) + convert.assert_not_called() + + def test_qwen_requires_a_voice_without_clone_or_instructions(self): + code, err, convert = self.run_main(["--backend", "qwen"], backend=None) + self.assertEqual(code, 2) + self.assertIn("--backend qwen needs a voice", err) + convert.assert_not_called() + + def test_qwen_clone_run_needs_no_voice(self): + code, _, convert = self.run_main( + ["--backend", "qwen", "--clone", "ref.wav"], backend=None) + self.assertEqual(code, 0) + convert.assert_called_once() + + def test_faster_requires_a_voice(self): + code, err, convert = self.run_main(["--backend", "faster"], + backend=None) + self.assertEqual(code, 2) + self.assertIn("--backend faster requires --voice", err) + convert.assert_not_called() + + def test_noninteractive_no_args_stops_with_guidance(self): + # No args in a non-interactive session cannot guess a backend: + # point the user at --backend / the TUI instead of converting. + out, err = io.StringIO(), io.StringIO() + with patch.object(sys, "argv", ["audiobook.py"]), \ + patch.object(sys, "stdin", io.StringIO()), \ + patch.object(sys, "stdout", io.StringIO()), \ + contextlib.redirect_stdout(out), \ + contextlib.redirect_stderr(err), \ + patch.object(audiobook._envs, "bootstrap"): + with self.assertRaises(SystemExit) as ctx: + audiobook.main() + self.assertEqual(ctx.exception.code, 2) + self.assertIn("No --backend given", out.getvalue()) + self.assertIn("No --backend given", err.getvalue() + out.getvalue()) + + class MainFlagConflictTests(MainTestCase): """Mixing the directory and single-book flag pairs stops with an error.""" @@ -200,6 +269,7 @@ class ConvertWiringTests(unittest.TestCase): self._old_folders def _convert(self, **kwargs): + kwargs.setdefault("backend", "audiocpp") preflight = MagicMock( return_value=([self.book], [(self.book, "dune")])) fake_instance = MagicMock() diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 09ad3cc..64fd483 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -145,31 +145,33 @@ class DetectAllTests(unittest.TestCase): self.assertEqual(status.remote_models, [model]) self.assertEqual(status.running_models, [model]) - def test_qwen_detect_uses_one_spec_for_the_configured_model(self): - # One demo server hosts one model on the single port: the spec's - # argv launches config.QWEN_MODEL's repo, and its identity matches. + def test_qwen_detect_builds_one_spec_for_the_default_model(self): + # One demo server hosts one model on the single port: the detect() + # spec launches the default model's repo (CustomVoice), and its + # identity matches; runs wanting another model boot their own spec. from backends import qwen from backends.probe import (IDENTITY_QWEN_CLONE, IDENTITY_QWEN_CUSTOM, IDENTITY_QWEN_DESIGN) - cases = {"CustomVoice": IDENTITY_QWEN_CUSTOM, - "Base": IDENTITY_QWEN_CLONE, - "VoiceDesign": IDENTITY_QWEN_DESIGN} - for model, identity in cases.items(): + identity = IDENTITY_QWEN_CUSTOM + model = qwen.DEFAULT_MODEL + with patch.object(qwen, "_is_installed", return_value=True), \ + patch("backends.common.server_running", + return_value=False): + status = qwen.detect() + self.assertEqual([spec.name for spec in status.servers], ["qwen"]) + spec = status.servers[0] + self.assertEqual(spec.identity, identity) + self.assertIn(qwen.MODEL_REPOS[model], spec.argv) + self.assertIn(qwen.MODEL_REPOS[model], status.launch_hint) + + # An explicit per-run spec can target any of the three models. + for model, wanted in (("Base", IDENTITY_QWEN_CLONE), + ("VoiceDesign", IDENTITY_QWEN_DESIGN)): with self.subTest(model=model): - with patch.object(qwen.config, "QWEN_MODEL", model), \ - patch.object(qwen, "_is_installed", - return_value=True), \ - patch("backends.common.server_running", - return_value=False): - status = qwen.detect() - self.assertEqual([spec.name for spec in status.servers], - ["qwen"]) - spec = status.servers[0] - self.assertEqual(spec.identity, identity) + spec = qwen._build_spec(model) + self.assertEqual(spec.identity, wanted) self.assertIn(qwen.MODEL_REPOS[model], spec.argv) - self.assertIn(qwen.MODEL_REPOS[model], - status.launch_hint) def test_qwen_detect_marks_our_server_as_managed(self): from backends import qwen diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 95f8bec..82d6b88 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -27,14 +27,6 @@ FAKE_CONFIG = ( "CHUNK_SIZE = 250\n" ) -FAKE_CONFIG_WITH_MODEL_IDS = ( - 'AUDIOCPP_API_URL = "http://127.0.0.1:9999" # audio.cpp audiocpp_server\n' - "\n" - 'AUDIOCPP_MODEL_ID = "qwen" # server entry for speaker mode\n' - 'AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"\n' -) - - def _write_spec(checkout: Path, family: str, *, display_name=None, tasks=("tts", "clone"), languages=("en",), packages=None, category="tts"): @@ -278,59 +270,6 @@ class UpdateConfigPortTests(unittest.TestCase): 8080, config_path=Path(self._tmp.name) / "nope.py")) -class UpdateConfigModelIdsTests(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.config_path = Path(self._tmp.name) / "config.py" - self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS, - encoding="utf-8") - # The shared helper also mirrors values onto converter.config. - self._saved_ids = (config.AUDIOCPP_MODEL_ID, - config.AUDIOCPP_CLONE_MODEL_ID) - - def tearDown(self): - (config.AUDIOCPP_MODEL_ID, - config.AUDIOCPP_CLONE_MODEL_ID) = self._saved_ids - self._tmp.cleanup() - - def test_rewrites_both_ids_preserving_lines(self): - changed = make_server.configsync.update_config_model_ids( - "higgs", "higgs", config_path=self.config_path) - self.assertTrue(changed) - text = self.config_path.read_text(encoding="utf-8") - self.assertIn('AUDIOCPP_MODEL_ID = "higgs" # server entry for speaker mode', - text) - self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "higgs"', text) - self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:9999"', text) - - def test_clone_id_optional(self): - changed = make_server.configsync.update_config_model_ids( - "voxcpm2", config_path=self.config_path) - self.assertTrue(changed) - text = self.config_path.read_text(encoding="utf-8") - self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text) - self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text) - - def test_ids_unchanged_is_a_success_noop(self): - # Both ids already hold their values: success, nothing rewritten. - changed = make_server.configsync.update_config_model_ids( - "qwen", "qwen-clone", config_path=self.config_path) - self.assertTrue(changed) - self.assertEqual(self.config_path.read_text(encoding="utf-8"), - FAKE_CONFIG_WITH_MODEL_IDS) - - def test_returns_false_when_lines_missing(self): - path = Path(self._tmp.name) / "other.py" - path.write_text('CHUNK_SIZE = 250\n', encoding="utf-8") - self.assertFalse(make_server.configsync.update_config_model_ids( - "higgs", "higgs", config_path=path)) - - def test_returns_false_when_file_missing(self): - self.assertFalse(make_server.configsync.update_config_model_ids( - "higgs", "higgs", - config_path=Path(self._tmp.name) / "nope.py")) - - class ResolveWavDirArgTests(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() @@ -1934,7 +1873,7 @@ class NonInteractiveMainTests(unittest.TestCase): def test_default_run_hosts_recommended_entry(self): exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts")) self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual(data["host"], "127.0.0.1") @@ -1952,8 +1891,7 @@ class NonInteractiveMainTests(unittest.TestCase): with patch.object(config, "AUDIOCPP_API_URL", "http://127.0.0.1:9999"): exit_code = self._run( - self._args("--families", "higgs_audio_tts", - "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts")) self.assertEqual(exit_code, 0) self.assertIn('"http://127.0.0.1:9999"', self.fake_config.read_text(encoding="utf-8")) @@ -1968,21 +1906,10 @@ class NonInteractiveMainTests(unittest.TestCase): with self.assertRaises(SystemExit): parser.parse_args([flag, "x"]) - def test_model_id_sync_accepted_updates_config(self): - self.fake_config.write_text(FAKE_CONFIG_WITH_MODEL_IDS, - encoding="utf-8") - exit_code = self._run(self._args("--families", "higgs_audio_tts")) - self.assertEqual(exit_code, 0) - text = self.fake_config.read_text(encoding="utf-8") - self.assertIn('AUDIOCPP_MODEL_ID = "Higgs-Audio-v3-TTS-4B-GGUF"', text) - self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "Higgs-Audio-v3-TTS-4B-GGUF"', - text) - def test_multi_family_lazy_with_voice_dir(self): (self.folder / "narrator.wav").write_bytes(b"x") exit_code = self._run( - self._args("--families", "qwen3_tts,higgs_audio_tts", - "--no-sync-model-ids"), + self._args("--families", "qwen3_tts,higgs_audio_tts"), transcribe=lambda path, model_name="base": "a transcript") self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) @@ -1998,8 +1925,7 @@ class NonInteractiveMainTests(unittest.TestCase): def test_force_overwrites_existing_output(self): self.output.write_text('{"old": true}', encoding="utf-8") exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--force", - "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts", "--force")) self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) self.assertEqual(len(data["models"]), 1) @@ -2007,15 +1933,14 @@ class NonInteractiveMainTests(unittest.TestCase): def test_existing_output_declined_keeps_file(self): self.output.write_text('{"old": true}', encoding="utf-8") exit_code = self._run( - self._args("--families", "higgs_audio_tts", "--no-sync-model-ids")) + self._args("--families", "higgs_audio_tts")) self.assertEqual(exit_code, 1) self.assertEqual(json.loads(self.output.read_text(encoding="utf-8")), {"old": True}) def test_all_packages_hosts_design_as_vdes(self): exit_code = self._run( - self._args("--families", "qwen3_tts", "--all-packages", - "--no-sync-model-ids")) + self._args("--families", "qwen3_tts", "--all-packages")) self.assertEqual(exit_code, 0) data = json.loads(self.output.read_text(encoding="utf-8")) by_id = {m["id"]: m for m in data["models"]} @@ -2029,14 +1954,13 @@ class NonInteractiveMainTests(unittest.TestCase): def test_unknown_family_rejected(self): with self.assertRaises(SystemExit) as ctx: - self._run(self._args("--families", "not_a_family", - "--no-sync-model-ids")) + self._run(self._args("--families", "not_a_family")) self.assertEqual(ctx.exception.code, 2) def test_missing_checkout_rejected(self): with self.assertRaises(SystemExit) as ctx: self._run(["--families", "higgs_audio_tts", "--output", - str(self.output), "--no-sync-model-ids"], + str(self.output)], no_checkout=True) self.assertEqual(ctx.exception.code, 2) @@ -2044,12 +1968,12 @@ class NonInteractiveMainTests(unittest.TestCase): missing = self.root / "nope" with self.assertRaises(SystemExit) as ctx: self._run(["--wavs", str(missing), "--output", str(self.output), - "--families", "higgs_audio_tts", "--no-sync-model-ids"]) + "--families", "higgs_audio_tts"]) self.assertEqual(ctx.exception.code, 2) def test_families_required_in_noninteractive_run(self): with self.assertRaises(SystemExit) as ctx: - self._run(self._args("--no-sync-model-ids")) + self._run(self._args()) self.assertEqual(ctx.exception.code, 2) @@ -2666,7 +2590,6 @@ class WizardNavigationTests(unittest.TestCase): self.assertEqual(settings["backend"], "cuda") # default choice self.assertTrue(settings["build"]) # not built yet → offered (default Yes) self.assertFalse(settings["download"]) # no manager script here - self.assertTrue(settings["sync_model_ids"]) def test_tree_screen_starts_on_confirm(self): # The model-tree screen opens with focus on Confirm so Enter @@ -2895,7 +2818,6 @@ class ExecuteLanesTests(unittest.TestCase): "wav_dir": None, "plan": None, "sync_port": None, - "sync_model_ids": None, "delete_unused": False, "unused_entries": [], "model_entries": [], diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 4d7a064..eaf96ef 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -64,20 +64,29 @@ class ConfigurationValidationTests(unittest.TestCase): def test_language_defaults_to_config(self): with patch("converter.converter.QwenTTSClient") as mock_tts: - AudiobookConverter(backend=BACKEND_QWEN) + AudiobookConverter(backend=BACKEND_QWEN, voice="Vivian") self.assertEqual(mock_tts.call_args.kwargs["language"], config.LANGUAGE) def test_output_format_defaults_to_config(self): with patch("converter.converter.QwenTTSClient"): - converter = AudiobookConverter(backend=BACKEND_QWEN) + converter = AudiobookConverter(backend=BACKEND_QWEN, + voice="Vivian") self.assertEqual(converter.output_format, config.AUDIO_FORMAT) def test_language_normalized_before_tts_client(self): with patch("converter.converter.QwenTTSClient") as mock_tts: - converter = AudiobookConverter(language="ja", backend=BACKEND_QWEN) + converter = AudiobookConverter(language="ja", backend=BACKEND_QWEN, + voice="Vivian") self.assertEqual(converter.language, "Japanese") self.assertEqual(mock_tts.call_args.kwargs["language"], "Japanese") + def test_qwen_custom_voice_requires_a_speaker(self): + # There is no configured default speaker: a qwen built-in-speaker + # run must be told which one to use. + with self.assertRaises(ValueError) as ctx: + AudiobookConverter(backend=BACKEND_QWEN) + self.assertIn("requires a speaker", str(ctx.exception)) + class FindExistingOutputsTests(unittest.TestCase): def setUp(self): @@ -133,23 +142,23 @@ class FindExistingOutputsTests(unittest.TestCase): class NarratorTagTests(unittest.TestCase): - def _converter(self, voice_mode, ref_audio=None, instructions=None): + def _converter(self, voice_mode, ref_audio=None, instructions=None, + voice=None): converter = AudiobookConverter.__new__(AudiobookConverter) converter.voice_mode = voice_mode converter.voice_clone_ref_audio = ref_audio converter.backend = BACKEND_QWEN - converter.voice = None + converter.voice = voice converter.instructions = instructions return converter def test_custom_voice_uses_speaker_display_name(self): - self.assertEqual(self._converter(VOICE_MODE_CUSTOM)._narrator_tag(), - "Vivian") + converter = self._converter(VOICE_MODE_CUSTOM, voice="Vivian") + self.assertEqual(converter._narrator_tag(), "Vivian") def test_multi_word_display_name_gets_underscores(self): - with patch.object(config, "SPEAKER", "uncle_fu"): - self.assertEqual(self._converter(VOICE_MODE_CUSTOM)._narrator_tag(), - "Uncle_Fu") + converter = self._converter(VOICE_MODE_CUSTOM, voice="uncle_fu") + self.assertEqual(converter._narrator_tag(), "Uncle_Fu") def test_clone_uses_reference_audio_stem(self): self.assertEqual(self._converter(VOICE_MODE_CLONE, "/x/ref.wav")._narrator_tag(), @@ -186,14 +195,21 @@ class NarratorTagTests(unittest.TestCase): self.assertEqual(converter._narrator_tag(), "narrator") def test_audiocpp_speaker_mode_keeps_speaker_tag(self): - converter = self._audiocpp_converter() + converter = self._audiocpp_converter(voice="Vivian") self.assertEqual(converter._narrator_tag(), "Vivian") def test_audiocpp_explicit_speaker_uses_speaker_tag(self): - # A chosen CustomVoice speaker names the output, not config.SPEAKER. + # A chosen CustomVoice speaker names the output. converter = self._audiocpp_converter(voice="Ryan") self.assertEqual(converter._narrator_tag(), "Ryan") + def test_audiocpp_without_voice_or_instruction_uses_fallback_tag(self): + # A run like this fails at connect time (the client refuses a + # speaker-capable entry without --voice); the pre-flight still + # needs a stable tag for it. + converter = self._audiocpp_converter() + self.assertEqual(converter._narrator_tag(), "narrator") + def test_audiocpp_explicit_speaker_normalizes_display_name(self): converter = self._audiocpp_converter(voice="Uncle_Fu") self.assertEqual(converter._narrator_tag(), "Uncle_Fu") @@ -332,8 +348,11 @@ class DebugDumpTests(unittest.TestCase): def test_debug_flag_wiring(self): with patch("converter.converter.QwenTTSClient"): - self.assertFalse(AudiobookConverter(backend=BACKEND_QWEN).debug) - self.assertTrue(AudiobookConverter(debug=True, backend=BACKEND_QWEN).debug) + self.assertFalse(AudiobookConverter(backend=BACKEND_QWEN, + voice="Vivian").debug) + self.assertTrue(AudiobookConverter(debug=True, + backend=BACKEND_QWEN, + voice="Vivian").debug) class SetupLoggingTests(unittest.TestCase): @@ -549,14 +568,14 @@ class PreflightOverwritesTests(unittest.TestCase): (converter_mod.BOOKS_FOLDER / "book.txt").unlink() with patch("builtins.input", side_effect=AssertionError("should not prompt")): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(book_files, []) self.assertEqual(planned, []) def test_new_book_planned_without_prompt(self): with patch("builtins.input", side_effect=AssertionError("should not prompt")): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(len(book_files), 1) self.assertEqual(planned, [(book_files[0], "book_Vivian")]) @@ -564,14 +583,14 @@ class PreflightOverwritesTests(unittest.TestCase): (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing") with patch("builtins.input", return_value=""): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(planned, [(book_files[0], "book_Vivian")]) def test_existing_output_declined_is_skipped(self): (converter_mod.AUDIOBOOKS_FOLDER / "book_Vivian.mp3").write_bytes(b"existing") with patch("builtins.input", return_value="n"): book_files, planned = AudiobookConverter.preflight_overwrites( - BACKEND_QWEN, None, VOICE_MODE_CUSTOM, None, "mp3") + BACKEND_QWEN, "Vivian", VOICE_MODE_CUSTOM, None, "mp3") self.assertEqual(len(book_files), 1) self.assertEqual(planned, []) @@ -590,7 +609,7 @@ class RunOverwritePromptTests(unittest.TestCase): self.converter.voice_mode = VOICE_MODE_CUSTOM self.converter.voice_clone_ref_audio = None self.converter.backend = BACKEND_QWEN - self.converter.voice = None + self.converter.voice = "Vivian" self.converter.instructions = None self.converter.speed = 1.0 self.converter.single_file = False diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 9a1147c..d77e9e0 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -116,6 +116,7 @@ class _ConvertFixture: return_value=MagicMock()): converter = AudiobookConverter( voice_mode=VOICE_MODE_CUSTOM, backend=BACKEND_QWEN, + voice="Vivian", output_format="mp3", language="English", progress=progress, cancel=cancel) converter.tts.process_chunk_with_retry.return_value = "chunk_0001.wav" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index ee1425d..86dd9e3 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -916,8 +916,7 @@ class ConvertFlowTests(unittest.TestCase): patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ patch.object(hub.config, "LANGUAGE", "English"), \ patch.object(hub.config, "SPEED", 1.25), \ - patch.object(hub.config, "DEBUG", False), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + patch.object(hub.config, "DEBUG", False): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") cmd = self._convert( @@ -964,11 +963,10 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "short", "family": "higgs_audio_tts", "task": "tts"}, {"id": "a-much-longer-model-id", "family": "qwen3_tts", "task": "tts"}]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="short", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="short", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) choices = self._field("model_id")["choices"] # "a-much-longer-model-id" is 22 columns wide; both types open at @@ -990,13 +988,12 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", - audiocpp_voice="Ryan", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + audiocpp_voice="Ryan", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) # The picked speaker is passed as --voice; no separate speaker kwarg. self.assertEqual(cmd[2]["voice"], "Ryan") self.assertNotIn("speaker", cmd[2]) @@ -1016,13 +1013,12 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", - audiocpp_voice="narrator", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[2]["voice"], "narrator") self.assertNotIn("speaker", cmd[2]) self.assertIsNone(cmd[2]["instructions"]) @@ -1044,12 +1040,11 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", "family": "qwen3_tts", "task": "tts"}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", - audiocpp_voice="", instructions="") - self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + audiocpp_voice="", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] voice_field = self._field("audiocpp_voice") self.assertTrue(voice_field["visible"](fields)) @@ -1069,9 +1064,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="", instructions="") self._convert(None, @@ -1091,11 +1084,10 @@ class ConvertFlowTests(unittest.TestCase): # built-in speaker. self._patch_remote([{"id": "legacy", "family": "", "task": ""}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="legacy", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="legacy", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) self.assertNotIn("speaker", cmd[2]) @@ -1106,12 +1098,11 @@ class ConvertFlowTests(unittest.TestCase): def test_audiocpp_vdes_hides_voice_and_requires_instructions(self): self._patch_remote( [{"id": "design", "family": "qwen3_tts", "task": "vdes"}]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="design", - audiocpp_voice=None, - instructions="A warm British narrator") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="design", + audiocpp_voice=None, + instructions="A warm British narrator") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNone(cmd[2]["voice"]) self.assertEqual(cmd[2]["instructions"], "A warm British narrator") fields = self.tui.forms_seen[0][1] @@ -1129,12 +1120,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", - instructions="stale description") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", + instructions="stale description") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[2]["instructions"], "stale description") def test_audiocpp_required_voice_validates(self): @@ -1142,11 +1132,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) voice_field = self._field("audiocpp_voice") self.assertIsNotNone(voice_field["validate"]("")) self.assertIsNone(voice_field["validate"]("narrator")) @@ -1157,14 +1146,12 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", "task": "tts"}]) - with patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", - model_id="Qwen3-TTS-CustomVoice-GGUF", - audiocpp_voice="Vivian", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", + model_id="Qwen3-TTS-CustomVoice-GGUF", + audiocpp_voice="Vivian", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] label = self._field("audiocpp_voice")["label"] self.assertEqual(label(fields), "Built-in voice") @@ -1175,12 +1162,11 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "Qwen3-TTS-Base-GGUF", "family": "qwen3_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", - model_id="Qwen3-TTS-Base-GGUF", - audiocpp_voice="narrator", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", + model_id="Qwen3-TTS-Base-GGUF", + audiocpp_voice="narrator", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] label = self._field("audiocpp_voice")["label"] self.assertEqual(label(fields), "Voice to clone") @@ -1192,11 +1178,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="", instructions="") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] voice = self._field("audiocpp_voice") instr = self._field("instructions") @@ -1211,11 +1196,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) fields = self.tui.forms_seen[0][1] voice = self._field("audiocpp_voice") instr = self._field("instructions") @@ -1230,13 +1214,12 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form( - backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="", - request_options="emotion=neutral, speed=1.1") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form( + backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="", + request_options="emotion=neutral, speed=1.1") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[2]["request_options"], {"emotion": "neutral", "speed": "1.1"}) @@ -1244,12 +1227,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="", - request_options="oops") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="", + request_options="oops") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) options_field = self._field("request_options") self.assertIsNone(options_field["validate"]("emotion=neutral")) self.assertIsNotNone(options_field["validate"]("oops")) @@ -1284,9 +1266,7 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=self._specs_checkout( - ("higgs_audio_tts",))), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + return_value=self._specs_checkout(("higgs_audio_tts",))): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") cmd = self._convert( @@ -1302,9 +1282,7 @@ class ConvertFlowTests(unittest.TestCase): # A checkout exists but only qwen3_tts declares request options: # higgs is provably unsupported -> hidden. with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=self._specs_checkout( - ("qwen3_tts",))), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + return_value=self._specs_checkout(("qwen3_tts",))): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") self._convert(None, @@ -1317,9 +1295,7 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=None), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=None): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") self._convert(None, @@ -1335,12 +1311,11 @@ class ConvertFlowTests(unittest.TestCase): {"id": "design", "family": "qwen3_tts", "task": "vdes"}, {"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}, ], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="design", - audiocpp_voice=None, - instructions="A warm British narrator") - self._convert(None, - [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="design", + audiocpp_voice=None, + instructions="A warm British narrator") + self._convert(None, + [self._remote("audiocpp", "audio.cpp")]) instr = self._field("instructions") self.assertEqual(instr["help"], [ "TTS style instructions. Supported by some clone models. Example:", @@ -1352,9 +1327,7 @@ class ConvertFlowTests(unittest.TestCase): [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=self._specs_checkout( - ("qwen3_tts", "higgs_audio_tts"))), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + return_value=self._specs_checkout(("qwen3_tts", "higgs_audio_tts"))): self._answer_form(backend="audiocpp-remote", model_id="higgs", audiocpp_voice="narrator", instructions="") self._convert(None, @@ -1371,7 +1344,6 @@ class ConvertFlowTests(unittest.TestCase): # The Settings Language setting travels on the run kwargs as-is; # the converter normalizes it (short codes included). with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ patch.object(hub.config, "LANGUAGE", "en"): self._answer_form(backend="qwen", mode="custom", speaker="Vivian", clone="") @@ -1399,11 +1371,10 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=[]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) fields = self.tui.forms_seen[0][1] @@ -1429,11 +1400,8 @@ class ConvertFlowTests(unittest.TestCase): "voice_dir": str(root), }), encoding="utf-8") (root / "Narrator.wav").write_bytes(b"x") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.audiocpp_backend, "fetch_server_models", - must_not_query), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root), \ + patch.object(hub.audiocpp_backend, "fetch_server_models", must_not_query): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="Narrator", instructions="") cmd = self._convert(None, @@ -1456,9 +1424,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): self._answer_form(backend="audiocpp", model_id="qwen", audiocpp_voice="", instructions="") cmd = self._convert(None, [ @@ -1496,9 +1462,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): # Managed selected: its picks must survive next to the # remote entry's same-shaped fields. self.tui.form_script.append({ @@ -1529,12 +1493,11 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - with patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): - self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="") - cmd = self._convert( - None, [self._remote("audiocpp", "audio.cpp", - url="http://10.0.0.5:8080")]) + self._answer_form(backend="audiocpp-remote", model_id="higgs", + audiocpp_voice="narrator", instructions="") + cmd = self._convert( + None, [self._remote("audiocpp", "audio.cpp", + url="http://10.0.0.5:8080")]) self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8080") # ------------------------------------------------------------------ @@ -1556,29 +1519,17 @@ class ConvertFlowTests(unittest.TestCase): # ------------------------------------------------------------------ def test_qwen_builds_speaker_and_clone_form(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", - ["Vivian", "Serena"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian", "Serena"]): self._answer_form(backend="qwen", mode="custom", speaker="Serena", clone="") - # The fake mirrors the real update_config_value contract: - # persisting a value also lands it on the imported module. - def fake_update(key, value, config_path=None): - setattr(hub.config, key, value) - return True - - with patch.object(hub.common, "update_config_value", - fake_update): - cmd = self._convert(None, - [self._ready("qwen", "qwen-tts")]) - speaker_in_memory = hub.config.SPEAKER + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertIsNone(cmd[2]["clone"]) self.assertIsNone(cmd[2].get("instructions")) - self.assertEqual(speaker_in_memory, "Serena") + # The picked speaker travels with the run; nothing is persisted. + self.assertEqual(cmd[2]["voice"], "Serena") fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone_dir", @@ -1617,11 +1568,8 @@ class ConvertFlowTests(unittest.TestCase): self.assertFalse(clone_field["visible"](fields)) self.assertTrue(design_field["visible"](fields)) - def test_qwen_design_mode_passes_instructions_and_persists_model(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + def test_qwen_design_mode_passes_instructions(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen", mode="design", qwen_instructions="A warm narrator") with patch.object(hub.common, "update_config_value") as mk_update: @@ -1629,51 +1577,37 @@ class ConvertFlowTests(unittest.TestCase): [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], None) self.assertEqual(cmd[2]["instructions"], "A warm narrator") - # The model switch is persisted (CustomVoice -> VoiceDesign). - mk_update.assert_called_once_with("QWEN_MODEL", "VoiceDesign") + # Per-run choices are not persisted to the config file. + mk_update.assert_not_called() - def test_qwen_clone_mode_passes_path_and_persists_model(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + def test_qwen_clone_mode_passes_path(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") with patch.object(hub.common, "update_config_value") as mk_update: cmd = self._convert(None, - [self._ready("qwen", "qwen-tts")]) + [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") - # Clone mode does not touch the global speaker. - keys = [c.args[0] for c in mk_update.call_args_list] - self.assertNotIn("SPEAKER", keys) - # ...but remembers the switch to the Base model. - self.assertEqual(keys, ["QWEN_MODEL"]) - self.assertEqual(mk_update.call_args.args[1], "Base") - - def test_qwen_same_model_run_persists_nothing_new(self): - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "CustomVoice"): + mk_update.assert_not_called() + + def test_qwen_custom_mode_passes_the_speaker(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen", mode="custom", speaker="Vivian") with patch.object(hub.common, "update_config_value") as mk_update: cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) self.assertIsNotNone(cmd) + self.assertEqual(cmd[2]["voice"], "Vivian") mk_update.assert_not_called() - def test_qwen_form_opens_on_the_configured_model(self): - # The persisted QWEN_MODEL seeds the Model picker's default, so the - # form opens on what the last run chose (not always the first row). + def test_qwen_form_defaults_to_the_first_model(self): + # No persisted default: the Model picker opens on CustomVoice. with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ - patch.object(hub.qwen_backend.config, "QWEN_MODEL", - "VoiceDesign"), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="design", qwen_instructions="A warm narrator") self._convert(None, [self._ready("qwen", "qwen-tts")]) - self.assertEqual(self._field("mode")["value"], "design") + self.assertEqual(self._field("mode")["value"], "custom") def test_qwen_clone_dir_defaults_to_the_project_voices(self): # The Clone .wav directory is the shared directory widget, seeded @@ -1689,9 +1623,7 @@ class ConvertFlowTests(unittest.TestCase): ("narrator.wav", str(root / "narrator.wav")), ] with patch.object(hub.common, "VOICES_DIR", root), \ - patch.object(hub.qwen_backend, "QWEN_SPEAKERS", - ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="custom", speaker="Vivian") @@ -1720,9 +1652,7 @@ class ConvertFlowTests(unittest.TestCase): (other / "beta.wav").write_bytes(b"") (other / "alpha.wav").write_bytes(b"") with patch.object(hub.common, "VOICES_DIR", root), \ - patch.object(hub.qwen_backend, "QWEN_SPEAKERS", - ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="clone", clone="") self._convert(None, [self._ready("qwen", "qwen-tts")]) @@ -1746,9 +1676,7 @@ class ConvertFlowTests(unittest.TestCase): with tempfile.TemporaryDirectory() as td: empty = Path(td) with patch.object(hub.common, "VOICES_DIR", empty), \ - patch.object(hub.qwen_backend, "QWEN_SPEAKERS", - ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.common, "update_config_value"): self._answer_form(backend="qwen", mode="clone", clone="") cmd = self._convert(None, @@ -1846,8 +1774,7 @@ class ConvertFlowTests(unittest.TestCase): "qwen", "qwen-tts", remote_urls={"qwen": "http://10.0.0.5:7861"}, remote_models=["Base"]) - with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]): self._answer_form(backend="qwen-remote", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") cmd = self._convert(None, [st]) @@ -1870,9 +1797,7 @@ class ConvertFlowTests(unittest.TestCase): "models": [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], }), encoding="utf-8") - with patch.object(hub.audiocpp_backend, "find_local_checkout", - return_value=root), \ - patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): + with patch.object(hub.audiocpp_backend, "find_local_checkout", return_value=root): self._answer_form(backend="qwen", mode="custom", speaker="Vivian", clone="") cmd = self._convert(None, [ @@ -1974,7 +1899,11 @@ class PrepareRunConfigTests(unittest.TestCase): with patch.object(hub, "detect_all", return_value=[]), \ patch.object(hub, "_find_spec", return_value=spec): cfg = hub._prepare_run_config("qwen", kwargs) - self.assertIs(cfg.autostart_spec, spec) + # The qwen spec is rebuilt for the model this run selected, so an + # autostart boots exactly what the conversion needs. + self.assertEqual(cfg.autostart_spec.name, "qwen") + self.assertEqual(cfg.autostart_spec.identity, "qwen-custom") + self.assertEqual(cfg.autostart_spec.url, spec.url) self.assertFalse(cfg.restart_first) self.assertEqual(cfg.server_name, "qwen") self.assertNotIn("autostart", kwargs) @@ -1985,13 +1914,15 @@ class PrepareRunConfigTests(unittest.TestCase): spec = self._spec() status = BackendStatus("qwen", "qwen-tts", installed=True, configured=True, servers=[spec]) - kwargs = {"restart_server": "qwen"} + kwargs = {"restart_server": "qwen", "clone": "/tmp/ref.wav"} with patch.object(hub, "detect_all", return_value=[status]), \ patch("backends.common.server_running", return_value=True), \ patch.object(hub.servers, "alive", return_value=True): cfg = hub._prepare_run_config("qwen", kwargs) - self.assertIs(cfg.autostart_spec, spec) + self.assertEqual(cfg.autostart_spec.name, spec.name) + # The restart spec hosts the Base model (the run clones a voice). + self.assertEqual(cfg.autostart_spec.identity, "qwen-clone") self.assertTrue(cfg.restart_first) self.assertNotIn("restart_server", kwargs) self.assertEqual(cfg.server_url, spec.url) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index ce2dbb6..3538d8b 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -48,6 +48,10 @@ from converter.converter import AudiobookConverter # Chunks folder handed to clients whose tests never write chunk files. _DUMMY_CHUNKS = Path(tempfile.gettempdir()) / "audiobook_tts_test_chunks" +# A concrete audio.cpp model entry id (no config default anymore): the +# tests request it explicitly, the way --model / the Generate form does. +_AUDIOCPP_MODEL_ID = "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF" + class NormalizeLanguageTests(unittest.TestCase): def test_display_names_case_insensitive(self): @@ -116,14 +120,16 @@ class QwenTTSClientLanguageTests(unittest.TestCase): return QwenTTSClient(_DUMMY_CHUNKS, **kwargs) def test_default_follows_config_for_each_mode(self): - custom = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + custom = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(custom.language, config.LANGUAGE) clone = self._make_client(voice_mode=VOICE_MODE_CLONE, voice_clone_ref_audio="ref.wav") self.assertEqual(clone.language, config.LANGUAGE) def test_explicit_language_normalized(self): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, language="ja") + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + language="ja", voice="Vivian") self.assertEqual(client.language, "Japanese") def test_invalid_language_fails_before_connect(self): @@ -134,7 +140,8 @@ class QwenTTSClientLanguageTests(unittest.TestCase): def test_api_url_override_stored(self): client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, - api_url="http://10.0.0.5:7860") + api_url="http://10.0.0.5:7860", + voice="Vivian") self.assertEqual(client.api_url, "http://10.0.0.5:7860") def test_api_url_override_used_by_connect(self): @@ -158,19 +165,22 @@ class SeedResolutionTests(unittest.TestCase): def test_constant_seed_draws_one_nonnegative_seed(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", -1): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertGreaterEqual(client._seed, 0) def test_explicit_seed_wins_over_constant_seed(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", 42): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(client._seed, 42) def test_without_constant_seed_minus_one_is_forwarded(self): with patch.object(config, "CONSTANT_SEED", False), \ patch.object(config, "SEED", -1): - client = self._make_client(voice_mode=VOICE_MODE_CUSTOM) + client = self._make_client(voice_mode=VOICE_MODE_CUSTOM, + voice="Vivian") self.assertEqual(client._seed, -1) def test_resolved_seed_is_reused_across_requests(self): @@ -183,6 +193,7 @@ class SeedResolutionTests(unittest.TestCase): } client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = VOICE_MODE_CUSTOM + client.speaker = "Vivian" client.language = "English" client._seed = 1234 client.api_info = api_info @@ -208,6 +219,7 @@ class PayloadLanguageTests(unittest.TestCase): def _custom_client(self, language, endpoint, api_info=None): client = QwenTTSClient.__new__(QwenTTSClient) client.voice_mode = VOICE_MODE_CUSTOM + client.speaker = "Vivian" client.language = language client._seed = config.SEED client.api_info = api_info if api_info is not None else { @@ -287,7 +299,7 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=urllib.error.URLError("Connection refused")): with self.assertRaises(RuntimeError) as ctx: - FasterTTSClient(_DUMMY_CHUNKS) + FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") message = str(ctx.exception) self.assertIn("not reachable", message) self.assertIn("README", message) @@ -296,14 +308,25 @@ class FasterTTSClientHealthTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._health_response(model_loaded=False)): with self.assertRaises(RuntimeError) as ctx: - FasterTTSClient(_DUMMY_CHUNKS) + FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") self.assertIn("not loaded", str(ctx.exception)) - def test_healthy_server_defaults_from_config(self): + def test_missing_voice_raises_before_connecting(self): + # There is no configured default voice: a faster run names its + # voice per run (the server silently falls back when the key is + # not in its voices.json). + with patch("converter.clients.faster.urllib.request.urlopen") \ + as mock_urlopen: + with self.assertRaises(RuntimeError) as ctx: + FasterTTSClient(_DUMMY_CHUNKS) + self.assertIn("requires a voice", str(ctx.exception)) + mock_urlopen.assert_not_called() + + def test_healthy_server_uses_the_requested_voice(self): with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._health_response()): - client = FasterTTSClient(_DUMMY_CHUNKS) - self.assertEqual(client.voice, config.FASTER_VOICE) + client = FasterTTSClient(_DUMMY_CHUNKS, voice="narrator") + self.assertEqual(client.voice, "narrator") self.assertEqual(client.api_url, config.FASTER_API_URL.rstrip("/")) def test_explicit_voice_and_url_override_config(self): @@ -533,8 +556,7 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): client.chunks_dir = Path(self._tmp.name) client.voice_mode = VOICE_MODE_DESIGN client.language = config.LANGUAGE - client.instructions = (instructions if instructions is not None - else config.INSTRUCT).strip() + client.instructions = (instructions or "").strip() client.api_info = {"named_endpoints": {"/run_voice_design": { "parameters": [ {"parameter_name": "text"}, @@ -577,10 +599,11 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): self.assertNotIn("seed", captured) # not accepted by this endpoint self.assertEqual(result, (self._fake_output(),)) - def test_payload_defaults_instructions_to_config(self): + def test_payload_uses_empty_design_field_when_no_instructions_given(self): + # There is no configured default instruction: the client sends + # whatever the run provided (empty when none). client = self._client(instructions=None) - self.assertEqual(client.instructions, - (config.INSTRUCT or "").strip()) + self.assertEqual(client.instructions, "") def test_unknown_api_falls_back_to_the_requested_name(self): client = self._client() @@ -596,15 +619,6 @@ class QwenTTSClientVoiceDesignTests(unittest.TestCase): class AudioCppTTSClientHealthTests(unittest.TestCase): """Connection behavior of the audio.cpp client.""" - def setUp(self): - # The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests - # exercise a configured single-model CustomVoice server, so pin a - # concrete id whose "customvoice" substring marks it speaker-capable. - patcher = patch.object( - config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -621,7 +635,7 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): else {"status": "ok"}) if url.endswith("/v1/models"): return self._json_response(models if models is not None else - {"data": [{"id": config.AUDIOCPP_MODEL_ID, + {"data": [{"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) if "/v1/audio/voices" in url: if voices is Exception: @@ -631,7 +645,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): raise AssertionError(f"unexpected URL: {url}") return _dispatch - def _client(self, voice=None, language=None, model_id=None, **kwargs): + def _client(self, voice=None, language=None, + model_id=_AUDIOCPP_MODEL_ID, **kwargs): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=self._get_responses(**kwargs)): return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, @@ -656,25 +671,33 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [{"id": "pocket-tts"}, {"id": "other"}]}) message = str(ctx.exception) - self.assertIn(config.AUDIOCPP_MODEL_ID, message) + self.assertIn(_AUDIOCPP_MODEL_ID, message) self.assertIn("pocket-tts", message) self.assertIn("other", message) def test_healthy_server_speaker_mode_defaults(self): - client = self._client() + client = self._client(voice="Vivian") self.assertEqual(client.api_url, config.AUDIOCPP_API_URL.rstrip("/")) - self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) + self.assertEqual(client.model_id, _AUDIOCPP_MODEL_ID) self.assertEqual(client.language, config.LANGUAGE) self.assertEqual(client.voice, "Vivian") self.assertFalse(client.preset_mode) self.assertTrue(client.speaker_mode) - def test_speaker_mode_uses_configured_speaker(self): - with patch.object(config, "SPEAKER", "uncle_fu"): - client = self._client() + def test_speaker_mode_normalizes_the_speaker_name(self): + client = self._client(voice="uncle_fu") self.assertEqual(client.voice, "Uncle Fu") self.assertTrue(client.speaker_mode) + def test_no_voice_on_speaker_entry_raises(self): + # There is no configured default speaker: a CustomVoice entry + # without --voice fails fast instead of guessing one. + with self.assertRaises(RuntimeError) as ctx: + self._client() + message = str(ctx.exception) + self.assertIn("built-in speakers", message) + self.assertIn("--voice", message) + def test_voice_speaker_name_selects_speaker_mode(self): # --voice naming a built-in CustomVoice speaker selects speaker # mode; the name is normalized to its wire (display) form and no @@ -697,18 +720,15 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("'Ryan'", message) self.assertIn("--voice", message) - def test_voice_speaker_name_does_not_reroute_to_clone_model(self): - # A built-in speaker name on a CustomVoice primary selects speaker - # mode without the AUDIOCPP_CLONE_MODEL_ID reroute. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-CustomVoice"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="Ryan", - models={"data": [{"id": "Qwen3-TTS-CustomVoice", - "family": "qwen3_tts"}, - {"id": "qwen3-tts-clone", - "family": "qwen3_tts"}]}) + def test_speaker_mode_stays_on_the_selected_entry(self): + # A built-in speaker name selects speaker mode on the entry the + # run picked; no second-entry rerouting exists anymore. + client = self._client( + voice="Ryan", model_id="Qwen3-TTS-CustomVoice", + models={"data": [{"id": "Qwen3-TTS-CustomVoice", + "family": "qwen3_tts"}, + {"id": "qwen3-tts-clone", + "family": "qwen3_tts"}]}) self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") self.assertTrue(client.speaker_mode) self.assertFalse(client.preset_mode) @@ -750,33 +770,23 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): mock_urlopen.assert_not_called() def test_explicit_language_normalized(self): - client = self._client(language="ja") + client = self._client(language="ja", voice="Vivian") self.assertEqual(client.language, "Japanese") def test_seed_resolved_once_per_run(self): with patch.object(config, "CONSTANT_SEED", True), \ patch.object(config, "SEED", -1): - client = self._client() + client = self._client(voice="Vivian") self.assertGreaterEqual(client._seed, 0) - def test_preset_mode_routes_to_clone_model_when_configured(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) - self.assertEqual(client.model_id, "qwen3-tts-clone") - - def test_preset_mode_falls_back_when_clone_model_not_on_server(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts", "family": "qwen3_tts"}, - {"id": "pocket-tts"}]}) + def test_preset_mode_stays_on_the_requested_entry(self): + # Preset (cloning) requests synthesize with the entry the run + # selected; pick the Base entry with --model to clone on it. + client = self._client( + voice="narrator", model_id="qwen3-tts", + models={"data": [{"id": "qwen3-tts"}, {"id": "qwen3-tts-clone"}]}) self.assertEqual(client.model_id, "qwen3-tts") - self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) + self.assertTrue(client.preset_mode) def test_empty_model_id_auto_picks_single_server_entry(self): # A multi-model server used without editing config.py: an empty @@ -798,78 +808,47 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("higgs", message) self.assertIn("voxcpm2", message) - def test_model_id_override_reaches_request(self): - # --model overrides AUDIOCPP_MODEL_ID for the run. - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen"): - client = self._client( - voice="narrator", model_id="higgs", - models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]}, - voices={"voices": ["narrator"]}) + def test_model_id_reaches_request(self): + # The per-run --model value is what the client requests. + client = self._client( + voice="narrator", model_id="higgs", + models={"data": [{"id": "higgs", "family": "higgs_audio_tts"}]}, + voices={"voices": ["narrator"]}) self.assertEqual(client.model_id, "higgs") - def test_clone_model_id_ignored_for_speaker_mode(self): - # Speaker mode (no --voice on a CustomVoice entry) never reroutes to - # AUDIOCPP_CLONE_MODEL_ID — that reroute is a preset-mode concern. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-CustomVoice"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - models={"data": [{"id": "Qwen3-TTS-CustomVoice", - "family": "qwen3_tts"}, - {"id": "qwen3-tts-clone", - "family": "qwen3_tts"}]}) - self.assertEqual(client.model_id, "Qwen3-TTS-CustomVoice") - - def test_clone_model_id_equal_to_primary_is_noop(self): - with patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", - config.AUDIOCPP_MODEL_ID): - client = self._client(voice="narrator") - self.assertEqual(client.model_id, config.AUDIOCPP_MODEL_ID) - - def test_preset_mode_with_clone_only_server_uses_clone_model(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - client = self._client( - voice="narrator", - models={"data": [{"id": "qwen3-tts-clone"}]}) + def test_preset_mode_on_a_single_clone_entry_server(self): + # A server hosting only the Base (cloning) entry: select it with + # --model and a preset voice works. + client = self._client( + voice="narrator", model_id="qwen3-tts-clone", + models={"data": [{"id": "qwen3-tts-clone"}]}) self.assertEqual(client.model_id, "qwen3-tts-clone") + self.assertTrue(client.preset_mode) - def test_speaker_mode_with_clone_only_server_suggests_voice(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"): - with self.assertRaises(RuntimeError) as ctx: - self._client(models={"data": [{"id": "qwen3-tts-clone"}]}) + def test_unknown_model_id_error_suggests_a_model(self): + # Requesting an id the server does not host fails fast and names + # both the requested and the hosted ids. + with self.assertRaises(RuntimeError) as ctx: + self._client(voice="narrator", model_id="qwen3-tts", + models={"data": [{"id": "qwen3-tts-clone"}]}) message = str(ctx.exception) self.assertIn("qwen3-tts", message) - self.assertIn("--voice", message) + self.assertIn("qwen3-tts-clone", message) + self.assertIn("--model", message) def test_preset_mode_with_no_matching_model_lists_both_ids(self): - # Neither the primary nor the clone id is on the server, so the - # family is unknown and no degradation warning is logged — the - # requirement error lists both configured ids instead. - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): + with self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): with self.assertRaises(RuntimeError) as ctx: - self._client(voice="narrator", + self._client(voice="narrator", model_id="qwen3-tts", models={"data": [{"id": "pocket-tts"}]}) message = str(ctx.exception) self.assertIn("qwen3-tts", message) - self.assertIn("qwen3-tts-clone", message) self.assertIn("pocket-tts", message) class AudioCppTaskDetectionTests(unittest.TestCase): """Task auto-detection (tts/clon/vdes) and voice design validation.""" - def setUp(self): - # Pin a CustomVoice id so the default (no-voice) path is speaker - # mode; individual tests override family/task to exercise other paths. - patcher = patch.object( - config, "AUDIOCPP_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -880,7 +859,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def _client(self, voice=None, instructions=None, request_options=None, models=None): if models is None: - models = {"data": [{"id": config.AUDIOCPP_MODEL_ID, + models = {"data": [{"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]} def _dispatch(request, **_kwargs): @@ -897,18 +876,19 @@ class AudioCppTaskDetectionTests(unittest.TestCase): side_effect=_dispatch): return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, instructions=instructions, - request_options=request_options) + request_options=request_options, + model_id=_AUDIOCPP_MODEL_ID) def test_missing_task_falls_back_to_tts(self): # Servers that predate the task field hosted plain TTS models. - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) + client = self._client(voice="Vivian", models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) self.assertEqual(client.task, AUDIOCPP_TASK_TTS) self.assertFalse(client.design_mode) def test_task_detected_from_models_endpoint(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertEqual(client.task, AUDIOCPP_TASK_VDES) @@ -916,7 +896,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_clon_task_entry_connects_in_preset_mode(self): client = self._client(voice="narrator", models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "chatterbox", + {"id": _AUDIOCPP_MODEL_ID, "family": "chatterbox", "task": "clon"}]}) self.assertEqual(client.task, "clon") self.assertFalse(client.design_mode) @@ -925,7 +905,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_unsupported_task_rejected_with_available_entries(self): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_asr", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_asr", "task": "asr"}, {"id": "tts-1", "family": "qwen3_tts", "task": "tts"}]}, instructions="unused") @@ -937,7 +917,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_vdes_without_instructions_requires_description(self): with self.assertRaises(RuntimeError) as ctx: self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}) message = str(ctx.exception) self.assertIn("voice design", message) @@ -946,7 +926,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_vdes_with_voice_rejected(self): with self.assertRaises(RuntimeError) as ctx: self._client(voice="narrator", models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertIn("--voice", str(ctx.exception)) @@ -956,7 +936,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): buf = io.StringIO() with redirect_stdout(buf): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, instructions="A warm adult narrator") self.assertTrue(client.design_mode) @@ -971,7 +951,7 @@ class AudioCppTaskDetectionTests(unittest.TestCase): buf = io.StringIO() with redirect_stdout(buf): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "omnivoice", + {"id": _AUDIOCPP_MODEL_ID, "family": "omnivoice", "task": "tts"}]}, instructions="female, young adult, moderate pitch") self.assertFalse(client.design_mode) @@ -981,39 +961,28 @@ class AudioCppTaskDetectionTests(unittest.TestCase): def test_instructions_with_builtin_speaker_family_stays_speaker_mode(self): buf = io.StringIO() with redirect_stdout(buf): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", - "task": "tts"}]}, + client = self._client( + voice="Vivian", + models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + "task": "tts"}]}, instructions="Very happy.") self.assertFalse(client.design_mode) self.assertFalse(client.instruction_voice) self.assertIn("speaker 'Vivian'", buf.getvalue()) - def test_config_instructions_used_when_flag_omitted(self): - with patch.object(config, "AUDIOCPP_INSTRUCTIONS", - "A calm elderly storyteller"): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", - "task": "vdes"}]}) - self.assertEqual(client.instructions, "A calm elderly storyteller") - - def test_explicit_instructions_override_config_default(self): - with patch.object(config, "AUDIOCPP_INSTRUCTIONS", "from config"): - client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts", + def test_instructions_reach_the_client(self): + client = self._client( + models={"data": [ + {"id": _AUDIOCPP_MODEL_ID, "family": "qwen3_tts", "task": "vdes"}]}, - instructions="from flag") + instructions="from flag") self.assertEqual(client.instructions, "from flag") class AudioCppFamilyDetectionTests(unittest.TestCase): """Family auto-detection and per-family adaptations.""" - def setUp(self): - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") - patcher.start() - self.addCleanup(patcher.stop) - @staticmethod def _json_response(payload): response = MagicMock() @@ -1034,11 +1003,12 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): with patch("converter.clients.faster.urllib.request.urlopen", side_effect=_dispatch): - return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice) + return AudioCppTTSClient(_DUMMY_CHUNKS, voice=voice, + model_id=_AUDIOCPP_MODEL_ID) def test_family_detected_from_models_endpoint(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "higgs_audio_tts"}]}) self.assertEqual(client.family, "higgs_audio_tts") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) @@ -1046,13 +1016,13 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): # A missing family is unknown (not guessed as qwen3_tts): it falls # through to the generic clone-only profile. client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID}]}) + {"id": _AUDIOCPP_MODEL_ID}]}) self.assertEqual(client.family, "") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) def test_unknown_family_uses_generic_profile(self): client = self._client(models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "future_tts"}]}) self.assertEqual(client.family, "future_tts") self.assertIs(client.profile, AUDIOCPP_DEFAULT_FAMILY_PROFILE) self.assertEqual(client.profile.language_style, AUDIOCPP_LANG_OMIT) @@ -1061,7 +1031,7 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): client = None try: client = self._client(voice=None, models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) + {"id": _AUDIOCPP_MODEL_ID, "family": "voxcpm2"}]}) except RuntimeError as exc: message = str(exc) self.assertIn("voxcpm2", message) @@ -1071,13 +1041,13 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): def test_speaker_mode_allowed_for_customvoice_entry(self): # A Qwen3-TTS entry whose id names CustomVoice is speaker-capable; - # no --voice is needed. - with patch.object(config, "AUDIOCPP_MODEL_ID", - "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"): - client = self._client(voice=None, models={"data": [ + # a built-in speaker name selects speaker mode on it. + client = self._client( + voice="Vivian", models={"data": [ {"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", "family": "qwen3_tts"}]}) self.assertEqual(client.family, "qwen3_tts") + self.assertTrue(client.speaker_mode) def test_speaker_mode_rejected_for_qwen_base_entry(self): # A Qwen3-TTS entry whose id names Base (not CustomVoice) is @@ -1094,36 +1064,6 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIn("--voice", message) self.assertIsNone(client) - def test_clone_model_id_of_different_family_is_ignored(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client(models={"data": [ - {"id": "higgs", "family": "higgs_audio_tts"}, - {"id": "qwen-clone", "family": "qwen3_tts"}]}) - self.assertEqual(client.model_id, "higgs") - self.assertTrue(any("different family" in line.lower() or - "hosts family" in line.lower() - for line in logs.output)) - - def test_clone_model_id_missing_on_non_qwen_server_is_debug_only(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "higgs"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen-clone"), \ - self.assertNoLogs("converter.clients.audiocpp", level="WARNING"): - client = self._client(models={"data": [ - {"id": "higgs", "family": "higgs_audio_tts"}]}) - self.assertEqual(client.model_id, "higgs") - - def test_clone_model_id_missing_on_qwen_server_still_warns(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ - patch.object(config, "AUDIOCPP_CLONE_MODEL_ID", "qwen3-tts-clone"), \ - self.assertLogs("converter.clients.audiocpp", level="WARNING") as logs: - client = self._client(models={"data": [ - {"id": "qwen3-tts", "family": "qwen3_tts"}, - {"id": "pocket-tts", "family": "pocket_tts"}]}) - self.assertEqual(client.model_id, "qwen3-tts") - self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) - def test_iso_language_code_helper(self): self.assertEqual(LANGUAGE_ISO_CODES["English"], "en") self.assertIsNone(LANGUAGE_ISO_CODES.get("Auto")) @@ -1198,7 +1138,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = preset_mode client.voice = voice client.language = language @@ -1246,7 +1186,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): self.assertEqual(request.full_url, "http://127.0.0.1:8080/v1/audio/speech") payload = json.loads(request.data.decode("utf-8")) - self.assertEqual(payload["model"], config.AUDIOCPP_MODEL_ID) + self.assertEqual(payload["model"], _AUDIOCPP_MODEL_ID) self.assertEqual(payload["input"], "Hello world.") self.assertEqual(payload["voice"], "narrator") self.assertEqual(payload["language"], "Japanese") @@ -1270,16 +1210,17 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): timeout = mock_urlopen.call_args[1]["timeout"] self.assertEqual(timeout, config.API_TIMEOUT) - def test_speaker_mode_sends_instruct(self): + def test_speaker_mode_without_instructions_omits_the_field(self): + # There is no configured style instruction: speaker mode sends no + # instructions field unless the run provides one. client = self._make_client(preset_mode=False) with patch("converter.clients.faster.urllib.request.urlopen", return_value=self._post_response(self._wav_bytes())) as mock_urlopen: client._request_wav("Hello.") payload = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) - self.assertEqual(payload["instructions"], config.INSTRUCT) + self.assertNotIn("instructions", payload) - def test_explicit_instructions_replace_config_instruct(self): - # --instructions overrides the INSTRUCT default in speaker mode. + def test_explicit_instructions_reach_the_payload(self): client = self._make_client(preset_mode=False, instructions="Read whisper quiet.") with patch("converter.clients.faster.urllib.request.urlopen", @@ -1677,7 +1618,7 @@ class AudioCppHeartbeatTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = False client.voice = "Vivian" client.language = "English" @@ -1730,7 +1671,7 @@ class AudioCppTTSClientTruncationTests(unittest.TestCase): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.chunks_dir = Path(self._tmp.name) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1830,7 +1771,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): def test_connect_unloads_before_returning(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1848,7 +1789,6 @@ class AudioCppUnloadModelsTests(unittest.TestCase): "family": "qwen3_tts", "task": "tts"}]), \ patch.object(client, "_auto_pick_model_id"), \ - patch.object(client, "_select_model"), \ patch.object(client, "_require_model_id"), \ patch.object(client, "_resolve_family"), \ patch.object(client, "_resolve_task"), \ @@ -1860,7 +1800,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): def test_connect_skips_unload_when_disabled(self): client = AudioCppTTSClient.__new__(AudioCppTTSClient) client.api_url = "http://127.0.0.1:8080" - client.model_id = config.AUDIOCPP_MODEL_ID + client.model_id = _AUDIOCPP_MODEL_ID client.preset_mode = True client.voice = "narrator" client.language = "English" @@ -1878,7 +1818,6 @@ class AudioCppUnloadModelsTests(unittest.TestCase): "family": "qwen3_tts", "task": "tts"}]), \ patch.object(client, "_auto_pick_model_id"), \ - patch.object(client, "_select_model"), \ patch.object(client, "_require_model_id"), \ patch.object(client, "_resolve_family"), \ patch.object(client, "_resolve_task"), \ @@ -1962,8 +1901,9 @@ class BackendWiringTests(unittest.TestCase): patch("converter.converter.QwenTTSClient") as mock_qwen, \ patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN) - mock_qwen.assert_called_once() + backend=BACKEND_QWEN, voice="Vivian") + _, kwargs = mock_qwen.call_args + self.assertEqual(kwargs["voice"], "Vivian") mock_faster.assert_not_called() mock_audiocpp.assert_not_called() @@ -2016,14 +1956,14 @@ class BackendWiringTests(unittest.TestCase): quiet=False) with patch("converter.converter.QwenTTSClient") as mock_qwen: AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN, + backend=BACKEND_QWEN, voice="Vivian", api_url="http://10.0.0.5:7860") mock_qwen.assert_called_once_with( chunks_dir=converter_mod.CHUNKS_FOLDER, voice_mode=VOICE_MODE_CUSTOM, voice_clone_ref_audio=None, voice_clone_ref_text=None, skip_transcription=False, language=config.LANGUAGE, instructions=None, - api_url="http://10.0.0.5:7860", quiet=False) + api_url="http://10.0.0.5:7860", quiet=False, voice="Vivian") def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the @@ -2045,7 +1985,8 @@ class BackendWiringTests(unittest.TestCase): def test_chapter_chunks_qwen_always_splits(self): with patch("converter.converter.QwenTTSClient"): converter = AudiobookConverter(voice_mode=VOICE_MODE_CUSTOM, - backend=BACKEND_QWEN) + backend=BACKEND_QWEN, + voice="Vivian") text = " ".join(f"word{i}" for i in range(50)) with patch.object(config, "CHUNK_SIZE", 10): chunks = converter._chapter_chunks(text) @@ -2070,27 +2011,32 @@ class BackendWiringTests(unittest.TestCase): return AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_FASTER, voice=voice) - def _audiocpp_converter(self, voice=None): + def _audiocpp_converter(self, voice=None, instructions=None): with patch("converter.converter.AudioCppTTSClient"): return AudiobookConverter( voice_mode=VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM, - backend=BACKEND_AUDIOCPP, voice=voice) + backend=BACKEND_AUDIOCPP, voice=voice, + instructions=instructions) def test_narrator_tag_uses_faster_voice_name(self): converter = self._faster_converter(voice="male_richard_poe") self.assertEqual(converter._narrator_tag(), "male_richard_poe") - def test_narrator_tag_falls_back_to_config_voice(self): + def test_narrator_tag_faster_without_voice_uses_default_key(self): + # Unreachable in a valid run (--voice is required); the tag stays + # stable for pre-flights of runs that will fail client-side. converter = self._faster_converter() - self.assertEqual(converter._narrator_tag(), config.FASTER_VOICE) + self.assertEqual(converter._narrator_tag(), "default") def test_narrator_tag_audiocpp_uses_voice_name(self): converter = self._audiocpp_converter(voice="female_narrator") self.assertEqual(converter._narrator_tag(), "female_narrator") - def test_narrator_tag_audiocpp_falls_back_to_speaker(self): + def test_narrator_tag_audiocpp_without_voice_uses_fallback(self): + # Unreachable in a valid run (the client refuses a speaker-capable + # entry without --voice); the tag stays stable for pre-flights. converter = self._audiocpp_converter() - self.assertEqual(converter._narrator_tag(), "Vivian") + self.assertEqual(converter._narrator_tag(), "narrator") def test_banner_and_narrator_work_without_reference_audio(self): converter = self._faster_converter(voice="male_richard_poe") @@ -2100,7 +2046,7 @@ class BackendWiringTests(unittest.TestCase): def test_audiocpp_banner_prints_without_reference_audio(self): converter = self._audiocpp_converter(voice="narrator") converter._print_banner() # must not raise - converter = self._audiocpp_converter() + converter = self._audiocpp_converter(instructions="Calm and warm.") converter._print_banner() def test_audiocpp_banner_prints_model_family(self): diff --git a/app/ui/hub.py b/app/ui/hub.py index 8a6fda7..f63ff99 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -390,9 +390,9 @@ class _Hub: cmd = mapper(result) if cmd is None: return tui.Wizard.BACK - # The mapper may have persisted settings (qwen model/speaker), - # making the form-time statuses stale — the autostart plan - # must read the freshly-configured server argv/model. + # Nothing is persisted anymore, but server state may have + # changed since the form opened — the autostart plan must + # read fresh statuses. invalidate_detect_cache() statuses = detect_all(refresh=True) autostart_error = _add_autostart(cmd, statuses) @@ -500,7 +500,8 @@ class _Hub: without an extra action menu. The state lives in the table, not on the entries, because the menu's selection bar would cover inline colors. Each server is labelled by its backend's name; qwen hosts - one model at a time (config.QWEN_MODEL decides which). + one model at a time (its default start runs CustomVoice — a + Generate-audiobooks run needing another model restarts it). """ statuses = detect_all() candidates = [st for st in statuses if st.installed] @@ -915,11 +916,9 @@ def _convert_form(stdscr) -> Optional[tuple]: return None choices = [(label, key) for key, label, _st, _remote in entries if key in builders] - default = config.BACKEND if config.BACKEND in builders \ - else choices[0][1] fields = [{ "key": "backend", "label": "Backend", "kind": "choice", - "value": default, "choices": choices, + "value": choices[0][1], "choices": choices, }] for key, _label, _st, _remote in entries: if key not in builders: @@ -1174,9 +1173,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, if capability == AUDIOCPP_VOICE_DESIGN: voice_field["value"] = None elif capability == AUDIOCPP_VOICE_SPEAKER: - voice_field["value"] = (config.SPEAKER - if config.SPEAKER in QWEN3_TTS_SPEAKERS - else QWEN3_TTS_SPEAKERS[0]) + voice_field["value"] = QWEN3_TTS_SPEAKERS[0] else: # clone voices = voices_for(_field_value(fields, prefix + "model_id")) voice_field["value"] = voices[0] if voices else "" @@ -1223,8 +1220,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, else "This model needs a voice — pick one or switch models" model_ids = [m.get("id") for m in models] - default_model = config.AUDIOCPP_MODEL_ID \ - if config.AUDIOCPP_MODEL_ID in model_ids else model_ids[0] + default_model = model_ids[0] default_entry = next((m for m in models if m.get("id") == default_model), models[0]) default_capability = audiocpp_entry_voice_capability( @@ -1232,8 +1228,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, default_entry.get("id") or "") initial_voice = None if default_capability == AUDIOCPP_VOICE_SPEAKER: - initial_voice = (config.SPEAKER if config.SPEAKER in QWEN3_TTS_SPEAKERS - else QWEN3_TTS_SPEAKERS[0]) + initial_voice = QWEN3_TTS_SPEAKERS[0] elif default_capability == AUDIOCPP_VOICE_CLONE: initial = voices_for(default_model) initial_voice = initial[0] if initial else "" @@ -1295,7 +1290,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, # delivery instruction — or, on families without built-in speakers # that read instructions, the voice itself (instruction-voice mode). {"key": prefix + "instructions", "label": "Instructions", "kind": "text", - "value": config.AUDIOCPP_INSTRUCTIONS, + "value": "", "help": INSTRUCTIONS_HELP, "validate": lambda value: None if (model_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip()) @@ -1356,12 +1351,11 @@ def _qwen_fields(remote_modes: Optional[list] = None, One demo server hosts one model at a time, so the picked model decides which server must be up. For the managed entry REMOTE_MODES/URLS are - None: the picker offers every model and the choice (plus any changed - speaker) is persisted to app/converter/config.py so the autostart boots - the same model again later. For a "[remote]" entry REMOTE_MODES names - which demos answered remotely ("Base", "CustomVoice" and/or - "VoiceDesign") and URLS maps "qwen" to its URL: the picker is limited to - the available models and the mapper passes the URL as ``api_url``. + None: the picker offers every model and the autostart/restart plan + boots exactly the picked model. For a "[remote]" entry REMOTE_MODES + names which demos answered remotely ("Base", "CustomVoice" and/or + "VoiceDesign") and URLS maps "qwen" to its URL: the picker is limited + to the available models and the mapper passes the URL as ``api_url``. """ remote_modes = list(remote_modes or []) urls = dict(urls or {}) @@ -1380,13 +1374,9 @@ def _qwen_fields(remote_modes: Optional[list] = None, model_choices = [(label, value) for (label, value) in model_choices if dict(mode_keys)[value] in available] by_value = {value: label for label, value in model_choices} - configured_mode = {model: mode for mode, model in mode_keys}.get( - qwen_backend.current_model(), model_choices[0][1]) - default_mode = configured_mode if configured_mode in by_value \ - else model_choices[0][1] + default_mode = "custom" if "custom" in by_value else model_choices[0][1] speakers = list(qwen_backend.QWEN_SPEAKERS) - default_speaker = config.SPEAKER if config.SPEAKER in speakers \ - else speakers[0] + default_speaker = speakers[0] # Voice cloning references: the directory the .wavs live in — browsed # with the directory widget, defaulting to the project's ./voices (the @@ -1437,7 +1427,7 @@ def _qwen_fields(remote_modes: Optional[list] = None, "validate": clone_wav_validate, "visible": lambda fs: _field_value(fs, prefix + "mode") == "clone"}, {"key": prefix + "qwen_instructions", "label": "Instructions", - "kind": "text", "value": config.INSTRUCT, + "kind": "text", "value": "", "help": ["Describe the voice to design, e.g.", '"A warm adult female narrator with a British accent".'], "validate": lambda s: None if s.strip() else @@ -1451,19 +1441,10 @@ def _qwen_fields(remote_modes: Optional[list] = None, if mode == "clone": clone = str(result[prefix + "clone"] or "").strip() or None kwargs = {"clone": clone, **_common_kwargs(result)} + if mode == "custom": + kwargs["voice"] = result[prefix + "speaker"] if mode == "design": kwargs["instructions"] = result[prefix + "qwen_instructions"] - if not urls: - # Managed entry: persist the choices for this and future runs, - # so the server autostart boots the same model again - # (update_config_value keeps both the file and the imported - # module in sync). - model = dict(mode_keys)[mode] - if model != qwen_backend.current_model(): - common.update_config_value("QWEN_MODEL", model) - speaker = result.get(prefix + "speaker", config.SPEAKER) - if mode == "custom" and speaker != config.SPEAKER: - common.update_config_value("SPEAKER", speaker) if urls: api_url = urls.get("qwen") if api_url: @@ -1510,12 +1491,11 @@ def _faster_fields(stdscr, api_url: Optional[str] = None, fields = [ {"key": prefix + "faster_voice", "label": "Voice to clone", "kind": "text", - "value": config.FASTER_VOICE, + "value": "", "validate": lambda s: None if s.strip() else "Enter a voice name"}, ] else: - default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \ - next(iter(voices)) + default = next(iter(voices)) fields = [ {"key": prefix + "faster_voice", "label": "Voice to clone", "kind": "choice", @@ -1847,7 +1827,7 @@ def _prepare_run_config(backend: str, kwargs: dict log_path=log_path, stop_and_exit=stop_and_exit) status = next((s for s in detect_all(refresh=True) - if s.key == backend), None) + if s.key == backend), None) notice = "" spec: Optional[ServerSpec] = None if autostart: @@ -1867,6 +1847,12 @@ def _prepare_run_config(backend: str, kwargs: dict if spec is None: notice = (f"no server named '{restart_name}' — the model " "switch restart was skipped") + if spec is not None and backend == BACKEND_QWEN: + # One demo server hosts one model: aim the spec at the model this + # run selected (same URL/port, matching probe identity), so an + # autostart or model-switch restart boots exactly what the run + # needs instead of the Start/Stop menu's default model. + spec = qwen_backend._build_spec(_qwen_wanted_model(kwargs)) return runview.RunConfig( backend=backend, backend_label=label, kwargs=kwargs, book_files=book_files, planned=planned, |
