diff options
| author | historia <historiavg@proton.me> | 2026-08-26 19:58:45 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-26 19:58:45 -0400 |
| commit | 975bd960fd07e75799b8e3adc4c0033046b34792 (patch) | |
| tree | 6d5a13c0ad6eb3a6afeb5659bd9e9e21e8c215bc /app/ui | |
| parent | 5af37bbd575eec89c6ac2fecf2d8c2eda4c1728d (diff) | |
| download | tts-audiobook-generator-975bd960fd07e75799b8e3adc4c0033046b34792.tar.gz | |
feat: language and option fields in tui, context-sensitive voice label
Diffstat (limited to 'app/ui')
| -rw-r--r-- | app/ui/hub.py | 90 | ||||
| -rw-r--r-- | app/ui/tui.py | 24 |
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) |
