godosa-engine

git clone https://git.godosa.eu/godosa-engine

master

raw · 4102 bytes

#!/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()