#!/usr/bin/env python3
"""Levels (and a spectrogram) of a game audio capture — smoke runs write DIR/audio.wav
through OpenAL Soft's wave backend (audio spec §8). The game is killed at the end of a
run, so the WAV header sizes may be unfinished: data is read to end of file.
Usage: scripts/audio_report.py WAV [--png OUT.png] [--start S] [--end S]
prints one line per second: RMS and peak in dBFS (mono mix)
--png: spectrogram of [start, end] via ffmpeg (skipped if ffmpeg is missing)
"""
import argparse
import array
import math
import shutil
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
def read_wav(path):
"""Returns (rate, channels, samples as list of ints, interleaved). 16-bit PCM only."""
data = Path(path).read_bytes()
if data[:4] != b"RIFF" or data[8:12] != b"WAVE":
raise ValueError("not a RIFF/WAVE file")
pos, rate, channels, bits = 12, None, None, None
while pos + 8 <= len(data):
cid, size = data[pos:pos + 4], struct.unpack("<I", data[pos + 4:pos + 8])[0]
body = pos + 8
if cid == b"fmt ":
_fmt, channels, rate = struct.unpack("<HHI", data[body:body + 8])
bits = struct.unpack("<H", data[body + 14:body + 16])[0]
elif cid == b"data":
if bits != 16:
raise ValueError(f"need 16-bit PCM, got {bits}")
raw = data[body:] # to EOF: the size may be a placeholder
raw = raw[: len(raw) - len(raw) % (2 * channels)]
samples = array.array("h")
samples.frombytes(raw)
if sys.byteorder == "big":
samples.byteswap()
return rate, channels, samples
pos = body + size + (size & 1)
raise ValueError("no data chunk")
def db(x):
return -120.0 if x <= 0 else 20 * math.log10(x)
def levels(rate, channels, samples):
"""[(second, rms_dbfs, peak_dbfs)] of the mono mix, one per whole or partial second."""
frames = len(samples) // channels
out = []
for start in range(0, frames, rate):
end = min(frames, start + rate)
total, peak = 0.0, 0
for f in range(start, end):
v = sum(samples[f * channels + c] for c in range(channels)) / channels
total += v * v
peak = max(peak, abs(v))
rms = math.sqrt(total / (end - start)) / 32768
out.append((start // rate, db(rms), db(peak / 32768)))
return out
def write_pcm16(path, rate, channels, samples):
raw = samples.tobytes() if sys.byteorder == "little" else array.array("h", samples).byteswap() or samples.tobytes()
header = struct.pack("<4sI4s4sIHHIIHH4sI", b"RIFF", 36 + len(raw), b"WAVE", b"fmt ", 16, 1, channels,
rate, rate * channels * 2, channels * 2, 16, b"data", len(raw))
Path(path).write_bytes(header + raw)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("wav")
ap.add_argument("--png")
ap.add_argument("--start", type=float, default=0.0)
ap.add_argument("--end", type=float)
args = ap.parse_args()
rate, channels, samples = read_wav(args.wav)
print(f"{rate} Hz, {channels} ch, {len(samples) / channels / rate:.1f} s")
for second, rms, peak in levels(rate, channels, samples):
bar = "#" * max(0, int((rms + 60) / 2))
print(f"{second:4d}s rms {rms:7.1f} dBFS peak {peak:7.1f} {bar}")
if args.png:
if not shutil.which("ffmpeg"):
print("ffmpeg missing: no spectrogram")
return
first = int(args.start * rate) * channels
last = len(samples) if args.end is None else int(args.end * rate) * channels
with tempfile.TemporaryDirectory() as tmp:
fixed = Path(tmp) / "fixed.wav"
write_pcm16(fixed, rate, channels, samples[first:last])
subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(fixed), "-lavfi",
"showspectrumpic=s=1200x480:legend=1:scale=log:fscale=log", args.png], check=True)
print(f"spectrogram: {args.png}")
if __name__ == "__main__":
main()