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/hub.py | |
| 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/hub.py')
| -rw-r--r-- | app/ui/hub.py | 90 |
1 files changed, 69 insertions, 21 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: |
