diff options
| author | historia <historiavg@proton.me> | 2026-08-27 22:40:58 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-27 22:40:58 -0400 |
| commit | eb465fb2a972b6b07c4b427f940b186745ce6d90 (patch) | |
| tree | 416e0a706da7ede4f273458a2e243ac22393d606 | |
| parent | 4c3ba1b68107208fe32c223588576a7bdca19a12 (diff) | |
| download | tts-audiobook-generator-eb465fb2a972b6b07c4b427f940b186745ce6d90.tar.gz | |
fix: can't check checkboxes in tts model page
| -rw-r--r-- | README.md | 66 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 22 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 28 | ||||
| -rw-r--r-- | app/ui/hub.py | 18 | ||||
| -rw-r--r-- | app/ui/tui.py | 10 |
5 files changed, 88 insertions, 56 deletions
@@ -7,11 +7,10 @@ Generate audiobooks from books (epub, txt, pdf) using text-to-speech backends su The converter sends text extracted from your books to a locally running TTS server and assembles the returned audio into a single audiobook file. - Input: `.txt`, `.pdf`, or `.epub` -- Output: `.m4b`, `.mp3`, `.ogg`, or `.flac` +- Output: `.m4b`, `.mp3`, `.ogg`, or `.flac` (single file or one-per-chapter) - Supports [audio.cpp](https://github.com/0xShug0/audio.cpp), [qwen-tts](https://pypi.org/project/qwen-tts/), and [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts) backend servers -- Output a single file or one per chapter - Automatic metadata (title/artist/album tags, chapter track numbers) and a generated cover -- Supports text-to-speech, voice cloning, voice design, and per-model controls like emotion/speed +- Supports text-to-speech, voice cloning, and voice design | Backend | Description | | -------------------------------------------------------------------- | ------------------------------------------------------ | @@ -46,15 +45,13 @@ These directories (and output format, language, speed, debug, and more) can be c python audiobook.py ``` -4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. +4. When the TUI comes up, go to `Configure Backends > Install Backend`. Install `audio.cpp`, which supports numerous TTS models. If you have your own backend server you want to connect to, instead just configure the API URL in settings and you're done. 5. On the "Select TTS Models to Host" screen, install one or more TTS models. `qwen3_tts_1_7b_base_q8_0` is a good model for cloning and `qwen3_tts_1_7b_customvoice_q8_0` is good for built-in speakers. -6. Run through the rest of the wizard. The defaults are probably all correct. The script will simultaneously build audiocpp_server and download the model files. This will take a while. +6. The script will simultaneously build audiocpp_server and download the model files. This will take a while. -## Updating Backends - -`Configure Backends > Update Backends` refreshes every installed backend to the latest upstream version in one run: `pip install -U` for the pip packages (`qwen-tts`, `faster-qwen3-tts`) and a git fetch + hard reset to the remote's HEAD for the checkouts (`app/audio.cpp`, `app/faster-qwen3-tts`) — downloaded models, `server.json`, and `voices.json` are untouched. audiocpp_server is rebuilt automatically when the audio.cpp checkout moved or the on-disk binary is older than the checked-out sources (which also heals an update whose build was cancelled or failed); an up-to-date checkout with a fresh binary costs one fetch. Updates are always-latest with no version pinning; managed servers are stopped before their code is refreshed. +7. Go to `Generate Audiobooks` in the main menu to process the input files into audiobooks. The program will automatically start and stop the necessary backend server. ## CLI Options @@ -62,40 +59,41 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- ## Options -| Flag | Description | -| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--backend {audiocpp,qwen,faster}` | TTS server to use (default `audiocpp`). | -| `--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`). | -| `--input-file <file>` | Convert one specific book (`.txt`/`.pdf`/`.epub`) instead of scanning a directory; cannot be combined with `--input`. Without `--output-file` the audiobook goes to the output directory under its usual narrator-tagged name. | -| `--output-file <file>` | Base path for the audiobook from `--input-file` (requires it; cannot be combined with `--output`): the file's parent folder receives the audio and its stem is the base output name, without the narrator tag (chapters as `STEM_NN_Title.ext`). The extension must match the output format (`--format` / `AUDIO_FORMAT`) or the run stops without converting. | -| `--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. 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 | -| `--voice <name>` | `audiocpp`, `faster`: Voice to request. `audiocpp`: on the Qwen3-TTS CustomVoice entry a built-in speaker (e.g. `Vivian`, `Ryan`, `Uncle_Fu`); on every other family a `voice_preset`/`voice_dir` entry for cloning (with `AUDIOCPP_CLONE_MODEL_ID` set it reroutes to the clone model — typically the Qwen Base model). `faster`: a key in the server's `voices.json`. | -| `--clone <path>` | `qwen`: Reference audio (`wav`) for voice cloning. | -| `--transcription "..."` | `qwen`: Override whisper auto-transcription with manual audio transcript. | -| `--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 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 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. +| Flag | Example | Description | +| ---------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--backend {audiocpp,qwen,faster}` | `audiocpp` | TTS server to use (default `audiocpp`). | +| `--format {mp3,m4b,ogg,flac}` | `m4b` | Output format (default: the `AUDIO_FORMAT` setting in `app/converter/config.py`, `m4b`). | +| `--voice <name>` | `narrator` | Voice to request. For `faster` this is the key in `voices.json`. For the Qwen CustomVoice model this is the speaker (Ryan, Vivian, etc.) | +| `--input <dir>` | `./input` | 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>` | `./output` | Directory to write finished audiobooks to (default: the `OUTPUT_DIR` setting in `app/converter/config.py`, `./output`). | +| `--input-file <file>` | `books/dune.epub` | Convert one specific book (`.txt`/`.pdf`/`.epub`) | +| `--output-file <file>` | `out/dune.mp3` | Base output path for the audiobook from `--input-file` | +| `--instructions "..."` | `"A warm adult female narrator with a British accent"` | Voice design or style instruction. Usually used for voice design models, some clone models also accept this. | +| `--speed <n>` | `1.0` | 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>` | `English` | Output language for the synthesized speech. May 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. Forces the `DEBUG` setting in `app/converter/config.py` on for this run. | +| `--api-url <url>` | `http://10.20.30.40:8080` | URL of a remote TTS server. Accepts `host:port` or a full `http(s)://` URL. | +| `--model <id>` | `qwen3_tts_1_7b_base_q8_0` | `audiocpp`: Choose the TTS model from `server.json` | +| `--option KEY=VALUE` | `emotion=neutral` | `audiocpp`: Some models support custom options with this (e.g. `emotion=neutral`) | + +| Flag | Example | Description | +| ----------------------- | ---------------------------------------- | ------------------------------------------------------------------- | +| `--clone <path>` | `./voices/speaker.wav` | Path to reference audio (`wav`) for voice cloning (e.g. `./voices`) | +| `--transcription "..."` | `"Hello! This is a sample of my voice."` | Override whisper auto-transcription with manual audio transcript. | +| `--no-transcription` | | Skip auto-transcription of the reference audio. | + +Other options and defaults are configured in `app/converter/config.py` ## Manual TTS Backend Setup -If the TUI auto-install doesn't work, you may need to set up the backends manually. +If the TUI auto-install doesn't work for some reason, you may need to set up the backends manually. - [audio.cpp instructions](app/docs/backend-audiocpp.md) - [qwen-tts instructions](app/docs/backend-qwen.md) - [faster-qwen-tts instructions](app/docs/backend-faster.md) -`./audiobook.py` can also connect to external servers running these backends. Point the relevant `*_REMOTE_URL` in `app/converter/config.py` (or the **Settings** → remote URL fields) at the server's `host:port` — the hub probes it and offers a `[remote]` entry in **Generate Audiobooks…** next to the managed one. The defaults are the local ports (`127.0.0.1:<port>`), so a server started outside this tool on the local port is picked up automatically. For scripting, `--api-url` targets any server directly. +`./audiobook.py` can also connect to external servers running these backends if you want to completely manage your own install. The TUI will automatically look for and connect to the API URLs configured in settings. For CLI use, manually point at `--api-url` for an external server. ## Development diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index daa4abd..fbede75 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -364,20 +364,24 @@ class HubMenuTests(unittest.TestCase): self.assertIn("qwen3_tts_1_7b_customvoice_q8_0", text) self.assertIn("5. Go to Generate Audiobooks.", text) # The "no need to manually start/stop" sentence sits on its own - # indented line below the step-5 paragraph. - self.assertIn("stop it.\nThere is no need to manually " + # indented line below the step-5 paragraph (one leading space + # beyond the 2-space indent, lining up with the step text). + self.assertIn("stop it.\n There is no need to manually " "start/stop servers.", text) 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. 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), + # ("input") rows; the numbered steps start at the margin and + # the indented rows carry a leading space of their own, so + # they line up with the step text after the "N. " prefixes. + # 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.common.VOICES_DIR), "input")], 1), items) - self.assertIn(([(str(hub.converter_mod.AUDIOBOOKS_FOLDER), "input")], - 1), items) + self.assertIn(([(" " + str(hub.converter_mod.AUDIOBOOKS_FOLDER), + "input")], 1), items) self.assertEqual(items[0][1], 0) diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index e12da76..e765e60 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -1147,6 +1147,34 @@ class CheckboxTreeTests(TuiTestCase): picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) self.assertEqual(picked, [(0, "pkg-a")]) + def test_space_toggles_option_row(self): + # Enter on the family checks pkg-a and expands; Down twice lands + # on the pkg-b option row; Space checks it; confirm returns both. + screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, + FakeCurses.KEY_DOWN, ord(" "), 9, 10]) + picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + self.assertEqual(picked, [(0, "pkg-a"), (0, "pkg-b")]) + + def test_enter_toggles_option_row(self): + # Enter on the family checks pkg-a and expands; Down lands on the + # pkg-a option row; Enter there unchecks it; Down to pkg-b, Space + # checks it. Confirm returns only pkg-b. + screen = FakeScreen(keys=[10, FakeCurses.KEY_DOWN, 10, + FakeCurses.KEY_DOWN, ord(" "), 9, 10]) + picked = tui.checkbox_tree(screen, "Pick models", self.FAMILIES) + self.assertEqual(picked, [(0, "pkg-b")]) + + def test_unhandled_key_on_row_is_ignored(self): + # A key that is neither motion nor toggle (here "x") must not + # toggle anything or crash on either row kind; Esc then aborts. + marker = object() + screen = FakeScreen(keys=[ord("x"), FakeCurses.KEY_DOWN, ord("x"), + 27]) + self.assertIs( + tui.checkbox_tree(screen, "Pick models", self.FAMILIES, + back_value=marker), + marker) + def test_prechecked_selection_confirmed(self): # checked= seeds the tree (modify flow): Tab then Confirm accepts # the pre-checked options without any extra key presses. diff --git a/app/ui/hub.py b/app/ui/hub.py index 68417f7..4fae67b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -834,7 +834,9 @@ 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. The + line is indented three spaces — Frame's two-space indent unit + plus a leading space in the row's first segment — so it lines up + with the step text after the "N. " prefixes. The input/output folders are read from the converter module at call time, so a Settings change this session is reflected without a restart. @@ -842,11 +844,11 @@ def _help_lines() -> list: return [ ([("1. ", "title"), ("Put your ebooks (epub, txt, or pdf) here:", None)], 0), - ([(str(converter_mod.BOOKS_FOLDER), "input")], 1), + ([(" " + str(converter_mod.BOOKS_FOLDER), "input")], 1), "", ([("2. ", "title"), ("Put any .wavs of voices to clone here:", None)], 0), - ([(str(common.VOICES_DIR), "input")], 1), + ([(" " + str(common.VOICES_DIR), "input")], 1), "", ([("3. ", "title"), ("If no backend is installed, go to ", None), @@ -858,24 +860,24 @@ def _help_lines() -> list: ("Select TTS models to install. If you're unsure, try " "these qwen3-tts models:", None)], 0), "", - ([("Voice cloning:", None), (" ", None), + ([(" Voice cloning:", None), (" ", None), ("qwen3_tts_1_7b_base_q8_0", "ok")], 1), - ([("Built-in-voice:", None), (" ", None), + ([(" Built-in-voice:", None), (" ", None), ("qwen3_tts_1_7b_customvoice_q8_0", "ok")], 1), "", - ([("It will take a while to build audio.cpp and download " + ([(" It will take a while to build audio.cpp and download " "the model files.", None)], 1), "", ([("5. ", "title"), ("Go to ", None), ("Generate Audiobooks", "accent"), (". It will automatically start the necessary server, " "generate the books, and stop it.", None)], 0), - ([("There is no need to manually start/stop servers.", None)], 1), + ([(" There is no need to manually start/stop servers.", None)], 1), "", ([("6. ", "title"), ("Generated audiobooks (m4b, mp3, etc.) will output here:", None)], 0), - ([(str(converter_mod.AUDIOBOOKS_FOLDER), "input")], 1), + ([(" " + str(converter_mod.AUDIOBOOKS_FOLDER), "input")], 1), ] diff --git a/app/ui/tui.py b/app/ui/tui.py index 1997415..7da1dd1 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -1565,12 +1565,12 @@ def checkbox_tree(scr, title: str, families: List[dict], if options: checked.add((index, options[0]["key"])) expanded.add(index) - else: - _, index, option_key = node - if (index, option_key) in checked: - checked.discard((index, option_key)) else: - checked.add((index, option_key)) + _, index, option_key = node + if (index, option_key) in checked: + checked.discard((index, option_key)) + else: + checked.add((index, option_key)) # --------------------------------------------------------------------------- |
