"""Extract an auditable prosody profile from a GLaDOS reference clip.""" import argparse import json import subprocess from pathlib import Path import librosa import numpy as np import soundfile as sf def finite_percentiles(values, percentiles): values = np.asarray(values) values = values[np.isfinite(values)] if len(values) == 0: return [None for _ in percentiles] return [round(float(x), 3) for x in np.percentile(values, percentiles)] def analyze(path: Path, text: str | None = None) -> dict: audio, sr = librosa.load(str(path), sr=16000, mono=True) duration = len(audio) / sr hop = 160 f0, voiced, _ = librosa.pyin( audio, fmin=65, fmax=500, sr=sr, frame_length=1024, hop_length=hop, ) rms = librosa.feature.rms(y=audio, frame_length=1024, hop_length=hop)[0] rms_db = librosa.amplitude_to_db(np.maximum(rms, 1e-8), ref=1.0) centroid = librosa.feature.spectral_centroid(y=audio, sr=sr, hop_length=hop)[0] flatness = librosa.feature.spectral_flatness(y=audio, hop_length=hop)[0] intervals = librosa.effects.split(audio, top_db=32, frame_length=1024, hop_length=hop) pauses = [] for left, right in zip(intervals, intervals[1:]): start = right[0] / sr end = left[1] / sr if start - end >= 0.08: pauses.append({"start": round(end, 3), "end": round(start, 3), "duration": round(start - end, 3)}) voiced_f0 = f0[np.isfinite(f0) & voiced] times = np.arange(len(f0)) * hop / sr valid = np.isfinite(f0) contour_times = np.linspace(0, duration, 20) if valid.any(): contour_f0 = np.interp(contour_times, times[valid], f0[valid]) else: contour_f0 = np.full_like(contour_times, np.nan) energy_times = np.arange(len(rms_db)) * hop / sr contour_energy = np.interp(contour_times, energy_times, rms_db) profile = { "file": str(path), "sample_rate_for_analysis": sr, "duration_sec": round(duration, 3), "voiced_fraction": round(float(np.mean(voiced)), 3), "pause_map": pauses, "f0_hz": { "median": finite_percentiles(voiced_f0, [50])[0], "p10_p90": finite_percentiles(voiced_f0, [10, 90]), "contour_20_points": [round(float(x), 2) for x in contour_f0], }, "energy_db": { "p10_p50_p90": finite_percentiles(rms_db, [10, 50, 90]), "dynamic_range_db": round(float(np.percentile(rms_db, 90) - np.percentile(rms_db, 10)), 3), "contour_20_points": [round(float(x), 2) for x in contour_energy], }, "timbral_proxies": { "spectral_centroid_hz_median": finite_percentiles(centroid, [50])[0], "spectral_flatness_median": finite_percentiles(flatness, [50])[0], }, "interpretation": { "target": "soft, close-mic, restrained mechanical delivery", "sarcasm": "low-to-moderate pitch movement with deliberate clause-final timing", "smile": "look for local energy/brightness lift around emphasized words; do not synthesize a broad cheerful smile", }, } if text: profile["text"] = text profile["text_chars_per_sec"] = round(len(text) / duration, 3) return profile def warp_duration(source: Path, reference: Path, output: Path) -> None: reference_duration = librosa.get_duration(path=str(reference)) output.parent.mkdir(parents=True, exist_ok=True) subprocess.run( [ "rubberband", "-3", "-D", f"{reference_duration:.6f}", str(source), str(output), ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) audio, sr = sf.read(output, always_2d=False) peak = float(np.max(np.abs(audio))) if peak > 0.95: audio = audio * (0.95 / peak) sf.write(output, audio, sr, subtype="PCM_16") def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--reference", type=Path, required=True) parser.add_argument("--source", type=Path) parser.add_argument("--text") parser.add_argument("--output-profile", type=Path, required=True) parser.add_argument("--warp-source", type=Path) args = parser.parse_args() profile = analyze(args.reference, args.text) if args.source: profile["source"] = analyze(args.source, args.text) args.output_profile.parent.mkdir(parents=True, exist_ok=True) args.output_profile.write_text(json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.warp_source: if not args.source: raise SystemExit("--warp-source requires --source") warp_duration(args.source, args.reference, args.warp_source) print(json.dumps(profile, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()