aboutsummaryrefslogtreecommitdiff
path: root/app/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/ui')
-rw-r--r--app/ui/hub.py90
-rw-r--r--app/ui/tui.py24
2 files changed, 87 insertions, 27 deletions
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 80fe843..d9d291b 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -879,17 +879,23 @@ def _field_value(fields, key: str, default=None):
def _common_fields() -> list:
- """Field dicts for output format, speed, single-file, and debug.
+ """Field dicts for output format, language, speed, single-file, 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.
+ output-format value from the field list. The per-run Language field
+ mirrors the CLI's --language and is hidden for faster entries (the
+ faster server owns the language).
"""
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": "language", "label": "Language", "kind": "text",
+ "value": config.LANGUAGE, "validate": _validate_language,
+ "visible": lambda fs: not str(_field_value(fs, "backend") or "")
+ .startswith("faster")},
{"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"},
@@ -905,7 +911,15 @@ def _common_fields() -> list:
def _common_kwargs(values: dict) -> dict:
"""Map the common form fields to converter keyword arguments."""
output_format = values["output_format"]
+ # The Language field is hidden for faster entries and may then hold
+ # stale, unvalidated text; a failed normalization falls back to None
+ # so convert() applies config.LANGUAGE instead of failing the run.
+ try:
+ language = normalize_language(values.get("language"))
+ except ValueError:
+ language = None
return {
+ "language": language,
"output_format": output_format,
"speed": float(values["speed"]),
"single_file": bool(values["single_file"])
@@ -1063,12 +1077,20 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"directory on that machine.")
def voice_validate(value):
- """Refuse Generate! when this entry's clone voice is unavailable."""
+ """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.
+ """
if model_capability(fields) != AUDIOCPP_VOICE_CLONE:
return None
+ has_instruction = bool(str(_field_value(
+ fields, prefix + "instructions") or "").strip())
if not voices_for(_field_value(fields, prefix + "model_id")):
- return no_voices_hint()
- return None if value \
+ return None if has_instruction else no_voices_hint()
+ return None if (value or has_instruction) \
else "This model needs a voice — pick one or switch models"
model_ids = [m.get("id") for m in models]
@@ -1102,38 +1124,53 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
"value": default_model,
"choices": [(_label(m), m.get("id")) for m in models],
"on_change": reset_voice},
- {"key": prefix + "audiocpp_voice", "label": "Voice", "kind": "choice",
+ # The label tracks the entry's capability: a built-in speaker on
+ # CustomVoice, otherwise the name of a server-side voice to clone.
+ {"key": prefix + "audiocpp_voice",
+ "label": lambda fs: ("Built-in voice"
+ if model_capability(fs) == AUDIOCPP_VOICE_SPEAKER
+ else "Voice to clone"),
+ "kind": "choice",
"value": initial_voice,
"choices": lambda fs: voice_choices(fs),
"visible": lambda fs: model_capability(fs) != AUDIOCPP_VOICE_DESIGN,
"on_empty_choices": no_voices_hint,
"validate": voice_validate},
+ # Style/voice-design instruction. Required for design entries (the
+ # voice comes from it); on every other entry an optional style/
+ # delivery instruction — or, on families without built-in speakers
+ # that read instructions, the voice itself (instruction-voice mode).
{"key": prefix + "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_capability(fields) != AUDIOCPP_VOICE_DESIGN or str(value).strip())
else "Describe the voice, e.g. 'A warm female narrator'"},
+ # Free-form per-model controls (--option KEY=VALUE on the CLI),
+ # e.g. "emotion=neutral, speed=1.1".
+ {"key": prefix + "request_options", "label": "Request options",
+ "kind": "text", "value": "",
+ "validate": _validate_request_options},
]
def mapper(result) -> Optional[tuple]:
model_id = result[prefix + "model_id"]
- entry = next((m for m in models if m.get("id") == model_id), {})
- capability = audiocpp_entry_voice_capability(
- entry.get("family") or "", entry.get("task") or "tts",
- entry.get("id") or "")
# The picked voice (a built-in speaker name on a CustomVoice entry,
# a server-side preset otherwise); the client resolves which it is.
voice = result[prefix + "audiocpp_voice"] or None
- # design: the voice comes from --instructions
- instructions = None
- if capability in (AUDIOCPP_VOICE_DESIGN, AUDIOCPP_VOICE_SPEAKER):
- instructions = ((result[prefix + "instructions"] or "")
- .strip() or None)
+ # The instruction is forwarded for every capability: required on
+ # design entries, optional style/delivery control elsewhere. With
+ # no voice it defines the voice on instruction-conditioned families.
+ instructions = ((result.get(prefix + "instructions")
+ or "").strip() or None)
+ try:
+ request_options = common.parse_request_options(
+ result.get(prefix + "request_options") or "")
+ except ValueError:
+ request_options = {} # submit-time validation already caught this
kwargs = {
"model_id": model_id, "voice": voice,
"instructions": instructions,
+ "request_options": request_options,
**_common_kwargs(result),
}
if api_url is not None:
@@ -1214,8 +1251,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None,
"""faster-specific fields and a result mapper for the Convert form.
Returns ``(fields, mapper)`` where FIELDS are the faster options
- (Voice, as a picker when a local voices.json lists them, else typed
- free text) and MAPPER turns a submitted form values dict into the
+ (Voice to clone, as a picker when a local voices.json lists them,
+ else typed free text) and MAPPER turns a submitted form values dict into the
faster converter kwargs. Returns None when a local voices.json
exists but cannot be read/used (a flash explains why), so the caller
drops faster from the Backend choices. PREFIX namespaces the field
@@ -1244,7 +1281,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None,
if voices is None:
# No local voices.json: prompt for a server-side voice name.
fields = [
- {"key": prefix + "faster_voice", "label": "Voice", "kind": "text",
+ {"key": prefix + "faster_voice", "label": "Voice to clone",
+ "kind": "text",
"value": config.FASTER_VOICE,
"validate": lambda s: None if s.strip() else "Enter a voice name"},
]
@@ -1252,7 +1290,8 @@ def _faster_fields(stdscr, api_url: Optional[str] = None,
default = config.FASTER_VOICE if config.FASTER_VOICE in voices else \
next(iter(voices))
fields = [
- {"key": prefix + "faster_voice", "label": "Voice", "kind": "choice",
+ {"key": prefix + "faster_voice", "label": "Voice to clone",
+ "kind": "choice",
"value": default, "choices": [(k, k) for k in voices]},
]
@@ -1376,6 +1415,15 @@ def _validate_remote_url(value: str) -> Optional[str]:
return str(exc)
+def _validate_request_options(value: str) -> Optional[str]:
+ """Error message for malformed KEY=VALUE request options, or None."""
+ try:
+ common.parse_request_options(value)
+ return None
+ except ValueError as exc:
+ return str(exc)
+
+
def _port_from_url(url: str, default: int) -> int:
"""Return the port in URL, or DEFAULT when it has none/unparsable."""
try:
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 56aa79e..f1c5419 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -891,7 +891,11 @@ def form(scr, title: str, fields: Sequence[dict],
"kind": "dir", "value": Path("./voices")}
Fields render as a two-column table: each label is padded to the
- widest label so every value starts in the same column. KINDS:
+ widest label so every value starts in the same column. A field's
+ ``label`` may be a callable of the field list (like ``visible`` and
+ ``choices``); it is re-resolved on every redraw, so a label can track
+ other fields' values, and sub-dialogs (choice menu, line editor,
+ directory browser) are titled with the resolved label. KINDS:
``choice`` opens a single choice menu (its ``choices`` may be a
callable of the field list, resolved when the menu opens); ``text``
opens a line editor (reusing its VALIDATE); ``bool`` shows Yes/No and
@@ -942,7 +946,11 @@ def form(scr, title: str, fields: Sequence[dict],
on_buttons = start_on_buttons
btn_index = 0
edit_cancel = object() # sentinel: backed out of a field editor
- label_w = max(len(field["label"]) for field in fields)
+
+ def field_label(field: dict) -> str:
+ """Resolve FIELD's label (a string, or a callable of FIELDS)."""
+ label = field["label"]
+ return str(label(fields)) if callable(label) else str(label)
def shown_fields() -> List[dict]:
result: List[dict] = []
@@ -992,13 +1000,17 @@ def form(scr, title: str, fields: Sequence[dict],
if help_lines:
frame.mark("")
field_rows: List[int] = [] # visible field index -> row index
+ # Labels can be callables, so the pad width is recomputed from the
+ # visible fields on every redraw (a dynamic label's length may vary).
+ label_w = max((len(field_label(field)) for field in shown),
+ default=0)
for field in shown:
if field.get("note"):
frame.mark("")
frame.mark(field["note"], frame.theme["dim"], align="left")
frame.mark("")
field_rows.append(len(frame.rows))
- name = f"{field['label']}:".ljust(label_w + 1)
+ name = f"{field_label(field)}:".ljust(label_w + 1)
frame.mark_segments(
[(name, frame.theme["body"]),
(" " + display_value(field), frame.theme["input"])],
@@ -1089,7 +1101,7 @@ def form(scr, title: str, fields: Sequence[dict],
values = [value for _, value in pairs]
default = values.index(field["value"]) \
if field["value"] in values else 0
- chosen = menu(scr, field["label"], pairs,
+ chosen = menu(scr, field_label(field), pairs,
default_index=default,
back_value=edit_cancel)
if chosen is not edit_cancel:
@@ -1099,7 +1111,7 @@ def form(scr, title: str, fields: Sequence[dict],
start = field["value"]
start = Path(start) if start else Path.cwd()
picked = browse_directory(
- scr, field["label"], start=start,
+ scr, field_label(field), start=start,
validate=field.get("validate"),
back_value=edit_cancel)
if picked is not edit_cancel:
@@ -1111,7 +1123,7 @@ def form(scr, title: str, fields: Sequence[dict],
on_buttons = True
btn_index = 0
else:
- edited = line_edit(scr, field["label"],
+ edited = line_edit(scr, field_label(field),
field["value"],
validate=field.get("validate"),
back_value=edit_cancel)