"""Orchestrates book-to-audiobook conversion.""" import logging import sys import time import traceback from datetime import datetime from pathlib import Path from typing import Optional from . import audio, chunking, config, extractors from .tts import QwenTTSClient logger = logging.getLogger(__name__) def setup_logging() -> None: """Configure logging to both a dated file and the console.""" config.LOGS_FOLDER.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ logging.FileHandler( config.LOGS_FOLDER / f"audiobook_{datetime.now():%Y%m%d}.log", encoding="utf-8", ), logging.StreamHandler(sys.stdout), ], ) def setup_directories() -> None: """Create necessary directories.""" for directory in (config.BOOKS_FOLDER, config.AUDIOBOOKS_FOLDER, config.CHUNKS_FOLDER, config.LOGS_FOLDER): Path(directory).mkdir(parents=True, exist_ok=True) class AudiobookConverter: """Audiobook converter using the Qwen TTS 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): if speed <= 0: raise ValueError(f"Speed must be a positive number, got {speed}") self.voice_mode = voice_mode self.voice_clone_ref_audio = voice_clone_ref_audio self.speed = speed self._validate_configuration() self.tts = QwenTTSClient( voice_mode=voice_mode, voice_clone_ref_audio=voice_clone_ref_audio, voice_clone_ref_text=voice_clone_ref_text, skip_transcription=skip_transcription, ) def _validate_configuration(self) -> None: """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 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) def convert_book(self, file_path: Path) -> bool: """Convert a single book to an audiobook.""" logger.info("Converting: %s", file_path.name) start_time = time.time() try: # Start from a clean scratch folder so a previous crash can never # affect this run audio.cleanup_chunks() # Extract text logger.info("Extracting text...") text = extractors.extract_text(file_path) if not text.strip(): logger.error("No text extracted") return False logger.info("Extracted %d characters (%d words)", len(text), len(text.split())) # Split into chunks chunks = chunking.split_into_chunks(text) total_chunks = len(chunks) if total_chunks == 0: 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) logger.info("Split into %d chunks (avg %.0f words per chunk)", total_chunks, avg_chunk_size) print(f"[INFO] Processing {total_chunks} chunks via Qwen API...") print(f"[INFO] Estimated time: ~{total_chunks * 4} minutes (4 min per chunk)") print(f"\n{'=' * 50}") print(f"PROCESSING {total_chunks} CHUNKS") print(f"{'=' * 50}") # Process chunks sequentially to ensure correct order and naming: # chunks are named 1, 2, 3, 4... in order. results = {} # chunk_num -> success (bool) for chunk_num, chunk_text in enumerate(chunks, 1): try: result = self.tts.process_chunk_with_retry(chunk_num, chunk_text) results[chunk_num] = result if result: print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed") logger.info("+ Chunk %d/%d completed", chunk_num, total_chunks) else: print(f"[FAIL] Chunk {chunk_num:3d}/{total_chunks} FAILED") logger.error("- Chunk %d/%d failed", chunk_num, total_chunks) except Exception as exc: results[chunk_num] = False print(f"[ERROR] Chunk {chunk_num:3d}/{total_chunks} ERROR: {exc}") logger.error("- Chunk %d/%d error: %s", chunk_num, total_chunks, exc) successful_chunks = sum(1 for v in results.values() if v) print(f"\n{'=' * 50}") print("CHUNK PROCESSING COMPLETE") print(f"Successful: {successful_chunks}/{total_chunks}") print(f"{'=' * 50}") logger.info("Qwen processing completed: %d/%d chunks", successful_chunks, total_chunks) if successful_chunks == 0: logger.error("No chunks were successfully processed") audio.cleanup_chunks() # Cleanup even on failure return False if successful_chunks < total_chunks: logger.warning("Only %d/%d chunks succeeded. Proceeding with partial audiobook.", successful_chunks, total_chunks) # Combine chunks (only the successful ones) output_path = config.AUDIOBOOKS_FOLDER / f"{file_path.stem}.{config.AUDIO_FORMAT}" success = audio.combine_chunks(total_chunks, output_path, results, speed=self.speed) if success: duration = time.time() - start_time minutes = int(duration // 60) seconds = int(duration % 60) logger.info("Conversion completed in %dm %ds: %s", minutes, seconds, output_path) print(f"[SUCCESS] Conversion completed in {minutes}m {seconds}s") else: logger.error("Failed to combine chunks into final audiobook") # Always cleanup, even on failure audio.cleanup_chunks() return success except Exception as exc: logger.error("Conversion failed: %s", exc) logger.error(traceback.format_exc()) # Cleanup on exception audio.cleanup_chunks() return False def run(self) -> None: """Main conversion process.""" api_url = config.VOICE_CLONE_API_URL if self.voice_mode == "voice_clone" else config.QWEN_API_URL print("=" * 70) print("QWEN-BASED AUDIOBOOK CONVERTER") print("=" * 70) print(f"Books folder: {config.BOOKS_FOLDER}") print(f"Output folder: {config.AUDIOBOOKS_FOLDER}") print(f"Qwen API endpoint: {api_url}") print(f"Voice mode: {self.voice_mode}") print("Model size: 1.7B (always)") if self.voice_mode == "custom_voice": print(f"Speaker: {config.CUSTOM_VOICE_SPEAKER}") print(f"Language: {config.CUSTOM_VOICE_LANGUAGE}") elif self.voice_mode == "voice_clone": print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") print(f"Language: {config.VOICE_CLONE_LANGUAGE}") print(f"Output format: {config.AUDIO_FORMAT}") if abs(self.speed - 1.0) >= 1e-6: print(f"Playback speed: {self.speed:g}x") print("=" * 70) # Check for books book_files = sorted( f for f in config.BOOKS_FOLDER.iterdir() if f.is_file() and f.suffix.lower() in config.SUPPORTED_FORMATS ) if not book_files: print(f"[INFO] No supported files found in {config.BOOKS_FOLDER}") print(f"Supported formats: {', '.join(config.SUPPORTED_FORMATS)}") # Create sample file sample_file = config.BOOKS_FOLDER / "sample.txt" sample_file.write_text( "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.", encoding="utf-8", ) 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 exc: logger.error("Unexpected error: %s", exc) 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: {config.AUDIOBOOKS_FOLDER}/")