aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md16
-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
-rwxr-xr-xaudiobook.py52
6 files changed, 317 insertions, 146 deletions
diff --git a/README.md b/README.md
index 251b3a2..dd51735 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,8 @@ cd tts-audiobook-generator
- `./output` - Audio files will output here
- `./voices` - `.wav` files of voices to clone (10-20 seconds)
+These directories (and output format, language, speed, debug, and more) can be changed any time in `app/converter/config.py` or the TUI's **Settings** menu.
+
3. Run `audiobook.py`. It will automatically create the app's virtual environment and install its requirements (each pip-installed TTS backend later gets a managed venv of its own under `app/envs/`).
```
python audiobook.py
@@ -63,13 +65,13 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py --
| Flag | Description |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--backend {audiocpp,qwen,faster}` | TTS server to use (default `audiocpp`). |
-| `--format {mp3,m4b,ogg,flac}` | Output format (default `m4b`). |
-| `--input <dir>` | Directory containing the books to convert (default `./input`). |
-| `--output <dir>` | Directory to write finished audiobooks to (default `./output`). |
-| `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. |
+| `--format {mp3,m4b,ogg,flac}` | Output format (default: the `AUDIO_FORMAT` setting in `app/converter/config.py`, `m4b`). |
+| `--input <dir>` | Directory containing the books to convert (default: the `INPUT_DIR` setting in `app/converter/config.py`, `./input`; relative paths resolve against the project root). |
+| `--output <dir>` | Directory to write finished audiobooks to (default: the `OUTPUT_DIR` setting in `app/converter/config.py`, `./output`). |
+| `--speed <n>` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. Defaults to the `SPEED` setting in `app/converter/config.py`. |
| `--single-file` | Merge all chapters into a single file. `m4b` is always one file. |
| `--language <lang>` | Output language for the synthesized speech. Can add an accent even if the text is English. |
-| `--debug` | Dump each chunk's raw audio and sent text to `debug/` and log every request. |
+| `--debug` | Dump each chunk's raw audio and sent text to `debug/` and log every request. Forces the `DEBUG` setting in `app/converter/config.py` on for this run. |
| `--model <id>` | `audiocpp`: Choose the model from `server.json` |
| `--instructions "..."` | `audiocpp`: voice design or style instruction (required for voice design models, task `vdes`). `qwen`: selects the VoiceDesign model and describes the voice to synthesize with. |
| `--option KEY=VALUE` | `audiocpp`: Some models support custom options (e.g. `emotion=netural`) that can be passed with this flag |
@@ -79,9 +81,9 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py --
| `--no-transcription` | `qwen`: Skip auto-transcription of the reference audio. |
| `--api-url <url>` | URL of the TTS server to talk to, overriding the configured endpoint for the selected backend. Accepts `host:port` or a full `http(s)://` URL. |
-Other options — including backend server URLs, ports, and the remote-server URLs the hub probes for `[remote]` entries — are configured in `app/converter/config.py` (or the TUI's **Settings** menu, whose **Default Language** entry is the same static language picker).
+Other options — including the input/output directories, output format, language, speed, debug, backend server URLs, ports, and the remote-server URLs the hub probes for `[remote]` entries — are configured in `app/converter/config.py` (or the TUI's **Settings** menu, whose **Language** entry is the same static language picker).
-The **Generate Audiobooks** TUI form exposes the same per-run controls as these flags: for `audiocpp` the Model picker labels each entry's voice capability (`speaker` / `clone` / `design`), the Voice field is labelled **Built-in voice** on CustomVoice entries and **Voice to clone** everywhere else, Instructions work on every entry (required for `vdes`, optional style/delivery control elsewhere — or the voice itself on families without built-in speakers), a Request options field accepts `KEY=VALUE` items (the `--option` equivalent, shown only for model families whose audio.cpp spec declares request options), and Language (a static picker over the languages of audio.cpp's WebUI menus, hinting "Check model documentation for supported languages." while editing) overrides the global setting per run (hidden for `faster`, which owns language server-side). For `qwen-tts` a single **Model** picker selects which demo the server hosts — Base (voice cloning), CustomVoice (built-in voices) or VoiceDesign (design) — with the matching controls shown below it; the choice is remembered, and switching models restarts the managed server on its single port. The Instructions and Request options editors show dim hints with examples while editing.
+The **Generate Audiobooks** TUI form exposes the per-run controls as flags: for `audiocpp` the Model picker labels each entry's voice capability (`speaker` / `clone` / `design`), the Voice field is labelled **Built-in voice** on CustomVoice entries and **Voice to clone** everywhere else, Instructions work on every entry (required for `vdes`, optional style/delivery control elsewhere — or the voice itself on families without built-in speakers), a Request options field accepts `KEY=VALUE` items (the `--option` equivalent, shown only for model families whose audio.cpp spec declares request options), and a **Combine all chapters** toggle mirrors `--single-file` (hidden for `m4b`, which is always a single file). Output format, Language, Speed, Debug, and Stop-server-and-exit are configured once in the **Settings** menu and apply to every run. For `qwen-tts` a single **Model** picker selects which demo the server hosts — Base (voice cloning), CustomVoice (built-in voices) or VoiceDesign (design) — with the matching controls shown below it; the choice is remembered, and switching models restarts the managed server on its single port. The Instructions and Request options editors show dim hints with examples while editing.
## Manual TTS Backend Setup
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()
diff --git a/audiobook.py b/audiobook.py
index 97171ac..03ba7c9 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -55,8 +55,8 @@ from converter.converter import (
def convert(backend: str = None, voice: str = None, clone: str = None,
transcription: str = None, no_transcription: bool = False,
- language: str = None, speed: float = 1.0, single_file: bool = False,
- output_format: str = None, debug: bool = False,
+ language: str = None, speed: float = None, single_file: bool = False,
+ output_format: str = None, debug: bool = None,
model_id: str = None, instructions: str = None,
request_options: dict = None, input_dir: Path = None,
output_dir: Path = None, api_url: str = None,
@@ -67,8 +67,10 @@ def convert(backend: str = None, voice: str = None, clone: str = None,
Returns the process exit code (0 on success, 1 on failure, 130 on
Ctrl-C). BACKEND defaults to config.BACKEND, OUTPUT_FORMAT to
config.AUDIO_FORMAT. LANGUAGE is already-normalized where required.
- INPUT_DIR/OUTPUT_DIR override the default input/ and output/ folders
- when given. API_URL, when given, overrides the configured server URL
+ INPUT_DIR/OUTPUT_DIR override the configured INPUT_DIR/OUTPUT_DIR
+ folders when given (relative paths resolve against the project
+ root); SPEED/DEBUG default to the config.SPEED/config.DEBUG
+ settings. API_URL, when given, overrides the configured server URL
for the selected backend (used by the hub's "[remote]" entries and
--api-url).
@@ -81,11 +83,15 @@ def convert(backend: str = None, voice: str = None, clone: str = None,
"""
backend = backend or config.BACKEND
output_format = output_format or config.AUDIO_FORMAT
+ speed = config.SPEED if speed is None else speed
+ debug = config.DEBUG if debug is None else debug
+ input_dir = config.INPUT_DIR if input_dir is None else input_dir
+ output_dir = config.OUTPUT_DIR if output_dir is None else output_dir
request_options = request_options or {}
- if input_dir is not None:
- _converter_mod.BOOKS_FOLDER = Path(input_dir)
- if output_dir is not None:
- _converter_mod.AUDIOBOOKS_FOLDER = Path(output_dir)
+ _converter_mod.BOOKS_FOLDER = _converter_mod.resolve_dir(
+ input_dir, "input")
+ _converter_mod.AUDIOBOOKS_FOLDER = _converter_mod.resolve_dir(
+ output_dir, "output")
# With a progress callback the run view owns the screen: keep log
# records in the file only and let the events carry the state.
setup_logging(debug=debug, console=progress is None)
@@ -222,8 +228,10 @@ Examples:
"the LANGUAGE setting in app/converter/config.py (English).")
)
parser.add_argument(
- "--speed", type=float, default=1.0,
- help="Playback speed factor for the final audiobook (1.0 = normal). Pitch-preserving."
+ "--speed", type=float, default=None,
+ help=("Playback speed factor for the final audiobook (1.0 = normal). "
+ "Pitch-preserving. Defaults to the SPEED setting in "
+ "app/converter/config.py.")
)
parser.add_argument(
"--format", choices=list(AUDIO_FORMATS), default=config.AUDIO_FORMAT,
@@ -232,11 +240,15 @@ Examples:
parser.add_argument(
"--input", type=Path, metavar="DIR", default=None,
help=("Directory containing the source books (.txt/.pdf/.epub). "
- "Defaults to ./input.")
+ "Defaults to the INPUT_DIR setting in "
+ "app/converter/config.py (./input); relative paths resolve "
+ "against the project root.")
)
parser.add_argument(
"--output", type=Path, metavar="DIR", default=None,
- help="Directory to write finished audiobooks to. Defaults to ./output."
+ help=("Directory to write finished audiobooks to. Defaults to the "
+ "OUTPUT_DIR setting in app/converter/config.py (./output); "
+ "relative paths resolve against the project root.")
)
parser.add_argument(
"--single-file", action="store_true",
@@ -271,7 +283,8 @@ Examples:
"--debug", action="store_true",
help=("Troubleshooting mode: 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.")
+ "and log every TTS request and response to the console and log file. "
+ "Forces the DEBUG setting in app/converter/config.py on for this run.")
)
parser.add_argument(
"--model", type=str, default=None, metavar="ID",
@@ -314,8 +327,15 @@ Examples:
args = parser.parse_args()
- if args.speed <= 0:
- parser.error(f"--speed must be a positive number (got {args.speed:g})")
+ # An explicit --speed overrides the config SPEED setting; either way
+ # the value must be a positive number.
+ speed = args.speed if args.speed is not None else config.SPEED
+ try:
+ bad_speed = not isinstance(speed, (int, float)) or speed <= 0
+ except TypeError:
+ bad_speed = True
+ if bad_speed:
+ parser.error(f"--speed must be a positive number (got {speed!r})")
if args.input is not None and not args.input.is_dir():
parser.error(f"--input: no such directory: {args.input}")
@@ -405,7 +425,7 @@ Examples:
backend=args.backend, voice=args.voice, clone=args.clone,
transcription=args.transcription, no_transcription=args.no_transcription,
language=args.language, speed=args.speed, single_file=args.single_file,
- output_format=args.format, debug=args.debug,
+ output_format=args.format, debug=args.debug or None,
model_id=args.model, instructions=args.instructions,
request_options=request_options,
input_dir=args.input, output_dir=args.output,