#!/usr/bin/env python3 """ extract_audio_features.py — per-second audio features for scene-boundary detection. Audio is often a stronger scene-boundary cue than video: music swells, silence, and ambience changes at narrative scene transitions — exactly the coarse boundaries Amazon X-Ray marks, and exactly what the grayscale video cut detector misses on low-contrast films. This extracts a small per-second feature series per film, aligned to the 1 fps timeline the embedding dumps use, so it can be fused with the RGB-histogram features in train_scene_boundary.py. Two-tower design: this is the AUDIO tower's input, mirroring the video tower's per-second RGB histogram. Because the scene model is an LSTM (temporal context comes from the recurrence, not a 2D spectrogram), each second needs only a single log-PSD vector — one FFT over a WIN_SEC window centred on that second. The LSTM sees the sequence of per-second PSDs and learns the boundary dynamics itself. Per second t: - log-PSD over [t-WIN/2, t+WIN/2], N_BINS log-spaced frequency bins, L1-norm'd then log1p — the spectral shape (music vs speech vs silence vs ambience), which changes at scene transitions. No new dependency: ffmpeg (CLI) decodes the whole track to mono 16 kHz WAV; numpy does the FFT. Writes /.npz with `ts` (second grid) and `feat` [T, N_BINS]. Usage: python scripts/scene_detector/extract_audio_features.py \ --manifest experiments/manifests/films_LVFace_opencv5.json \ --file-lut experiments/file-lut.json \ --out experiments/dumps/audio_features """ from __future__ import annotations import argparse, json, subprocess, sys, tempfile, os from pathlib import Path import numpy as np from scipy import signal as sps from scipy.io import wavfile SR = 16000 HOP_SEC = 1.0 # one feature vector per second (matches 1 fps presence grid) WIN_SEC = 4.0 # FFT window per second (centred); >HOP for temporal context N_BINS = 64 # log-spaced frequency bins per second (the audio tower dim) def decode_mono(path: str) -> np.ndarray: """Whole-file mono 16 kHz float32 PCM via ffmpeg.""" with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf: wav = tf.name try: subprocess.run( ["ffmpeg", "-v", "error", "-y", "-i", path, "-ac", "1", "-ar", str(SR), "-f", "wav", wav], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) sr, x = wavfile.read(wav) if x.dtype == np.int16: x = x.astype(np.float32) / 32768.0 else: x = x.astype(np.float32) return x finally: try: os.unlink(wav) except OSError: pass def _logbin_edges(win_samples: int) -> np.ndarray: """Indices into the rfft output that bound N_BINS log-spaced freq bands.""" nfreq = win_samples // 2 + 1 # log-space from bin 1 (skip DC) to Nyquist; unique integer edges edges = np.unique(np.geomspace(1, nfreq - 1, N_BINS + 1).astype(int)) return edges def features(mono: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Return (ts[T], feat[T, N_BINS]) — one per-second log-PSD row. One FFT per second over a WIN_SEC window centred on that second. Power is pooled into N_BINS log-spaced frequency bands (mel-like), L1-normalised across bands (so loudness doesn't dominate — the SHAPE is the scene cue), then log1p-compressed. The LSTM downstream supplies temporal context, so no spectrogram/2D input is needed.""" hop = int(SR * HOP_SEC) win = int(SR * WIN_SEC) T = len(mono) // hop if T == 0: return np.zeros(0), np.zeros((0, N_BINS), np.float32) edges = _logbin_edges(win) nb = len(edges) - 1 hann = sps.windows.hann(win) feat = np.zeros((T, nb), np.float32) half = win // 2 for t in range(T): centre = t * hop + hop // 2 s = centre - half seg = mono[max(0, s): s + win] if len(seg) < win: # pad edges seg = np.pad(seg, (0, win - len(seg))) psd = np.abs(np.fft.rfft(seg * hann))**2 + 1e-12 band = np.array([psd[edges[i]:edges[i+1]].sum() for i in range(nb)]) band /= band.sum() # normalise shape, drop loudness feat[t] = np.log1p(band * 1e3) ts = np.arange(T, dtype=np.float64) return ts, feat def main(): ap = argparse.ArgumentParser() ap.add_argument("--manifest", required=True) ap.add_argument("--file-lut", default="experiments/file-lut.json") ap.add_argument("--out", default="experiments/dumps/audio_features") args = ap.parse_args() films = json.load(open(args.manifest)) lut = json.load(open(args.file_lut)) Path(args.out).mkdir(parents=True, exist_ok=True) for f in films: slug = f["slug"] outp = Path(args.out) / f"{slug}.npz" if outp.exists(): print(f"[audio] {slug}: exists, skip", file=sys.stderr); continue path = lut.get(slug) if not path or not os.path.exists(path): print(f"[audio] {slug}: movie missing ({path})", file=sys.stderr); continue try: mono = decode_mono(path) ts, feat = features(mono) np.savez_compressed(outp, ts=ts, feat=feat) print(f"[audio] {slug}: {len(ts)}s feat{feat.shape} → {outp.name}", file=sys.stderr) except subprocess.CalledProcessError: print(f"[audio] {slug}: ffmpeg decode failed", file=sys.stderr) if __name__ == "__main__": main()