From de5b2d9ca7302ade711d9dfb32b2fe8047f55cc2 Mon Sep 17 00:00:00 2001 From: historia Date: Tue, 18 Aug 2026 03:00:16 -0400 Subject: feat: support .ogg and .flac output --- README.md | 22 +++++++++++----------- audiobook_converter.py | 4 ++-- converter/audio.py | 4 ++++ converter/config.py | 4 ++-- converter/converter.py | 2 +- tests/test_audio.py | 6 ++++++ tests/test_converter.py | 2 +- 7 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 20ee20a..f3ce5d9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Qwen3 Audiobook Converter -Convert TXT, PDF, and EPUB files into audiobooks using the Qwen3-TTS voice model. This builds upon [WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter) adding m4b support, transcription and speed options, and clearer instructions. It also splits the qwen3-tts server into two processes running models on different ports. +Convert TXT, PDF, and EPUB files into audiobooks using the Qwen3-TTS voice model. This builds upon [WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter) adding more output files, transcription/speed options, better text cleanup, and clearer instructions. It also expects the qwen-tts server to be on different ports per model, so two server processes can run at once. ## Overview The converter sends text extracted from your books to a locally running Qwen3-TTS server and assembles the returned audio into a single audiobook file. - Input: `.txt`, `.pdf`, or `.epub` -- Output: `.mp3` or `.m4b` +- Output: `.mp3`, `.m4b`, `.ogg`, or `.flac` - Output a single mp3 or one per chapter - Two voice modes: - Custom voice: pre-built speakers @@ -64,7 +64,7 @@ qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861 ## Converting books -Put your book files (epub, txt, etc.) in the `input/` folder. Then run the script. The output goes to `output/`. +Put your book files (epub, etc.) in the `input/` folder. Then run the script. The output goes to `output/`. ### Custom voice @@ -86,19 +86,19 @@ CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a dramatic b python audiobook_converter.py --voice-clone --voice-sample path/to/reference.wav ``` -The reference `.wav` should be ~10-15 seconds (3 second minimum, 60 second maximum; ~15 seconds is ideal). +The reference `.wav` should be ~10-15 seconds (3 second minimum, 60 second maximum; ~15 seconds is ideal). Longer is **not** better. Whisper (`faster_whisper` or `whisper`) is used automatically to transcribe the reference audio; without a Whisper backend it falls back to x-vector-only cloning. Override with `--voice-sample-text "..."` or skip transcription with `--no-transcription`. ### Options -| Flag | Description | -| --------------------------- | ----------------------------------------------------------------------------------------------------- | -| `--speed ` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. | -| `--format {mp3,m4b}` | Output format (default `mp3`). `m4b` uses AAC audio and has built-in chapters. | -| `--single-file` | mp3 only: Merge all chapters into a single mp3 (default: one mp3 per chapter). | -| `--voice-sample-text "..."` | Override whisper auto-transcription with your own manual reference audio transcript. Not required. | -| `--no-transcription` | Skip auto-transcription of the reference audio. Usually worse, but can give a different voice affect. | +| Flag | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------- | +| `--speed ` | Playback speed, pitch-preserving (`1.0` = normal). A normal-speed copy is also output. | +| `--format {mp3,m4b,ogg,flac}` | Output format (default `mp3`). `m4b` uses AAC audio and has built-in chapters. | +| `--single-file` | Not m4b: Merge all chapters into a single file (default: one file per chapter). | +| `--voice-sample-text "..."` | Override whisper auto-transcription with your own manual reference audio transcript. Not required. | +| `--no-transcription` | Skip auto-transcription of the reference audio. Usually worse, but can give a different voice affect. | ## Running tests diff --git a/audiobook_converter.py b/audiobook_converter.py index 82eae9a..eb9beba 100755 --- a/audiobook_converter.py +++ b/audiobook_converter.py @@ -84,8 +84,8 @@ Examples: parser.add_argument( "--single-file", action="store_true", - help=("Combine all chapters into a single mp3. By default books with " - "chapters (e.g. EPUB) are converted to one mp3 per chapter. " + help=("Combine all chapters into a single audio file. By default books with " + "chapters (e.g. EPUB) are converted to one file per chapter. " "Ignored for m4b, which is always a single file.") ) diff --git a/converter/audio.py b/converter/audio.py index 5a7ec35..98864f6 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -52,6 +52,10 @@ def _encode_args(output_format: str) -> List[str]: """Return ffmpeg output codec/bitrate args for the requested container.""" if output_format == "m4b": return ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE] + if output_format == "ogg": + return ["-c:a", "libvorbis", "-b:a", config.AUDIO_BITRATE] + if output_format == "flac": + return ["-c:a", "flac"] if output_format == "wav": # Lossless intermediate for per-chapter scratch audio; avoids # generational loss when the final m4b re-encodes to AAC. diff --git a/converter/config.py b/converter/config.py index b4d7f22..ec84986 100644 --- a/converter/config.py +++ b/converter/config.py @@ -82,8 +82,8 @@ HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" this often during a chu # AUDIO OUTPUT SETTINGS # ============================================================================= -AUDIO_FORMAT = "mp3" # Default output container ("mp3" or "m4b") -AUDIO_FORMATS = ("mp3", "m4b") +AUDIO_FORMAT = "mp3" # Default output container +AUDIO_FORMATS = ("mp3", "m4b", "ogg", "flac") AUDIO_BITRATE = "128k" SUPPORTED_FORMATS = [".txt", ".pdf", ".epub"] diff --git a/converter/converter.py b/converter/converter.py index bc64f38..c0e97e4 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -304,7 +304,7 @@ class AudiobookConverter: print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") print(f"Language: {config.VOICE_CLONE_LANGUAGE}") print(f"Output format: {self.output_format}") - if self.single_file and self.output_format == "mp3": + if self.single_file and self.output_format != "m4b": print("Chapter mode: single file (--single-file)") if abs(self.speed - 1.0) >= 1e-6: print(f"Playback speed: {self.speed:g}x") diff --git a/tests/test_audio.py b/tests/test_audio.py index 7e782cc..7116e2b 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -90,6 +90,12 @@ class EncodeArgsTests(unittest.TestCase): def test_wav_is_lossless_pcm(self): self.assertEqual(_encode_args("wav"), ["-c:a", "pcm_s16le"]) + def test_ogg_uses_libvorbis(self): + self.assertEqual(_encode_args("ogg"), ["-c:a", "libvorbis", "-b:a", config.AUDIO_BITRATE]) + + def test_flac_is_lossless(self): + self.assertEqual(_encode_args("flac"), ["-c:a", "flac"]) + class M4bContainerArgsTests(unittest.TestCase): def setUp(self): diff --git a/tests/test_converter.py b/tests/test_converter.py index 9c03e30..fce0439 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -28,7 +28,7 @@ class ConfigurationValidationTests(unittest.TestCase): def test_unknown_format_rejected(self): with self.assertRaises(ValueError): - AudiobookConverter(output_format="ogg") + AudiobookConverter(output_format="wma") if __name__ == "__main__": -- cgit v1.2.3