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 /app/converter | |
| 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
Diffstat (limited to 'app/converter')
| -rw-r--r-- | app/converter/converter.py | 43 | ||||
| -rw-r--r-- | app/converter/tts.py | 218 |
2 files changed, 185 insertions, 76 deletions
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 " |
