aboutsummaryrefslogtreecommitdiff
path: root/converter/audio.py
diff options
context:
space:
mode:
Diffstat (limited to 'converter/audio.py')
-rw-r--r--converter/audio.py117
1 files changed, 117 insertions, 0 deletions
diff --git a/converter/audio.py b/converter/audio.py
new file mode 100644
index 0000000..ab55e9b
--- /dev/null
+++ b/converter/audio.py
@@ -0,0 +1,117 @@
+"""Audio assembly: combining chunks, speed adjustment, cleanup."""
+
+import logging
+import traceback
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from . import config
+
+logger = logging.getLogger(__name__)
+
+
+def speed_export_params(speed: float) -> List[str]:
+ """Return ffmpeg filter args for pitch-preserving speed adjustment.
+
+ 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 speed <= 0:
+ raise ValueError(f"Speed must be a positive number, got {speed}")
+ if abs(speed - 1.0) < 1e-6:
+ return []
+ remaining = float(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(total_chunks: int, output_path: Path,
+ results: Optional[Dict[int, bool]] = None, speed: float = 1.0) -> bool:
+ """Combine audio chunks into the final audiobook.
+
+ ``results`` maps chunk numbers to success flags; failed chunks are
+ skipped. When ``speed`` differs from 1.0, an additional speed-adjusted
+ copy is written next to the normal-speed file.
+ """
+ try:
+ from pydub import AudioSegment
+ except ImportError:
+ logger.error("pydub is required to combine audio chunks (pip install pydub)")
+ return False
+
+ 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 = config.CHUNKS_FOLDER / f"chunk_{i:04d}.wav"
+ if chunk_file.exists():
+ try:
+ combined += AudioSegment.from_wav(str(chunk_file))
+ successful += 1
+ if successful % 10 == 0:
+ logger.info("Combined %d chunks", successful)
+ except Exception as exc:
+ logger.warning("Failed to load chunk %d: %s", i, exc)
+ missing_chunks.append(i)
+ else:
+ logger.warning("Chunk file not found: %s", chunk_file)
+ missing_chunks.append(i)
+
+ if successful == 0:
+ raise RuntimeError("No valid chunks found")
+
+ if missing_chunks:
+ logger.warning("Missing chunks: %s", missing_chunks)
+
+ combined.export(str(output_path), format=config.AUDIO_FORMAT, bitrate=config.AUDIO_BITRATE)
+ logger.info("Audiobook saved: %s (%d/%d chunks)", output_path, successful, total_chunks)
+ print(f"[INFO] Saved audiobook: {output_path.name} ({successful}/{total_chunks} chunks)")
+
+ export_params = speed_export_params(speed)
+ if export_params:
+ speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}")
+ combined.export(str(speed_path), format=config.AUDIO_FORMAT,
+ bitrate=config.AUDIO_BITRATE, parameters=export_params)
+ logger.info("Saved speed-adjusted audiobook (%gx): %s", speed, speed_path)
+ print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({speed:g}x)")
+
+ if missing_chunks:
+ print(f"[WARNING] Missing chunks: {missing_chunks}")
+ return True
+
+ except Exception as exc:
+ logger.error("Failed to combine chunks: %s", exc)
+ logger.error(traceback.format_exc())
+ return False
+
+
+def cleanup_chunks() -> None:
+ """Remove temporary chunk files from the scratch folder."""
+ try:
+ chunk_count = 0
+ for chunk_file in config.CHUNKS_FOLDER.glob("chunk_*.wav"):
+ try:
+ chunk_file.unlink()
+ chunk_count += 1
+ except Exception as exc:
+ logger.warning("Failed to delete %s: %s", chunk_file, exc)
+
+ if chunk_count > 0:
+ logger.info("Cleaned up %d chunk files", chunk_count)
+ print(f"[INFO] Cleaned up {chunk_count} chunk files")
+ except Exception as exc:
+ logger.warning("Cleanup failed: %s", exc)