aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/converter/clients/__init__.py4
-rw-r--r--app/converter/clients/languages.py30
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/tests/test_hub.py32
-rw-r--r--app/tests/test_tts.py25
-rw-r--r--app/tests/test_tui.py24
-rw-r--r--app/ui/hub.py22
-rw-r--r--app/ui/tui.py1
8 files changed, 128 insertions, 12 deletions
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py
index 6ae8850..1fcfd1a 100644
--- a/app/converter/clients/__init__.py
+++ b/app/converter/clients/__init__.py
@@ -16,7 +16,7 @@ BACKENDS = (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER)
from .base import BaseTTSClient, ConversionCancelled, VOICE_MODE_CLONE, \
VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN, VOICE_MODES, resolve_request_seed
-from .languages import LANGUAGE_ISO_CODES, TTS_LANGUAGES, \
+from .languages import LANGUAGE_CHOICES, LANGUAGE_ISO_CODES, TTS_LANGUAGES, \
TTS_LANGUAGE_ALIASES, normalize_language
from .speakers import QWEN3_TTS_SPEAKERS, SPEAKER_DISPLAY_NAMES, \
is_builtin_speaker, speaker_display_name, speaker_display_name_for
@@ -52,7 +52,7 @@ __all__ = [
"MODEL_SIZE", "CUSTOM_VOICE_MODEL_ID", "SAMPLE_RATE",
# languages
"TTS_LANGUAGES", "TTS_LANGUAGE_ALIASES", "LANGUAGE_ISO_CODES",
- "normalize_language",
+ "LANGUAGE_CHOICES", "normalize_language",
# speakers
"QWEN3_TTS_SPEAKERS", "SPEAKER_DISPLAY_NAMES",
"speaker_display_name", "speaker_display_name_for", "is_builtin_speaker",
diff --git a/app/converter/clients/languages.py b/app/converter/clients/languages.py
index 079dead..c73dc7c 100644
--- a/app/converter/clients/languages.py
+++ b/app/converter/clients/languages.py
@@ -4,14 +4,18 @@ from typing import Optional
# Languages the Qwen3-TTS demo accepts as display names (its API silently
# falls back to "Auto" for anything else, so unknown names are rejected
-# before a run starts instead of mispronouncing a whole book).
+# before a run starts instead of mispronouncing a whole book). Arabic,
+# Hindi and Vietnamese come from the audio.cpp WebUI language menus
+# (MagpieTTS / IndexTTS2); on Qwen they degrade to Auto.
TTS_LANGUAGES = (
"Auto", "Chinese", "English", "German", "Italian", "Portuguese",
- "Spanish", "Japanese", "Korean", "French", "Russian",
+ "Spanish", "Japanese", "Korean", "French", "Russian", "Arabic",
+ "Hindi", "Vietnamese",
)
# Short aliases accepted on the command line (ISO 639-1 codes and common
-# shorthands), mapped to the display names above.
+# shorthands), mapped to the display names above. The Arabic regional
+# variants collapse to plain Arabic.
TTS_LANGUAGE_ALIASES = {
"zh": "Chinese",
"en": "English",
@@ -23,11 +27,17 @@ TTS_LANGUAGE_ALIASES = {
"ko": "Korean",
"fr": "French",
"ru": "Russian",
+ "ar": "Arabic",
+ "hi": "Hindi",
+ "vi": "Vietnamese",
"zh-cn": "Chinese",
"zh-tw": "Chinese",
"pt-br": "Portuguese",
"en-us": "English",
"en-gb": "English",
+ "ar-ae": "Arabic",
+ "ar-msa": "Arabic",
+ "ar-sa": "Arabic",
}
# Qwen display names -> ISO 639-1 codes, for audio.cpp families whose
@@ -45,8 +55,22 @@ LANGUAGE_ISO_CODES = {
"Korean": "ko",
"French": "fr",
"Russian": "ru",
+ "Arabic": "ar",
+ "Hindi": "hi",
+ "Vietnamese": "vi",
}
+# Static selection-list order for the TUI Language menus (Settings and
+# Generate audiobooks): the languages of audio.cpp's WebUI language menus
+# (MagpieTTS + IndexTTS2) plus the shared display names, with common
+# languages near the top. A static list — new audio.cpp menu languages
+# need this tuple updated by hand.
+LANGUAGE_CHOICES = (
+ "English", "Spanish", "Chinese", "French", "German", "Italian",
+ "Portuguese", "Japanese", "Korean", "Russian", "Arabic", "Hindi",
+ "Vietnamese", "Auto",
+)
+
def normalize_language(value: Optional[str]) -> str:
"""Normalize a user-provided language name to a Qwen3-TTS display name.
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index 1d76648..41da554 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -98,7 +98,7 @@ python audiobook.py --backend audiocpp --model <id> --voice narrator \
--option emotion=neutral --option speed=1.1
```
-In the hub's **Generate audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language overrides the global setting for this run only.
+In the hub's **Generate audiobooks** form the Model picker shows each entry's voice capability (`speaker` / `clone` / `design`). The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** everywhere else (listing the server's preset/voice_dir entries). Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only.
The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config).
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 439be26..7f16798 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -2186,6 +2186,35 @@ class SettingsTests(unittest.TestCase):
self.assertIsNotNone(hub._validate_port("70000"))
self.assertIsNotNone(hub._validate_port("abc"))
+ def test_language_fields_are_pickers_with_edit_hint(self):
+ # Both Language fields (Settings and Generate audiobooks) are
+ # static pickers over the audio.cpp-menu languages, with a dim
+ # hint inside their edit dialog. Common languages lead.
+ expected_choices = ["English", "Spanish", "Chinese", "French",
+ "German", "Italian", "Portuguese", "Japanese",
+ "Korean", "Russian", "Arabic", "Hindi",
+ "Vietnamese", "Auto"]
+ hint = hub._LANGUAGE_EDIT_HINT
+ self.assertEqual(hint,
+ ["Check model documentation for supported "
+ "languages."])
+
+ fields = hub._settings_fields()
+ field = next(f for f in fields if f["key"] == "language")
+ self.assertEqual(field["label"], "Default Language")
+ self.assertEqual(field["kind"], "choice")
+ self.assertEqual(field["choices"], expected_choices)
+ self.assertEqual(field["help"], hint)
+ self.assertEqual(field["value"], hub.config.LANGUAGE)
+
+ for gen_fields in (hub._common_fields(),):
+ field = next(f for f in gen_fields
+ if f["key"] == "language")
+ self.assertEqual(field["label"], "Language")
+ self.assertEqual(field["kind"], "choice")
+ self.assertEqual(field["choices"], expected_choices)
+ self.assertEqual(field["help"], hint)
+
def test_settings_menu_builds_form_and_saves(self):
captured = {}
@@ -2228,6 +2257,9 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(labels["audiocpp_remote_url"],
"audio.cpp remote URL")
self.assertNotIn("(clone)", " ".join(labels.values()))
+ # The language setting is a picker labelled "Default Language".
+ self.assertEqual(labels["language"], "Default Language")
+ self.assertEqual(kinds["language"], "choice")
# The ports section note hangs off the first port field, the remote
# section note off the first remote URL field.
notes = {f["key"]: f.get("note") for f in captured["fields"]}
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 49eb6f6..cf67c6c 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -25,6 +25,7 @@ from converter.clients import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ LANGUAGE_CHOICES,
LANGUAGE_ISO_CODES,
MODEL_SIZE,
SAMPLE_RATE,
@@ -66,6 +67,25 @@ class NormalizeLanguageTests(unittest.TestCase):
self.assertEqual(normalize_language("pt"), "Portuguese")
self.assertEqual(normalize_language("es"), "Spanish")
self.assertEqual(normalize_language("it"), "Italian")
+ self.assertEqual(normalize_language("ar"), "Arabic")
+ self.assertEqual(normalize_language("hi"), "Hindi")
+ self.assertEqual(normalize_language("vi"), "Vietnamese")
+
+ def test_audio_cpp_menu_languages_accepted(self):
+ # audio.cpp's WebUI menus add Arabic, Hindi and Vietnamese; the
+ # MagpieTTS Arabic regional variants collapse to plain Arabic.
+ self.assertEqual(normalize_language("arabic"), "Arabic")
+ self.assertEqual(normalize_language("Hindi"), "Hindi")
+ self.assertEqual(normalize_language("vietnamese"), "Vietnamese")
+ for variant in ("ar-AE", "ar-MSA", "ar-SA"):
+ self.assertEqual(normalize_language(variant), "Arabic")
+
+ def test_language_choices_are_valid_display_names(self):
+ # The TUI's static picker lists a permutation of TTS_LANGUAGES
+ # (common languages first), so every entry normalizes.
+ self.assertEqual(sorted(LANGUAGE_CHOICES), sorted(TTS_LANGUAGES))
+ for name in LANGUAGE_CHOICES:
+ self.assertEqual(normalize_language(name), name)
def test_all_supported_languages_round_trip(self):
for name in TTS_LANGUAGES:
@@ -1105,6 +1125,11 @@ class AudioCppFamilyDetectionTests(unittest.TestCase):
self.assertEqual(LANGUAGE_ISO_CODES["English"], "en")
self.assertIsNone(LANGUAGE_ISO_CODES.get("Auto"))
+ def test_iso_codes_cover_the_audio_cpp_menu_languages(self):
+ self.assertEqual(LANGUAGE_ISO_CODES["Arabic"], "ar")
+ self.assertEqual(LANGUAGE_ISO_CODES["Hindi"], "hi")
+ self.assertEqual(LANGUAGE_ISO_CODES["Vietnamese"], "vi")
+
class AudiocppEntryVoiceCapabilityTests(unittest.TestCase):
"""The per-entry voice capability resolver (speaker/clone/design)."""
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index c836178..eb6872a 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -688,6 +688,30 @@ class FormTests(TuiTestCase):
self.assertIn("Value: ''", texts)
self.assert_inside_border(screen)
+ def test_choice_menu_shows_the_fields_help_lines(self):
+ # A choice field's "help" renders as dim lines inside its
+ # selection menu (like the text editor's hints).
+ fields = [
+ {"key": "fmt", "label": "Format", "kind": "choice",
+ "value": "mp3", "choices": ["mp3", "ogg"],
+ "help": ["Check the format docs for supported codecs."]},
+ {"key": "chunk", "label": "Chunk", "kind": "text",
+ "value": "250"},
+ ]
+ # Enter opens the choice menu, Esc backs out; Tab -> Save, Enter.
+ screen = FakeScreen(keys=[10, 27, 9, 10])
+ tui.form(screen, "Settings", fields)
+ texts = [text for _, _, text, _ in screen.strings]
+ self.assertIn("Check the format docs for supported codecs.", texts)
+ self.assert_inside_border(screen)
+
+ # Without the hint nothing renders it.
+ fields[0].pop("help")
+ screen = FakeScreen(keys=[10, 27, 9, 10])
+ tui.form(screen, "Settings", fields)
+ texts = [text for _, _, text, _ in screen.strings]
+ self.assertNotIn("Check the format docs for supported codecs.", texts)
+
def test_field_note_renders_and_save(self):
fields = self._fields()
fields[1]["note"] = "A short section note"
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 230def3..7ac938e 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -58,6 +58,7 @@ from converter.clients import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ LANGUAGE_CHOICES,
QWEN3_TTS_SPEAKERS,
audiocpp_entry_voice_capability,
normalize_language,
@@ -908,22 +909,29 @@ def _field_value(fields, key: str, default=None):
return default
+# Dim hint shown while editing a Language field (Settings and Generate
+# audiobooks): which languages a model accepts varies by backend/model.
+_LANGUAGE_EDIT_HINT = ["Check model documentation for supported languages."]
+
+
def _common_fields() -> list:
"""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. The per-run Language field
- mirrors the CLI's --language and is hidden for faster entries (the
- faster server owns the language).
+ mirrors the CLI's --language (a static audio.cpp-menu picker) 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,
+ {"key": "language", "label": "Language", "kind": "choice",
+ "value": config.LANGUAGE, "choices": list(LANGUAGE_CHOICES),
+ "help": _LANGUAGE_EDIT_HINT,
+ "validate": _validate_language,
"visible": lambda fs: not str(_field_value(fs, "backend") or "")
.startswith("faster")},
{"key": "speed", "label": "Speed", "kind": "text", "value": "1.0",
@@ -1437,8 +1445,10 @@ def _settings_fields() -> list:
{"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text",
"value": config.AUDIO_BITRATE,
"validate": _validate_bitrate},
- {"key": "language", "label": "Language", "kind": "text",
- "value": config.LANGUAGE, "validate": _validate_language},
+ {"key": "language", "label": "Default Language", "kind": "choice",
+ "value": config.LANGUAGE, "choices": list(LANGUAGE_CHOICES),
+ "help": _LANGUAGE_EDIT_HINT,
+ "validate": _validate_language},
{"key": "chunk_size", "label": "Chunk size (words)", "kind": "text",
"value": str(config.CHUNK_SIZE), "validate": _validate_chunk_size},
{"key": "stop_and_exit", "label": "Default stop server and exit",
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 319c123..a647444 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -1114,6 +1114,7 @@ def form(scr, title: str, fields: Sequence[dict],
if field["value"] in values else 0
chosen = menu(scr, field_label(field), pairs,
default_index=default,
+ help_lines=field_help(field),
back_value=edit_cancel)
if chosen is not edit_cancel:
field["value"] = chosen