aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/scripts/audio_report.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/audio_report.py')
-rwxr-xr-xscripts/audio_report.py101
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()