diff options
| author | historia <historiavg@proton.me> | 2026-08-25 16:40:30 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-25 16:40:30 -0400 |
| commit | 867866f131b0b6c76c54272791e7f7dea01db990 (patch) | |
| tree | b57ecdd66eeaf7ad73742d2f2bbe15d3a5498fa3 | |
| parent | fca3431721a55277f139efc83df2438207917448 (diff) | |
| download | tts-audiobook-generator-867866f131b0b6c76c54272791e7f7dea01db990.tar.gz | |
feat: better tui menu option gating for models that support custom voices (qwen) and models that do not support instructions
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | app/backends/qwen.py | 8 | ||||
| -rw-r--r-- | app/converter/converter.py | 43 | ||||
| -rw-r--r-- | app/converter/tts.py | 218 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 14 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 95 | ||||
| -rw-r--r-- | app/tests/test_tts.py | 177 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 11 | ||||
| -rw-r--r-- | app/ui/hub.py | 104 | ||||
| -rw-r--r-- | app/ui/tui.py | 7 | ||||
| -rwxr-xr-x | audiobook.py | 26 |
11 files changed, 535 insertions, 171 deletions
@@ -68,7 +68,8 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- | `--model <id>` | `audiocpp`: Choose the model from `server.json` | | `--instructions "..."` | `audiocpp`: voice design or style instruction. Required for voice design models (`vdes`) | | `--option KEY=VALUE` | `audiocpp`: Some models support custom options (e.g. `emotion=netural`) that can be passed with this flag | -| `--voice <name>` | `audiocpp`, `faster`: Server-side voice to request | +| `--voice <name>` | `audiocpp`, `faster`: Server-side voice to request (`audiocpp`: a `voice_preset`/`voice_dir` entry for cloning; with `AUDIOCPP_CLONE_MODEL_ID` set it reroutes to the clone model — typically the Qwen Base model). | +| `--speaker <name>` | `audiocpp`: Built-in Qwen3-TTS CustomVoice speaker (e.g. `Vivian`, `Ryan`, `Uncle_Fu`). Selects speaker mode on the CustomVoice model entry; mutually exclusive with `--voice`. | | `--clone <path>` | `qwen`: Reference audio (`wav`) for voice cloning. | | `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. | | `--no-transcription` | `qwen`: Skip auto-transcription of the reference audio. | diff --git a/app/backends/qwen.py b/app/backends/qwen.py index 7fba7e0..be7ebfe 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -30,6 +30,7 @@ from backends import ( servers, ) from converter import config +from converter.tts import QWEN3_TTS_SPEAKERS from ui import taskview, tui QWEN_PIP_PKG = "qwen-tts" @@ -38,9 +39,10 @@ QWEN_BASE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base" DEFAULT_CUSTOM_PORT = 7860 DEFAULT_CLONE_PORT = 7861 -# Built-in CustomVoice speakers (see app/converter/config.py SPEAKER). -QWEN_SPEAKERS = ("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", "Ryan", - "Aiden", "Ono_Anna", "Sohee") +# Built-in CustomVoice speakers (see app/converter/config.py SPEAKER). The +# canonical list lives in converter.tts (shared with the audiocpp backend's +# Convert-form Speaker picker). +QWEN_SPEAKERS = QWEN3_TTS_SPEAKERS def _is_installed() -> bool: diff --git a/app/converter/converter.py b/app/converter/converter.py index b2c2923..6e677c8 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -185,6 +185,7 @@ class AudiobookConverter: instructions: Optional[str] = None, request_options: Optional[Dict[str, str]] = None, api_url: Optional[str] = None, + speaker: Optional[str] = None, progress: Optional[Callable[[dict], None]] = None, cancel=None): if speed <= 0: @@ -205,6 +206,7 @@ class AudiobookConverter: self.output_format = output_format self.backend = backend self.voice = voice + self.speaker = speaker self.debug = bool(debug) # Voice design / style instruction and free-form request options # (audio.cpp only): forwarded to AudioCppTTSClient, which validates @@ -217,16 +219,18 @@ class AudiobookConverter: # configured on the server, so no local reference audio is needed. self.tts = FasterTTSClient(voice=voice, api_url=api_url) elif backend == BACKEND_AUDIOCPP: - # Speaker mode (no voice) uses a built-in CustomVoice speaker; - # an explicit voice selects a server-side preset (cloning). - # 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. + # Speaker mode (--speaker or no flag on a CustomVoice entry) uses + # a built-in speaker name; an explicit --voice selects a + # server-side preset (cloning). 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. self.tts = AudioCppTTSClient(voice=voice, language=self.language, model_id=model_id, instructions=instructions, request_options=self.request_options, - api_url=api_url) + api_url=api_url, + speaker=speaker) else: self.tts = QwenTTSClient( voice_mode=voice_mode, @@ -290,22 +294,24 @@ class AudiobookConverter: """Narrator name used in output file names (see compute_narrator_tag).""" return self.compute_narrator_tag( self.backend, self.voice, self.voice_mode, - self.voice_clone_ref_audio, self.instructions) + self.voice_clone_ref_audio, self.instructions, self.speaker) @staticmethod def compute_narrator_tag(backend: str, voice: Optional[str], voice_mode: str, voice_clone_ref_audio: Optional[str], - instructions: Optional[str] = None) -> str: + instructions: Optional[str] = None, + speaker: Optional[str] = None) -> str: """Narrator name used in output file names, without a server connection. 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 (falling back to - the built-in speaker for the audiocpp backend's speaker mode). 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 (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"). Pure (no I/O, no server) so the pre-flight overwrite check can compute the exact output names a run would produce before spending @@ -316,6 +322,8 @@ class AudiobookConverter: elif backend == BACKEND_AUDIOCPP: if voice: narrator = voice + elif speaker: + narrator = speaker elif instructions: # The voice comes from the instruction, not a speaker name. narrator = "designed" @@ -672,6 +680,9 @@ class AudiobookConverter: if self.voice: self._say("Backend: audio.cpp (voice cloning, reference configured on server)") self._say(f"Voice: {self.voice}") + elif self.speaker: + self._say("Backend: audio.cpp (custom voice, built-in speaker)") + self._say(f"Speaker: {self.speaker}") elif self.instructions: self._say("Backend: audio.cpp (voice from --instructions description)") self._say(f"Instruction: {self.instructions}") @@ -715,6 +726,7 @@ class AudiobookConverter: voice_clone_ref_audio: Optional[str], output_format: str, instructions: Optional[str] = None, + speaker: Optional[str] = None, confirm: Optional[Callable[[str, bool], bool]] = None, ) -> Tuple[List[Path], List[Tuple[Path, str]]]: """Discover books and ask every overwrite question up front. @@ -746,7 +758,8 @@ class AudiobookConverter: # starts, so the rest of the run is unattended. planned: List[Tuple[Path, str]] = [] narrator_tag = AudiobookConverter.compute_narrator_tag( - backend, voice, voice_mode, voice_clone_ref_audio, instructions) + backend, voice, voice_mode, voice_clone_ref_audio, instructions, + speaker) for book_file in book_files: output_name = book_file.stem if stem_counts[book_file.stem] > 1: @@ -782,7 +795,7 @@ class AudiobookConverter: book_files, planned = AudiobookConverter.preflight_overwrites( self.backend, self.voice, self.voice_mode, self.voice_clone_ref_audio, self.output_format, - self.instructions) + self.instructions, self.speaker) if not book_files: self._say(f"[INFO] No supported files found in {BOOKS_FOLDER}") diff --git a/app/converter/tts.py b/app/converter/tts.py index 0667ec3..a88697e 100644 --- a/app/converter/tts.py +++ b/app/converter/tts.py @@ -121,32 +121,51 @@ AUDIOCPP_LANG_DISPLAY = "display" # Qwen display names, e.g. "English" AUDIOCPP_LANG_ISO = "iso" # ISO 639-1 codes, e.g. "en" AUDIOCPP_LANG_OMIT = "omit" # no language field; the model detects it -# The only family with a built-in speaker mode (CustomVoice speaker names -# plus the INSTRUCT style prompt). Every other family is clone-only: the -# voice comes from a server-side preset requested with --voice. +# The Qwen3-TTS family. Unlike every other family (one model type each), +# qwen3_tts hosts several model *types* under one family id, distinguished +# only by the server entry's id/task: the CustomVoice model (built-in +# speakers, e.g. Vivian/Ryan), the Base model (voice cloning via a +# server-side preset), and the VoiceDesign model (task "vdes"). The +# per-entry voice capability below (audiocpp_entry_voice_capability) +# resolves which is which, driving both the Convert form (which voice +# list to show) and the converter's mode selection. AUDIOCPP_FAMILY_QWEN3_TTS = "qwen3_tts" # Server model entry tasks this client can synthesize audiobooks with, -# taken from GET /v1/models (the "task" field of each entry; servers that -# predate the field reported TTS models only, so a missing task is treated -# as "tts"). "vdes" entries are voice design models: the voice is described -# with --instructions instead of coming from a speaker or a reference clip. -# Entries with any other task (asr, vc, diar, ...) are rejected at connect -# time with a hint to pick a synthesis entry. +# taken from GET /v1/models (the "task" field of each entry; a missing task +# is treated as "tts" — a harmless generic default). "vdes" entries are +# voice design models: the voice is described with --instructions instead +# of coming from a speaker or a reference clip. Entries with any other task +# (asr, vc, diar, ...) are rejected at connect time with a hint to pick a +# synthesis entry. AUDIOCPP_TASK_TTS = "tts" AUDIOCPP_TASK_VDES = "vdes" AUDIOCPP_SYNTHESIS_TASKS = (AUDIOCPP_TASK_TTS, "clon", AUDIOCPP_TASK_VDES) +# The voice capability of a server model entry — how its voice is supplied. +# Resolved per entry from (family, task, id) by +# audiocpp_entry_voice_capability; drives both the Convert form (which +# voice list to show) and the converter (speaker vs preset vs design mode). +# Most families are clone-only; only the Qwen3-TTS CustomVoice entry has +# built-in speakers, and only VoiceDesign entries take a description. +AUDIOCPP_VOICE_SPEAKER = "speaker" # built-in speaker name (Qwen CustomVoice) +AUDIOCPP_VOICE_CLONE = "clone" # server-side preset / voice_dir (Base, others) +AUDIOCPP_VOICE_DESIGN = "design" # voice described by --instructions (vdes) + class AudioCppFamilyProfile: - """Request conventions of one audio.cpp model family.""" + """Request conventions of one audio.cpp model family. + + Language style and whether the family reads a style/instruction prompt; + these are family-level (every entry of a family shares them). Whether a + *specific entry* has built-in speakers is an entry-level concern, decided + by audiocpp_entry_voice_capability, not this profile. + """ def __init__(self, language_style: str = AUDIOCPP_LANG_OMIT, - sends_instructions: bool = False, - builtin_speakers: bool = False): + sends_instructions: bool = False): self.language_style = language_style self.sends_instructions = sends_instructions - self.builtin_speakers = builtin_speakers # Generic profile for families not listed in AUDIOCPP_FAMILY_PROFILES: @@ -161,7 +180,6 @@ AUDIOCPP_FAMILY_PROFILES = { AUDIOCPP_FAMILY_QWEN3_TTS: AudioCppFamilyProfile( language_style=AUDIOCPP_LANG_DISPLAY, sends_instructions=True, - builtin_speakers=True, ), # Families whose language option takes a code (e.g. "en") instead of # a Qwen display name; otherwise clone-only like the default profile. @@ -172,6 +190,34 @@ AUDIOCPP_FAMILY_PROFILES = { "supertonic": AudioCppFamilyProfile(language_style=AUDIOCPP_LANG_ISO), } + +def audiocpp_entry_voice_capability(family: str, task: str, + model_id: str) -> str: + """How a server model entry's voice is supplied — speaker/clone/design. + + Resolved from the entry's family, task and id — the same {id, family, + task} triple GET /v1/models reports, so it works for local server.json + entries and remote live-queried entries alike. Qwen3-TTS is the one + family hosting several model *types* under one family id: the + CustomVoice model (id contains "customvoice") has built-in speakers, the + Base model and any other entry are clone-only, and VoiceDesign entries + (task "vdes") take a description. Every other family is clone-only. + """ + if task == AUDIOCPP_TASK_VDES: + return AUDIOCPP_VOICE_DESIGN + if family == AUDIOCPP_FAMILY_QWEN3_TTS \ + and "customvoice" in (model_id or "").lower(): + return AUDIOCPP_VOICE_SPEAKER + return AUDIOCPP_VOICE_CLONE + +# 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. +QWEN3_TTS_SPEAKERS = ("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", + "Ryan", "Aiden", "Ono_Anna", "Sohee") + # Canonical speaker names -> display names used by the qwen-tts demo. SPEAKER_DISPLAY_NAMES = { "ryan": "Ryan", @@ -209,10 +255,21 @@ def _resolve_request_seed() -> int: return seed +def speaker_display_name_for(name: str) -> str: + """Return the wire (display) form of a Qwen3-TTS CustomVoice speaker NAME. + + Accepts either the canonical/config form (e.g. "uncle_fu", "Uncle_Fu") + or the display form ("Uncle Fu"), case-insensitively; unknown names pass + through unchanged. Used by AudioCppTTSClient to normalize the --speaker / + Speaker-picker value into what audiocpp_server expects in the request's + voice field. + """ + return SPEAKER_DISPLAY_NAMES.get((name or "").lower(), name) + + def speaker_display_name() -> str: """Return the display name for the configured custom speaker.""" - return SPEAKER_DISPLAY_NAMES.get( - config.SPEAKER.lower(), config.SPEAKER) + return speaker_display_name_for(config.SPEAKER) def normalize_language(value: Optional[str]) -> str: @@ -808,30 +865,39 @@ class AudioCppTTSClient(_BaseTTSClient): serving stack). The server API is family-agnostic; the family and task of the configured model entry are read from GET /v1/models at startup and adapt the request payload (language field style, style instructions) - through AUDIOCPP_FAMILY_PROFILES. Three voice modes, all resolved - server-side from the request's "voice"/"instructions" fields: - - - Speaker mode (no ``voice``): Qwen3-TTS only. A built-in CustomVoice - speaker name (e.g. "Vivian") is passed through, plus the INSTRUCT - style prompt. The server must be configured with the CustomVoice - model for this. Families without built-in speakers reject this mode - with a hint to pass --voice (or --instructions, see below). - - Preset mode (``voice=NAME``): a voice configured on the server + through AUDIOCPP_FAMILY_PROFILES. The entry's voice capability + (audiocpp_entry_voice_capability: speaker / clone / design) decides how + its voice is supplied; all three are resolved server-side from the + request's "voice"/"instructions" fields: + + - Speaker mode (--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); selecting it + on a non-speaker entry fails fast with a hint. + - 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. Only the entry actually used needs to exist on the server: a - clone-only (Base) server works for --voice runs, while speaker mode - on such a server fails with a hint to pass --voice. + to it. Selecting --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. - 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). A constant per-run seed keeps the designed voice consistent across chunk boundaries. + With neither --voice nor --speaker 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. + ``instructions`` also works on non-design entries, where it acts as a generic style/delivery instruction (voice control): families that read it (OmniVoice, Qwen3-TTS CustomVoice, ...) shape the voice or delivery @@ -852,7 +918,13 @@ class AudioCppTTSClient(_BaseTTSClient): api_url: Optional[str] = None, model_id: Optional[str] = None, instructions: Optional[str] = None, - request_options: Optional[Dict[str, str]] = None): + request_options: Optional[Dict[str, str]] = None, + speaker: Optional[str] = None): + if voice and speaker: + raise ValueError( + "--voice and --speaker are mutually exclusive: --voice selects " + "a server-side preset (cloning), --speaker a built-in " + "CustomVoice speaker (see README).") 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 @@ -868,8 +940,15 @@ class AudioCppTTSClient(_BaseTTSClient): # negative "randomize" seed, so a negative value means "send no seed # at all" (see _request_wav) and the server randomizes. self._seed = _resolve_request_seed() + # Voice/picker selection. preset_mode is True iff a server-side + # preset (--voice) was requested: it gates _select_model's reroute + # to AUDIOCPP_CLONE_MODEL_ID and the INSTRUCT style-prompt logic. + # speaker is a built-in CustomVoice speaker name; the entry's + # capability (resolved in _connect) validates it. The request's + # "voice" field (self.voice) is filled in _connect per the mode. self.preset_mode = bool(voice) - self.voice = voice or speaker_display_name() + self.speaker = speaker or None + self.voice = voice or None # Style/voice-design instruction sent with every request (the CLI # --instructions flag overrides AUDIOCPP_INSTRUCTIONS in config.py). # For task "vdes" entries it describes the voice to design; for other @@ -879,9 +958,10 @@ class AudioCppTTSClient(_BaseTTSClient): # 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 {}) - # Both set during _connect once the entry's task is known: design_mode - # for "vdes" entries, instruction_voice when a family without built-in - # speakers gets its voice from the instruction alone (no voice field). + # 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). self.design_mode = False self.instruction_voice = False # Family and task of the selected model entry and the family's request @@ -898,12 +978,15 @@ class AudioCppTTSClient(_BaseTTSClient): def _connect(self) -> None: """Health-check the server and resolve the model, family, task, and voice. - Speaker mode is only offered to families with built-in speakers - (Qwen3-TTS); every other family must select a server-side voice - with --voice or describe one with --instructions, so it fails fast - with a hint instead of silently synthesizing with a random default - voice. Voice design entries (task "vdes") require --instructions - and reject --voice. + The entry's voice capability (audiocpp_entry_voice_capability, from + family/task/id) plus the caller's --voice/--speaker/--instructions + pick the mode: design entries require --instructions and reject + --voice/--speaker; a --speaker name requires a speaker-capable + (CustomVoice) entry; a --voice preset validates against the server's + voice library (and reroutes to AUDIOCPP_CLONE_MODEL_ID when set); + with neither flag, speaker-capable entries use the built-in + config.SPEAKER and clone entries fail fast with a hint instead of + silently synthesizing with a random default voice. """ self._check_health() models = self._list_models() @@ -921,11 +1004,13 @@ class AudioCppTTSClient(_BaseTTSClient): f"model entries (tasks {', '.join(AUDIOCPP_SYNTHESIS_TASKS)}). " f"Pick a synthesis entry with --model (available: {available})." ) + capability = audiocpp_entry_voice_capability( + self.family, self.task, self.model_id) if self.design_mode: - if self.preset_mode: + if self.voice or self.speaker: raise RuntimeError( - f"--voice cannot be used with the voice design model " - f"'{self.model_id}': the voice is described by the " + f"--voice/--speaker cannot be used with the voice design " + f"model '{self.model_id}': the voice is described by the " "--instructions text instead (see README).") if not self.instructions: raise RuntimeError( @@ -938,18 +1023,31 @@ class AudioCppTTSClient(_BaseTTSClient): f"(model '{self.model_id}', family '{self.family}', " "voice design)") print(f"[INFO] Designing the voice from: {self.instructions}") - elif self.preset_mode: + elif self.speaker is not None: + if capability != AUDIOCPP_VOICE_SPEAKER: + raise RuntimeError( + f"The audio.cpp model '{self.model_id}' (family " + f"'{self.family}') has no built-in speakers; --speaker " + "selects a CustomVoice speaker, so choose the " + "CustomVoice model entry, or use --voice for a " + "server-side preset (see README).") + self.voice = speaker_display_name_for(self.speaker) + print(f"[OK] Connected to audio.cpp server at {self.api_url} " + f"(model '{self.model_id}', family '{self.family}', " + f"speaker '{self.voice}')") + elif self.voice is not None: + # Explicit server-side preset (--voice): validate it and route + # to the clone model entry when AUDIOCPP_CLONE_MODEL_ID is set. self._check_voice() print(f"[OK] Connected to audio.cpp server at {self.api_url} " f"(model '{self.model_id}', family '{self.family}', " f"voice '{self.voice}')") - elif self.profile.builtin_speakers: + elif capability == AUDIOCPP_VOICE_SPEAKER: + # No flag on a CustomVoice entry: the built-in config.SPEAKER. + self.voice = speaker_display_name() print(f"[OK] Connected to audio.cpp server at {self.api_url} " f"(model '{self.model_id}', family '{self.family}', " f"speaker '{self.voice}')") - print("[INFO] Speaker mode expects the server to be configured with the " - "CustomVoice model; with the Base model the speaker name is ignored " - "and a random default voice is used (see README).") elif self.instructions: # Families without built-in speakers can still get their voice # from the instruction alone (e.g. OmniVoice voice design). @@ -965,7 +1063,8 @@ class AudioCppTTSClient(_BaseTTSClient): "must come from the server: rerun with --voice NAME " "matching a voice_preset or voice_dir entry in the server " "config, or describe a voice with --instructions for " - "families that support it (see README).") + "families that support it, or select the CustomVoice entry " + "for built-in speakers (see README).") if self.instructions and not self.design_mode and not self.instruction_voice: print(f"[INFO] Sending instruction with every request: {self.instructions}") print("[INFO] Its effect (style, emotion, delivery) depends on the " @@ -1135,10 +1234,8 @@ class AudioCppTTSClient(_BaseTTSClient): # (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_is_qwen = (families.get(self.model_id) - or AUDIOCPP_FAMILY_QWEN3_TTS) \ - == AUDIOCPP_FAMILY_QWEN3_TTS - if primary_is_qwen: + 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", @@ -1166,22 +1263,21 @@ class AudioCppTTSClient(_BaseTTSClient): def _resolve_family(self, models: List[Dict[str, str]]) -> None: """Resolve the selected model's family and its request profile. - The family comes from GET /v1/models. Servers that predate the - family field served Qwen3-TTS only, so a missing family is treated - as qwen3_tts, which also preserves this client's legacy behavior - against those versions. + The family comes from GET /v1/models; a missing family is an unknown + family that falls through to the generic (clone-only) profile rather + than guessing a specific one — audiocpp_server always reports family + for entries its server.json describes. """ entry = next( (model for model in models if model["id"] == self.model_id), None) family = (entry["family"] if entry is not None else "") or "" - if not family: - family = AUDIOCPP_FAMILY_QWEN3_TTS - logger.debug("Model '%s' reported no family; assuming qwen3_tts", - self.model_id) self.family = family self.profile = AUDIOCPP_FAMILY_PROFILES.get( family, AUDIOCPP_DEFAULT_FAMILY_PROFILE) - if family not in AUDIOCPP_FAMILY_PROFILES: + if not family: + logger.debug("Model '%s' reported no family; using the generic " + "profile", self.model_id) + elif family not in AUDIOCPP_FAMILY_PROFILES: logger.info( "audio.cpp family '%s' has no dedicated profile; using the " "generic profile (voice cloning via --voice, model-detected " diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 9b0ddb4..317e772 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -131,6 +131,7 @@ class NarratorTagTests(unittest.TestCase): converter.voice_clone_ref_audio = ref_audio converter.backend = tts.BACKEND_QWEN converter.voice = None + converter.speaker = None converter.instructions = instructions return converter @@ -159,11 +160,12 @@ class NarratorTagTests(unittest.TestCase): self.assertEqual(self._converter(tts.VOICE_MODE_CLONE, "/x/???.wav")._narrator_tag(), "narrator") - def _audiocpp_converter(self, voice=None, instructions=None): + def _audiocpp_converter(self, voice=None, instructions=None, speaker=None): converter = self._converter(tts.VOICE_MODE_CUSTOM, instructions=instructions) converter.backend = tts.BACKEND_AUDIOCPP converter.voice = voice + converter.speaker = speaker return converter def test_audiocpp_design_run_uses_designed_tag(self): @@ -181,6 +183,15 @@ class NarratorTagTests(unittest.TestCase): converter = self._audiocpp_converter() 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. + converter = self._audiocpp_converter(speaker="Ryan") + self.assertEqual(converter._narrator_tag(), "Ryan") + + def test_audiocpp_explicit_speaker_normalizes_display_name(self): + converter = self._audiocpp_converter(speaker="Uncle_Fu") + self.assertEqual(converter._narrator_tag(), "Uncle_Fu") + def test_preflight_design_run_uses_designed_tag(self): with tempfile.TemporaryDirectory() as books_tmp, \ tempfile.TemporaryDirectory() as output_tmp: @@ -547,6 +558,7 @@ class RunOverwritePromptTests(unittest.TestCase): self.converter.voice_clone_ref_audio = None self.converter.backend = tts.BACKEND_QWEN self.converter.voice = None + self.converter.speaker = None self.converter.instructions = None self.converter.speed = 1.0 self.converter.single_file = False diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 9842148..c5ca346 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -750,40 +750,75 @@ class ConvertFlowTests(unittest.TestCase): [("audio.cpp [remote]", "audiocpp-remote")]) # The model menu was fed from the live query (label, id). self.assertEqual(self._field("model_id")["choices"], - [("higgs (higgs_audio_tts, tts)", "higgs")]) + [("higgs (higgs_audio_tts, clone)", "higgs")]) - def test_audiocpp_qwen3_tts_voice_choices_lead_with_builtin_speaker(self): + def test_audiocpp_customvoice_entry_lists_builtin_speakers(self): + # A CustomVoice entry populates the Voice menu with the Qwen3-TTS + # built-in speakers and maps the pick to --speaker. self._patch_remote( - [{"id": "qwen", "family": "qwen3_tts", "task": "tts"}], - voices=["narrator"]) + [{"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="qwen", - audiocpp_voice="(built-in speaker)", - 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")]) - # The sentinel maps to "no voice" (built-in speaker). + # The speaker is passed as --speaker, not --voice. self.assertIsNone(cmd[2]["voice"]) + self.assertEqual(cmd[2]["speaker"], "Ryan") fields = self.tui.forms_seen[0][1] voice_field = self._field("audiocpp_voice") - choices = voice_field["choices"](fields) - self.assertEqual(choices, - [("(built-in speaker)", "(built-in speaker)"), - ("narrator", "narrator")]) - - def test_audiocpp_remote_missing_family_treated_as_qwen3_tts(self): - # Legacy servers omit family/task; the converter defaults them to - # qwen3_tts/tts and so must the form (voice optional). + self.assertEqual(voice_field["choices"](fields), + [(s, s) for s in hub.QWEN3_TTS_SPEAKERS]) + # CustomVoice reads a style instruction, so the field stays visible. + instr = self._field("instructions") + self.assertTrue(instr["visible"](fields)) + self.assertIsNone(instr["validate"]("")) + + def test_audiocpp_qwen3_tts_base_entry_lists_clone_voices(self): + # A Base entry populates the Voice menu with the server's clone + # voices only (no built-in speakers) and maps the pick to --voice. + self._patch_remote( + [{"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.assertEqual(cmd[2]["voice"], "narrator") + self.assertIsNone(cmd[2]["speaker"]) + self.assertIsNone(cmd[2]["instructions"]) + fields = self.tui.forms_seen[0][1] + voice_field = self._field("audiocpp_voice") + self.assertEqual(voice_field["choices"](fields), + [("narrator", "narrator")]) + # Base (clone) ignores instructions, so the field is hidden. + instr = self._field("instructions") + self.assertFalse(instr["visible"](fields)) + + def test_audiocpp_remote_missing_family_is_clone_capable(self): + # A missing family is unknown — not guessed as qwen3_tts — so the + # entry is clone-only: it needs a --voice rather than offering a + # 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="(built-in speaker)", - instructions="") + audiocpp_voice="", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertIsNotNone(cmd) self.assertIsNone(cmd[2]["voice"]) + self.assertIsNone(cmd[2]["speaker"]) + voice_field = self._field("audiocpp_voice") + # Clone-only: an empty voice is refused (no built-in speaker option). + self.assertIsNotNone(voice_field["validate"]("")) def test_audiocpp_vdes_hides_voice_and_requires_instructions(self): self._patch_remote( @@ -800,9 +835,24 @@ class ConvertFlowTests(unittest.TestCase): voice_field = self._field("audiocpp_voice") self.assertFalse(voice_field["visible"](fields)) instr = self._field("instructions") + self.assertTrue(instr["visible"](fields)) self.assertIsNotNone(instr["validate"]("")) self.assertIsNone(instr["validate"]("describe me")) + def test_audiocpp_clone_drops_stale_instructions(self): + # A Base/clone entry ignores instructions: even if the form held a + # leftover value, the mapper must not send it to the model. + 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.assertIsNone(cmd[2]["instructions"]) + def test_audiocpp_required_voice_validates(self): # A non-qwen3_tts family needs a --voice; a blank value refuses. self._patch_remote( @@ -1082,9 +1132,11 @@ class ConvertFlowTests(unittest.TestCase): "mode", "speaker", "clone", "output_format", "speed", "single_file", "debug"]) # The form opens on the configured default (audio.cpp): its fields - # show, the other backend's hide. - for key in ("model_id", "audiocpp_voice", "instructions"): + # show, the other backend's hide. (Instructions is hidden too: the + # default higgs entry is clone-only, which ignores instructions.) + for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) + self.assertFalse(self._field("instructions")["visible"](fields)) for key in ("mode", "speaker", "clone"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. @@ -1100,8 +1152,9 @@ class ConvertFlowTests(unittest.TestCase): self.assertFalse(self._field(key)["visible"](fields)) # And back to audio.cpp. fields[0]["value"] = "audiocpp" - for key in ("model_id", "audiocpp_voice", "instructions"): + for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) + self.assertFalse(self._field("instructions")["visible"](fields)) for key in ("mode", "speaker", "clone"): self.assertFalse(self._field(key)["visible"](fields)) diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index b43919d..0e35f78 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -479,8 +479,10 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): def setUp(self): # The default AUDIOCPP_MODEL_ID is empty (auto-select); these tests - # exercise a configured single-model server, so pin a concrete id. - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") + # 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) @@ -500,7 +502,8 @@ 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": config.AUDIOCPP_MODEL_ID, + "family": "qwen3_tts"}]}) if "/v1/audio/voices" in url: if voices is Exception: raise Exception("voices endpoint down") @@ -509,11 +512,12 @@ 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=None, speaker=None, + **kwargs): with patch("converter.tts.urllib.request.urlopen", side_effect=self._get_responses(**kwargs)): return AudioCppTTSClient(voice=voice, language=language, - model_id=model_id) + model_id=model_id, speaker=speaker) def test_unreachable_server_raises_with_readme_pointer(self): import urllib.error @@ -551,6 +555,42 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): client = self._client() self.assertEqual(client.voice, "Uncle Fu") + def test_explicit_speaker_selects_speaker_mode(self): + # --speaker picks a CustomVoice speaker; the name is normalized to + # its wire (display) form and no preset validation runs. + client = self._client(speaker="Uncle_Fu") + self.assertEqual(client.voice, "Uncle Fu") + self.assertFalse(client.preset_mode) + + def test_explicit_speaker_on_clone_entry_raises(self): + # --speaker is meaningless on a clone-only (Base) entry. + with self.assertRaises(RuntimeError) as ctx: + self._client(speaker="Ryan", model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts"}]}) + message = str(ctx.exception) + self.assertIn("no built-in speakers", message) + self.assertIn("--voice", message) + + def test_voice_and_speaker_are_mutually_exclusive(self): + with self.assertRaises(ValueError) as ctx: + AudioCppTTSClient(voice="narrator", speaker="Ryan") + self.assertIn("mutually exclusive", str(ctx.exception)) + + def test_no_voice_on_base_entry_raises_instead_of_silent_speaker(self): + # The Base model has no built-in speakers: without --voice the run + # fails fast instead of silently sending a speaker name that the + # model ignores. + with self.assertRaises(RuntimeError) as ctx: + self._client(model_id="Qwen3-TTS-12Hz-1.7B-Base-GGUF", + models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts"}]}) + message = str(ctx.exception) + self.assertIn("Base-GGUF", message) + self.assertIn("--voice", message) + def test_preset_mode_uses_requested_voice(self): client = self._client(voice="narrator") self.assertEqual(client.voice, "narrator") @@ -598,7 +638,8 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertLogs("converter.tts", level="WARNING") as logs: client = self._client( voice="narrator", - models={"data": [{"id": "qwen3-tts"}, {"id": "pocket-tts"}]}) + models={"data": [{"id": "qwen3-tts", "family": "qwen3_tts"}, + {"id": "pocket-tts"}]}) self.assertEqual(client.model_id, "qwen3-tts") self.assertTrue(any("qwen3-tts-clone" in line for line in logs.output)) @@ -632,11 +673,17 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertEqual(client.model_id, "higgs") def test_clone_model_id_ignored_for_speaker_mode(self): - with patch.object(config, "AUDIOCPP_MODEL_ID", "qwen3-tts"), \ + # 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"}, {"id": "qwen3-tts-clone"}]}) - self.assertEqual(client.model_id, "qwen3-tts") + 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", @@ -662,9 +709,12 @@ class AudioCppTTSClientHealthTests(unittest.TestCase): self.assertIn("--voice", 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.assertLogs("converter.tts", level="WARNING"): + self.assertNoLogs("converter.tts", level="WARNING"): with self.assertRaises(RuntimeError) as ctx: self._client(voice="narrator", models={"data": [{"id": "pocket-tts"}]}) @@ -678,7 +728,10 @@ class AudioCppTaskDetectionTests(unittest.TestCase): """Task auto-detection (tts/clon/vdes) and voice design validation.""" def setUp(self): - patcher = patch.object(config, "AUDIOCPP_MODEL_ID", "qwen") + # 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) @@ -853,18 +906,19 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertEqual(client.family, "higgs_audio_tts") self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) - def test_missing_family_falls_back_to_qwen3_tts(self): + def test_missing_family_uses_generic_profile(self): + # 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}]}) - self.assertEqual(client.family, "qwen3_tts") - self.assertTrue(client.profile.builtin_speakers) + self.assertEqual(client.family, "") + self.assertIs(client.profile, tts.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"}]}) self.assertEqual(client.family, "future_tts") self.assertIs(client.profile, tts.AUDIOCPP_DEFAULT_FAMILY_PROFILE) - self.assertFalse(client.profile.builtin_speakers) self.assertEqual(client.profile.language_style, tts.AUDIOCPP_LANG_OMIT) def test_speaker_mode_rejected_for_clone_only_family(self): @@ -879,11 +933,31 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIn("no built-in speakers", message) self.assertIsNone(client) - def test_speaker_mode_allowed_for_qwen_family(self): - client = self._client(voice=None, models={"data": [ - {"id": config.AUDIOCPP_MODEL_ID, "family": "qwen3_tts"}]}) + 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": [ + {"id": "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + "family": "qwen3_tts"}]}) self.assertEqual(client.family, "qwen3_tts") + def test_speaker_mode_rejected_for_qwen_base_entry(self): + # A Qwen3-TTS entry whose id names Base (not CustomVoice) is + # clone-only, even though its family has built-in speakers on other + # entries: without --voice it fails fast. + client = None + try: + client = self._client(voice=None, models={"data": [ + {"id": "Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "family": "qwen3_tts"}]}) + except RuntimeError as exc: + message = str(exc) + self.assertIn("Base-GGUF", message) + 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"), \ @@ -919,6 +993,52 @@ class AudioCppFamilyDetectionTests(unittest.TestCase): self.assertIsNone(tts.LANGUAGE_ISO_CODES.get("Auto")) +class AudiocppEntryVoiceCapabilityTests(unittest.TestCase): + """The per-entry voice capability resolver (speaker/clone/design).""" + + def _cap(self, family="", task="tts", model_id=""): + return tts.audiocpp_entry_voice_capability(family, task, model_id) + + def test_vdes_task_is_design(self): + self.assertEqual(self._cap("qwen3_tts", "vdes", + "Qwen3-TTS-VoiceDesign-GGUF"), + tts.AUDIOCPP_VOICE_DESIGN) + + def test_qwen_customvoice_entry_is_speaker(self): + self.assertEqual(self._cap("qwen3_tts", "tts", + "Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF"), + tts.AUDIOCPP_VOICE_SPEAKER) + + def test_qwen_base_entry_is_clone(self): + self.assertEqual(self._cap("qwen3_tts", "tts", + "Qwen3-TTS-12Hz-1.7B-Base-GGUF"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_qwen_unidentified_entry_is_clone(self): + self.assertEqual(self._cap("qwen3_tts", "tts", "qwen"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_other_families_are_clone(self): + self.assertEqual(self._cap("higgs_audio_tts", "tts", "higgs"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_missing_family_is_clone(self): + self.assertEqual(self._cap("", "tts", "legacy"), + tts.AUDIOCPP_VOICE_CLONE) + + def test_customvoice_match_is_case_insensitive(self): + self.assertEqual(self._cap("qwen3_tts", "tts", + "Qwen3-TTS-12Hz-1.7B-CUSTOMVOICE-GGUF"), + tts.AUDIOCPP_VOICE_SPEAKER) + + def test_customvoice_id_in_other_family_is_not_speaker(self): + # The "customvoice" substring only marks a speaker for the qwen3_tts + # family; another family with a lookalike id stays clone-only. + self.assertEqual(self._cap("future_tts", "tts", + "Qwen3-TTS-CustomVoice"), + tts.AUDIOCPP_VOICE_CLONE) + + class AudioCppTTSClientRequestTests(unittest.TestCase): """The /v1/audio/speech payload and response validation.""" @@ -943,6 +1063,7 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.model_id = config.AUDIOCPP_MODEL_ID client.preset_mode = preset_mode client.voice = voice + client.speaker = None client.language = language client._seed = seed client.family = family @@ -953,10 +1074,12 @@ class AudioCppTTSClientRequestTests(unittest.TestCase): client.request_options = dict(request_options or {}) client.design_mode = task == tts.AUDIOCPP_TASK_VDES # Mirrors the connect-time rule: an instruction-defined voice on a - # family without built-in speakers (design mode takes precedence). + # clone-capable entry with no --voice (design mode takes precedence). + capability = tts.audiocpp_entry_voice_capability( + family, task, client.model_id) client.instruction_voice = ( not preset_mode and not client.design_mode - and not client.profile.builtin_speakers + and capability == tts.AUDIOCPP_VOICE_CLONE and bool(client.instructions)) return client @@ -1396,6 +1519,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client.design_mode = False client.instruction_voice = False client.instructions = "" + client.speaker = None with patch.object(client, "_check_health"), \ patch.object(client, "_list_models", return_value=[{"id": client.model_id, @@ -1425,6 +1549,7 @@ class AudioCppUnloadModelsTests(unittest.TestCase): client.design_mode = False client.instruction_voice = False client.instructions = "" + client.speaker = None with patch.object(client, "_check_health"), \ patch.object(client, "_list_models", return_value=[{"id": client.model_id, @@ -1466,7 +1591,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions=None, request_options={}, - api_url=None) + api_url=None, + speaker=None) mock_faster.assert_not_called() mock_qwen.assert_not_called() @@ -1478,7 +1604,8 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions=None, request_options={}, - api_url=None) + api_url=None, + speaker=None) def test_audiocpp_backend_model_id_is_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1488,7 +1615,7 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with( voice="narrator", language=config.LANGUAGE, model_id="higgs", instructions=None, - request_options={}, api_url=None) + request_options={}, api_url=None, speaker=None) def test_audiocpp_backend_instructions_and_options_are_wired_through(self): with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1502,7 +1629,7 @@ class BackendWiringTests(unittest.TestCase): model_id=None, instructions="A warm adult narrator", request_options={"emotion": "neutral", "speed": "1.1"}, - api_url=None) + api_url=None, speaker=None) def test_qwen_backend_uses_qwen_client(self): with patch("converter.converter.FasterTTSClient") as mock_faster, \ @@ -1529,7 +1656,7 @@ class BackendWiringTests(unittest.TestCase): mock_audiocpp.assert_called_once_with( voice="narrator", language=config.LANGUAGE, model_id=None, instructions=None, request_options={}, - api_url="http://10.0.0.5:8080") + api_url="http://10.0.0.5:8080", speaker=None) with patch("converter.converter.FasterTTSClient") as mock_faster: AudiobookConverter(voice_mode=tts.VOICE_MODE_CLONE, backend=tts.BACKEND_FASTER, voice="narrator", diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 964f983..543194e 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -204,6 +204,17 @@ class MenuTests(TuiTestCase): self.assertGreater(help_x, margin) self.assert_inside_border(screen) + def test_blank_line_below_the_title(self): + # A titled dialog reserves a blank line between the title row + # and the first body row. + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Pick", self.OPTIONS) + title_y = next(y for y, _, text, _ in screen.strings + if text == " Pick ") + first_y = next(y for y, _, text, _ in screen.strings + if text == "first option") + self.assertEqual(first_y, title_y + 2) + def test_up_wraps_around_to_last_option(self): screen = FakeScreen(keys=[FakeCurses.KEY_UP, 10]) value = tui.menu(screen, "Pick one", self.OPTIONS) diff --git a/app/ui/hub.py b/app/ui/hub.py index 29edf9b..84b4155 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -53,10 +53,14 @@ from converter.converter import ( voice_mode_for, ) from converter.tts import ( - AUDIOCPP_FAMILY_QWEN3_TTS, + AUDIOCPP_VOICE_CLONE, + AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_SPEAKER, BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, + QWEN3_TTS_SPEAKERS, + audiocpp_entry_voice_capability, normalize_language, ) from ui import runview, taskview, tui @@ -737,6 +741,7 @@ def _preflight(stdscr, cmd: tuple) -> bool: voice_clone_ref_audio=kwargs.get("clone"), output_format=kwargs.get("output_format") or config.AUDIO_FORMAT, instructions=kwargs.get("instructions"), + speaker=kwargs.get("speaker"), confirm=confirm) if not book_files: tui.flash(stdscr, "No books to convert. Add a .txt, .pdf or .epub " @@ -770,11 +775,6 @@ def _gate_backend(field: dict, key: str) -> Callable: return visible -# Sentinel value the audio.cpp Voice field uses for "no --voice" (the -# built-in CustomVoice speaker); mapped to None when the form returns. -_AUDIOCPP_BUILTIN_SPEAKER = "(built-in speaker)" - - def _field_value(fields, key: str, default=None): """Current value of the field named KEY, or DEFAULT when absent.""" for field in fields: @@ -870,14 +870,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: return None # Normalize each entry so the form logic sees a family/task always. + # A missing family is left empty (an unknown family resolves to the + # clone capability, requiring a --voice) rather than guessing a specific + # one — audiocpp_server always reports family for entries it hosts. models = [dict(m) for m in models] for entry in models: entry["family"] = entry.get("family") or "" entry["task"] = entry.get("task") or "tts" - if not local and not entry["family"]: - # Servers predating the family field omit it; mirror the - # converter's default: unknown family means qwen3_tts. - entry["family"] = AUDIOCPP_FAMILY_QWEN3_TTS if local: # Only offer entries whose model files are actually on disk: a @@ -914,75 +913,100 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None) -> Optional[tuple]: return next((m for m in models if m.get("id") == model_id), models[0]) - def model_task(fields) -> str: - return model_entry(fields).get("task", "tts") - - def model_family(fields) -> str: - return model_entry(fields).get("family") or "" + def model_capability(fields) -> str: + entry = model_entry(fields) + return audiocpp_entry_voice_capability( + entry.get("family") or "", entry.get("task") or "tts", + entry.get("id") or "") def reset_voice(fields) -> None: """Re-point the Voice field at the newly selected model's voice.""" voice_field = next(f for f in fields if f.get("key") == "audiocpp_voice") - if model_task(fields) == "vdes": + capability = model_capability(fields) + if capability == AUDIOCPP_VOICE_DESIGN: voice_field["value"] = None - elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: - voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER - else: + elif capability == AUDIOCPP_VOICE_SPEAKER: + voice_field["value"] = (config.SPEAKER + if config.SPEAKER in QWEN3_TTS_SPEAKERS + else QWEN3_TTS_SPEAKERS[0]) + else: # clone voices = voices_for(_field_value(fields, "model_id")) voice_field["value"] = voices[0] if voices else "" def voice_choices(fields) -> list: - if model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS: - return [(_AUDIOCPP_BUILTIN_SPEAKER, _AUDIOCPP_BUILTIN_SPEAKER)] \ - + [(v, v) for v in voices_for(_field_value(fields, + capability = model_capability(fields) + if capability == AUDIOCPP_VOICE_SPEAKER: + # Built-in Qwen3-TTS CustomVoice speakers; no server query needed. + return [(s, s) for s in QWEN3_TTS_SPEAKERS] + if capability == AUDIOCPP_VOICE_CLONE: + return [(v, v) for v in voices_for(_field_value(fields, "model_id"))] - return [(v, v) for v in voices_for(_field_value(fields, "model_id"))] + return [] # design: the field is hidden 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_entry = next((m for m in models if m.get("id") == default_model), models[0]) - initial_voice = _AUDIOCPP_BUILTIN_SPEAKER - if default_entry.get("task") == "vdes": - initial_voice = None - elif default_entry.get("family") != AUDIOCPP_FAMILY_QWEN3_TTS: + default_capability = audiocpp_entry_voice_capability( + default_entry.get("family") or "", default_entry.get("task") or "tts", + 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]) + elif default_capability == AUDIOCPP_VOICE_CLONE: initial = voices_for(default_model) initial_voice = initial[0] if initial else "" + def _label(entry: dict) -> str: + capability = audiocpp_entry_voice_capability( + entry.get("family") or "", entry.get("task") or "tts", + entry.get("id") or "") + return (f"{entry.get('id')} ({entry.get('family') or '?'}, " + f"{capability})") + fields = [ {"key": "model_id", "label": "Model", "kind": "choice", "value": default_model, - "choices": [(f"{m.get('id')} ({m.get('family') or '?'}, " - f"{m.get('task') or 'tts'})", m.get("id")) - for m in models], + "choices": [(_label(m), m.get("id")) for m in models], "on_change": reset_voice}, {"key": "audiocpp_voice", "label": "Voice", "kind": "choice", "value": initial_voice, "choices": lambda fs: voice_choices(fs), - "visible": lambda fs: model_task(fs) != "vdes", + "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN, "validate": lambda value: None - if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value) + if (model_capability(fields) != AUDIOCPP_VOICE_CLONE or value) else "This model needs a voice — pick one or switch models"}, {"key": "instructions", "label": "Instructions", "kind": "text", "value": config.AUDIOCPP_INSTRUCTIONS, + "visible": lambda fs: model_capability(fs) in (AUDIOCPP_VOICE_DESIGN, + AUDIOCPP_VOICE_SPEAKER), "validate": lambda value: None - if (model_task(fields) != "vdes" or str(value).strip()) + if (model_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip()) else "Describe the voice, e.g. 'A warm female narrator'"}, ] def mapper(result) -> Optional[tuple]: model_id = result["model_id"] - voice = result["audiocpp_voice"] - if voice == _AUDIOCPP_BUILTIN_SPEAKER or not voice: - voice = None entry = next((m for m in models if m.get("id") == model_id), {}) - if entry.get("task") == "vdes": - voice = None - instructions = (result["instructions"] or "").strip() or None + capability = audiocpp_entry_voice_capability( + entry.get("family") or "", entry.get("task") or "tts", + entry.get("id") or "") + picked = result["audiocpp_voice"] + voice = None + speaker = None + if capability == AUDIOCPP_VOICE_SPEAKER: + speaker = picked or None + elif capability == AUDIOCPP_VOICE_CLONE: + voice = picked or None + # design: neither — the voice comes from --instructions + instructions = None + if capability in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER): + instructions = (result["instructions"] or "").strip() or None kwargs = { - "model_id": model_id, "voice": voice, + "model_id": model_id, "voice": voice, "speaker": speaker, "instructions": instructions, **_common_kwargs(result), } diff --git a/app/ui/tui.py b/app/ui/tui.py index 663df0a..d675946 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -373,7 +373,10 @@ class Frame: Returns (y0, x0, dialog_h, visible); also refreshes self.scroll and self.page_size. """ - chrome = 7 if self.buttons else 6 # title/gap/status/footer/borders + # Borders, title, status and footer are fixed chrome; a titled + # frame also reserves a blank line below its title, and buttons + # take their own row above the status. + chrome = 6 + (1 if self.title else 0) + (1 if self.buttons else 0) dialog_h = min(max(self.MIN_HEIGHT, len(flat) + chrome), height) visible = max(1, dialog_h - chrome) self.page_size = max(1, visible) @@ -447,7 +450,7 @@ class Frame: inner_w = dialog_w - 2 for line in range(self.scroll, min(len(flat), self.scroll + visible)): logical, row, piece = flat[line] - y = y0 + 2 + (line - self.scroll) + y = y0 + (3 if self.title else 2) + (line - self.scroll) selected = logical == self.cursor and row["selectable"] if selected: _addstr(scr, y, inner_x, " " * inner_w, theme["bar"]) diff --git a/audiobook.py b/audiobook.py index 9bdad19..b4cd89d 100755 --- a/audiobook.py +++ b/audiobook.py @@ -59,6 +59,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None, model_id: str = None, instructions: str = None, request_options: dict = None, input_dir: Path = None, output_dir: Path = None, api_url: str = None, + speaker: str = None, progress=None, cancel=None, confirm=None, book_files=None, planned=None) -> int: """Run one conversion pass with explicit options (used by the CLI and hub). @@ -101,7 +102,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None, book_files, planned = AudiobookConverter.preflight_overwrites( backend=backend, voice=voice, voice_mode=voice_mode, voice_clone_ref_audio=clone, output_format=output_format, - instructions=instructions, confirm=confirm, + instructions=instructions, speaker=speaker, confirm=confirm, ) if not book_files: print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file " @@ -120,6 +121,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None, language=language, backend=backend, voice=voice, debug=debug, model_id=model_id, instructions=instructions, request_options=request_options, api_url=api_url, + speaker=speaker, progress=progress, cancel=cancel, ) converter._book_files = book_files @@ -247,11 +249,21 @@ Examples: "a key in the server's voices.json ('default' when it was started " "with --ref-audio). audiocpp: a voice_preset or voice_dir entry " "(cloning); required for audio.cpp families without built-in " - "speakers (everything except Qwen3-TTS CustomVoice). Not used by " + "speakers (everything except Qwen3-TTS CustomVoice). When " + "AUDIOCPP_CLONE_MODEL_ID names a second server entry (typically " + "the Qwen Base model), --voice reroutes to it. Not used by " "the qwen backend (use app/converter/config.py SPEAKER or --clone " "there).") ) parser.add_argument( + "--speaker", type=str, default=None, metavar="NAME", + help=("audiocpp only: a built-in Qwen3-TTS CustomVoice speaker name " + "(e.g. Vivian, Ryan, Uncle Fu). Selects speaker mode on the " + "CustomVoice model entry; mutually exclusive with --voice. " + "Without --voice/--speaker, a CustomVoice entry defaults to " + "the SPEAKER in app/converter/config.py.") + ) + parser.add_argument( "--debug", action="store_true", help=("Troubleshooting mode: dump each chunk's raw audio and the exact text " "sent for it under the debug/ folder (organized per book and chapter), " @@ -326,6 +338,11 @@ Examples: "uses a voice configured on the server (voice_presets or " "voice_dir in its config); select it with --voice (see README)") args.clone = None + if args.voice and args.speaker: + parser.error("--voice and --speaker are mutually exclusive with " + "--backend audiocpp: --voice selects a server-side " + "preset (cloning), --speaker a built-in CustomVoice " + "speaker (see README)") if args.transcription or args.no_transcription: print("[WARNING] --transcription/--no-transcription are ignored with " "--backend audiocpp: the reference transcript is configured on " @@ -356,6 +373,10 @@ Examples: parser.error("--model requires --backend audiocpp; it selects an " "audio.cpp server model entry id") + if args.speaker is not None and args.backend != BACKEND_AUDIOCPP: + parser.error("--speaker requires --backend audiocpp; it selects a " + "built-in Qwen3-TTS CustomVoice speaker") + if args.instructions is not None and args.backend != BACKEND_AUDIOCPP: parser.error("--instructions requires --backend audiocpp; it is " "sent as the audio.cpp request's instructions field") @@ -390,6 +411,7 @@ Examples: request_options=request_options, input_dir=args.input, output_dir=args.output, api_url=api_url, + speaker=args.speaker, )) |
