diff options
| author | historia <historiavg@proton.me> | 2026-08-27 18:41:10 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-27 18:41:10 -0400 |
| commit | b6795046bf20023fd7b2e083ead20e7362f0c0d7 (patch) | |
| tree | 20c8fd1cde5ed7fbb2c630e8250af098f547be05 /app | |
| parent | 0047a875d0a969005f3cea3df18de909d285f910 (diff) | |
| download | tts-audiobook-generator-b6795046bf20023fd7b2e083ead20e7362f0c0d7.tar.gz | |
feat: simply generate audiobooks menu, move more config to settings menu
Diffstat (limited to 'app')
| -rw-r--r-- | app/converter/config.py | 21 | ||||
| -rw-r--r-- | app/converter/converter.py | 19 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 224 | ||||
| -rw-r--r-- | app/ui/hub.py | 131 |
4 files changed, 272 insertions, 123 deletions
diff --git a/app/converter/config.py b/app/converter/config.py index 7424a76..9a07353 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -10,8 +10,25 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" in console logs every N # Words per TTS generation request (client-side chunking). CHUNK_SIZE = 250 -# Default for "Stop server and exit" on the Generate Audiobooks form -# (TUI Settings menu: "Default stop server and exit"). +# Where books are read from and where finished audiobooks are written. +# Relative paths resolve against the project root (the folder containing +# audiobook.py). The --input/--output CLI flags override these per run. +INPUT_DIR = "./input" +OUTPUT_DIR = "./output" + +# Playback speed factor for the final audiobook (1.0 = normal). +# Pitch-preserving. The --speed CLI flag overrides this per run. +SPEED = 1.0 + +# Dump each chunk's raw audio and the exact text sent for it under the +# debug/ folder (organized per book and chapter), and log every TTS +# request and response to the console and log file. The --debug CLI flag +# forces this on for a single run. +DEBUG = False + +# Default for "Stop server and exit" (TUI Settings menu: "Stop server +# and exit"): automatically stop the TTS server and exit the TUI after +# generating audiobooks. STOP_SERVER_AND_EXIT = True # Default TTS backend. diff --git a/app/converter/converter.py b/app/converter/converter.py index b356448..d2d06b9 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -36,13 +36,26 @@ from .clients import ( logger = logging.getLogger(__name__) # Folders, resolved from the project root so the converter runs from any -# working directory. User-facing dirs (input/, output/) stay at the root; +# working directory. User-facing dirs (input/, output/) come from config +# (INPUT_DIR/OUTPUT_DIR; relative paths resolve against the project root); # scratch/log dirs live under the app/ container. BASE_DIR = Path(__file__).resolve().parent.parent.parent APP_DIR = BASE_DIR / "app" -BOOKS_FOLDER = BASE_DIR / "input" -AUDIOBOOKS_FOLDER = BASE_DIR / "output" + +def resolve_dir(value, default: str) -> Path: + """Resolve a configured directory VALUE (str/Path) to a Path. + + Blank values fall back to DEFAULT ("input"/"output"); relative + paths resolve against the project root so the converter runs from + any working directory, and "~" expands to the home directory. + """ + path = Path(str(value or "").strip() or default).expanduser() + return path if path.is_absolute() else BASE_DIR / path + + +BOOKS_FOLDER = resolve_dir(config.INPUT_DIR, "input") +AUDIOBOOKS_FOLDER = resolve_dir(config.OUTPUT_DIR, "output") CHUNKS_FOLDER = APP_DIR / "chunks" # Per-chunk scratch audio, cleaned per book LOGS_FOLDER = APP_DIR / "logs" DEBUG_FOLDER = APP_DIR / "debug" # --debug dumps, kept across runs diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index fd22a4e..3550f5b 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -370,10 +370,14 @@ class HubMenuTests(unittest.TestCase): self.assertIn("6. Generated audiobooks (m4b, mp3, etc.) will " "output here:", text) # The three folder paths are their own indented, white-bold - # ("input") rows; the numbered steps start at the margin. - self.assertIn(([(str(hub.BOOKS_FOLDER), "input")], 1), items) + # ("input") rows; the numbered steps start at the margin. They + # resolve live from the converter module, so a Settings change + # this session is reflected. + self.assertIn(([(str(hub.converter_mod.BOOKS_FOLDER), "input")], 1), + items) self.assertIn(([(str(hub.common.VOICES_DIR), "input")], 1), items) - self.assertIn(([(str(hub.AUDIOBOOKS_FOLDER), "input")], 1), items) + self.assertIn(([(str(hub.converter_mod.AUDIOBOOKS_FOLDER), "input")], + 1), items) self.assertEqual(items[0][1], 0) @@ -812,16 +816,16 @@ class ConvertFlowTests(unittest.TestCase): # Keys shared by every backend entry; a "-remote" backend's other # option keys are namespaced under "<entry>." in the form dict # (mirroring hub.py), so _form_values maps them automatically. - _COMMON_KEYS = frozenset(("backend", "output_format", "language", - "speed", "single_file", "debug", - "stop_and_exit")) + _COMMON_KEYS = frozenset(("backend", "single_file")) def _form_values(self, **overrides): - """A fully-populated form result, with sensible defaults.""" - values = {"output_format": "m4b", "language": "English", - "speed": "1.0", - "single_file": False, "debug": False, - "stop_and_exit": True} + """A fully-populated form result, with sensible defaults. + + Output format, language, speed, debug, and stop-and-exit are no + longer form fields: they live in config.py (Settings) and reach + the run kwargs via _common_kwargs(). + """ + values = {"single_file": False} values.update(overrides) backend = values.get("backend") or "" if backend.endswith("-remote"): @@ -897,13 +901,16 @@ class ConvertFlowTests(unittest.TestCase): self._patch_remote( [{"id": "higgs", "family": "higgs_audio_tts", "task": "tts"}], voices=["narrator"]) - # Pin the seeded toggle so this test does not depend on the user's - # saved STOP_SERVER_AND_EXIT value in config.py. + # Pin the config settings the common kwargs are built from, so + # this test does not depend on the user's saved config.py values. with patch.object(hub.config, "STOP_SERVER_AND_EXIT", True), \ + patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \ + patch.object(hub.config, "LANGUAGE", "English"), \ + patch.object(hub.config, "SPEED", 1.25), \ + patch.object(hub.config, "DEBUG", False), \ patch.object(hub.config, "AUDIOCPP_INSTRUCTIONS", ""): self._answer_form(backend="audiocpp-remote", model_id="higgs", - audiocpp_voice="narrator", instructions="", - speed="1.5") + audiocpp_voice="narrator", instructions="") cmd = self._convert( None, [self._remote("audiocpp", "audio.cpp")]) self.assertEqual(cmd[0], "convert") @@ -913,10 +920,14 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(kwargs["voice"], "narrator") self.assertIsNone(kwargs["instructions"]) self.assertEqual(kwargs["api_url"], "http://audiocpp.local:8080") + # The output settings come from config (the Settings menu), not + # the form. self.assertEqual(kwargs["output_format"], "m4b") - self.assertEqual(kwargs["speed"], 1.5) + self.assertEqual(kwargs["language"], "English") + self.assertEqual(kwargs["speed"], 1.25) self.assertFalse(kwargs["single_file"]) self.assertFalse(kwargs["debug"]) + self.assertTrue(kwargs["stop_and_exit"]) # One form, not a cascade of menus/editors. self.assertEqual(len(self.tui.forms_seen), 1) title, fields, form_kwargs = self.tui.forms_seen[0] @@ -926,15 +937,9 @@ class ConvertFlowTests(unittest.TestCase): "audiocpp-remote.audiocpp_voice", "audiocpp-remote.instructions", "audiocpp-remote.request_options", - "output_format", "language", - "speed", "single_file", "debug", "stop_and_exit"]) + "single_file"]) self.assertEqual(form_kwargs["buttons"], ("Generate!", "Cancel")) self.assertTrue(form_kwargs["start_on_buttons"]) - # The stop-and-exit toggle ships on by default. - stop_field = self._field("stop_and_exit") - self.assertEqual(stop_field["kind"], "bool") - self.assertTrue(stop_field["value"]) - self.assertTrue(cmd[2]["stop_and_exit"]) # The backend field offers the remote entry under a [remote] label. self.assertEqual(fields[0]["choices"], [("audio.cpp [remote]", "audiocpp-remote")]) @@ -1353,14 +1358,17 @@ class ConvertFlowTests(unittest.TestCase): self.assertIn("KEY=VALUE", help_text) self.assertIn("emotion=neutral", help_text) - def test_language_passes_through_normalized(self): + def test_language_passes_through_from_config(self): + # The Settings Language setting travels on the run kwargs as-is; + # the converter normalizes it (short codes included). with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.config, "LANGUAGE", "en"): self._answer_form(backend="qwen", mode="custom", - speaker="Vivian", clone="", language="en") + speaker="Vivian", clone="") cmd = self._convert(None, [self._ready("qwen", "qwen-tts")]) - self.assertEqual(cmd[2]["language"], "English") + self.assertEqual(cmd[2]["language"], "en") def test_audiocpp_remote_unreachable_models_flash_and_abort(self): self._patch_remote(None) # endpoint did not answer valid JSON @@ -1472,9 +1480,7 @@ class ConvertFlowTests(unittest.TestCase): voices=["narrator"]) statuses = [self._ready("audiocpp", "audio.cpp"), self._remote("audiocpp", "audio.cpp")] - common = {"output_format": "m4b", "speed": "1.0", - "single_file": False, "debug": False, - "stop_and_exit": True} + common = {"single_file": False} with tempfile.TemporaryDirectory() as td: root = Path(td) (root / "server.json").write_text(json.dumps({ @@ -1523,17 +1529,18 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:8080") # ------------------------------------------------------------------ - # common fields: output format, speed, single-file, debug + # common fields: the per-run combine-all-chapters toggle # ------------------------------------------------------------------ def test_common_fields_hide_combine_for_m4b(self): + # The toggle's visibility tracks the configured output format + # (the Settings menu's Audio format), not a form field. fields = hub._common_fields() - fmt = next(f for f in fields if f["key"] == "output_format") single = next(f for f in fields if f["key"] == "single_file") - fmt["value"] = "m4b" - self.assertFalse(single["visible"](fields)) - fmt["value"] = "mp3" - self.assertTrue(single["visible"](fields)) + with patch.object(hub.config, "AUDIO_FORMAT", "m4b"): + self.assertFalse(single["visible"](fields)) + with patch.object(hub.config, "AUDIO_FORMAT", "mp3"): + self.assertTrue(single["visible"](fields)) # ------------------------------------------------------------------ # qwen: model picker (Base / CustomVoice / VoiceDesign) @@ -1566,10 +1573,7 @@ class ConvertFlowTests(unittest.TestCase): fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone", - "qwen_instructions", - "output_format", "language", "speed", - "single_file", "debug", - "stop_and_exit"]) + "qwen_instructions", "single_file"]) mode_field = self._field("mode") self.assertEqual(mode_field["choices"], [("CustomVoice (built-in voices)", "custom"), @@ -1654,9 +1658,6 @@ class ConvertFlowTests(unittest.TestCase): # faster voices are server-side clone references. self.assertEqual(self._field("faster_voice")["label"], "Voice to clone") - # Language is server-owned on faster: the per-run field is hidden. - fields = self.tui.forms_seen[0][1] - self.assertFalse(self._field("language")["visible"](fields)) def test_faster_local_still_lists_voices_json(self): with tempfile.TemporaryDirectory() as td: @@ -1704,16 +1705,15 @@ class ConvertFlowTests(unittest.TestCase): self.assertIs(self.nav, hub.tui.Wizard.BACK) def test_settings_default_feeds_the_stop_and_exit_toggle(self): - # The Settings menu's "Default stop server and exit" value seeds the - # generate-form toggle (and so the run's stop-and-exit behavior). + # The Settings menu's "Stop server and exit" value decides the + # run's stop-and-exit behavior (it travels on the run kwargs). st = self._remote("faster", "faster-qwen3-tts", url="http://10.0.0.5:8000") with patch.object(hub.config, "STOP_SERVER_AND_EXIT", False): self._answer_form(backend="faster-remote", - faster_voice="obama", stop_and_exit=False) + faster_voice="obama") cmd = self._convert(None, [st]) self.assertIsNotNone(cmd) - self.assertFalse(self._field("stop_and_exit")["value"]) self.assertFalse(cmd[2]["stop_and_exit"]) def test_qwen_remote_limited_modes_and_api_url(self): @@ -1765,9 +1765,7 @@ class ConvertFlowTests(unittest.TestCase): [f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", "request_options", "mode", "speaker", "clone", - "qwen_instructions", - "output_format", "language", "speed", - "single_file", "debug", "stop_and_exit"]) + "qwen_instructions", "single_file"]) # The form opens on the configured default (audio.cpp): its fields # show, the other backend's hide. Instructions shows too (optional # style/delivery control even on the clone-only higgs entry), while @@ -1776,15 +1774,12 @@ class ConvertFlowTests(unittest.TestCase): for key in ("model_id", "audiocpp_voice", "instructions"): self.assertTrue(self._field(key)["visible"](fields)) self.assertFalse(self._field("request_options")["visible"](fields)) - # Language shows for every backend except faster entries. - self.assertTrue(self._field("language")["visible"](fields)) for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. fields[0]["value"] = "qwen" self.assertTrue(self._field("mode")["visible"](fields)) self.assertTrue(self._field("speaker")["visible"](fields)) - self.assertTrue(self._field("language")["visible"](fields)) self.assertFalse(self._field("clone")["visible"](fields)) # qwen's clone mode hides the speaker and shows the .wav path. self._field("mode")["value"] = "clone" @@ -2128,7 +2123,8 @@ class SettingsTests(unittest.TestCase): # Keys _apply_settings persists; every test that triggers a real or # fake config write restores these afterwards. _SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE", "STOP_SERVER_AND_EXIT", + "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR", + "SPEED", "DEBUG", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", "QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL", @@ -2191,8 +2187,18 @@ class SettingsTests(unittest.TestCase): return True self._snapshot_settings() + # _apply_settings re-derives the converter module's folder + # globals; restore them afterwards. + original_folders = (hub.converter_mod.BOOKS_FOLDER, + hub.converter_mod.AUDIOBOOKS_FOLDER) + self.addCleanup(setattr, hub.converter_mod, "BOOKS_FOLDER", + original_folders[0]) + self.addCleanup(setattr, hub.converter_mod, "AUDIOBOOKS_FOLDER", + original_folders[1]) values = {"audio_format": "ogg", "audio_bitrate": " 192k ", "language": "en", "chunk_size": "300", + "input_dir": " /books ", "output_dir": "/audiobooks", + "speed": "1.5", "debug": True, "stop_and_exit": False, "unload_models": True, "qwen_port": "7862", @@ -2210,6 +2216,10 @@ class SettingsTests(unittest.TestCase): "AUDIO_BITRATE": "192k", "LANGUAGE": "English", "CHUNK_SIZE": 300, + "INPUT_DIR": "/books", + "OUTPUT_DIR": "/audiobooks", + "SPEED": 1.5, + "DEBUG": True, "STOP_SERVER_AND_EXIT": False, "AUDIOCPP_UNLOAD_MODELS": True, "QWEN_API_URL": "http://127.0.0.1:7862", @@ -2221,22 +2231,32 @@ class SettingsTests(unittest.TestCase): "http://10.0.0.6:8000", "AUDIOCPP_REMOTE_URL": "http://10.0.0.5:8080"}) - # In-memory config is reloaded so this session sees the change. + # In-memory config is reloaded so this session sees the change, + # and the converter module's folder globals follow the directories. self.assertEqual(hub.config.AUDIO_FORMAT, "ogg") self.assertEqual(hub.config.AUDIO_BITRATE, "192k") self.assertEqual(hub.config.LANGUAGE, "English") self.assertEqual(hub.config.CHUNK_SIZE, 300) + self.assertEqual(hub.config.INPUT_DIR, "/books") + self.assertEqual(hub.config.OUTPUT_DIR, "/audiobooks") + self.assertEqual(hub.config.SPEED, 1.5) + self.assertEqual(hub.config.DEBUG, True) self.assertEqual(hub.config.STOP_SERVER_AND_EXIT, False) self.assertEqual(hub.config.AUDIOCPP_UNLOAD_MODELS, True) self.assertEqual(hub.config.QWEN_API_URL, "http://127.0.0.1:7862") self.assertEqual(hub.config.FASTER_API_URL, "http://127.0.0.1:8001") self.assertEqual(hub.config.AUDIOCPP_REMOTE_URL, "http://10.0.0.5:8080") + self.assertEqual(hub.converter_mod.BOOKS_FOLDER, Path("/books")) + self.assertEqual(hub.converter_mod.AUDIOBOOKS_FOLDER, + Path("/audiobooks")) def test_apply_settings_rejects_bad_values(self): self._snapshot_settings() base = {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", + "input_dir": "./input", "output_dir": "./output", + "speed": "1.0", "debug": False, "stop_and_exit": True, "unload_models": True, "qwen_port": "7860", @@ -2247,6 +2267,14 @@ class SettingsTests(unittest.TestCase): with self.assertRaises(ValueError): hub._apply_settings({**base, "chunk_size": "0"}) with self.assertRaises(ValueError): + hub._apply_settings({**base, "speed": "0"}) + with self.assertRaises(ValueError): + hub._apply_settings({**base, "speed": "fast"}) + with self.assertRaises(ValueError): + hub._apply_settings({**base, "input_dir": " "}) + with self.assertRaises(ValueError): + hub._apply_settings({**base, "output_dir": ""}) + with self.assertRaises(ValueError): hub._apply_settings({**base, "audiocpp_port": "70000"}) with self.assertRaises(ValueError): hub._apply_settings({**base, @@ -2262,6 +2290,17 @@ class SettingsTests(unittest.TestCase): self.assertIsNone(hub._validate_chunk_size("250")) self.assertIsNotNone(hub._validate_chunk_size("abc")) self.assertIsNotNone(hub._validate_chunk_size("0")) + self.assertIsNone(hub._validate_speed("1.0")) + self.assertIsNone(hub._validate_speed("1.5")) + self.assertIsNone(hub._validate_speed(" 2 ")) + self.assertIsNotNone(hub._validate_speed("0")) + self.assertIsNotNone(hub._validate_speed("-1")) + self.assertIsNotNone(hub._validate_speed("fast")) + self.assertIsNotNone(hub._validate_speed("")) + self.assertIsNone(hub._validate_dir("./input")) + self.assertIsNone(hub._validate_dir(Path("/books"))) + self.assertIsNotNone(hub._validate_dir("")) + self.assertIsNotNone(hub._validate_dir(" ")) self.assertIsNone(hub._validate_port("8080")) self.assertIsNone(hub._validate_port("1")) self.assertIsNone(hub._validate_port("65535")) @@ -2270,9 +2309,9 @@ class SettingsTests(unittest.TestCase): 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. + # The Settings Language field is a static picker over the + # audio.cpp-menu languages, with a dim hint inside its edit + # dialog. Common languages lead. expected_choices = ["English", "Spanish", "Chinese", "French", "German", "Italian", "Portuguese", "Japanese", "Korean", "Russian", "Arabic", "Hindi", @@ -2284,19 +2323,34 @@ class SettingsTests(unittest.TestCase): fields = hub._settings_fields() field = next(f for f in fields if f["key"] == "language") - self.assertEqual(field["label"], "Default Language") + self.assertEqual(field["label"], "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_directory_fields_are_browsers(self): + # The Input/Output Directory settings use the DOS-style directory + # browser, seeded with the configured folder resolved against the + # project root. + fields = hub._settings_fields() + for key, config_name in (("input_dir", "INPUT_DIR"), + ("output_dir", "OUTPUT_DIR")): + field = next(f for f in fields if f["key"] == key) + self.assertEqual(field["kind"], "dir") + self.assertTrue(field["label"].endswith("Directory")) + self.assertEqual(field["value"], + hub.converter_mod.resolve_dir( + getattr(hub.config, config_name), + key.removesuffix("_dir"))) + self.assertIsNone(field["validate"](str(field["value"]))) + self.assertIsNotNone(field["validate"](" ")) + with patch.object(hub.config, "INPUT_DIR", "books"): + fields = hub._settings_fields() + field = next(f for f in fields if f["key"] == "input_dir") + self.assertEqual( + field["value"], + hub.converter_mod.BASE_DIR / "books") def test_settings_menu_builds_form_and_saves(self): captured = {} @@ -2305,6 +2359,8 @@ class SettingsTests(unittest.TestCase): captured["fields"] = fields return {"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", "chunk_size": "300", + "input_dir": "/books", "output_dir": "/audiobooks", + "speed": "1.0", "debug": False, "stop_and_exit": True, "unload_models": True, "qwen_port": "7860", @@ -2324,13 +2380,19 @@ class SettingsTests(unittest.TestCase): hub._Hub(None).screen_settings() self.assertEqual([f["key"] for f in captured["fields"]], ["audio_format", "audio_bitrate", "language", - "chunk_size", "stop_and_exit", "unload_models", + "chunk_size", "input_dir", "output_dir", + "speed", "debug", "stop_and_exit", + "unload_models", "audiocpp_port", "faster_port", "qwen_port", "audiocpp_remote_url", "faster_remote_url", "qwen_remote_url"]) kinds = {f["key"]: f["kind"] for f in captured["fields"]} self.assertEqual(kinds["audio_format"], "choice") self.assertEqual(kinds["audio_bitrate"], "text") + self.assertEqual(kinds["input_dir"], "dir") + self.assertEqual(kinds["output_dir"], "dir") + self.assertEqual(kinds["speed"], "text") + self.assertEqual(kinds["debug"], "bool") self.assertEqual(kinds["audiocpp_port"], "text") self.assertEqual(kinds["stop_and_exit"], "bool") self.assertEqual(kinds["unload_models"], "bool") @@ -2339,22 +2401,32 @@ class SettingsTests(unittest.TestCase): self.assertEqual(labels["qwen_port"], "qwen-tts port") self.assertEqual(labels["audiocpp_remote_url"], "audio.cpp remote URL") + self.assertEqual(labels["input_dir"], "Input Directory") + self.assertEqual(labels["output_dir"], "Output Directory") self.assertNotIn("(clone)", " ".join(labels.values())) - # The language setting is a picker labelled "Default Language". - self.assertEqual(labels["language"], "Default Language") + # The language setting is a picker labelled "Language". + self.assertEqual(labels["language"], "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"]} + self.assertTrue(notes["input_dir"]) + self.assertTrue(notes["output_dir"]) + self.assertTrue(notes["debug"]) self.assertTrue(notes["stop_and_exit"]) self.assertTrue(notes["audiocpp_port"]) self.assertTrue(notes["audiocpp_remote_url"]) self.assertIsNone(notes["audio_format"]) + self.assertIsNone(notes["speed"]) self.assertIsNone(notes["qwen_port"]) self.assertEqual(applied, [{"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", "chunk_size": "300", + "input_dir": "/books", + "output_dir": "/audiobooks", + "speed": "1.0", + "debug": False, "stop_and_exit": True, "unload_models": True, "qwen_port": "7860", @@ -2468,6 +2540,8 @@ class SettingsTests(unittest.TestCase): return back_value # first exit: Esc return {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", + "input_dir": "./input", "output_dir": "./output", + "speed": "1.0", "debug": False, "stop_and_exit": True, "unload_models": True, "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} @@ -2499,7 +2573,8 @@ class SettingsTests(unittest.TestCase): original = {name: getattr(hub.config, name) for name in ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", - "CHUNK_SIZE", "STOP_SERVER_AND_EXIT", + "CHUNK_SIZE", "INPUT_DIR", "OUTPUT_DIR", + "SPEED", "DEBUG", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", "QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL", @@ -2517,6 +2592,10 @@ class SettingsTests(unittest.TestCase): 'LANGUAGE = "English"\n' "\n" "CHUNK_SIZE = 250\n" + 'INPUT_DIR = "./input"\n' + 'OUTPUT_DIR = "./output"\n' + "SPEED = 1.0\n" + "DEBUG = False\n" "STOP_SERVER_AND_EXIT = True\n" "AUDIOCPP_UNLOAD_MODELS = True\n" 'QWEN_API_URL = "http://127.0.0.1:7860"\n' @@ -2538,6 +2617,11 @@ class SettingsTests(unittest.TestCase): text = path.read_text(encoding="utf-8") self.assertIn('AUDIO_FORMAT = "m4b"', text) self.assertIn("CHUNK_SIZE = 300", text) + # The settings-only fields are written back unchanged. + self.assertIn('INPUT_DIR = "', text) + self.assertIn('OUTPUT_DIR = "', text) + self.assertIn("SPEED = 1.0", text) + self.assertIn("DEBUG = False", text) # The running session also picked up the change in-memory. self.assertEqual(hub.config.CHUNK_SIZE, 300) diff --git a/app/ui/hub.py b/app/ui/hub.py index 25baf37..172a4ed 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -45,11 +45,10 @@ from backends import faster as faster_backend from backends import probe as backend_probe from backends import qwen as qwen_backend from converter import config +from converter import converter as converter_mod from converter.converter import ( - AUDIOBOOKS_FOLDER, AUDIO_FORMATS, AudiobookConverter, - BOOKS_FOLDER, LOGS_FOLDER, voice_mode_for, ) @@ -837,12 +836,15 @@ def _help_lines() -> list: Items are text_viewer rows: "" (a blank line) or a (segments, indent) pair — SEGMENTS are (text, kind) with KIND a theme key (None = body). Numbered steps start at the margin; every other - line is indented two spaces so it reads as part of its step. + line is indented two spaces so it reads as part of its step. The + input/output folders are read from the converter module at call + time, so a Settings change this session is reflected without a + restart. """ return [ ([("1. ", "title"), ("Put your ebooks (epub, txt, or pdf) here:", None)], 0), - ([(str(BOOKS_FOLDER), "input")], 1), + ([(str(converter_mod.BOOKS_FOLDER), "input")], 1), "", ([("2. ", "title"), ("Put any .wavs of voices to clone here:", None)], 0), @@ -875,7 +877,7 @@ def _help_lines() -> list: ([("6. ", "title"), ("Generated audiobooks (m4b, mp3, etc.) will output here:", None)], 0), - ([(str(AUDIOBOOKS_FOLDER), "input")], 1), + ([(str(converter_mod.AUDIOBOOKS_FOLDER), "input")], 1), ] @@ -884,9 +886,10 @@ def _convert_form(stdscr) -> Optional[tuple]: The first field is the Backend picker; the remaining fields are that backend's options (audio.cpp: model/voice/instructions; qwen: - model + speaker / clone .wav / design instruction; faster: voice), - plus the shared output - settings. A backend appears once as a managed entry ("audio.cpp") when + model + speaker / clone .wav / design instruction; faster: voice) + plus the per-run combine-all-chapters toggle (the shared output + settings live in the Settings menu). A backend appears once as a + managed entry ("audio.cpp") when it is installed+configured here, and once as a remote entry ("audio.cpp [remote]") when a running server was found at its remote URL. Managed entries read the local server.json / voices.json; remote @@ -1033,61 +1036,44 @@ 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. +# Dim hint shown while editing the Settings Language field: 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 per-run Generate-form fields (the global output options — + output format, language, speed, debug, stop-server-and-exit — live + in the Settings menu and reach the run via _common_kwargs()). 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 (a static audio.cpp-menu picker) and is - hidden for faster entries (the faster server owns the language). + embedded chapter markers), so its "visible" callable reads the + configured output format. """ - 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": "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", - "validate": lambda s: None if (_is_float(s) and float(s) > 0) - else "Enter a positive number, e.g. 1.0"}, {"key": "single_file", "label": "Combine all chapters", "kind": "bool", "value": False, - "visible": lambda fs: _field_value(fs, "output_format") != "m4b"}, - {"key": "debug", "label": "Debug", "kind": "bool", "value": False}, - {"key": "stop_and_exit", "label": "Stop server and exit", - "kind": "bool", "value": config.STOP_SERVER_AND_EXIT}, + "visible": lambda fs: config.AUDIO_FORMAT != "m4b"}, ] 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 + """Map the common form fields plus the configured output settings to + converter keyword arguments. + + Output format, language, speed, debug, and stop-server-and-exit are + configured once in the Settings menu (config.py) and apply to every + run; only per-run choices (combine-all-chapters) come from the form. + """ + output_format = config.AUDIO_FORMAT return { - "language": language, + "language": config.LANGUAGE, "output_format": output_format, - "speed": float(values["speed"]), + "speed": float(config.SPEED), "single_file": bool(values["single_file"]) and output_format != "m4b", - "debug": bool(values["debug"]), - "stop_and_exit": bool(values["stop_and_exit"]), + "debug": bool(config.DEBUG), + "stop_and_exit": bool(config.STOP_SERVER_AND_EXIT), } @@ -1562,20 +1548,35 @@ def _settings_changed(fields: list, original: dict) -> bool: def _settings_fields() -> list: - """The global output-settings field list (Save writes to config.py).""" + """The global settings field list (Save writes to config.py).""" return [ {"key": "audio_format", "label": "Audio format", "kind": "choice", "value": config.AUDIO_FORMAT, "choices": list(AUDIO_FORMATS)}, {"key": "audio_bitrate", "label": "Audio bitrate", "kind": "text", "value": config.AUDIO_BITRATE, "validate": _validate_bitrate}, - {"key": "language", "label": "Default Language", "kind": "choice", + {"key": "language", "label": "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", + {"key": "input_dir", "label": "Input Directory", "kind": "dir", + "value": converter_mod.resolve_dir(config.INPUT_DIR, "input"), + "validate": _validate_dir, + "note": "Directory the books (.txt/.pdf/.epub) are read from. " + "Relative paths resolve against the project root."}, + {"key": "output_dir", "label": "Output Directory", "kind": "dir", + "value": converter_mod.resolve_dir(config.OUTPUT_DIR, "output"), + "validate": _validate_dir, + "note": "Directory the finished audiobooks are written to."}, + {"key": "speed", "label": "Speed", "kind": "text", + "value": str(config.SPEED), "validate": _validate_speed}, + {"key": "debug", "label": "Debug", "kind": "bool", + "value": config.DEBUG, + "note": "Dump each chunk's raw audio and the exact text sent " + "for it, and log every TTS request and response."}, + {"key": "stop_and_exit", "label": "Stop server and exit", "kind": "bool", "value": config.STOP_SERVER_AND_EXIT, "note": "Automatically stop the TTS server and exit TUI " "after generating audiobooks"}, @@ -1638,6 +1639,20 @@ def _validate_chunk_size(value: str) -> Optional[str]: return None +def _validate_speed(value: str) -> Optional[str]: + """Error message for an invalid SPEED, or None to accept it.""" + if _is_float(value.strip()) and float(value) > 0: + return None + return "Enter a positive number, e.g. 1.0" + + +def _validate_dir(value) -> Optional[str]: + """Error message for a blank directory setting, or None to accept it.""" + if str(value).strip(): + return None + return "Directory must not be empty" + + def _validate_port(value: str) -> Optional[str]: """Error message for an invalid port, or None to accept it.""" try: @@ -1685,6 +1700,15 @@ def _apply_settings(values: dict) -> None: raise ValueError("Audio bitrate must not be empty") if values["audio_format"] not in AUDIO_FORMATS: raise ValueError(f"Unsupported audio format: {values['audio_format']}") + speed = float(str(values["speed"]).strip()) + if speed <= 0: + raise ValueError("Speed must be a positive number") + input_dir = str(values["input_dir"]).strip() + output_dir = str(values["output_dir"]).strip() + if not input_dir: + raise ValueError("Input Directory must not be empty") + if not output_dir: + raise ValueError("Output Directory must not be empty") ports = { "qwen_port": _read_port(values, "qwen_port"), @@ -1704,6 +1728,10 @@ def _apply_settings(values: dict) -> None: "AUDIO_BITRATE": bitrate, "LANGUAGE": normalize_language(values["language"]), "CHUNK_SIZE": chunk_size, + "INPUT_DIR": input_dir, + "OUTPUT_DIR": output_dir, + "SPEED": speed, + "DEBUG": bool(values["debug"]), "STOP_SERVER_AND_EXIT": bool(values["stop_and_exit"]), "AUDIOCPP_UNLOAD_MODELS": bool(values["unload_models"]), "QWEN_API_URL": common.url_with_port( @@ -1724,6 +1752,13 @@ def _apply_settings(values: dict) -> None: if not common.update_config_value(name, value): raise ValueError(f"Could not save {name} to " f"{common.CONFIG_PATH}") + # The input/output folders changed: re-derive the converter module's + # folder globals so this session's preflight/runs (and the Help text) + # see the new directories without a restart. + converter_mod.BOOKS_FOLDER = converter_mod.resolve_dir( + input_dir, "input") + converter_mod.AUDIOBOOKS_FOLDER = converter_mod.resolve_dir( + output_dir, "output") # Ports/URLs may have changed: the cached backend statuses are stale. invalidate_detect_cache() |
