From 477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 21:22:40 -0400 Subject: feat: design model support for qwen-tts backend. remove unnecessary port split for qwen models --- app/ui/hub.py | 211 +++++++++++++++++++++++++++++++++++------------------- app/ui/runview.py | 24 +++++-- 2 files changed, 154 insertions(+), 81 deletions(-) (limited to 'app/ui') 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 diff --git a/app/ui/runview.py b/app/ui/runview.py index 1da8690..9448c06 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -72,13 +72,17 @@ class RunConfig: conversion talks to (the name is the backends.ServerSpec name; the URL is what the monitor polls). AUTOSTART_SPEC, when not None, is the ServerSpec the worker boots first (the hub only sets it when the - server is not already running). LOG_PATH names the converter's log - file for the error screen's "details" hint. NOTICE is an optional - warning line shown under the progress panel (e.g. a foreign server - holding the managed port). STOP_AND_EXIT ("Stop server and exit after - generating") skips the finished screen entirely: the server is stopped - automatically, the TUI quits, and the results are printed to the real - terminal after curses closes. + server is not already running). RESTART_FIRST marks an autostart of a + different kind: the managed qwen server is up but hosting another + model than this run selected, so the worker stops it and boots the + spec again (with the new model's argv) before converting. LOG_PATH + names the converter's log file for the error screen's "details" hint. + NOTICE is an optional warning line shown under the progress panel + (e.g. a foreign server holding the managed port). + STOP_AND_EXIT ("Stop server and exit after generating") skips the + finished screen entirely: the server is stopped automatically, the + TUI quits, and the results are printed to the real terminal after + curses closes. """ backend: str backend_label: str @@ -89,6 +93,7 @@ class RunConfig: server_url: Optional[str] = None server_identity: Optional[str] = None autostart_spec: object = None + restart_first: bool = False log_path: str = "" notice: str = "" stop_and_exit: bool = False @@ -229,6 +234,11 @@ class RunView(ScreenView): try: with contextlib.redirect_stdout(io.StringIO()): if config.autostart_spec is not None: + if config.restart_first: + # The managed qwen server hosts another model than + # this run selected: stop it so the new model's + # argv can boot on the same port. + servers.stop(config.autostart_spec.name) ok = servers.start(config.autostart_spec, progress=self._queue.put, cancel=self._cancel) -- cgit v1.2.3