aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-16 05:28:38 -0400
committerhistoria <historiavg@proton.me>2026-08-16 05:28:38 -0400
commit2aa61120f6b1691211d724b7603caea14641a6be (patch)
tree5ae3e36737cf6e8ed19080e711f0cc9fb4f38a00
downloadtts-audiobook-generator-2aa61120f6b1691211d724b7603caea14641a6be.tar.gz
initial commit
-rw-r--r--.gitignore225
-rw-r--r--LICENSE21
-rw-r--r--README.md135
-rw-r--r--audiobook_converter.py1019
-rw-r--r--audiobooks/test.mp3bin0 -> 396716 bytes
-rw-r--r--audiobooks/test_0.8.mp3bin0 -> 495404 bytes
-rw-r--r--book_to_convert/test.txt1
-rw-r--r--config.py89
-rw-r--r--requirements.txt16
9 files changed, 1506 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..438f15f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,225 @@
+voices/
+chunks/
+cache/
+logs/
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[codz]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py.cover
+*.lcov
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+# Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+# poetry.lock
+# poetry.toml
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
+# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
+# pdm.lock
+# pdm.toml
+.pdm-python
+.pdm-build/
+
+# pixi
+# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
+# pixi.lock
+# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
+# in the .venv directory. It is recommended not to include this directory in version control.
+.pixi/*
+!.pixi/config.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule*
+celerybeat.pid
+
+# Redis
+*.rdb
+*.aof
+*.pid
+
+# RabbitMQ
+mnesia/
+rabbitmq/
+rabbitmq-data/
+
+# ActiveMQ
+activemq-data/
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.envrc
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+# .idea/
+
+# Abstra
+# Abstra is an AI-powered process automation framework.
+# Ignore directories containing user credentials, local state, and settings.
+# Learn more at https://abstra.io/docs
+.abstra/
+
+# Visual Studio Code
+# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
+# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
+# and can be added to the global gitignore or merged into this file. However, if you prefer,
+# you could uncomment the following to ignore the entire vscode folder
+# .vscode/
+# Temporary file for partial code execution
+tempCodeRunnerFile.py
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Marimo
+marimo/_static/
+marimo/_lsp/
+__marimo__/
+
+# Streamlit
+.streamlit/secrets.toml
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..fd5ebe7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 WhiskeyCoder
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8307b11
--- /dev/null
+++ b/README.md
@@ -0,0 +1,135 @@
+# Qwen3 Audiobook Converter
+
+Convert TXT, PDF, EPUB, DOCX, and DOC files into audiobooks using the Qwen3-TTS voice model.
+
+Original project: [https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter
+). This repo just has minor fixes, flags, and doc changes. It also optionally splits the qwen3-tts server into two processes running models on different ports.
+
+## 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.
+
+- Supported input: `.txt`, `.pdf`, `.epub`, `.docx`, `.doc`
+- Output: `.mp3`
+- Two voice modes:
+ - Custom voice: pre-built speakers
+ - Voice clone: clone a voice from a `.wav` reference audio file
+
+## Prerequisites
+
+- Python 3.12
+- ffmpeg
+- Enough VRAM to run the 1.7B model (~6GB)
+
+## Installation
+
+```bash
+# Arch Linux
+sudo pacman -S conda ffmpeg
+
+# Debian, conda must be installed separately
+sudo apt-get install ffmpeg
+```
+
+### Install Qwen3-TTS (Server)
+
+```bash
+conda create -n qwen3-tts python=3.12 -y
+conda activate qwen3-tts
+pip install -U qwen-tts
+```
+
+### Install the conversion script
+
+```bash
+git clone https://git.historia.vg/git/qwen3-audiobook-converter
+cd qwen3-audiobook-converter
+pip install -r requirements.txt
+```
+
+## Running the Qwen-TTS server
+
+The converter script talks to a Qwen3-TTS Gradio server that is run using `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). The script expects the custom voice model and base model to be on different ports depending on which you're using:
+
+### Custom voice
+
+```bash
+conda activate qwen3-tts
+qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --ip 127.0.0.1 --port 7860
+```
+
+### Voice clone
+
+```bash
+conda activate qwen3-tts
+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 `book_to_convert/` folder. Then run the script. The output mp3 goes to `audiobooks/`.
+
+### Custom voice
+
+```bash
+python audiobook_converter.py
+```
+
+Edit the parameters at the top of `audiobook_converter.py` to change which built-in voice is used.
+
+```
+CUSTOM_VOICE_SPEAKER = "Vivian" # Serena, Vivian, Uncle_Fu, Aiden, Ono_Anna, Sohee, Eric, Dylan
+CUSTOM_VOICE_LANGUAGE = "English"
+CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience."
+```
+
+### Voice clone
+
+```bash
+python audiobook_converter.py --voice-clone --voice-sample path/to/reference.wav
+```
+
+Omit `--voice-sample-text` and Whisper will be used automatically to transcribe the reference audio (`faster_whisper` or `whisper`). If no Whisper backend is installed, it falls back to x-vector-only cloning.
+
+To skip automatic transcription explicitly, pass `--no-transcription`. This should be worse, but in my experience may give a preferable flatter tone to certain voices.
+
+You can override whisper by passing your own transcription with `--voice-sample-text "What the reference audio says"`
+
+### Playback speed
+
+Adjust the speed of the final audiobook without changing pitch (uses ffmpeg `atempo` before encoding). The normal-speed audiobook file is also preserved in the output directory.
+
+```bash
+python audiobook_converter.py --speed 0.9
+```
+
+## FlashAttention (optional)
+
+The server tries to use FlashAttention 2 by default, but `--no-flash-attn` works without it. On supported GPUs FlashAttention can give a modest speedup.
+
+1. Build from source (takes absolutely forever). If you run out of memory, lower MAX_JOBS until you don't.
+
+```bash
+conda activate qwen3-tts
+pip install ninja packaging psutil
+MAX_JOBS=4 pip install --no-build-isolation flash-attn
+```
+
+2. Or pip install a prebuilt wheel matching your torch / CUDA / Python / CXX11-ABI combination:
+
+```bash
+python -c "import torch; print(torch.__version__, torch.version.cuda, torch._C._GLIBCXX_USE_CXX11_ABI)"
+```
+
+Official wheels: https://github.com/Dao-AILab/flash-attention/releases (pick `cp312` + matching `cuX` + `torchX.Y` + `cxx11abiTRUE/FALSE`).
+
+Third-party wheels: https://mjunya.com/flash-attention-prebuild-wheels/ (hosted at https://github.com/mjun0812/flash-attention-prebuild-wheels).
+
+## License
+
+MIT
+
+## Credits
+
+[Qwen3-Audiobook-Converter](https://github.com/WhiskeyCoder/Qwen3-Audiobook-Converter) by WhiskeyCoder.
+[Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS) voice model.
diff --git a/audiobook_converter.py b/audiobook_converter.py
new file mode 100644
index 0000000..c8bd240
--- /dev/null
+++ b/audiobook_converter.py
@@ -0,0 +1,1019 @@
+#!/usr/bin/env python3
+"""
+Qwen-Based Audiobook Converter
+Converts PDFs, EPUBs, DOCX, DOC, TXT files into audiobooks using Qwen Voice API
+
+Author: Rewritten for Qwen Voice Model
+License: MIT
+"""
+
+import os
+import shutil
+import logging
+import hashlib
+import argparse
+from pathlib import Path
+from typing import List, Optional, Dict, Any, Tuple
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import time
+import sys
+import threading
+import contextlib
+import zipfile
+import xml.etree.ElementTree as ET
+from html import unescape
+import re
+from datetime import datetime
+import PyPDF2
+import ebooklib
+from ebooklib import epub
+from pydub import AudioSegment
+from pydub.exceptions import CouldntDecodeError
+from gradio_client import Client, handle_file
+
+# Fix Windows console encoding for emoji/unicode
+if sys.platform == 'win32':
+ try:
+ sys.stdout.reconfigure(encoding='utf-8')
+ sys.stderr.reconfigure(encoding='utf-8')
+ except AttributeError:
+ # Python < 3.7
+ import codecs
+ sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
+ sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
+
+# =============================================================================
+# HARDCODED CONFIGURATION
+# =============================================================================
+
+# Qwen API Configuration
+QWEN_API_URL = "http://127.0.0.1:7860"
+API_TIMEOUT = 300
+MAX_RETRIES = 3
+
+# Hardcoded Voice Settings (Always use 1.7B model)
+CUSTOM_VOICE_SPEAKER = "Vivian"
+CUSTOM_VOICE_LANGUAGE = "English"
+CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience."
+CUSTOM_VOICE_MODEL_SIZE = "1.7B" # Always use 1.7B
+CUSTOM_VOICE_SEED = -1
+CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
+
+# Map canonical speaker names to the display names used by the qwen-tts Gradio demo.
+SPEAKER_DISPLAY_NAMES = {
+ "ryan": "Ryan",
+ "serena": "Serena",
+ "vivian": "Vivian",
+ "uncle_fu": "Uncle Fu",
+ "aiden": "Aiden",
+ "ono_anna": "Ono Anna",
+ "sohee": "Sohee",
+ "eric": "Eric",
+ "dylan": "Dylan",
+}
+
+# Voice Clone Settings (Always use 1.7B model)
+VOICE_CLONE_LANGUAGE = "English"
+VOICE_CLONE_USE_XVECTOR_ONLY = False
+VOICE_CLONE_MODEL_SIZE = "1.7B" # Always use 1.7B
+VOICE_CLONE_MAX_CHUNK_CHARS = 200
+VOICE_CLONE_CHUNK_GAP = 0
+VOICE_CLONE_SEED = -1
+
+# Voice clone requires the Base-model demo (Qwen3-TTS-12Hz-1.7B-Base), which
+# exposes /run_voice_clone. The CustomVoice demo only exposes /run_instruct, so
+# run the Base demo on a separate port and point this at it.
+VOICE_CLONE_API_URL = "http://127.0.0.1:7861"
+
+# Processing Settings
+BOOKS_FOLDER = "book_to_convert" # Input folder
+AUDIOBOOKS_FOLDER = "audiobooks" # Output folder
+CHUNK_SIZE_WORDS = 1500 # Increased to reduce number of chunks and speed up processing
+MAX_WORKERS = 1 # Keep at 1 to avoid rate limiting
+AUDIO_FORMAT = "mp3"
+AUDIO_BITRATE = "128k"
+MIN_DELAY_BETWEEN_CHUNKS = 1 # Reduced delay
+HEARTBEAT_INTERVAL_SECONDS = 30 # Print "still working" progress this often during a chunk
+
+# Optional imports with fallbacks
+try:
+ from docx import Document
+ DOCX_AVAILABLE = True
+except ImportError:
+ DOCX_AVAILABLE = False
+
+try:
+ import docx2txt
+ DOC_AVAILABLE = True
+except ImportError:
+ DOC_AVAILABLE = False
+
+try:
+ from bs4 import BeautifulSoup
+ BS4_AVAILABLE = True
+except ImportError:
+ BS4_AVAILABLE = False
+
+
+class QwenAudiobookConverter:
+ """Audiobook converter using Qwen Voice API"""
+
+ def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None,
+ voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
+ speed: float = 1.0):
+ self.voice_mode = voice_mode
+ self.voice_clone_ref_audio = voice_clone_ref_audio
+ self.voice_clone_ref_text = (voice_clone_ref_text or "").strip()
+ self.skip_transcription = skip_transcription
+ self.speed = speed
+ self.setup_logging()
+ self.setup_directories()
+ self.validate_configuration()
+ self.client = None
+ self.api_info: Dict[str, Any] = {}
+ self.clone_client = None
+ self.clone_api_info: Dict[str, Any] = {}
+ self.init_qwen_client()
+
+ def setup_logging(self):
+ """Setup logging configuration"""
+ Path("logs").mkdir(exist_ok=True)
+ logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s',
+ handlers=[
+ logging.FileHandler(f"logs/audiobook_{datetime.now().strftime('%Y%m%d')}.log"),
+ logging.StreamHandler(sys.stdout)
+ ]
+ )
+ self.logger = logging.getLogger(__name__)
+
+ def setup_directories(self):
+ """Create necessary directories"""
+ directories = [BOOKS_FOLDER, AUDIOBOOKS_FOLDER, "chunks", "cache/audio_chunks", "logs"]
+ for directory in directories:
+ Path(directory).mkdir(parents=True, exist_ok=True)
+
+ def transcribe_audio(self, audio_path: str) -> Optional[str]:
+ """Transcribe reference audio locally using an optional Whisper backend.
+
+ The current qwen-tts demo does not expose a transcription endpoint, so
+ transcription is done client-side when a Whisper package is available.
+ Returns None if no backend is installed.
+ """
+ for backend in ("faster_whisper", "whisper"):
+ try:
+ if backend == "faster_whisper":
+ from faster_whisper import WhisperModel
+ model = WhisperModel("base", device="cpu", compute_type="int8")
+ segments, _ = model.transcribe(audio_path)
+ text = " ".join(seg.text.strip() for seg in segments).strip()
+ else:
+ import whisper
+ model = whisper.load_model("base")
+ result = model.transcribe(audio_path)
+ text = (result.get("text") or "").strip()
+ if text:
+ self.logger.info(f"Transcription complete via {backend}: {text[:100]}...")
+ return text
+ except ImportError:
+ continue
+ except Exception as e:
+ self.logger.warning(f"{backend} transcription failed: {e}")
+ continue
+ self.logger.warning("No Whisper backend available; transcription skipped.")
+ return None
+
+ def validate_configuration(self):
+ """Validate configuration settings"""
+ if self.voice_mode == "voice_clone":
+ if not self.voice_clone_ref_audio:
+ print("[ERROR] Configuration Error!")
+ print("Voice Clone mode requires a reference audio file.")
+ print("Use --voice-sample <path> to specify the reference audio.")
+ sys.exit(1)
+
+ if not Path(self.voice_clone_ref_audio).exists():
+ print("[ERROR] Configuration Error!")
+ print(f"Reference audio file not found: {self.voice_clone_ref_audio}")
+ sys.exit(1)
+
+ # Transcribe the audio if client is available (will be done after init)
+ # For now, we'll transcribe it in init_qwen_client if needed
+
+ def init_qwen_client(self):
+ """Initialize Qwen Gradio client(s)"""
+ try:
+ if self.voice_mode == "voice_clone":
+ # Voice clone uses the Base-model demo, which is a separate server
+ # from the CustomVoice demo (that one only exposes /run_instruct).
+ self._init_client(VOICE_CLONE_API_URL, clone=True)
+ print(f"[OK] Connected to Voice Clone API at {VOICE_CLONE_API_URL}")
+
+ # Resolve the reference transcript: explicit text, then local
+ # transcription, then fall back to x-vector-only mode.
+ if not self.voice_clone_ref_text and self.voice_clone_ref_audio:
+ if self.skip_transcription:
+ print("[INFO] Skipping reference audio transcription (--no-transcription).")
+ else:
+ print("[INFO] Transcribing reference audio for voice cloning...")
+ self.voice_clone_ref_text = self.transcribe_audio(self.voice_clone_ref_audio) or ""
+ if not self.voice_clone_ref_text:
+ print("[WARNING] No reference text available; using x-vector-only clone mode (lower quality).")
+ print(" Pass --voice-sample-text \"...\" for higher-quality in-context cloning.")
+ else:
+ print(f"[OK] Reference text: {self.voice_clone_ref_text[:100]}...")
+ else:
+ self._init_client(QWEN_API_URL, clone=False)
+ print("[OK] Connected to Qwen API")
+ except Exception as e:
+ print("[ERROR] Qwen API initialization failed!")
+ print(f"API endpoint: {VOICE_CLONE_API_URL if self.voice_mode == 'voice_clone' else QWEN_API_URL}")
+ print("Make sure:")
+ print("1. Qwen Gradio server is running")
+ print("2. The server is accessible at the configured URL")
+ print("3. The endpoint URL is correct")
+ print("4. Your installed Qwen3-TTS version matches this converter's API expectations")
+ print(" (voice clone requires the Base-model demo: Qwen/Qwen3-TTS-12Hz-1.7B-Base)")
+ print(f"Error: {e}")
+ sys.exit(1)
+
+ def _init_client(self, url: str, clone: bool = False):
+ """Initialize a Gradio client and store its API metadata."""
+ self.logger.info(f"Connecting to Qwen API at {url}...")
+ import io
+ old_stdout = sys.stdout
+ sys.stdout = io.TextIOWrapper(io.BytesIO(), encoding='utf-8', errors='replace')
+ try:
+ client = Client(url)
+ finally:
+ sys.stdout = old_stdout
+ if clone:
+ self.clone_client = client
+ self.clone_api_info = self._load_api_info(client)
+ else:
+ self.client = client
+ self.api_info = self._load_api_info(client)
+ self.logger.info("Connected to Qwen API")
+
+ def _load_api_info(self, client: "Client" = None) -> Dict[str, Any]:
+ """Load available API metadata from Gradio app."""
+ client = client or self.client
+ try:
+ return client.view_api(return_format="dict")
+ except Exception as exc:
+ self.logger.warning(f"Unable to read API metadata: {exc}")
+ return {}
+
+ def _resolve_api_name(self, *candidates: str, api_info: Optional[Dict[str, Any]] = None) -> str:
+ """Return the first available api_name from candidate list."""
+ info = api_info if api_info is not None else self.api_info
+ named_endpoints = info.get("named_endpoints", {})
+ for candidate in candidates:
+ if candidate in named_endpoints:
+ return candidate
+ return candidates[0]
+
+ def _endpoint_accepts_param(self, api_name: str, param_name: str, api_info: Optional[Dict[str, Any]] = None) -> bool:
+ """Check whether endpoint input schema includes the given parameter."""
+ info = api_info if api_info is not None else self.api_info
+ endpoint = info.get("named_endpoints", {}).get(api_name, {})
+ parameters = endpoint.get("parameters", [])
+ return any(parameter.get("parameter_name") == param_name for parameter in parameters)
+
+ def generate_chunk_via_qwen(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate audio chunk using Qwen API"""
+ try:
+ # Check cache first
+ cache_path = self.get_cache_path(text)
+ if cache_path.exists():
+ output_path = Path("chunks") / f"chunk_{chunk_num:04d}.wav"
+ shutil.copy2(cache_path, output_path)
+ self.logger.debug(f"Using cached audio for chunk {chunk_num}")
+ return str(output_path)
+
+ # Generate audio based on selected mode
+ if self.voice_mode == "custom_voice":
+ with self._chunk_heartbeat(chunk_num):
+ result = self._generate_custom_voice(text)
+ elif self.voice_mode == "voice_clone":
+ with self._chunk_heartbeat(chunk_num):
+ result = self._generate_voice_clone(text)
+ else:
+ raise ValueError(f"Unknown voice mode: {self.voice_mode}")
+
+ if not result or len(result) < 2:
+ raise RuntimeError("Qwen API returned invalid result")
+
+ audio_path = result[0] # First element is the audio file path
+ status = result[1] if len(result) > 1 else ""
+
+ if not audio_path or not Path(audio_path).exists():
+ raise RuntimeError(f"Generated audio file not found: {audio_path}")
+
+ # Copy to chunks directory
+ output_path = Path("chunks") / f"chunk_{chunk_num:04d}.wav"
+ shutil.copy2(audio_path, output_path)
+
+ # Cache the result
+ shutil.copy2(output_path, cache_path)
+
+ self.logger.debug(f"Chunk {chunk_num} generated successfully")
+ return str(output_path)
+
+ except Exception as e:
+ self.logger.error(f"Qwen chunk processing failed for chunk {chunk_num}: {e}")
+ return None
+
+ @contextlib.contextmanager
+ def _chunk_heartbeat(self, chunk_num: int):
+ """Print a periodic "still working" message while a chunk generates."""
+ stop = threading.Event()
+
+ def _beat():
+ start = time.time()
+ while not stop.wait(HEARTBEAT_INTERVAL_SECONDS):
+ elapsed = time.time() - start
+ print(f"[...] Chunk {chunk_num} still generating — "
+ f"{int(elapsed // 60)}m {int(elapsed % 60)}s elapsed", flush=True)
+
+ thread = threading.Thread(target=_beat, daemon=True)
+ thread.start()
+ try:
+ yield
+ finally:
+ stop.set()
+
+ def _generate_custom_voice(self, text: str) -> Tuple:
+ """Generate audio using CustomVoice mode"""
+ custom_api = self._resolve_api_name("/run_instruct", "/run_custom_voice", "/generate_custom_voice")
+ if custom_api == "/run_instruct":
+ payload = dict(
+ text=text,
+ lang_disp=CUSTOM_VOICE_LANGUAGE,
+ spk_disp=SPEAKER_DISPLAY_NAMES.get(CUSTOM_VOICE_SPEAKER.lower(), CUSTOM_VOICE_SPEAKER),
+ instruct=CUSTOM_VOICE_INSTRUCT,
+ )
+ else:
+ payload = dict(
+ text=text,
+ language=CUSTOM_VOICE_LANGUAGE,
+ speaker=CUSTOM_VOICE_SPEAKER,
+ instruct=CUSTOM_VOICE_INSTRUCT,
+ )
+ if self._endpoint_accepts_param(custom_api, "model_id_cv"):
+ payload["model_id_cv"] = CUSTOM_VOICE_MODEL_ID
+ elif self._endpoint_accepts_param(custom_api, "model_size"):
+ payload["model_size"] = CUSTOM_VOICE_MODEL_SIZE
+
+ if self._endpoint_accepts_param(custom_api, "seed"):
+ payload["seed"] = CUSTOM_VOICE_SEED
+
+ return self.client.predict(**payload, api_name=custom_api)
+
+ def _generate_voice_clone(self, text: str) -> Tuple:
+ """Generate audio using Voice Clone mode"""
+ if not Path(self.voice_clone_ref_audio).exists():
+ raise FileNotFoundError(f"Reference audio not found: {self.voice_clone_ref_audio}")
+
+ if self.clone_client is None:
+ raise RuntimeError("Voice Clone client is not initialized. Is the Base-model demo running?")
+
+ clone_api = self._resolve_api_name("/run_voice_clone", "/generate_voice_clone",
+ api_info=self.clone_api_info)
+ use_xvector = VOICE_CLONE_USE_XVECTOR_ONLY or not self.voice_clone_ref_text
+
+ if clone_api == "/run_voice_clone":
+ payload = dict(
+ ref_aud=handle_file(self.voice_clone_ref_audio),
+ ref_txt=self.voice_clone_ref_text,
+ use_xvec=use_xvector,
+ text=text,
+ lang_disp=VOICE_CLONE_LANGUAGE,
+ )
+ else:
+ payload = dict(
+ ref_audio=handle_file(self.voice_clone_ref_audio),
+ ref_text=self.voice_clone_ref_text,
+ target_text=text,
+ language=VOICE_CLONE_LANGUAGE,
+ use_xvector_only=use_xvector,
+ model_size=VOICE_CLONE_MODEL_SIZE,
+ max_chunk_chars=VOICE_CLONE_MAX_CHUNK_CHARS,
+ chunk_gap=VOICE_CLONE_CHUNK_GAP,
+ seed=VOICE_CLONE_SEED,
+ )
+
+ return self.clone_client.predict(**payload, api_name=clone_api)
+
+ def process_chunk_with_retry(self, args: Tuple[int, str]) -> bool:
+ """Process chunk with retry logic and rate limiting"""
+ chunk_num, text = args
+
+ # Small delay between chunks to avoid rate limiting (only if not first chunk)
+ if chunk_num > 1:
+ time.sleep(MIN_DELAY_BETWEEN_CHUNKS)
+
+ for attempt in range(MAX_RETRIES):
+ try:
+ result = self.generate_chunk_via_qwen(text, chunk_num)
+ if result and Path(result).exists():
+ return True
+ else:
+ self.logger.warning(f"Chunk {chunk_num} attempt {attempt + 1} failed")
+ except Exception as e:
+ self.logger.warning(f"Chunk {chunk_num} attempt {attempt + 1} error: {e}")
+
+ if attempt < MAX_RETRIES - 1:
+ sleep_time = 5 + (2 ** attempt)
+ self.logger.info(f"Waiting {sleep_time}s before retry...")
+ time.sleep(sleep_time)
+
+ self.logger.error(f"Chunk {chunk_num} failed after {MAX_RETRIES} attempts")
+ return False
+
+ def get_cache_path(self, text: str) -> Path:
+ """Get cache path for text chunk"""
+ content = f"{text}_{self.voice_mode}_{CUSTOM_VOICE_SPEAKER if self.voice_mode == 'custom_voice' else Path(self.voice_clone_ref_audio).name if self.voice_clone_ref_audio else ''}"
+ hash_obj = hashlib.md5(content.encode())
+ return Path("cache/audio_chunks") / f"{hash_obj.hexdigest()}.wav"
+
+ def extract_text_from_epub(self, file_path: Path) -> str:
+ """Extract text from EPUB with fallback methods"""
+ methods = [
+ self._extract_epub_ebooklib,
+ self._extract_epub_zipfile,
+ self._extract_epub_manual
+ ]
+
+ for method in methods:
+ try:
+ text = method(file_path)
+ if text and text.strip():
+ self.logger.info(f"EPUB extraction successful: {len(text)} characters")
+ return text
+ except Exception as e:
+ self.logger.warning(f"EPUB method failed: {e}")
+ continue
+
+ raise RuntimeError("All EPUB extraction methods failed")
+
+ def _extract_epub_ebooklib(self, file_path: Path) -> str:
+ """Extract using ebooklib"""
+ book = epub.read_epub(str(file_path))
+ text_parts = []
+
+ for item_id, linear in book.spine:
+ try:
+ item = book.get_item_by_id(item_id)
+ if item and isinstance(item, ebooklib.ITEM_DOCUMENT):
+ content = item.get_body_content()
+ if content:
+ if isinstance(content, bytes):
+ content = content.decode('utf-8', errors='ignore')
+ clean_text = self._clean_html(str(content))
+ if clean_text.strip():
+ text_parts.append(clean_text)
+ except Exception:
+ continue
+
+ return '\n\n'.join(text_parts)
+
+ def _extract_epub_zipfile(self, file_path: Path) -> str:
+ """Extract using zipfile parsing"""
+ text_parts = []
+ with zipfile.ZipFile(file_path, 'r') as epub_zip:
+ for file_name in epub_zip.namelist():
+ if file_name.lower().endswith(('.html', '.xhtml', '.htm')):
+ try:
+ content = epub_zip.read(file_name).decode('utf-8', errors='ignore')
+ clean_text = self._clean_html(content)
+ if clean_text.strip():
+ text_parts.append(clean_text)
+ except Exception:
+ continue
+ return '\n\n'.join(text_parts)
+
+ def _extract_epub_manual(self, file_path: Path) -> str:
+ """Manual extraction fallback"""
+ text_parts = []
+ with zipfile.ZipFile(file_path, 'r') as epub_zip:
+ for file_name in epub_zip.namelist():
+ if not any(file_name.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif', '.css', '.js']):
+ try:
+ content = epub_zip.read(file_name).decode('utf-8', errors='ignore')
+ if '<' in content and len(content.strip()) > 100:
+ clean_text = self._clean_html(content)
+ if clean_text:
+ text_parts.append(clean_text)
+ except Exception:
+ continue
+ return '\n\n'.join(text_parts)
+
+ def _clean_html(self, html_content: str) -> str:
+ """Clean HTML content"""
+ if not html_content:
+ return ""
+
+ if BS4_AVAILABLE:
+ try:
+ soup = BeautifulSoup(html_content, 'html.parser')
+ for script in soup(["script", "style"]):
+ script.decompose()
+ text = soup.get_text()
+ lines = (line.strip() for line in text.splitlines())
+ chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
+ return ' '.join(chunk for chunk in chunks if chunk)
+ except Exception:
+ pass
+
+ # Fallback regex cleaning
+ html_content = re.sub(r'<style[^>]*>.*?</style>', '', html_content, flags=re.DOTALL | re.IGNORECASE)
+ html_content = re.sub(r'<script[^>]*>.*?</script>', '', html_content, flags=re.DOTALL | re.IGNORECASE)
+ html_content = re.sub(r'<[^>]+>', ' ', html_content)
+ html_content = unescape(html_content)
+ html_content = re.sub(r'\s+', ' ', html_content)
+ return html_content.strip()
+
+ def extract_text_from_file(self, file_path: Path) -> str:
+ """Extract text from various file formats"""
+ extension = file_path.suffix.lower()
+
+ if extension == '.txt':
+ return self._extract_txt(file_path)
+ elif extension == '.pdf':
+ return self._extract_pdf(file_path)
+ elif extension == '.epub':
+ return self.extract_text_from_epub(file_path)
+ elif extension == '.docx' and DOCX_AVAILABLE:
+ return self._extract_docx(file_path)
+ elif extension == '.doc' and DOC_AVAILABLE:
+ return self._extract_doc(file_path)
+ else:
+ raise ValueError(f"Unsupported file format: {extension}")
+
+ def _extract_txt(self, file_path: Path) -> str:
+ """Extract from TXT with encoding detection"""
+ for encoding in ['utf-8', 'utf-16', 'latin-1', 'cp1252']:
+ try:
+ with open(file_path, 'r', encoding=encoding) as f:
+ return self._clean_text(f.read())
+ except UnicodeDecodeError:
+ continue
+ raise ValueError("Could not decode text file")
+
+ def _extract_pdf(self, file_path: Path) -> str:
+ """Extract from PDF"""
+ text = ""
+ with open(file_path, 'rb') as file:
+ pdf_reader = PyPDF2.PdfReader(file)
+ total_pages = len(pdf_reader.pages)
+ self.logger.info(f"PDF has {total_pages} pages")
+
+ for page_num, page in enumerate(pdf_reader.pages, 1):
+ try:
+ page_text = page.extract_text()
+ if page_text.strip():
+ text += f"\n\n{page_text}"
+ if page_num % 10 == 0:
+ self.logger.debug(f"Extracted {page_num}/{total_pages} pages")
+ except Exception as e:
+ self.logger.warning(f"Failed to extract page {page_num}: {e}")
+ continue
+
+ self.logger.info(f"Extracted text from {total_pages} pages, {len(text)} characters total")
+ return self._clean_text(text)
+
+ def _extract_docx(self, file_path: Path) -> str:
+ """Extract from DOCX"""
+ doc = Document(file_path)
+ text = '\n\n'.join([para.text for para in doc.paragraphs if para.text.strip()])
+ return self._clean_text(text)
+
+ def _extract_doc(self, file_path: Path) -> str:
+ """Extract from DOC"""
+ text = docx2txt.process(str(file_path))
+ return self._clean_text(text) if text else ""
+
+ def _clean_text(self, text: str) -> str:
+ """Clean and normalize text"""
+ if not text:
+ return ""
+ text = re.sub(r'\s+', ' ', text)
+ text = text.replace('\n', ' ')
+ text = re.sub(r'\b\d{1,3}\b(?=\s|$)', '', text)
+ return text.strip()
+
+ def split_into_chunks(self, text: str) -> List[str]:
+ """Split text into manageable chunks"""
+ if not text.strip():
+ return []
+
+ sentences = re.split(r'(?<=[.!?])\s+', text)
+ chunks = []
+ current_chunk = ""
+ current_words = 0
+
+ for sentence in sentences:
+ sentence_words = len(sentence.split())
+
+ if sentence_words > CHUNK_SIZE_WORDS:
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = ""
+ current_words = 0
+
+ # Split long sentences
+ parts = re.split(r'[,;:]', sentence)
+ for part in parts:
+ part_words = len(part.split())
+ if current_words + part_words <= CHUNK_SIZE_WORDS:
+ current_chunk += part + " "
+ current_words += part_words
+ else:
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = part + " "
+ current_words = part_words
+ else:
+ if current_words + sentence_words <= CHUNK_SIZE_WORDS:
+ current_chunk += sentence + " "
+ current_words += sentence_words
+ else:
+ if current_chunk:
+ chunks.append(current_chunk.strip())
+ current_chunk = sentence + " "
+ current_words = sentence_words
+
+ if current_chunk.strip():
+ chunks.append(current_chunk.strip())
+
+ return [chunk for chunk in chunks if chunk.strip()]
+
+ def _speed_export_params(self) -> List[str]:
+ """Return ffmpeg filter args for pitch-preserving speed adjustment, if any.
+
+ Uses ffmpeg's atempo filter, which accepts 0.5..2.0 per filter. Values
+ outside that range are handled by chaining multiple atempo filters.
+ """
+ if not self.speed or abs(self.speed - 1.0) < 1e-6:
+ return []
+ remaining = float(self.speed)
+ chain = []
+ while remaining > 2.0:
+ chain.append("atempo=2.0")
+ remaining /= 2.0
+ while remaining < 0.5:
+ chain.append("atempo=0.5")
+ remaining /= 0.5
+ chain.append(f"atempo={remaining:g}")
+ return ["-filter:a", ",".join(chain)]
+
+ def combine_chunks(self, total_chunks: int, output_path: Path, results: Optional[Dict[int, bool]] = None) -> bool:
+ """Combine audio chunks into final audiobook"""
+ try:
+ combined = AudioSegment.empty()
+ successful = 0
+ missing_chunks = []
+
+ for i in range(1, total_chunks + 1):
+ # Skip chunks that failed if we have results tracking
+ if results is not None and not results.get(i, False):
+ missing_chunks.append(i)
+ continue
+
+ chunk_file = Path("chunks") / f"chunk_{i:04d}.wav"
+ if chunk_file.exists():
+ try:
+ chunk_audio = AudioSegment.from_wav(str(chunk_file))
+ combined += chunk_audio
+ successful += 1
+ if successful % 10 == 0:
+ self.logger.info(f"Combined {successful} chunks")
+ except Exception as e:
+ self.logger.warning(f"Failed to load chunk {i}: {e}")
+ missing_chunks.append(i)
+ else:
+ self.logger.warning(f"Chunk file not found: {chunk_file}")
+ missing_chunks.append(i)
+
+ if successful == 0:
+ raise RuntimeError("No valid chunks found")
+
+ if missing_chunks:
+ self.logger.warning(f"Missing chunks: {missing_chunks}")
+
+ combined.export(str(output_path), format=AUDIO_FORMAT, bitrate=AUDIO_BITRATE)
+ self.logger.info(f"Audiobook saved: {output_path} ({successful}/{total_chunks} chunks)")
+ print(f"[INFO] Saved audiobook: {output_path.name} ({successful}/{total_chunks} chunks)")
+
+ export_params = self._speed_export_params()
+ if export_params:
+ speed_path = output_path.with_name(f"{output_path.stem}_{self.speed:g}{output_path.suffix}")
+ combined.export(str(speed_path), format=AUDIO_FORMAT, bitrate=AUDIO_BITRATE,
+ parameters=export_params)
+ self.logger.info(f"Saved speed-adjusted audiobook ({self.speed:g}x): {speed_path}")
+ print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({self.speed:g}x)")
+
+ if missing_chunks:
+ print(f"[WARNING] Missing chunks: {missing_chunks}")
+ return True
+
+ except Exception as e:
+ self.logger.error(f"Failed to combine chunks: {e}")
+ import traceback
+ self.logger.error(traceback.format_exc())
+ return False
+
+ def cleanup_chunks(self):
+ """Remove temporary chunk files and cache"""
+ try:
+ # Clean up chunks folder
+ chunk_count = 0
+ for chunk_file in Path("chunks").glob("chunk_*.wav"):
+ try:
+ chunk_file.unlink()
+ chunk_count += 1
+ except Exception as e:
+ self.logger.warning(f"Failed to delete {chunk_file}: {e}")
+
+ # Clean up cache folder
+ cache_count = 0
+ cache_dir = Path("cache/audio_chunks")
+ if cache_dir.exists():
+ for cache_file in cache_dir.glob("*.wav"):
+ try:
+ cache_file.unlink()
+ cache_count += 1
+ except Exception as e:
+ self.logger.warning(f"Failed to delete cache file {cache_file}: {e}")
+
+ if chunk_count > 0 or cache_count > 0:
+ self.logger.info(f"Cleaned up {chunk_count} chunk files and {cache_count} cache files")
+ print(f"[INFO] Cleaned up {chunk_count} chunk files and {cache_count} cache files")
+ except Exception as e:
+ self.logger.warning(f"Cleanup failed: {e}")
+
+ def convert_book(self, file_path: Path) -> bool:
+ """Convert a single book to audiobook using Qwen API"""
+ self.logger.info(f"Converting: {file_path.name}")
+ start_time = time.time()
+
+ try:
+ # Extract text
+ self.logger.info("Extracting text...")
+ text = self.extract_text_from_file(file_path)
+ if not text.strip():
+ self.logger.error("No text extracted")
+ return False
+
+ self.logger.info(f"Extracted {len(text)} characters ({len(text.split())} words)")
+
+ # Split into chunks
+ chunks = self.split_into_chunks(text)
+ total_chunks = len(chunks)
+ if total_chunks == 0:
+ self.logger.error("No chunks created")
+ return False
+
+ # Log chunk info
+ chunk_sizes = [len(chunk.split()) for chunk in chunks]
+ avg_chunk_size = sum(chunk_sizes) / len(chunk_sizes) if chunk_sizes else 0
+ self.logger.info(f"Split into {total_chunks} chunks (avg {avg_chunk_size:.0f} words per chunk)")
+ print(f"[INFO] Processing {total_chunks} chunks via Qwen API...")
+ print(f"[INFO] Estimated time: ~{total_chunks * 4} minutes (4 min per chunk)")
+
+ # Process chunks - process in order to ensure correct naming
+ chunk_args = [(i + 1, chunk) for i, chunk in enumerate(chunks)]
+
+ print(f"\n{'=' * 50}")
+ print(f"PROCESSING {total_chunks} CHUNKS")
+ print(f"{'=' * 50}")
+
+ # Track results by chunk number
+ results = {} # chunk_num -> success (bool)
+
+ # Process chunks sequentially to ensure correct order and naming
+ # This ensures chunks are named 1, 2, 3, 4... in order
+ for chunk_num, chunk_text in chunk_args:
+ try:
+ result = self.process_chunk_with_retry((chunk_num, chunk_text))
+ results[chunk_num] = result
+
+ if result:
+ print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed")
+ self.logger.info(f"+ Chunk {chunk_num}/{total_chunks} completed")
+ else:
+ print(f"[FAIL] Chunk {chunk_num:3d}/{total_chunks} FAILED")
+ self.logger.error(f"- Chunk {chunk_num}/{total_chunks} failed")
+
+ except Exception as e:
+ results[chunk_num] = False
+ print(f"[ERROR] Chunk {chunk_num:3d}/{total_chunks} ERROR: {e}")
+ self.logger.error(f"- Chunk {chunk_num}/{total_chunks} error: {e}")
+
+ successful_chunks = sum(1 for v in results.values() if v)
+ print(f"\n{'=' * 50}")
+ print(f"CHUNK PROCESSING COMPLETE")
+ print(f"Successful: {successful_chunks}/{total_chunks}")
+ print(f"{'=' * 50}")
+ self.logger.info(f"Qwen processing completed: {successful_chunks}/{total_chunks} chunks")
+
+ if successful_chunks == 0:
+ self.logger.error("No chunks were successfully processed")
+ self.cleanup_chunks() # Cleanup even on failure
+ return False
+
+ if successful_chunks < total_chunks:
+ self.logger.warning(f"Only {successful_chunks}/{total_chunks} chunks succeeded. Proceeding with partial audiobook.")
+
+ # Combine chunks (only the successful ones)
+ output_path = Path(AUDIOBOOKS_FOLDER) / f"{file_path.stem}.{AUDIO_FORMAT}"
+ success = self.combine_chunks(total_chunks, output_path, results)
+
+ if success:
+ duration = time.time() - start_time
+ minutes = int(duration // 60)
+ seconds = int(duration % 60)
+ self.logger.info(f"Conversion completed in {minutes}m {seconds}s: {output_path}")
+ print(f"[SUCCESS] Conversion completed in {minutes}m {seconds}s")
+ else:
+ self.logger.error("Failed to combine chunks into final audiobook")
+
+ # Always cleanup, even on failure
+ self.cleanup_chunks()
+ return success
+
+ except Exception as e:
+ self.logger.error(f"Conversion failed: {e}")
+ import traceback
+ self.logger.error(traceback.format_exc())
+ # Cleanup on exception
+ self.cleanup_chunks()
+ return False
+
+ def run(self):
+ """Main conversion process"""
+ print("=" * 70)
+ print("QWEN-BASED AUDIOBOOK CONVERTER")
+ print("=" * 70)
+ print(f"Books folder: {BOOKS_FOLDER}")
+ print(f"Output folder: {AUDIOBOOKS_FOLDER}")
+ print(f"Qwen API endpoint: {QWEN_API_URL}")
+ print(f"Voice mode: {self.voice_mode}")
+ print(f"Model size: 1.7B (always)")
+ if self.voice_mode == "custom_voice":
+ print(f"Speaker: {CUSTOM_VOICE_SPEAKER}")
+ print(f"Language: {CUSTOM_VOICE_LANGUAGE}")
+ elif self.voice_mode == "voice_clone":
+ print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}")
+ print(f"Language: {VOICE_CLONE_LANGUAGE}")
+ print(f"Output format: {AUDIO_FORMAT}")
+ print(f"Max workers: {MAX_WORKERS}")
+ if abs(self.speed - 1.0) >= 1e-6:
+ print(f"Playback speed: {self.speed:g}x")
+ print("=" * 70)
+
+ # Check for books
+ books_dir = Path(BOOKS_FOLDER)
+ supported_formats = ['.txt', '.pdf', '.epub']
+ if DOCX_AVAILABLE:
+ supported_formats.append('.docx')
+ if DOC_AVAILABLE:
+ supported_formats.append('.doc')
+
+ book_files = [f for f in books_dir.iterdir()
+ if f.is_file() and f.suffix.lower() in supported_formats]
+
+ if not book_files:
+ print(f"[INFO] No supported files found in {BOOKS_FOLDER}")
+ print(f"Supported formats: {', '.join(supported_formats)}")
+
+ # Create sample file
+ sample_file = books_dir / "sample.txt"
+ with open(sample_file, 'w') as f:
+ f.write("This is a sample audiobook for testing the Qwen-based converter. "
+ "The system will send this text to the Qwen API for voice generation. "
+ "You can replace this file with your own books to convert.")
+ print(f"[INFO] Created sample file: {sample_file}")
+ return
+
+ print(f"[INFO] Found {len(book_files)} books to convert")
+
+ # Convert each book
+ results = {}
+ for book_file in book_files:
+ try:
+ success = self.convert_book(book_file)
+ results[book_file.name] = success
+ except KeyboardInterrupt:
+ print("\n[WARNING] Conversion interrupted by user")
+ break
+ except Exception as e:
+ self.logger.error(f"Unexpected error: {e}")
+ results[book_file.name] = False
+
+ # Print summary
+ successful = sum(results.values())
+ total = len(results)
+
+ print("\n" + "=" * 70)
+ print("CONVERSION SUMMARY")
+ print("=" * 70)
+ print(f"Total: {total} | Success: {successful} | Failed: {total - successful}")
+ print("=" * 70)
+
+ for filename, success in results.items():
+ status = "[OK]" if success else "[FAIL]"
+ print(f"{status} {filename}")
+
+ if successful > 0:
+ print(f"\n[INFO] Audiobooks saved to: {AUDIOBOOKS_FOLDER}/")
+
+
+def main():
+ """Entry point with argparse"""
+ parser = argparse.ArgumentParser(
+ description="Convert books to audiobooks using Qwen Voice Model",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ # Use custom voice (default - Ryan speaker)
+ python audiobook_converter.py
+
+ # Use voice cloning with reference audio
+ python audiobook_converter.py --voice-clone --voice-sample path/to/reference.wav
+ """
+ )
+
+ parser.add_argument(
+ "--voice-clone",
+ action="store_true",
+ help="Use voice cloning mode instead of custom voice (requires --voice-sample)"
+ )
+
+ parser.add_argument(
+ "--voice-sample",
+ type=str,
+ help="Path to reference audio file for voice cloning (WAV format)."
+ )
+
+ parser.add_argument(
+ "--voice-sample-text",
+ type=str,
+ default=None,
+ help=("Transcript of the reference audio for in-context cloning (recommended for "
+ "highest quality). If omitted, a local Whisper backend is used if installed; "
+ "otherwise the converter falls back to x-vector-only mode.")
+ )
+
+ parser.add_argument(
+ "--no-transcription",
+ action="store_true",
+ help=("Skip automatic transcription of the reference audio (use x-vector-only "
+ "cloning). Ignored when --voice-sample-text is provided.")
+ )
+
+ parser.add_argument(
+ "--speed",
+ type=float,
+ default=1.0,
+ help="Playback speed factor for the final audiobook (1.0 = normal). Pitch-preserving."
+ )
+
+ args = parser.parse_args()
+
+ # Determine voice mode
+ if args.voice_clone:
+ if not args.voice_sample:
+ print("[ERROR] --voice-clone requires --voice-sample")
+ print("Usage: python audiobook_converter.py --voice-clone --voice-sample <path> [--voice-sample-text \"...\"]")
+ sys.exit(1)
+ voice_mode = "voice_clone"
+ voice_clone_ref_audio = args.voice_sample
+ voice_clone_ref_text = args.voice_sample_text
+ else:
+ voice_mode = "custom_voice"
+ voice_clone_ref_audio = None
+ voice_clone_ref_text = None
+
+ try:
+ converter = QwenAudiobookConverter(
+ voice_mode=voice_mode,
+ voice_clone_ref_audio=voice_clone_ref_audio,
+ voice_clone_ref_text=voice_clone_ref_text,
+ skip_transcription=args.no_transcription,
+ speed=args.speed,
+ )
+ converter.run()
+ except KeyboardInterrupt:
+ print("\n[WARNING] Shutdown requested by user")
+ except Exception as e:
+ print(f"[FATAL] Fatal error: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/audiobooks/test.mp3 b/audiobooks/test.mp3
new file mode 100644
index 0000000..ffaeed6
--- /dev/null
+++ b/audiobooks/test.mp3
Binary files differ
diff --git a/audiobooks/test_0.8.mp3 b/audiobooks/test_0.8.mp3
new file mode 100644
index 0000000..d867139
--- /dev/null
+++ b/audiobooks/test_0.8.mp3
Binary files differ
diff --git a/book_to_convert/test.txt b/book_to_convert/test.txt
new file mode 100644
index 0000000..a316be8
--- /dev/null
+++ b/book_to_convert/test.txt
@@ -0,0 +1 @@
+The truth about the world, he said, is that anything is possible. Had you not seen it all from birth and thereby bled it of its strangeness it would appear to you for what it is, a hat trick in a medicine show, a fevered dream, a trance bepopulate with chimeras having neither analogue nor precedent, an itinerant carnival, a migratory tentshow whose ultimate destination after many a pitch in a many a mudded field is unspeakable and calamitous beyond reckoning.
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..a586be4
--- /dev/null
+++ b/config.py
@@ -0,0 +1,89 @@
+# =============================================================================
+# QWEN API CONFIGURATION
+# =============================================================================
+
+QWEN_API_URL = "http://127.0.0.1:7860" # Qwen Gradio API endpoint
+API_TIMEOUT = 300 # 5 minutes per chunk
+MAX_RETRIES = 3 # Retry failed chunks
+
+# =============================================================================
+# VOICE GENERATION MODE
+# =============================================================================
+
+# Options: "custom_voice", "voice_clone", "voice_design"
+VOICE_MODE = "custom_voice"
+
+# =============================================================================
+# CUSTOM VOICE SETTINGS (Pre-built speakers)
+# =============================================================================
+# Use this mode for high-quality pre-built voices
+# Best for: General audiobook narration
+
+CUSTOM_VOICE_SPEAKER = "Ryan" # Options: Aiden, Dylan, Eric, Ono_anna, Ryan, Serena, Sohee, Uncle_fu, Vivian
+CUSTOM_VOICE_LANGUAGE = "English" # Auto, Chinese, English, Japanese, Korean, French, German, Spanish, Portuguese, Russian
+CUSTOM_VOICE_INSTRUCT = "Speak naturally and clearly, as if reading a book." # Style instruction (1.7B only)
+CUSTOM_VOICE_MODEL_SIZE = "1.7B" # 0.6B or 1.7B
+CUSTOM_VOICE_SEED = -1 # -1 for auto, or specific seed for consistency
+
+# =============================================================================
+# VOICE CLONE SETTINGS (Custom voice from reference audio)
+# =============================================================================
+# Use this mode to clone a specific voice from a reference audio file
+# Best for: Cloning a specific person's voice
+
+VOICE_CLONE_REF_AUDIO = "" # Path to reference audio file (WAV format)
+VOICE_CLONE_REF_TEXT = "" # Text matching what's spoken in the reference audio
+VOICE_CLONE_LANGUAGE = "Auto"
+VOICE_CLONE_USE_XVECTOR_ONLY = False # True for lower quality but faster (no text needed)
+VOICE_CLONE_MODEL_SIZE = "1.7B" # 0.6B or 1.7B
+VOICE_CLONE_MAX_CHUNK_CHARS = 200 # Maximum characters per chunk
+VOICE_CLONE_CHUNK_GAP = 0 # Gap between chunks in seconds
+VOICE_CLONE_SEED = -1 # -1 for auto
+
+# =============================================================================
+# VOICE DESIGN SETTINGS (Describe the voice you want)
+# =============================================================================
+# Use this mode to generate speech with a specific tone/emotion
+# Best for: Expressive narration, character voices
+# Note: Only available with 1.7B model
+
+VOICE_DESIGN_LANGUAGE = "Auto"
+VOICE_DESIGN_DESCRIPTION = "Speak in a clear, professional narrator voice suitable for reading audiobooks."
+VOICE_DESIGN_SEED = -1 # -1 for auto
+
+# =============================================================================
+# PROCESSING SETTINGS
+# =============================================================================
+
+BOOKS_FOLDER = "books_to_convert" # Input folder for books
+CHUNK_SIZE_WORDS = 1200 # Words per chunk (adjust based on your needs)
+MAX_WORKERS = 1 # Concurrent chunks (keep at 1 to avoid rate limiting)
+MIN_DELAY_BETWEEN_CHUNKS = 2 # Seconds delay between API calls
+
+# =============================================================================
+# AUDIO OUTPUT SETTINGS
+# =============================================================================
+
+AUDIO_FORMAT = "mp3" # Output format: mp3, wav, m4a
+AUDIO_BITRATE = "128k" # Audio quality: 64k, 128k, 192k, 256k, 320k
+
+# =============================================================================
+# ADVANCED SETTINGS
+# =============================================================================
+
+# Supported file extensions
+SUPPORTED_FORMATS = ['.txt', '.pdf', '.epub', '.docx', '.doc']
+
+# Text cleaning options
+CLEAN_PAGE_NUMBERS = True # Remove standalone numbers
+NORMALIZE_WHITESPACE = True # Clean up spacing
+SENTENCE_BOUNDARY_DETECTION = True # Smart sentence splitting
+
+# Cache settings
+ENABLE_CACHING = True # Cache processed chunks
+CACHE_CLEANUP_DAYS = 30 # Remove cache older than X days
+
+# Logging settings
+LOG_LEVEL = "INFO" # DEBUG, INFO, WARNING, ERROR
+LOG_TO_FILE = True # Save logs to file
+LOG_TO_CONSOLE = True # Display logs in terminal
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..8aee350
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,16 @@
+# Core dependencies for Qwen Audiobook Converter
+gradio_client>=0.7.0
+requests>=2.28.0
+PyPDF2>=3.0.0
+ebooklib>=0.18
+pydub>=0.25.1
+
+# Optional dependencies for additional format support
+python-docx>=0.8.11
+docx2txt>=0.8
+beautifulsoup4>=4.11.0
+faster-whisper>=1.0.0
+
+# Audio processing
+# Note: ffmpeg is required for pydub audio processing
+# Install separately: https://ffmpeg.org/download.html