aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 14:49:17 -0400
committerhistoria <historiavg@proton.me>2026-08-24 14:49:17 -0400
commit7ee1d4bb63c12982ec4900ec870ad96baba4b22b (patch)
tree3f4261201b1bf05546c7c9241f7f3bcb4ea42b5e /app/ui/hub.py
parente7a3d65f68659d17f37b79e8bfefea19d7ac0648 (diff)
downloadtts-audiobook-generator-7ee1d4bb63c12982ec4900ec870ad96baba4b22b.tar.gz
feat: combine wizard menus into a single generate options menu
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py388
1 files changed, 216 insertions, 172 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 0b599d0..b233d1b 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -31,6 +31,7 @@ from backends import (
)
from backends import audiocpp as audiocpp_backend
from backends import faster as faster_backend
+from backends import qwen as qwen_backend
from converter import config
from converter.converter import AUDIO_FORMATS
from converter.tts import (
@@ -218,7 +219,7 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]:
if key is _GO_BACK or key is None:
return None
if key == BACKEND_AUDIOCPP:
- cmd = _convert_audiocpp(stdscr, statuses)
+ cmd = _convert_audiocpp(stdscr)
elif key == BACKEND_QWEN:
cmd = _convert_qwen(stdscr)
elif key == BACKEND_FASTER:
@@ -227,17 +228,75 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]:
return None
if cmd is None:
return None
- _add_autostart(stdscr, cmd, statuses)
+ _add_autostart(cmd, statuses)
return cmd
-def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]:
- """Collect audio.cpp run settings for a managed or remote server.
+# 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)"
- With a local checkout configured (its server.json), the menus are fed
- from that file — the config of the server this tool manages. Without
- one, the running server is external and nothing is known about it
- locally, so its model and voice lists are queried live instead (the
+
+def _field_value(fields, key: str, default=None):
+ """Current value of the field named KEY, or DEFAULT when absent."""
+ for field in fields:
+ if field.get("key") == key:
+ return field["value"]
+ return default
+
+
+def _common_fields() -> list:
+ """Field dicts for output format, speed, single-file, and debug.
+
+ The single-file field is hidden for m4b (always a single file with
+ embedded chapter markers), so its "visible" callable reads the live
+ output-format value from the field list.
+ """
+ fmt_default = config.AUDIO_FORMAT \
+ if config.AUDIO_FORMAT in AUDIO_FORMATS else AUDIO_FORMATS[0]
+ return [
+ {"key": "output_format", "label": "Output format", "kind": "choice",
+ "value": fmt_default, "choices": list(AUDIO_FORMATS)},
+ {"key": "speed", "label": "Speed", "kind": "text", "value": "1.0",
+ "validate": lambda s: None if (_is_float(s) and float(s) > 0)
+ else "Enter a positive number, e.g. 1.0"},
+ {"key": "single_file", "label": "Combine all chapters",
+ "kind": "bool", "value": False,
+ "visible": lambda fs: _field_value(fs, "output_format") != "m4b"},
+ {"key": "debug", "label": "Debug", "kind": "bool", "value": False},
+ ]
+
+
+def _common_kwargs(values: dict) -> dict:
+ """Map the common form fields to converter keyword arguments."""
+ output_format = values["output_format"]
+ return {
+ "output_format": output_format,
+ "speed": float(values["speed"]),
+ "single_file": bool(values["single_file"])
+ and output_format != "m4b",
+ "debug": bool(values["debug"]),
+ }
+
+
+def _show_convert_form(stdscr, title: str, fields: list) -> Optional[dict]:
+ """Show the single conversion form (Generate!/Cancel, focus on
+ Generate!) and return its values, or None/_GO_BACK to go back."""
+ result = tui.form(stdscr, title, fields,
+ buttons=("Generate!", "Cancel"),
+ start_on_buttons=True, back_value=_GO_BACK)
+ if result is None or result is _GO_BACK:
+ return None
+ return result
+
+
+def _convert_audiocpp(stdscr) -> Optional[tuple]:
+ """Collect audio.cpp run settings on one form.
+
+ With a local checkout configured (its server.json), the model list is
+ fed from that file — the config of the server this tool manages.
+ Without one, the running server is external and nothing is known about
+ it locally, so its models and voices are queried live instead (the
same GET /v1/models and GET /v1/audio/voices endpoints the converter
resolves at run time).
"""
@@ -273,128 +332,146 @@ def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]:
return None
data = {}
- model_options = [(f"{m.get('id')} ({m.get('family') or '?'}, "
- f"{m.get('task') or 'tts'})", m.get("id"))
- for m in models]
- model_id = tui.menu(stdscr, "Select the audio.cpp model to use",
- model_options, back_value=_GO_BACK)
- if model_id is _GO_BACK or model_id is None:
- return None
- entry = next((m for m in models if m.get("id") == model_id), {})
- family = entry.get("family")
- task = entry.get("task", "tts")
- if not local:
- # Servers predating the family/task fields omit them; mirror the
- # converter's defaults (_resolve_family/_resolve_task): unknown
- # family means qwen3_tts, a missing task plain tts.
- family = family or AUDIOCPP_FAMILY_QWEN3_TTS
- task = task or "tts"
-
- def _voices() -> Optional[list]:
- """Voice names for MODEL_ID, or None when a remote server's voice
- list cannot be queried. Locally: the voice_dir's .wav stems;
- remotely: GET /v1/audio/voices."""
+ # Normalize each entry so the form logic sees a family/task always.
+ 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
+
+ local_voices = _list_voices(data.get("voice_dir")) \
+ if data.get("voice_dir") else []
+ voice_cache: dict = {} # model id -> voices (local: shared list)
+
+ def voices_for(model_id: str) -> list:
if local:
- voice_dir = data.get("voice_dir")
- return _list_voices(voice_dir) if voice_dir else []
- return audiocpp_backend.fetch_server_voices(url, model_id)
-
- # Voice: optional for qwen3_tts (built-in speaker), required otherwise.
- voice = None
- if task == "vdes":
- # Voice design: no voice, instructions required.
- pass
- elif family == AUDIOCPP_FAMILY_QWEN3_TTS:
- # Speaker mode available; voice optional.
- voices = _voices() or []
- if voices:
- opts = [("(built-in speaker)", None)] + [(v, v) for v in voices]
- voice = tui.menu(stdscr, "Voice", opts, back_value=_GO_BACK)
- if voice is _GO_BACK:
- return None
+ return local_voices
+ if model_id not in voice_cache:
+ fetched = audiocpp_backend.fetch_server_voices(url, model_id)
+ voice_cache[model_id] = fetched or []
+ return voice_cache[model_id]
+
+ def model_entry(fields):
+ model_id = _field_value(fields, "model_id")
+ 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 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") == "voice")
+ if model_task(fields) == "vdes":
+ voice_field["value"] = None
+ elif model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS:
+ voice_field["value"] = _AUDIOCPP_BUILTIN_SPEAKER
else:
- voice = None
- else:
- voices = _voices()
- if voices is None:
- tui.flash(stdscr, f"Could not list voices from the audio.cpp "
- f"server at {url}.")
- return None
- if not voices:
- if local:
- tui.flash(stdscr, f"This model needs a --voice but voice_dir "
- f"{data.get('voice_dir')} has no .wav voices. "
- "Reconfigure audio.cpp or add voices.")
- else:
- tui.flash(stdscr, f"This model needs a --voice but the "
- f"server at {url} lists none for '{model_id}'. "
- "Configure voice presets or a voice_dir on the "
- "server.")
- return None
- voice = tui.menu(stdscr, "Select the voice to clone", [(v, v) for v in voices],
- back_value=_GO_BACK)
- if voice is _GO_BACK or voice is None:
- return None
+ 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,
+ "model_id"))]
+ return [(v, v) for v in voices_for(_field_value(fields, "model_id"))]
+
+ 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:
+ initial = voices_for(default_model)
+ initial_voice = initial[0] if initial else ""
- # Instructions: required for vdes, optional otherwise.
- instructions = None
- if task == "vdes":
- instructions = tui.line_edit(
- stdscr, "Voice design instructions (required for this model)",
- config.AUDIOCPP_INSTRUCTIONS,
- validate=lambda s: None if s.strip()
- else "Describe the voice, e.g. 'A warm female narrator'",
- back_value=_GO_BACK)
- if instructions is _GO_BACK:
- return None
- else:
- instructions = tui.line_edit(
- stdscr, "Style instructions (optional, blank for none)",
- config.AUDIOCPP_INSTRUCTIONS, back_value=_GO_BACK)
- if instructions is _GO_BACK:
- return None
- if not instructions.strip():
- instructions = None
+ 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],
+ "on_change": reset_voice},
+ {"key": "voice", "label": "Voice", "kind": "choice",
+ "value": initial_voice,
+ "choices": lambda fs: voice_choices(fs),
+ "visible": lambda fs: model_task(fs) != "vdes",
+ "validate": lambda value: None
+ if (model_family(fields) == AUDIOCPP_FAMILY_QWEN3_TTS or value)
+ else "This model needs a voice — pick one or switch models"},
+ {"key": "instructions", "label": "Instructions", "kind": "text",
+ "value": config.AUDIOCPP_INSTRUCTIONS,
+ "validate": lambda value: None
+ if (model_task(fields) != "vdes" or str(value).strip())
+ else "Describe the voice, e.g. 'A warm female narrator'"},
+ ]
+ fields += _common_fields()
- common_kw = _common_options(stdscr)
- if common_kw is None:
+ result = _show_convert_form(stdscr, "Convert with audio.cpp", fields)
+ if result is None:
return None
+
+ model_id = result["model_id"]
+ voice = result["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
return ("convert", BACKEND_AUDIOCPP, {
"model_id": model_id, "voice": voice, "instructions": instructions,
- **common_kw,
+ **_common_kwargs(result),
})
def _convert_qwen(stdscr) -> Optional[tuple]:
- """Collect qwen run settings: built-in speaker or clone a .wav."""
- mode = tui.menu(
- stdscr, "qwen-tts mode",
- [("Custom voice (built-in speaker)", "custom"),
- ("Voice clone from a .wav file", "clone")],
- back_value=_GO_BACK,
- help_lines=[f"Speaker: {config.SPEAKER} (change it via Configure "
- "qwen-tts)"])
- if mode is _GO_BACK or mode is None:
- return None
- clone = None
- if mode == "clone":
- clone = tui.line_edit(
- stdscr, "Path to a reference .wav (10-15s is ideal)",
- "",
- validate=lambda s: None if (s and Path(s).is_file()
+ """Collect qwen run settings on one form: speaker or clone a .wav."""
+ speakers = list(qwen_backend.QWEN_SPEAKERS)
+ default_speaker = config.SPEAKER if config.SPEAKER in speakers \
+ else speakers[0]
+ fields = [
+ {"key": "mode", "label": "Voice mode", "kind": "choice",
+ "value": "custom",
+ "choices": [("Built-in speaker", "custom"),
+ ("Clone from a .wav file", "clone")]},
+ {"key": "speaker", "label": "Speaker", "kind": "choice",
+ "value": default_speaker, "choices": speakers,
+ "visible": lambda fs: _field_value(fs, "mode") == "custom"},
+ {"key": "clone", "label": "Clone .wav path", "kind": "text",
+ "value": "",
+ "validate": lambda s: None if (s and Path(s).is_file()
and s.lower().endswith(".wav"))
- else "Enter the path to an existing .wav file",
- back_value=_GO_BACK)
- if clone is _GO_BACK:
- return None
- common_kw = _common_options(stdscr)
- if common_kw is None:
+ else "Enter the path to an existing .wav file",
+ "visible": lambda fs: _field_value(fs, "mode") == "clone"},
+ ]
+ fields += _common_fields()
+ result = _show_convert_form(stdscr, "Convert with qwen-tts", fields)
+ if result is None:
return None
- return ("convert", BACKEND_QWEN, {"clone": clone, **common_kw})
+ clone = result["clone"].strip() if result["mode"] == "clone" else None
+ speaker = result["speaker"]
+ if result["mode"] == "custom" and speaker != config.SPEAKER:
+ # Persist the speaker choice for this and future runs (mirrors the
+ # qwen setup wizard), so the converter picks it up at request time.
+ common.update_config_value("SPEAKER", speaker)
+ config.SPEAKER = speaker
+ return ("convert", BACKEND_QWEN, {"clone": clone,
+ **_common_kwargs(result)})
def _convert_faster(stdscr) -> Optional[tuple]:
- """Collect faster run settings: pick or type a voice name.
+ """Collect faster run settings on one form: pick or type a voice name.
With a local checkout's voices.json the picker lists it (the config of
the server this tool manages). Without one, the running server was
@@ -416,60 +493,29 @@ def _convert_faster(stdscr) -> Optional[tuple]:
return None
if voices is None:
# No local voices.json: prompt for a server-side voice name.
- voice_text = tui.line_edit(
- stdscr, "Server-side voice to clone with "
- "(blank uses the server's first voice)",
- config.FASTER_VOICE,
- validate=lambda s: None if s.strip() else "Enter a voice name",
- back_value=_GO_BACK)
- if voice_text is _GO_BACK:
- return None
- voice = voice_text.strip()
+ fields = [
+ {"key": "voice", "label": "Voice", "kind": "text",
+ "value": config.FASTER_VOICE,
+ "validate": lambda s: None if s.strip() else "Enter a voice name"},
+ ]
else:
default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \
next(iter(voices))
- voice = tui.menu(
- stdscr, "Select the voice to clone",
- [(k, k) for k in voices],
- default_index=list(voices).index(default), back_value=_GO_BACK)
- if voice is _GO_BACK or voice is None:
- return None
- common_kw = _common_options(stdscr)
- if common_kw is None:
- return None
- return ("convert", BACKEND_FASTER, {"voice": voice, **common_kw})
-
-
-def _common_options(stdscr) -> Optional[dict]:
- """Collect output format, speed, single-file, debug."""
- fmt_options = [(f, f) for f in AUDIO_FORMATS]
- fmt_default = AUDIO_FORMATS.index(config.AUDIO_FORMAT) \
- if config.AUDIO_FORMAT in AUDIO_FORMATS else 0
- output_format = tui.menu(stdscr, "Output format", fmt_options,
- default_index=fmt_default, back_value=_GO_BACK)
- if output_format is _GO_BACK or output_format is None:
- return None
- speed_text = tui.line_edit(
- stdscr, "Playback speed (1.0 = normal)", "1.0",
- validate=lambda s: None if (_is_float(s) and float(s) > 0)
- else "Enter a positive number, e.g. 1.0",
- back_value=_GO_BACK)
- if speed_text is _GO_BACK:
+ fields = [
+ {"key": "voice", "label": "Voice", "kind": "choice",
+ "value": default, "choices": [(k, k) for k in voices]},
+ ]
+ fields += _common_fields()
+ result = _show_convert_form(stdscr, "Convert with faster-qwen3-tts",
+ fields)
+ if result is None:
return None
- single_file = tui.confirm(stdscr, "Combine all chapters into one file?",
- default=False, cancel_value=_GO_BACK)
- if single_file is _GO_BACK:
- return None
- debug = tui.confirm(stdscr, "Debug mode (dump per-chunk audio/text)?",
- default=False, cancel_value=_GO_BACK)
- if debug is _GO_BACK:
- return None
- return {
- "output_format": output_format,
- "speed": float(speed_text),
- "single_file": single_file,
- "debug": debug,
- }
+ voice = result["voice"].strip() if isinstance(result["voice"], str) \
+ else result["voice"]
+ return ("convert", BACKEND_FASTER, {
+ "voice": voice or None,
+ **_common_kwargs(result),
+ })
# ---------------------------------------------------------------------------
@@ -696,11 +742,13 @@ def _maybe_stop_server(name: str) -> None:
servers.stop(name)
-def _add_autostart(stdscr, cmd: tuple, statuses) -> None:
- """Offer to auto-start the conversion's target server when it isn't running.
+def _add_autostart(cmd: tuple, statuses) -> None:
+ """Auto-start the conversion's target server when it isn't running.
Records the chosen server spec name as ``kwargs['autostart']`` for
- ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone).
+ ``_run_conversion`` to act on. The user already accepted the run on the
+ Generate! screen, so no start-server prompt is asked here — the server
+ is simply started. Mode-aware for qwen (custom vs clone).
"""
_, key, kwargs = cmd
status = next((s for s in statuses if s.key == key), None)
@@ -711,11 +759,7 @@ def _add_autostart(stdscr, cmd: tuple, statuses) -> None:
return
if common.server_running(spec.url):
return
- choice = tui.confirm(stdscr, f"The {status.label} server is not running. "
- "Start it automatically?", default=True,
- cancel_value=False)
- if choice is True:
- kwargs["autostart"] = spec.name
+ kwargs["autostart"] = spec.name
def _select_spec(status, kwargs) -> Optional[ServerSpec]: