aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-25 16:40:30 -0400
committerhistoria <historiavg@proton.me>2026-08-25 16:40:30 -0400
commit867866f131b0b6c76c54272791e7f7dea01db990 (patch)
treeb57ecdd66eeaf7ad73742d2f2bbe15d3a5498fa3 /app
parentfca3431721a55277f139efc83df2438207917448 (diff)
downloadtts-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')
-rw-r--r--app/backends/qwen.py8
-rw-r--r--app/converter/converter.py43
-rw-r--r--app/converter/tts.py218
-rw-r--r--app/tests/test_converter.py14
-rw-r--r--app/tests/test_hub.py95
-rw-r--r--app/tests/test_tts.py177
-rw-r--r--app/tests/test_tui.py11
-rw-r--r--app/ui/hub.py104
-rw-r--r--app/ui/tui.py7
9 files changed, 509 insertions, 168 deletions
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"])