83 lines
2.7 KiB
Python
83 lines
2.7 KiB
Python
|
|
"""Robust audio preprocessing.
|
||
|
|
|
||
|
|
Libsyn MP3s confuse faster-whisper's decoder on this box (bogus multi-channel
|
||
|
|
allocation). We therefore demux+decode ourselves with PyAV in bounded chunks,
|
||
|
|
downmix to mono, resample to 16 kHz s16, and write a plain WAV that Whisper
|
||
|
|
ingests instantly.
|
||
|
|
|
||
|
|
Full-length prep of a 3 h episode takes ~1 min and ~340 MB; the file is
|
||
|
|
deleted by transcribe.py afterwards unless DS_PREP_KEEP=1.
|
||
|
|
"""
|
||
|
|
import os
|
||
|
|
import wave
|
||
|
|
|
||
|
|
import av
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
from config import AUDIO_DIR
|
||
|
|
from download import slug as ep_slug
|
||
|
|
|
||
|
|
PREP_DIR = os.path.join(AUDIO_DIR, "_prep")
|
||
|
|
TARGET_RATE = 16000
|
||
|
|
|
||
|
|
|
||
|
|
def prepared_path(ep: dict) -> str:
|
||
|
|
return os.path.join(PREP_DIR, ep_slug(ep) + ".16k.wav")
|
||
|
|
|
||
|
|
|
||
|
|
def prepare_audio(src_mp3: str, dst_wav: str,
|
||
|
|
max_seconds: float | None = None) -> str:
|
||
|
|
os.makedirs(PREP_DIR, exist_ok=True)
|
||
|
|
if os.path.exists(dst_wav):
|
||
|
|
return dst_wav
|
||
|
|
|
||
|
|
resampler = av.AudioResampler(format="s16", layout="mono",
|
||
|
|
rate=TARGET_RATE)
|
||
|
|
total_written = 0
|
||
|
|
limit = int(max_seconds * TARGET_RATE) if max_seconds else None
|
||
|
|
|
||
|
|
tmp = dst_wav + ".tmp"
|
||
|
|
with av.open(src_mp3, metadata_errors="ignore") as container:
|
||
|
|
# pick the first real audio stream explicitly
|
||
|
|
streams = [s for s in container.streams.audio]
|
||
|
|
if not streams:
|
||
|
|
raise ValueError(f"no audio stream in {src_mp3}")
|
||
|
|
stream = streams[0]
|
||
|
|
|
||
|
|
import wave
|
||
|
|
wf = wave.open(tmp, "wb")
|
||
|
|
wf.setnchannels(1)
|
||
|
|
wf.setsampwidth(2)
|
||
|
|
wf.setframerate(TARGET_RATE)
|
||
|
|
try:
|
||
|
|
for frame in container.decode(stream):
|
||
|
|
resampled = resampler.resample(frame)
|
||
|
|
for rf in resampled:
|
||
|
|
arr = rf.to_ndarray()
|
||
|
|
if arr.ndim == 2: # (1, samples) planar mono
|
||
|
|
arr = arr[0]
|
||
|
|
pcm = arr.astype(np.int16).tobytes()
|
||
|
|
n = len(pcm) // 2
|
||
|
|
if limit and total_written + n > limit:
|
||
|
|
n = limit - total_written
|
||
|
|
pcm = pcm[: n * 2]
|
||
|
|
wf.writeframes(pcm)
|
||
|
|
total_written += n
|
||
|
|
if limit and total_written >= limit:
|
||
|
|
raise StopIteration
|
||
|
|
except StopIteration:
|
||
|
|
pass
|
||
|
|
finally:
|
||
|
|
wf.close()
|
||
|
|
|
||
|
|
os.replace(tmp, dst_wav)
|
||
|
|
return dst_wav
|
||
|
|
|
||
|
|
|
||
|
|
def load_pcm(dst_wav: str) -> np.ndarray:
|
||
|
|
"""Load a prepared 16 kHz s16 mono wav as float32 in [-1, 1]."""
|
||
|
|
with wave.open(dst_wav, "rb") as wf:
|
||
|
|
assert wf.getnchannels() == 1 and wf.getframerate() == TARGET_RATE
|
||
|
|
frames = wf.readframes(wf.getnframes())
|
||
|
|
return np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
|