aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py115
1 files changed, 101 insertions, 14 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index fa05685..29d2229 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -56,6 +56,9 @@ from converter.converter import (
from converter.clients import (
AUDIOCPP_VOICE_CLONE,
AUDIOCPP_VOICE_DESIGN,
+ AUDIOCPP_VOICE_NONE,
+ AUDIOCPP_VOICE_OPTIONAL,
+ AUDIOCPP_VOICE_REQUIRED,
AUDIOCPP_VOICE_SPEAKER,
BACKEND_AUDIOCPP,
BACKEND_FASTER,
@@ -63,6 +66,7 @@ from converter.clients import (
LANGUAGE_CHOICES,
QWEN3_TTS_SPEAKERS,
audiocpp_entry_voice_capability,
+ audiocpp_family_voice_policy,
normalize_language,
)
from ui import runview, taskview, tui
@@ -1075,6 +1079,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
except (OSError, ValueError):
tui.flash(stdscr, f"Could not read {server_json}.")
return None
+ # Re-host clone-only families still carried with task "tts"
+ # (written before the hosting rule existed): their sessions fail
+ # on every request until the entry is hosted with "clon". The
+ # repair is saved to server.json; a running managed server is
+ # restarted by the autostart plan (see _add_autostart).
+ rehosted = audiocpp_backend.rehost_clone_only_entries(server_json,
+ data)
models = data.get("models") or []
if not models:
tui.flash(stdscr, "No model entries in server.json. Reconfigure "
@@ -1089,6 +1100,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
url = api_url
local = False
data = {}
+ rehosted: list = []
models = audiocpp_backend.fetch_server_models(url)
if models is None:
tui.flash(stdscr, f"Could not list models from the audio.cpp "
@@ -1161,22 +1173,39 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
entry.get("family") or "", entry.get("task") or "tts",
entry.get("id") or "")
+ def model_voice_policy(fields) -> str:
+ """The selected entry's family voice policy (required/optional/none)."""
+ return audiocpp_family_voice_policy(
+ model_entry(fields).get("family") or "")
+
def reset_voice(fields) -> None:
"""Re-point the Voice field at the newly selected model's voice.
A model switch that keeps the same voice list (two clone entries
sharing one server's voices) keeps the current pick: only a value
- the new list cannot offer is re-pointed at its default.
+ the new list cannot offer is re-pointed at its default. Families
+ that synthesize without a voice (design, pure TTS, mixed used
+ plainly) default to the blank pick.
"""
voice_field = next(f for f in fields
if f.get("key") == prefix + "audiocpp_voice")
capability = model_capability(fields)
- if capability == AUDIOCPP_VOICE_DESIGN:
+ if capability == AUDIOCPP_VOICE_DESIGN \
+ or model_voice_policy(fields) == AUDIOCPP_VOICE_NONE:
voice_field["value"] = None
return
if capability == AUDIOCPP_VOICE_SPEAKER:
voices = QWEN3_TTS_SPEAKERS
else: # clone
+ if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL:
+ # Blank is a valid pick (plain TTS): keep the current pick
+ # when the list still offers it, else fall back to blank.
+ voices = voices_for(_field_value(fields, prefix + "model_id"))
+ if not voice_field.get("value") \
+ or voice_field["value"] in voices:
+ return
+ voice_field["value"] = ""
+ return
voices = voices_for(_field_value(fields, prefix + "model_id"))
if voice_field.get("value") in voices:
return # the new list still offers the pick: keep it
@@ -1188,9 +1217,14 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
# 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(
+ voices = [(v, v) for v in voices_for(_field_value(
fields, prefix + "model_id"))]
- return [] # design: the field is hidden
+ if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL:
+ # Mixed tts+clone family: the blank pick means plain TTS
+ # (no reference voice), so it always leads the menu.
+ return [("", "(built-in)")] + voices
+ return voices
+ return [] # design or pure TTS: the field is hidden
def no_voices_hint(_fs=None) -> str:
"""Why a clone-capable entry has no selectable voices.
@@ -1209,15 +1243,20 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
def voice_validate(value):
"""Refuse Generate! when this entry's clone voice is unavailable.
- An Instructions text substitutes for the voice: on families that
- condition synthesis on instructions alone the client designs the
- voice from it (instruction-voice mode), so an empty Voice is
- accepted when an instruction is present.
+ A blank Voice is valid on mixed tts+clone families (plain TTS —
+ the model's own default voice) and, on any clone-capable entry,
+ when an Instructions text substitutes for the voice: on families
+ that condition synthesis on instructions alone the client designs
+ the voice from it (instruction-voice mode).
"""
if model_capability(fields) != AUDIOCPP_VOICE_CLONE:
return None
has_instruction = bool(str(_field_value(
fields, prefix + "instructions") or "").strip())
+ if model_voice_policy(fields) == AUDIOCPP_VOICE_OPTIONAL \
+ and not (value or "").strip():
+ # Mixed family, blank pick: plain TTS without a reference.
+ return None
if not voices_for(_field_value(fields, prefix + "model_id")):
return None if has_instruction else no_voices_hint()
return None if (value or has_instruction) \
@@ -1230,21 +1269,37 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
default_capability = audiocpp_entry_voice_capability(
default_entry.get("family") or "", default_entry.get("task") or "tts",
default_entry.get("id") or "")
+ default_policy = audiocpp_family_voice_policy(
+ default_entry.get("family") or "")
initial_voice = None
if default_capability == AUDIOCPP_VOICE_SPEAKER:
initial_voice = QWEN3_TTS_SPEAKERS[0]
elif default_capability == AUDIOCPP_VOICE_CLONE:
- initial = voices_for(default_model)
- initial_voice = initial[0] if initial else ""
+ if default_policy == AUDIOCPP_VOICE_OPTIONAL:
+ # Mixed family: the blank pick (plain TTS) is the default.
+ initial_voice = ""
+ else:
+ initial = voices_for(default_model)
+ initial_voice = initial[0] if initial else ""
# The Model picker reads as a two-column table: pad every id to the
# widest one so the (type) column starts on the same position.
id_width = max(len(entry.get("id") or "") for entry in models)
def _label(entry: dict) -> str:
+ family = entry.get("family") or ""
capability = audiocpp_entry_voice_capability(
- entry.get("family") or "", entry.get("task") or "tts",
- entry.get("id") or "")
+ family, entry.get("task") or "tts", entry.get("id") or "")
+ if capability == AUDIOCPP_VOICE_CLONE:
+ # The generic clone capability is refined by the family's
+ # voice policy: pure-TTS families need no voice at all, mixed
+ # families may run with or without one, clone-only families
+ # (and unknown families) always clone a reference.
+ capability = {
+ AUDIOCPP_VOICE_NONE: "tts",
+ AUDIOCPP_VOICE_OPTIONAL: "tts/clone",
+ AUDIOCPP_VOICE_REQUIRED: "clone",
+ }[audiocpp_family_voice_policy(family)]
return f"{entry.get('id') or '':<{id_width}} ({capability})"
def entry_supports_options(fs) -> bool:
@@ -1279,6 +1334,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"on_change": reset_voice},
# The label tracks the entry's capability: a built-in speaker on
# CustomVoice, otherwise the name of a server-side voice to clone.
+ # Hidden on design entries (the voice is described) and on
+ # pure-TTS families (no cloning, no voice at all).
{"key": prefix + "audiocpp_voice",
"label": lambda fs: ("Built-in voice"
if model_capability(fs) == AUDIOCPP_VOICE_SPEAKER
@@ -1286,7 +1343,10 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"kind": "choice",
"value": initial_voice,
"choices": lambda fs: voice_choices(fs),
- "visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN,
+ "visible": lambda fs: not (
+ model_capability(fs) == AUDIOCPP_VOICE_DESIGN
+ or (model_capability(fs) == AUDIOCPP_VOICE_CLONE
+ and model_voice_policy(fs) == AUDIOCPP_VOICE_NONE)),
"on_empty_choices": no_voices_hint,
"validate": voice_validate},
# Style/voice-design instruction. Required for design entries (the
@@ -1330,7 +1390,13 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"request_options": request_options,
**_common_kwargs(result),
}
- if api_url is not None:
+ if api_url is None:
+ # The managed entry: server.json was repaired on disk when it
+ # hosted clone-only families with task "tts" — the autostart
+ # plan restarts the running server to load the fix.
+ if rehosted:
+ kwargs["audiocpp_rehost"] = True
+ else:
kwargs["api_url"] = api_url
return ("convert", BACKEND_AUDIOCPP, kwargs)
@@ -1812,6 +1878,9 @@ def _prepare_run_config(backend: str, kwargs: dict
# 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 convert form re-hosted clone-only audio.cpp models with task
+ # "clon" in server.json (a config repair; the restart above loads it).
+ rehosted = bool(kwargs.pop("audiocpp_rehost", 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))
@@ -1833,6 +1902,11 @@ def _prepare_run_config(backend: str, kwargs: dict
status = next((s for s in detect_all(refresh=True)
if s.key == backend), None)
notice = ""
+ if rehosted:
+ notice = ('re-hosted clone-only audio.cpp model(s) with task '
+ '"clon" in server.json'
+ + ("; the managed server is restarted to load it"
+ if restart_name else ""))
spec: Optional[ServerSpec] = None
if autostart:
spec = _find_spec(autostart)
@@ -1922,6 +1996,19 @@ def _add_autostart(cmd: tuple, statuses) -> Optional[str]:
if not common.server_running(spec.url):
kwargs["autostart"] = spec.name
return None
+ if status.key == BACKEND_AUDIOCPP:
+ # The convert form repaired server.json on disk (clone-only
+ # families re-hosted with task "clon"): a running managed server
+ # still hosts the stale tasks, so stop and boot it before
+ # converting. A foreign server cannot be restarted here.
+ if kwargs.get("audiocpp_rehost"):
+ if servers.alive(spec.name):
+ kwargs["restart_server"] = spec.name
+ else:
+ return (f"a server this tool did not start is running at "
+ f"{spec.url} — stop it first so the corrected "
+ "audio.cpp configuration is loaded")
+ return None
if status.key != BACKEND_QWEN or len(status.servers) != 1:
return None
wanted = _qwen_wanted_model(kwargs)