aboutsummaryrefslogtreecommitdiff
path: root/app/ui/hub.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-26 21:22:40 -0400
committerhistoria <historiavg@proton.me>2026-08-26 21:22:40 -0400
commit477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 (patch)
tree1e21fa5af1d7a95ffc62fb03eed153056c38ae9e /app/ui/hub.py
parent65c6f737f1545ef225768af897acd20f163a4fb4 (diff)
downloadtts-audiobook-generator-477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52.tar.gz
feat: design model support for qwen-tts backend. remove unnecessary port split for qwen models
Diffstat (limited to 'app/ui/hub.py')
-rw-r--r--app/ui/hub.py211
1 files changed, 137 insertions, 74 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 0fd1b4d..10484d2 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -362,7 +362,15 @@ class _Hub:
cmd = mapper(result)
if cmd is None:
return tui.Wizard.BACK
- _add_autostart(cmd, statuses)
+ # The mapper may have persisted settings (qwen model/speaker),
+ # making the form-time statuses stale — the autostart plan
+ # must read the freshly-configured server argv/model.
+ invalidate_detect_cache()
+ statuses = detect_all(refresh=True)
+ autostart_error = _add_autostart(cmd, statuses)
+ if autostart_error:
+ tui.flash(self.stdscr, autostart_error, "err")
+ continue
try:
ok = _preflight(self.stdscr, cmd)
except _BackToForm:
@@ -451,9 +459,8 @@ class _Hub:
toggles it directly (starts a stopped server, stops a running one)
without an extra action menu. The state lives in the table, not on
the entries, because the menu's selection bar would cover inline
- colors. A backend with a single server is labelled by its name; a
- multi-server backend (qwen: CustomVoice + Base) gets one entry per
- server, suffixed with the server name.
+ colors. Each server is labelled by its backend's name; qwen hosts
+ one model at a time (config.QWEN_MODEL decides which).
"""
statuses = detect_all()
candidates = [st for st in statuses if st.installed]
@@ -735,7 +742,8 @@ def _convert_form(stdscr) -> Optional[tuple]:
The first field is the Backend picker; the remaining fields are that
backend's options (audio.cpp: model/voice/instructions; qwen:
- speaker or clone .wav; faster: voice), plus the shared output
+ model + speaker / clone .wav / design instruction; faster: voice),
+ plus the shared output
settings. A backend appears once as a managed entry ("audio.cpp") when
it is installed+configured here, and once as a remote entry
("audio.cpp [remote]") when a running server was found at its remote
@@ -825,7 +833,8 @@ def _preflight(stdscr, cmd: tuple) -> bool:
"""
_kind, backend, kwargs = cmd
voice_mode = voice_mode_for(backend, kwargs.get("voice"),
- kwargs.get("clone"))
+ kwargs.get("clone"),
+ kwargs.get("instructions"))
def confirm(message: str, default: bool) -> bool:
answer = tui.confirm(stdscr, message, default=default,
@@ -1229,35 +1238,49 @@ def _qwen_fields(remote_modes: Optional[list] = None,
prefix: str = "") -> Optional[tuple]:
"""qwen-specific fields and a result mapper for the Convert form.
- Returns ``(fields, mapper)`` where FIELDS are the qwen options
- (Voice mode / Speaker / Clone .wav path) and MAPPER turns a
- submitted form values dict into the qwen converter kwargs. qwen
- always has options to offer, so it never signals unavailability.
- PREFIX namespaces the field keys ("" for the managed entry) so two
- entries of this backend can share one form without overwriting each
- other.
-
- For the managed entry REMOTE_MODES/URLS are None and the mode picker
- offers both modes, targeting the configured local URLs. For a
- "[remote]" entry REMOTE_MODES names which demos answered remotely
- ("CustomVoice" and/or "Base") and URLS maps "qwen-custom"/"qwen-clone"
- to their URLs: the mode picker is limited to the available demos, and
- the mapper passes the matching remote URL as ``api_url``.
+ Returns ``(fields, mapper)`` where FIELDS are the qwen options — which
+ model the demo server hosts (Base (voice cloning) / CustomVoice (built-in
+ voices) / VoiceDesign (design)), plus the per-model controls: Speaker on
+ CustomVoice, Clone .wav path on Base, Instructions on VoiceDesign — and
+ MAPPER turns a submitted form values dict into the qwen converter
+ kwargs. qwen always has options to offer, so it never signals
+ unavailability. PREFIX namespaces the field keys ("" for the managed
+ entry) so two entries of this backend can share one form without
+ overwriting each other.
+
+ One demo server hosts one model at a time, so the picked model decides
+ which server must be up. For the managed entry REMOTE_MODES/URLS are
+ None: the picker offers every model and the choice (plus any changed
+ speaker) is persisted to app/converter/config.py so the autostart boots
+ the same model again later. For a "[remote]" entry REMOTE_MODES names
+ which demos answered remotely ("Base", "CustomVoice" and/or
+ "VoiceDesign") and URLS maps "qwen" to its URL: the picker is limited to
+ the available models and the mapper passes the URL as ``api_url``.
"""
remote_modes = list(remote_modes or [])
urls = dict(urls or {})
- mode_choices = []
- if urls.get("qwen-custom") or not remote_modes:
- mode_choices.append(("Built-in speaker", "custom"))
- if urls.get("qwen-clone") or not remote_modes:
- mode_choices.append(("Clone from a .wav file", "clone"))
- default_mode = mode_choices[0][1] if mode_choices else "custom"
+ mode_keys = (("custom", "CustomVoice"),
+ ("clone", "Base"), ("design", "VoiceDesign"))
+ model_choices = [
+ ("CustomVoice (built-in voices)", "custom"),
+ ("Base (voice cloning)", "clone"),
+ ("VoiceDesign (design)", "design"),
+ ]
+ if remote_modes:
+ available = set(remote_modes)
+ model_choices = [(label, value) for (label, value) in model_choices
+ if dict(mode_keys)[value] in available]
+ by_value = dict(model_choices)
+ configured_mode = dict(mode_keys).get(
+ qwen_backend.current_model(), model_choices[0][1])
+ default_mode = configured_mode if configured_mode in by_value \
+ else model_choices[0][1]
speakers = list(qwen_backend.QWEN_SPEAKERS)
default_speaker = config.SPEAKER if config.SPEAKER in speakers \
else speakers[0]
fields = [
- {"key": prefix + "mode", "label": "Voice mode", "kind": "choice",
- "value": default_mode, "choices": mode_choices},
+ {"key": prefix + "mode", "label": "Model", "kind": "choice",
+ "value": default_mode, "choices": model_choices},
{"key": prefix + "speaker", "label": "Speaker", "kind": "choice",
"value": default_speaker, "choices": speakers,
"visible": lambda fs: _field_value(fs, prefix + "mode") == "custom"},
@@ -1267,22 +1290,34 @@ def _qwen_fields(remote_modes: Optional[list] = None,
and s.lower().endswith(".wav"))
else "Enter the path to an existing .wav file",
"visible": lambda fs: _field_value(fs, prefix + "mode") == "clone"},
+ {"key": prefix + "qwen_instructions", "label": "Instructions",
+ "kind": "text", "value": config.INSTRUCT,
+ "help": ["Describe the voice to design, e.g.",
+ '"A warm adult female narrator with a British accent".'],
+ "validate": lambda s: None if s.strip() else
+ "Describe the voice to design",
+ "visible": lambda fs: _field_value(fs, prefix + "mode") == "design"},
]
def mapper(result) -> Optional[tuple]:
- clone = result[prefix + "clone"].strip() \
- if result[prefix + "mode"] == "clone" else None
- speaker = result[prefix + "speaker"]
- if result[prefix + "mode"] == "custom" and speaker != config.SPEAKER:
- # Persist the speaker choice for this and future runs (mirrors
- # the qwen setup wizard); update_config_value keeps both the
- # file and the imported module in sync.
- common.update_config_value("SPEAKER", speaker)
+ mode = result[prefix + "mode"]
+ clone = result[prefix + "clone"].strip() if mode == "clone" else None
kwargs = {"clone": clone, **_common_kwargs(result)}
+ if mode == "design":
+ kwargs["instructions"] = result[prefix + "qwen_instructions"]
+ if not urls:
+ # Managed entry: persist the choices for this and future runs,
+ # so the server autostart boots the same model again
+ # (update_config_value keeps both the file and the imported
+ # module in sync).
+ model = dict(mode_keys)[mode]
+ if model != qwen_backend.current_model():
+ common.update_config_value("QWEN_MODEL", model)
+ speaker = result.get(prefix + "speaker", config.SPEAKER)
+ if mode == "custom" and speaker != config.SPEAKER:
+ common.update_config_value("SPEAKER", speaker)
if urls:
- api_url = urls.get("qwen-clone") \
- if result[prefix + "mode"] == "clone" \
- else urls.get("qwen-custom")
+ api_url = urls.get("qwen")
if api_url:
kwargs["api_url"] = api_url
return ("convert", BACKEND_QWEN, kwargs)
@@ -1403,14 +1438,10 @@ def _settings_fields() -> list:
"kind": "text",
"value": str(_port_from_url(config.FASTER_API_URL, 8000)),
"validate": _validate_port},
- {"key": "qwen_custom_port", "label": "qwen-tts CustomVoice port",
+ {"key": "qwen_port", "label": "qwen-tts port",
"kind": "text",
"value": str(_port_from_url(config.QWEN_API_URL, 7860)),
"validate": _validate_port},
- {"key": "qwen_clone_port", "label": "qwen-tts Base port",
- "kind": "text",
- "value": str(_port_from_url(config.CLONE_API_URL, 7861)),
- "validate": _validate_port},
{"key": "audiocpp_remote_url", "label": "audio.cpp remote URL",
"kind": "text",
"value": config.AUDIOCPP_REMOTE_URL,
@@ -1420,14 +1451,10 @@ def _settings_fields() -> list:
"kind": "text",
"value": config.FASTER_REMOTE_URL,
"validate": _validate_remote_url},
- {"key": "qwen_custom_remote_url", "label": "qwen-tts CustomVoice remote URL",
+ {"key": "qwen_remote_url", "label": "qwen-tts remote URL",
"kind": "text",
"value": config.QWEN_REMOTE_URL,
"validate": _validate_remote_url},
- {"key": "qwen_clone_remote_url", "label": "qwen-tts Base remote URL",
- "kind": "text",
- "value": config.CLONE_REMOTE_URL,
- "validate": _validate_remote_url},
]
@@ -1507,16 +1534,13 @@ def _apply_settings(values: dict) -> None:
raise ValueError(f"Unsupported audio format: {values['audio_format']}")
ports = {
- "qwen_custom_port": _read_port(values, "qwen_custom_port"),
- "qwen_clone_port": _read_port(values, "qwen_clone_port"),
+ "qwen_port": _read_port(values, "qwen_port"),
"faster_port": _read_port(values, "faster_port"),
"audiocpp_port": _read_port(values, "audiocpp_port"),
}
remote_urls = {
"QWEN_REMOTE_URL": common.normalize_remote_url(
- values.get("qwen_custom_remote_url", "")),
- "CLONE_REMOTE_URL": common.normalize_remote_url(
- values.get("qwen_clone_remote_url", "")),
+ values.get("qwen_remote_url", "")),
"FASTER_REMOTE_URL": common.normalize_remote_url(
values.get("faster_remote_url", "")),
"AUDIOCPP_REMOTE_URL": common.normalize_remote_url(
@@ -1530,9 +1554,7 @@ def _apply_settings(values: dict) -> None:
"STOP_SERVER_AND_EXIT": bool(values["stop_and_exit"]),
"AUDIOCPP_UNLOAD_MODELS": bool(values["unload_models"]),
"QWEN_API_URL": common.url_with_port(
- config.QWEN_API_URL, ports["qwen_custom_port"]),
- "CLONE_API_URL": common.url_with_port(
- config.CLONE_API_URL, ports["qwen_clone_port"]),
+ config.QWEN_API_URL, ports["qwen_port"]),
"FASTER_API_URL": common.url_with_port(
config.FASTER_API_URL, ports["faster_port"]),
"AUDIOCPP_API_URL": common.url_with_port(
@@ -1607,6 +1629,9 @@ def _prepare_run_config(backend: str, kwargs: dict
LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
Path(log_path).touch()
autostart = kwargs.pop("autostart", None)
+ # A running managed qwen server hosting another model than the run's
+ # selection: stop it and boot the new model before converting.
+ restart_name = kwargs.pop("restart_server", None)
# The run-view behavior toggle (not a converter kwarg): stop the server
# and quit the TUI once the generation ends.
stop_and_exit = bool(kwargs.pop("stop_and_exit", False))
@@ -1641,57 +1666,95 @@ def _prepare_run_config(backend: str, kwargs: dict
# The recorded server vanished (backend reconfigured meanwhile):
# converting without it is still meaningful, so continue.
notice = (f"no server named '{autostart}' — starting it was skipped")
+ if restart_name and spec is None:
+ spec = _find_spec(restart_name)
+ if spec is None:
+ notice = (f"no server named '{restart_name}' — the model "
+ "switch restart was skipped")
return runview.RunConfig(
backend=backend, backend_label=label, kwargs=kwargs,
book_files=book_files, planned=planned,
server_name=spec.name if spec is not None else None,
server_url=spec.url if spec is not None else None,
server_identity=spec.identity if spec is not None else None,
- autostart_spec=spec if autostart else None,
+ autostart_spec=spec if (autostart or restart_name) else None,
+ restart_first=bool(restart_name) and spec is not None,
log_path=log_path, notice=notice, stop_and_exit=stop_and_exit)
+def _qwen_wanted_model(kwargs: dict) -> str:
+ """The qwen model a conversion with these kwargs needs hosted.
+
+ One demo server hosts one model; the selected voice mode picks it
+ (mirrors ``voice_mode_for``): instructions design the voice (VoiceDesign),
+ a reference .wav clones (Base), otherwise built-in speakers (CustomVoice).
+ """
+ if (kwargs.get("instructions") or "").strip():
+ return "VoiceDesign"
+ return "Base" if kwargs.get("clone") else "CustomVoice"
+
+
def _remote_identity(backend: str, kwargs: dict) -> Optional[str]:
"""The probe identity of the remote server a conversion targets."""
if backend == BACKEND_AUDIOCPP:
return backend_probe.IDENTITY_AUDIOCPP
if backend == BACKEND_QWEN:
- return backend_probe.IDENTITY_QWEN_CLONE if kwargs.get("clone") \
- else backend_probe.IDENTITY_QWEN_CUSTOM
+ wanted = _qwen_wanted_model(kwargs)
+ return {"CustomVoice": backend_probe.IDENTITY_QWEN_CUSTOM,
+ "Base": backend_probe.IDENTITY_QWEN_CLONE,
+ "VoiceDesign": backend_probe.IDENTITY_QWEN_DESIGN}[wanted]
if backend == BACKEND_FASTER:
return backend_probe.IDENTITY_FASTER
return None
-def _add_autostart(cmd: tuple, statuses) -> None:
+def _add_autostart(cmd: tuple, statuses) -> Optional[str]:
"""Auto-start the conversion's target server when it isn't running.
Records the chosen server spec name as ``kwargs['autostart']`` for
``_prepare_run_config`` 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).
- Remote conversions (an ``api_url`` in the kwargs) never autostart: the
- server is external to this tool.
+ server is simply started. Remote conversions (an ``api_url`` in the
+ kwargs) never autostart: the server is external to this tool.
+
+ The single-port qwen backend additionally checks the model the running
+ server hosts against the one this run selected: a managed server hosting
+ another model is recorded in ``kwargs['restart_server']`` (stopped and
+ rebooted with the new model before converting), while a foreign server
+ with the wrong model refuses the run — an explanatory message is
+ returned for the caller to flash. Returns None when no message is owed.
"""
_, key, kwargs = cmd
if kwargs.get("api_url"):
- return
+ return None
status = next((s for s in statuses if s.key == key), None)
if status is None or not status.servers:
- return
+ return None
spec = _select_spec(status, kwargs)
if spec is None:
- return
- if common.server_running(spec.url):
- return
- kwargs["autostart"] = spec.name
+ return None
+ if not common.server_running(spec.url):
+ kwargs["autostart"] = spec.name
+ return None
+ if status.key != BACKEND_QWEN or len(status.servers) != 1:
+ return None
+ wanted = _qwen_wanted_model(kwargs)
+ running = qwen_backend.model_for_identity(
+ backend_probe.identify_server(spec.url))
+ if running == wanted:
+ return None
+ if servers.alive(spec.name):
+ # Ours: the run view stops it and boots the newly-selected model.
+ kwargs["restart_server"] = spec.name
+ return None
+ return (f"a server this tool did not start is running at {spec.url} "
+ f"hosting {running or 'an unknown'} — this run needs "
+ f"{wanted}. Stop that server first, or convert with it by "
+ f"picking {running} as the Model.")
def _select_spec(status, kwargs) -> Optional[ServerSpec]:
- """The server spec this conversion needs (mode-aware for qwen)."""
- if status.key == BACKEND_QWEN:
- wanted = "qwen-clone" if kwargs.get("clone") else "qwen-custom"
- return next((s for s in status.servers if s.name == wanted), None)
+ """The server spec this conversion needs (qwen has exactly one)."""
return status.servers[0] if status.servers else None