aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 18:41:10 -0400
committerhistoria <historiavg@proton.me>2026-08-27 18:41:10 -0400
commitb6795046bf20023fd7b2e083ead20e7362f0c0d7 (patch)
tree20c8fd1cde5ed7fbb2c630e8250af098f547be05 /app
parent0047a875d0a969005f3cea3df18de909d285f910 (diff)
downloadtts-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.py21
-rw-r--r--app/converter/converter.py19
-rw-r--r--app/tests/test_hub.py224
-rw-r--r--app/ui/hub.py131
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()