aboutsummaryrefslogtreecommitdiff
path: root/converter/audio.py
blob: ab55e9bd4173185d350c181d7b6b29fc243bbb05 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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)