joeylib2/tests/host/vgmCheck.py

333 lines
14 KiB
Python
Executable file

#!/usr/bin/env python3
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
"""vgmCheck.py - checks of tools/vgm2song.py, for scripts/check-vgm.sh.
vgmCheck.py synthetic REPO WORK
VGM files written here, with register writes whose results are
worked out by hand (EXPECT_*), go through vgm2song.py and songbake;
the baked JYM1 is decoded and every row's voices must be the ones
expected. Covers the SN76489 (tones, white noise, periodic noise on a
free tone voice, the 447 kHz no-prescaler clock), the AY-3-8910
(tones, mixed noise, an envelope decay followed row by row, a buzz
sounding at the envelope's frequency), every wait command, a loop
point, skipped commands, .vgz, --tick, the fit to 64 KB, and the
warnings and refusals.
vgmCheck.py pitch WAV JYM
Furnace's own audio of a song (WAV) against the JYM1 baked from its
VGM: the share of WINDOW-sample windows, loud enough and not
dominated by noise, whose strongest pitch is a tone the JYM1 has
sounding at the window's middle (within PITCH_CENTS, or an octave
or the third harmonic of it). Prints "PITCH n/m".
"""
import gzip
import math
import os
import struct
import subprocess
import sys
VGM_RATE = 44100
FRAME = 735
FRAME_PAL = 882
WINDOW = 4096
HOP = 2048
PITCH_CENTS = 50
PITCH_BAND = (50, 5000)
PITCH_LOUD_SHARE = 0.1
HARMONICS = (1, 2, 3, 0.5)
OP_END = 0x00
OP_NOTE = 0x10
OP_OFF = 0x20
OP_TEMPO = 0x40
OP_WAIT = 0x80
VOICES = 4
# ----- JYM1 ------------------------------------------------------------------
def jymRows(path):
"""(tickMs, loop row or None, [voices after each row]) of a JYM1 file:
a row's voices are four of (Hz or pitch, attenuation) or None."""
data = open(path, "rb").read()
if data[:4] != b"JYM1":
raise SystemExit("vgmCheck: %s is no JYM1" % path)
tick = data[4] | (data[5] << 8)
loopOfs = data[6] | (data[7] << 8)
events = data[8:]
state = [None] * VOICES
rows = []
loopRow = None
at = 0
while at < len(events):
if at == loopOfs:
loopRow = len(rows)
op = events[at]
if op == OP_END:
break
if OP_NOTE <= op < OP_NOTE + VOICES:
state[op - OP_NOTE] = (events[at + 1] | (events[at + 2] << 8), events[at + 3])
at += 4
elif OP_OFF <= op < OP_OFF + VOICES:
state[op - OP_OFF] = None
at += 1
elif op == OP_TEMPO:
at += 3
elif op & OP_WAIT:
rows.extend([tuple(state)] * (op & 0x7F))
at += 1
else:
raise SystemExit("vgmCheck: bad JYM1 op 0x%02X" % op)
return tick, loopRow, rows
# ----- VGM writer --------------------------------------------------------------
def vgm(commands, snClock=0, ayClock=0, loopAt=None, snFlags=0, shiftWidth=16, rate=60):
"""A VGM 1.71 file: commands (bytes) after a 0x100-byte header, with the
loop at byte loopAt of the commands; rate is its update rate, as
Furnace writes it."""
header = bytearray(0x100)
header[0:4] = b"Vgm "
body = bytes(commands) + b"\x66"
struct.pack_into("<I", header, 0x04, len(header) + len(body) - 4)
struct.pack_into("<I", header, 0x08, 0x171)
struct.pack_into("<I", header, 0x0C, snClock)
struct.pack_into("<I", header, 0x24, rate)
struct.pack_into("<H", header, 0x28, 0x0009)
header[0x2A] = shiftWidth
header[0x2B] = snFlags
struct.pack_into("<I", header, 0x34, len(header) - 0x34)
struct.pack_into("<I", header, 0x74, ayClock)
if loopAt is not None:
struct.pack_into("<I", header, 0x1C, len(header) + loopAt - 0x1C)
return bytes(header) + body
def sn(*values):
return b"".join(bytes((0x50, v)) for v in values)
def snTone(channel, period, atten):
return sn(0x80 | (channel << 5) | (period & 0x0F), (period >> 4) & 0x3F, 0x90 | (channel << 5) | atten)
def ay(register, value):
return bytes((0xA0, register, value))
def wait(samples):
return bytes((0x61, samples & 0xFF, samples >> 8))
# ----- running the tools ------------------------------------------------------
def bake(repo, work, name, data, extra=()):
"""Write data as name.vgm, convert and bake it; (rows, stderr)."""
vgmPath = os.path.join(work, name + ".vgm")
songPath = os.path.join(work, name + ".song")
jymPath = os.path.join(work, name + ".jym")
open(vgmPath, "wb").write(data)
conv = subprocess.run([sys.executable, os.path.join(repo, "tools/vgm2song.py")] + list(extra) + [vgmPath, songPath], capture_output=True, text=True)
if conv.returncode != 0:
return None, conv.stderr
baked = subprocess.run([sys.executable, os.path.join(repo, "tools/songbake/songbake.py"), songPath, jymPath], capture_output=True, text=True)
if baked.returncode != 0:
return None, conv.stderr + baked.stderr
return jymRows(jymPath), conv.stderr
def rowOf(sample, tickMs):
return int(sample // (tickMs * VGM_RATE / 1000))
class Checks:
def __init__(self):
self.failures = []
self.count = 0
def expect(self, what, got, want):
self.count += 1
if got != want:
self.failures.append("%s: got %r, want %r" % (what, got, want))
# ----- the synthetic cases -----------------------------------------------------
def snCase(repo, work, checks):
# Frame 0: tone 0 period 254 (3579545 / (32 * 254) = 440.4 Hz) at
# attenuation 2, and white noise at clock / 1024 (3495.6 Hz: pitch
# 125000 / 3495.6 = 35.8, held at 31) at 5. Frame 3: tone 1 period 127
# (880.8 Hz) at 0, tone 0 off. Frame 6 (the loop): periodic noise clocked
# by tone 2 (period 100: 1118.6 Hz, attenuation 15, so tone voice 2 is
# free): a 1118.6 / 16 = 69.9 Hz pulse on tone voice 2 at the noise's 4.
# Frame 9: a Game Gear stereo write and a data block (skipped). The
# frames come by every wait form; the song ends at frame 12.
commands = bytearray()
commands += snTone(0, 254, 2) + sn(0xE5, 0xF5) + bytes((0x62,))
commands += bytes((0x62, 0x62))
commands += snTone(1, 127, 0) + sn(0x9F) + wait(FRAME)
commands += wait(FRAME - 16) + bytes((0x7F,))
commands += wait(FRAME)
loopAt = len(commands)
commands += snTone(2, 100, 15) + sn(0xE3, 0xF4) + bytes((0x70 + 14,)) + wait(FRAME - 15)
commands += wait(FRAME) + wait(FRAME)
commands += bytes((0x4F, 0xFF)) + bytes((0x67, 0x66, 0x00)) + struct.pack("<I", 4) + b"\x01\x02\x03\x04"
commands += bytes((0x62, 0x62, 0x62))
data = vgm(commands, snClock=3579545, loopAt=loopAt)
got, err = bake(repo, work, "sn", data)
if got is None:
checks.failures.append("sn: did not convert: " + err.strip())
return
tick, loopRow, rows = got
checks.expect("sn: tick (60 Hz updates)", tick, 16)
checks.expect("sn: loop row", loopRow, rowOf(6 * FRAME, 16))
checks.expect("sn: rows", len(rows), math.ceil(12 * FRAME / (16 * VGM_RATE / 1000)))
for frame, want in ((0, ((440, 2), None, None, (31, 5))),
(3, (None, (881, 0), None, (31, 5))),
(6, (None, (881, 0), (70, 4), None)),
(11, (None, (881, 0), (70, 4), None))):
checks.expect("sn: frame %d" % frame, rows[rowOf(frame * FRAME, 16)], want)
checks.expect("sn: the frame before 3", rows[rowOf(3 * FRAME, 16) - 1][0], (440, 2))
# The same file compressed (.vgz) bakes the same; at --tick 33 it keeps
# its tick and its length.
gotGz, _ = bake(repo, work, "snGz", gzip.compress(data))
checks.expect("sn: .vgz", gotGz, got)
slow, _ = bake(repo, work, "snSlow", data, ("--tick", "33"))
checks.expect("sn: --tick 33", slow[0] if slow else None, 33)
checks.expect("sn: --tick 33 rows", len(slow[2]) if slow else None, math.ceil(12 * FRAME / (33 * VGM_RATE / 1000)))
# The TI-99/4A's chip: 447443 Hz, no /8 prescaler: period 254 is still
# 447443 / (4 * 254) = 440.4 Hz.
ti, _ = bake(repo, work, "ti", vgm(snTone(0, 254, 0) + bytes((0x62,)), snClock=447443))
checks.expect("sn: 447 kHz tone", ti[2][0][0] if ti else None, (440, 0))
def ayCase(repo, work, checks):
# A 2 MHz chip, 50 Hz updates. Frame 0: tone A period 284 (2000000 /
# (16 * 284) = 440.1 Hz) at volume 13 (attenuation 2); channel B noise
# only (mixer 0x2E: tone A on, noise B on), noise period 10 (pitch 10)
# at volume 15 (attenuation 0); channel C on the envelope, shape 0 (one
# decay, then silence), period 2000 (a 256 ms cycle: one level every
# 16 ms) from frame 0, tone C on. Frame 10: tone C off, the envelope
# repeating (shape 8) at period 10 (2000000 / 2560 = 781.25 Hz): a buzz,
# which plays on tone voice 2 at its own frequency.
commands = bytearray()
commands += ay(0, 284 & 0xFF) + ay(1, 284 >> 8) + ay(4, 100) + ay(5, 0) + ay(6, 10)
commands += ay(7, 0x2A) + ay(8, 13) + ay(9, 15) + ay(10, 0x10)
commands += ay(11, 2000 & 0xFF) + ay(12, 2000 >> 8) + ay(13, 0x00)
commands += bytes((0x63,)) * 10
commands += ay(7, 0x2E) + ay(11, 10) + ay(12, 0) + ay(13, 0x08)
commands += bytes((0x63,)) * 4
got, err = bake(repo, work, "ay", vgm(commands, ayClock=2000000, rate=50))
if got is None:
checks.failures.append("ay: did not convert: " + err.strip())
return
tick, loopRow, rows = got
checks.expect("ay: tick (50 Hz updates)", tick, 20)
checks.expect("ay: no loop", loopRow, None)
checks.expect("ay: buzz warning", "envelope repeating faster" in err, True)
# Tone C (2000000 / 1600 = 1250 Hz) follows the decay: at the end of row
# r (20 * (r + 1) ms), the level is 15 - floor(time / 16 ms), silent
# once the decay is over.
for row in range(10):
level = 15 - int(20 * (row + 1) / 16)
want = ((440, 2), None, (1250, 15 - level) if level > 0 else None, (10, 0))
checks.expect("ay: row %d" % row, rows[row], want)
checks.expect("ay: buzz row", rows[11], ((440, 2), None, (781, 0), (10, 0)))
def fitCase(repo, work, checks):
# A note every 60 Hz frame for 25000 frames: about 100 KB at 16 ms rows,
# so the converter goes to 32 ms rows, which fit.
commands = bytearray()
for frame in range(25000):
commands += snTone(0, 200 + frame % 50, 0) + bytes((0x62,))
got, err = bake(repo, work, "fit", vgm(commands, snClock=3579545))
checks.expect("fit: tick", got[0] if got else err.strip(), 32)
checks.expect("fit: warning", "using 32 ms rows" in err, True)
def refusalCases(repo, work, checks):
_, err = bake(repo, work, "unknown", vgm(bytes((0x01, 0x62)), snClock=3579545))
checks.expect("unknown command refused", "unknown VGM command" in err, True)
_, err = bake(repo, work, "both", vgm(snTone(0, 254, 0) + ay(8, 15) + bytes((0x62,)), snClock=3579545, ayClock=2000000))
checks.expect("two chips need --chip", "--chip" in err, True)
got, err = bake(repo, work, "others", vgm(snTone(0, 254, 0) + bytes((0x51, 0x10, 0x20, 0x30, 0x40)) + bytes((0x62,)), snClock=3579545))
checks.expect("other chip warned", got is not None and "other chips" in err, True)
got, err = bake(repo, work, "second", vgm(snTone(0, 254, 0) + bytes((0x30, 0x9F, 0x62)), snClock=3579545))
checks.expect("second chip warned", got is not None and "second chip" in err, True)
def synthetic(repo, work):
checks = Checks()
snCase(repo, work, checks)
ayCase(repo, work, checks)
fitCase(repo, work, checks)
refusalCases(repo, work, checks)
for failure in checks.failures:
print("vgmCheck: FAIL %s" % failure)
print("SYNTHETIC %d/%d" % (checks.count - len(checks.failures), checks.count))
return 1 if checks.failures else 0
# ----- the pitch comparison ----------------------------------------------------
def pitch(wavPath, jymPath):
import numpy
import wave
w = wave.open(wavPath)
rate = w.getframerate()
audio = numpy.frombuffer(w.readframes(w.getnframes()), dtype=numpy.int16).astype(numpy.float64)
if w.getnchannels() > 1:
audio = audio.reshape(-1, w.getnchannels()).mean(axis=1)
tick, _, rows = jymRows(jymPath)
freqs = numpy.fft.rfftfreq(WINDOW, 1 / rate)
band = (freqs >= PITCH_BAND[0]) & (freqs <= PITCH_BAND[1])
shape = numpy.hanning(WINDOW)
loud = numpy.sqrt(numpy.mean(audio ** 2)) * PITCH_LOUD_SHARE
tested = 0
matched = 0
for start in range(0, len(audio) - WINDOW, HOP):
piece = audio[start:start + WINDOW]
if numpy.sqrt(numpy.mean(piece ** 2)) < loud:
continue
row = int(((start + WINDOW / 2) / rate) * 1000 // tick)
if row >= len(rows):
break
state = rows[row]
tones = [v for v in state[:3] if v is not None]
if not tones or (state[3] is not None and state[3][1] <= min(v[1] for v in tones)):
continue
spectrum = numpy.abs(numpy.fft.rfft(piece * shape))
spectrum[~band] = 0
peak = int(numpy.argmax(spectrum))
if peak <= 0 or peak >= len(spectrum) - 1:
continue
a, b, c = spectrum[peak - 1], spectrum[peak], spectrum[peak + 1]
bend = 0.5 * (a - c) / (a - 2 * b + c) if a - 2 * b + c else 0
heard = freqs[peak] + bend * (freqs[1] - freqs[0])
tested += 1
if any(abs(1200 * math.log2(heard / (hz * k))) <= PITCH_CENTS for hz, _ in tones for k in HARMONICS):
matched += 1
print("PITCH %d/%d" % (matched, tested))
return 0
def main(argv):
if len(argv) == 4 and argv[1] == "synthetic":
return synthetic(argv[2], argv[3])
if len(argv) == 4 and argv[1] == "pitch":
return pitch(argv[2], argv[3])
sys.stderr.write(__doc__)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv))