aboutsummaryrefslogtreecommitdiff
path: root/converter/converter.py
diff options
context:
space:
mode:
Diffstat (limited to 'converter/converter.py')
-rw-r--r--converter/converter.py244
1 files changed, 244 insertions, 0 deletions
diff --git a/converter/converter.py b/converter/converter.py
new file mode 100644
index 0000000..eea84ed
--- /dev/null
+++ b/converter/converter.py
@@ -0,0 +1,244 @@
+"""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 <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)
+
+ 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}/")