diff options
Diffstat (limited to 'scripts/audio_report.py')
| -rwxr-xr-x | scripts/audio_report.py | 101 |
1 files changed, 101 insertions, 0 deletions
diff --git a/scripts/audio_report.py b/scripts/audio_report.py new file mode 100755 index 0000000..14af9ab --- /dev/null +++ b/scripts/audio_report.py @@ -0,0 +1,101 @@ +#!/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() |
