joeylib2/tests/gate/vgmCheck.c

1183 lines
42 KiB
C

// Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
// See LICENSE.md for license information.
// SPDX-License-Identifier: MIT
//
// vgmCheck.c - checks of tools/vgm2song, for scripts/check-vgm.sh.
//
// vgmCheck synthetic REPO WORK
// VGM files written to WORK, with register writes whose results are
// worked out by hand, go through vgm2song and songbake (JOEY_BUILD's
// tools/, else REPO's build/tools/); 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. Prints each failure, then "SYNTHETIC passed/checked";
// exit 1 on a failure.
//
// vgmCheck 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 matched/tested".
//
// This replaces tests/host/vgmCheck.py and measures the pitch windows as it
// did, in numpy's order (gateFft), so the counts are the ones it gave.
#define _DEFAULT_SOURCE
#include <ctype.h>
#include <errno.h>
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#include "gateArg.h"
#include "gateFft.h"
#include "gateText.h"
#include "gateWav.h"
#include "toolBuf.h"
#include "toolFile.h"
#include "toolZlib.h"
#define TOOL_NAME "vgmCheck"
#define FAIL_EXIT 1
#define MODE_ARGS 4
#define MODE_ARG 1
#define FIRST_ARG 2
#define WHAT_MAX 64u
#define TOOL_ARGS_MAX 8u
#define CHILD_EXEC_FAILED 127
#define READ_CHUNK 4096u
#define ROWS_FIRST_CAP 64u
#define VGM_RATE 44100
#define FRAME 735
#define WINDOW 4096u
#define HOP 2048u
#define PITCH_CENTS 50.0
#define PITCH_BAND_LOW 50.0
#define PITCH_BAND_HIGH 5000.0
#define PITCH_LOUD_SHARE 0.1
#define CENTS_PER_OCTAVE 1200.0
#define MS_PER_SECOND 1000.0
// A JYM1 stream: "JYM1", the tick and the loop's event offset (16-bit
// little-endian), then the events.
#define JYM_MAGIC "JYM1"
#define JYM_MAGIC_BYTES 4u
#define JYM_TICK_AT 4u
#define JYM_LOOP_AT 6u
#define JYM_HEADER 8u
#define OP_END 0x00u
#define OP_NOTE 0x10u
#define OP_OFF 0x20u
#define OP_TEMPO 0x40u
#define OP_WAIT 0x80u
#define OP_WAIT_MASK 0x7Fu
#define NOTE_BYTES 4u
#define NOTE_VALUE_LO 1u
#define NOTE_VALUE_HI 2u
#define NOTE_ATTEN 3u
#define TEMPO_BYTES 3u
#define VOICES 4u
#define TONE_VOICES 3u
#define NOISE_VOICE 3u
#define BYTE_BITS 8u
#define BYTE_MASK 0xFFu
// A VGM 1.71 file as Furnace writes one: a 0x100-byte header, then the
// commands and the end command.
#define VGM_IDENT "Vgm "
#define VGM_IDENT_BYTES 4u
#define VGM_HEADER 0x100u
#define VGM_EOF_AT 0x04u
#define VGM_VERSION_AT 0x08u
#define VGM_VERSION 0x171u
#define VGM_SN_CLOCK_AT 0x0Cu
#define VGM_LOOP_AT 0x1Cu
#define VGM_RATE_AT 0x24u
#define VGM_SN_FEEDBACK_AT 0x28u
#define VGM_SN_FEEDBACK 0x0009u
#define VGM_SN_WIDTH_AT 0x2Au
#define VGM_DATA_AT 0x34u
#define VGM_AY_CLOCK_AT 0x74u
#define VGM_FIELD_BYTES 4u
#define VGM_SN_WIDTH 16u
#define VGM_NO_LOOP (-1L)
#define CMD_SN 0x50u
#define CMD_SN2 0x30u
#define CMD_AY 0xA0u
#define CMD_WAIT 0x61u
#define CMD_WAIT_NTSC 0x62u
#define CMD_WAIT_PAL 0x63u
#define CMD_END 0x66u
#define CMD_DATA_BLOCK 0x67u
#define CMD_SHORT_WAIT 0x70u
#define CMD_GG_STEREO 0x4Fu
#define CMD_YM2413 0x51u
#define CMD_UNKNOWN 0x01u
#define SN_LATCH 0x80u
#define SN_VOLUME 0x90u
#define SN_CHANNEL_SHIFT 5u
#define SN_LOW_MASK 0x0Fu
#define SN_HIGH_MASK 0x3Fu
#define SN_HIGH_SHIFT 4u
// The chips' clocks and update rates the cases use.
#define SN_CLOCK 3579545u
#define TI_CLOCK 447443u
#define AY_CLOCK 2000000u
#define NTSC_UPDATE_HZ 60u
#define PAL_UPDATE_HZ 50u
// snCase: tone 0 period 254 (3579545 / (32 * 254) = 440.4 Hz) at
// attenuation 2 and white noise at clock / 1024 (3495.6 Hz: pitch 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 song ends at frame 12.
#define SN_TONE0_PERIOD 254u
#define SN_TONE0_ATTEN 2u
#define SN_NOISE_WHITE_1024 0xE5u
#define SN_NOISE_ATTEN_5 0xF5u
#define SN_TONE1_PERIOD 127u
#define SN_TONE1_ATTEN 0u
#define SN_TONE0_SILENT 0x9Fu
#define SN_TONE2_PERIOD 100u
#define SN_TONE2_ATTEN 15u
#define SN_NOISE_PERIODIC_T2 0xE3u
#define SN_NOISE_ATTEN_4 0xF4u
#define SN_SHORT_WAIT_15 14u
#define SN_DAC_WAIT 16
#define SN_SHORT_WAIT_SAMPLES 15
#define SN_DAC_WAIT_16 0x7Fu
#define GG_STEREO_ALL 0xFFu
// A data block: 0x67, its 0x66 mark, its type (YM2612 PCM), its size, and
// its bytes.
#define DATA_BLOCK_MARK 0x66u
#define DATA_BLOCK_TYPE 0x00u
#define DATA_BLOCK_BYTES 4u
#define SN_LOOP_FRAME 6
#define SN_FRAMES 12
#define SN_OPENING_FRAMES 3u
#define SN_CLOSING_FRAMES 3u
#define SN_TICK_MS 16u
#define SN_SLOW_TICK "33"
#define SN_SLOW_TICK_MS 33u
#define SN_BEFORE_FRAME 3
#define TI_TONE_ATTEN 0u
// ayCase: frame 0 tone A period 284 (2000000 / (16 * 284) = 440.1 Hz) at
// volume 13 (attenuation 2); channel B noise only (mixer 0x2A, then 0x2E),
// 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), tone C period 1600 / 16 = 100 (1250 Hz).
// Frame 10: tone C off, the envelope repeating (shape 8) at period 10
// (2000000 / 2560 = 781.25 Hz): a buzz, on tone voice 2 at its frequency.
#define AY_TONE_A_LO 0u
#define AY_TONE_A_HI 1u
#define AY_TONE_C_LO 4u
#define AY_TONE_C_HI 5u
#define AY_NOISE 6u
#define AY_MIXER 7u
#define AY_VOLUME_A 8u
#define AY_VOLUME_B 9u
#define AY_VOLUME_C 10u
#define AY_ENVELOPE_LO 11u
#define AY_ENVELOPE_HI 12u
#define AY_SHAPE 13u
#define AY_TONE_A_PERIOD 284u
#define AY_TONE_C_PERIOD 100u
#define AY_NOISE_PERIOD 10u
#define AY_MIXER_START 0x2Au
#define AY_MIXER_BUZZ 0x2Eu
#define AY_VOLUME_A_13 13u
#define AY_VOLUME_B_15 15u
#define AY_VOLUME_C_ENVELOPE 0x10u
#define AY_DECAY_PERIOD 2000u
#define AY_SHAPE_DECAY 0x00u
#define AY_BUZZ_PERIOD 10u
#define AY_SHAPE_REPEAT 0x08u
#define AY_DECAY_FRAMES 10u
#define AY_BUZZ_FRAMES 4u
#define AY_TICK_MS 20u
#define AY_DECAY_ROWS 10
#define AY_BUZZ_ROW 11
#define AY_LEVEL_MAX 15L
#define AY_STEP_MS 16.0
#define AY_TONE_A_HZ 440u
#define AY_TONE_A_ATTEN 2u
#define AY_TONE_C_HZ 1250u
#define AY_BUZZ_HZ 781u
#define AY_NOISE_PITCH 10u
// fitCase: a note every 60 Hz frame for FIT_FRAMES frames, about 100 KB at
// 16 ms rows, so the converter goes to 32 ms rows, which fit.
#define FIT_FRAMES 25000u
#define FIT_PERIOD_BASE 200u
#define FIT_PERIODS 50u
#define FIT_TICK_MS 32
// refusalCases: an AY volume write in an SN file, a YM2413 write and a
// second SN chip's.
#define REFUSE_AY_VOLUME 15u
#define YM2413_REG 0x10u
#define YM2413_VALUE 0x20u
#define SN2_VALUE 0x40u
// gzip (RFC 1952) around the deflate stream toolDeflateZlib makes, its
// zlib header and Adler-32 dropped.
#define ZLIB_HEADER 2u
#define ZLIB_TRAILER 4u
#define GZIP_ID1 0x1Fu
#define GZIP_ID2 0x8Bu
#define GZIP_DEFLATE 8u
#define GZIP_OS_UNKNOWN 0xFFu
#define GZIP_PLAIN_BYTES 6u
// A voice after a row: silent (None), or a value (Hz, or the noise pitch)
// at an attenuation.
typedef struct VoiceT {
bool on;
uint32_t value;
uint32_t atten;
} VoiceT;
typedef struct RowT {
VoiceT voice[VOICES];
} RowT;
// A JYM1 decoded: its tick, its loop row (or none), and the voices after
// each row; ok is false for a bake that failed.
typedef struct SongT {
bool ok;
uint32_t tick;
bool hasLoop;
size_t loopRow;
RowT *rows;
size_t rowCount;
size_t rowCap;
} SongT;
// The checks made: how many, and each failure's line (each ending in a
// zero byte).
typedef struct ChecksT {
ToolBufT failures;
size_t failureCount;
size_t count;
} ChecksT;
// A frame's expected voices.
typedef struct FrameWantT {
long frame;
RowT row;
} FrameWantT;
static void ay(ToolBufT *commands, uint32_t reg, uint32_t value);
static void ayCase(const char *repo, const char *work, ChecksT *checks);
static void bake(const char *repo, const char *work, const char *name, const ToolBufT *data, const char *extra, SongT *song, ToolBufT *err);
static void check(ChecksT *checks, bool ok, const char *fmt, ...) __attribute__((format(printf, 3, 4)));
static void expectFlag(ChecksT *checks, const char *what, bool got);
static void expectNumber(ChecksT *checks, const char *what, bool have, long got, long want);
static void expectRow(ChecksT *checks, const char *what, const SongT *song, long row, const RowT *want);
static void expectVoice(ChecksT *checks, const char *what, const SongT *song, long row, uint32_t voice, VoiceT want);
static void fitCase(const char *repo, const char *work, ChecksT *checks);
static void gzipOf(const ToolBufT *data, ToolBufT *out);
static void jymRows(const char *path, SongT *song);
static char *pathOf(const char *dir, const char *name, const char *suffix);
static int pitch(const char *wavPath, const char *jymPath);
static void putLe32(uint8_t *at, uint32_t value);
static void refusalCases(const char *repo, const char *work, ChecksT *checks);
static long rowOf(long sample, uint32_t tickMs);
static bool rowSame(const RowT *a, const RowT *b);
static void rowText(ToolBufT *out, const RowT *row);
static long rowTotal(long samples, uint32_t tickMs);
static bool runTool(const char *path, char *const *args, ToolBufT *err);
static void sn(ToolBufT *commands, uint32_t value);
static void snCase(const char *repo, const char *work, ChecksT *checks);
static void snTone(ToolBufT *commands, uint32_t channel, uint32_t period, uint32_t atten);
static void songFree(SongT *song);
static bool songSame(const SongT *a, const SongT *b);
static int synthetic(const char *repo, const char *work);
static const char *trimmed(ToolBufT *text);
static void vgm(const ToolBufT *commands, uint32_t snClock, uint32_t ayClock, long loopAt, uint32_t rate, ToolBufT *out);
static void vgmWait(ToolBufT *commands, uint32_t samples);
static void vgmWaits(ToolBufT *commands, uint32_t command, uint32_t count);
static bool voiceSame(VoiceT a, VoiceT b);
static void voiceText(ToolBufT *out, VoiceT voice);
// The partials a heard pitch may be of a tone the song plays.
static const double kHarmonics[] = { 1.0, 2.0, 3.0, 0.5 };
#define VOICE_ON(value, atten) { true, (value), (atten) }
#define VOICE_OFF { false, 0u, 0u }
// snCase's rows: frame 0 tone 0 and white noise; frame 3 tone 1, tone 0
// off; frame 6 (the loop) periodic noise on tone voice 2; frame 11 the
// same.
static const FrameWantT kSnFrames[] = {
{ 0, { { VOICE_ON(440u, 2u), VOICE_OFF, VOICE_OFF, VOICE_ON(31u, 5u) } } },
{ 3, { { VOICE_OFF, VOICE_ON(881u, 0u), VOICE_OFF, VOICE_ON(31u, 5u) } } },
{ 6, { { VOICE_OFF, VOICE_ON(881u, 0u), VOICE_ON(70u, 4u), VOICE_OFF } } },
{ 11, { { VOICE_OFF, VOICE_ON(881u, 0u), VOICE_ON(70u, 4u), VOICE_OFF } } }
};
static const char kUsage[] = "usage: vgmCheck synthetic REPO WORK\n vgmCheck pitch WAV JYM\n";
// An AY-3-8910 register write.
static void ay(ToolBufT *commands, uint32_t reg, uint32_t value) {
toolBufByte(commands, CMD_AY);
toolBufByte(commands, reg);
toolBufByte(commands, value);
}
// The AY-3-8910: tones, mixed noise, an envelope's decay followed row by
// row, and a buzz (the defines above say what each write is).
static void ayCase(const char *repo, const char *work, ChecksT *checks) {
ToolBufT commands;
ToolBufT data;
ToolBufT err;
SongT song;
RowT want;
char what[WHAT_MAX];
long level;
int row;
memset(&commands, 0, sizeof(commands));
memset(&data, 0, sizeof(data));
memset(&err, 0, sizeof(err));
ay(&commands, AY_TONE_A_LO, AY_TONE_A_PERIOD & BYTE_MASK);
ay(&commands, AY_TONE_A_HI, AY_TONE_A_PERIOD >> BYTE_BITS);
ay(&commands, AY_TONE_C_LO, AY_TONE_C_PERIOD);
ay(&commands, AY_TONE_C_HI, 0u);
ay(&commands, AY_NOISE, AY_NOISE_PERIOD);
ay(&commands, AY_MIXER, AY_MIXER_START);
ay(&commands, AY_VOLUME_A, AY_VOLUME_A_13);
ay(&commands, AY_VOLUME_B, AY_VOLUME_B_15);
ay(&commands, AY_VOLUME_C, AY_VOLUME_C_ENVELOPE);
ay(&commands, AY_ENVELOPE_LO, AY_DECAY_PERIOD & BYTE_MASK);
ay(&commands, AY_ENVELOPE_HI, AY_DECAY_PERIOD >> BYTE_BITS);
ay(&commands, AY_SHAPE, AY_SHAPE_DECAY);
vgmWaits(&commands, CMD_WAIT_PAL, AY_DECAY_FRAMES);
ay(&commands, AY_MIXER, AY_MIXER_BUZZ);
ay(&commands, AY_ENVELOPE_LO, AY_BUZZ_PERIOD);
ay(&commands, AY_ENVELOPE_HI, 0u);
ay(&commands, AY_SHAPE, AY_SHAPE_REPEAT);
vgmWaits(&commands, CMD_WAIT_PAL, AY_BUZZ_FRAMES);
vgm(&commands, 0u, AY_CLOCK, VGM_NO_LOOP, PAL_UPDATE_HZ, &data);
bake(repo, work, "ay", &data, NULL, &song, &err);
if (!song.ok) {
check(checks, false, "ay: did not convert: %s", trimmed(&err));
return;
}
expectNumber(checks, "ay: tick (50 Hz updates)", true, (long)song.tick, AY_TICK_MS);
check(checks, !song.hasLoop, "ay: no loop: got row %zu, want none", song.loopRow);
expectFlag(checks, "ay: buzz warning", strstr(gateTextString(&err), "envelope repeating faster") != NULL);
// Tone C 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 = 0; row < AY_DECAY_ROWS; row++) {
level = AY_LEVEL_MAX - (long)((double)(AY_TICK_MS * (uint32_t)(row + 1)) / AY_STEP_MS);
memset(&want, 0, sizeof(want));
want.voice[0] = (VoiceT)VOICE_ON(AY_TONE_A_HZ, AY_TONE_A_ATTEN);
want.voice[NOISE_VOICE] = (VoiceT)VOICE_ON(AY_NOISE_PITCH, 0u);
if (level > 0) {
want.voice[TONE_VOICES - 1u] = (VoiceT)VOICE_ON(AY_TONE_C_HZ, (uint32_t)(AY_LEVEL_MAX - level));
}
snprintf(what, sizeof(what), "ay: row %d", row);
expectRow(checks, what, &song, row, &want);
}
want.voice[TONE_VOICES - 1u] = (VoiceT)VOICE_ON(AY_BUZZ_HZ, 0u);
expectRow(checks, "ay: buzz row", &song, AY_BUZZ_ROW, &want);
songFree(&song);
toolBufFree(&commands);
toolBufFree(&data);
toolBufFree(&err);
}
// Data written as WORK/name.vgm, converted (given "--tick extra"
// when extra is not NULL) and baked; the song decoded (song->ok false when
// a tool failed), and what vgm2song said on stderr (and songbake, when it
// failed).
static void bake(const char *repo, const char *work, const char *name, const ToolBufT *data, const char *extra, SongT *song, ToolBufT *err) {
const char *build;
char *tools;
char *converter;
char *baker;
char *vgmPath;
char *songPath;
char *jymPath;
char *args[TOOL_ARGS_MAX];
size_t count;
size_t converted;
memset(song, 0, sizeof(*song));
err->len = 0u;
build = getenv("JOEY_BUILD");
tools = build != NULL ? pathOf(build, "tools", "") : pathOf(repo, "build/tools", "");
converter = pathOf(tools, "vgm2song", "");
baker = pathOf(tools, "songbake", "");
vgmPath = pathOf(work, name, ".vgm");
songPath = pathOf(work, name, ".song");
jymPath = pathOf(work, name, ".jym");
if (!toolWriteFile(vgmPath, data->data, data->len)) {
toolFail("cannot write %s", vgmPath);
}
count = 0u;
args[count++] = converter;
if (extra != NULL) {
args[count++] = (char *)"--tick";
args[count++] = (char *)extra;
}
args[count++] = vgmPath;
args[count++] = songPath;
args[count] = NULL;
if (runTool(converter, args, err)) {
converted = err->len;
count = 0u;
args[count++] = baker;
args[count++] = songPath;
args[count++] = jymPath;
args[count] = NULL;
if (runTool(baker, args, err)) {
err->len = converted;
jymRows(jymPath, song);
}
}
free(tools);
free(converter);
free(baker);
free(vgmPath);
free(songPath);
free(jymPath);
}
// A check: counted, and its line (printf-style) kept when it fails.
static void check(ChecksT *checks, bool ok, const char *fmt, ...) {
va_list args;
checks->count++;
if (ok) {
return;
}
va_start(args, fmt);
gateTextAddV(&checks->failures, fmt, args);
va_end(args);
toolBufByte(&checks->failures, '\0');
checks->failureCount++;
}
// Something that must be so.
static void expectFlag(ChecksT *checks, const char *what, bool got) {
check(checks, got, "%s: got false, want true", what);
}
// A number a song gives (none when have is false), against the one wanted.
static void expectNumber(ChecksT *checks, const char *what, bool have, long got, long want) {
if (have) {
check(checks, got == want, "%s: got %ld, want %ld", what, got, want);
} else {
check(checks, false, "%s: got none, want %ld", what, want);
}
}
// A row's voices against the ones wanted.
static void expectRow(ChecksT *checks, const char *what, const SongT *song, long row, const RowT *want) {
ToolBufT text;
if (row < 0 || (size_t)row >= song->rowCount) {
check(checks, false, "%s: no row %ld in a song of %zu", what, row, song->rowCount);
return;
}
memset(&text, 0, sizeof(text));
gateTextAdd(&text, "%s: got ", what);
rowText(&text, &song->rows[row]);
gateTextAdd(&text, ", want ");
rowText(&text, want);
check(checks, rowSame(&song->rows[row], want), "%s", gateTextString(&text));
toolBufFree(&text);
}
// One voice of a row against the one wanted.
static void expectVoice(ChecksT *checks, const char *what, const SongT *song, long row, uint32_t voice, VoiceT want) {
RowT wanted;
if (row < 0 || (size_t)row >= song->rowCount) {
check(checks, false, "%s: no row %ld in a song of %zu", what, row, song->rowCount);
return;
}
wanted = song->rows[row];
wanted.voice[voice] = want;
expectRow(checks, what, song, row, &wanted);
}
// The fit to 64 KB: a song too long for 16 ms rows goes to 32 ms rows.
static void fitCase(const char *repo, const char *work, ChecksT *checks) {
ToolBufT commands;
ToolBufT data;
ToolBufT err;
SongT song;
uint32_t frame;
memset(&commands, 0, sizeof(commands));
memset(&data, 0, sizeof(data));
memset(&err, 0, sizeof(err));
for (frame = 0u; frame < FIT_FRAMES; frame++) {
snTone(&commands, 0u, FIT_PERIOD_BASE + frame % FIT_PERIODS, 0u);
toolBufByte(&commands, CMD_WAIT_NTSC);
}
vgm(&commands, SN_CLOCK, 0u, VGM_NO_LOOP, NTSC_UPDATE_HZ, &data);
bake(repo, work, "fit", &data, NULL, &song, &err);
if (song.ok) {
expectNumber(checks, "fit: tick", true, (long)song.tick, FIT_TICK_MS);
} else {
check(checks, false, "fit: did not convert: %s", trimmed(&err));
}
expectFlag(checks, "fit: warning", strstr(gateTextString(&err), "using 32 ms rows") != NULL);
songFree(&song);
toolBufFree(&commands);
toolBufFree(&data);
toolBufFree(&err);
}
// data gzip-compressed (a .vgz): one gzip member around a deflate stream.
static void gzipOf(const ToolBufT *data, ToolBufT *out) {
uint8_t *zlib;
size_t zlibLen;
if (!toolDeflateZlib(data->data, data->len, &zlib, &zlibLen)) {
toolFail("cannot compress");
}
toolBufByte(out, GZIP_ID1);
toolBufByte(out, GZIP_ID2);
toolBufByte(out, GZIP_DEFLATE);
toolBufZeros(out, GZIP_PLAIN_BYTES);
toolBufByte(out, GZIP_OS_UNKNOWN);
toolBufAdd(out, zlib + ZLIB_HEADER, zlibLen - ZLIB_HEADER - ZLIB_TRAILER);
toolBufLe32(out, toolCrc32(0u, data->data, data->len));
toolBufLe32(out, (uint32_t)data->len);
free(zlib);
}
// A JYM1 file's tick, loop row, and the voices after each row.
static void jymRows(const char *path, SongT *song) {
const uint8_t *events;
uint8_t *data;
size_t len;
size_t count;
size_t at;
size_t i;
uint32_t loopOfs;
uint32_t op;
RowT state;
ToolBufT problem;
memset(&problem, 0, sizeof(problem));
if (!gateTextReadFile(path, &data, &len, &problem)) {
toolFail("%s", gateTextString(&problem));
}
if (len < JYM_HEADER || memcmp(data, JYM_MAGIC, JYM_MAGIC_BYTES) != 0) {
toolFail("%s: not a JYM1 file", path);
}
memset(song, 0, sizeof(*song));
memset(&state, 0, sizeof(state));
song->ok = true;
song->tick = (uint32_t)data[JYM_TICK_AT] | ((uint32_t)data[JYM_TICK_AT + 1u] << BYTE_BITS);
loopOfs = (uint32_t)data[JYM_LOOP_AT] | ((uint32_t)data[JYM_LOOP_AT + 1u] << BYTE_BITS);
events = data + JYM_HEADER;
count = len - JYM_HEADER;
at = 0u;
while (at < count) {
if (at == loopOfs) {
song->hasLoop = true;
song->loopRow = song->rowCount;
}
op = events[at];
if (op == OP_END) {
break;
}
if (op >= OP_NOTE && op < OP_NOTE + VOICES) {
if (at + NOTE_BYTES > count) {
toolFail("%s: cut short", path);
}
state.voice[op - OP_NOTE].on = true;
state.voice[op - OP_NOTE].value = (uint32_t)events[at + NOTE_VALUE_LO] | ((uint32_t)events[at + NOTE_VALUE_HI] << BYTE_BITS);
state.voice[op - OP_NOTE].atten = events[at + NOTE_ATTEN];
at += NOTE_BYTES;
} else if (op >= OP_OFF && op < OP_OFF + VOICES) {
memset(&state.voice[op - OP_OFF], 0, sizeof(VoiceT));
at++;
} else if (op == OP_TEMPO) {
at += TEMPO_BYTES;
} else if ((op & OP_WAIT) != 0u) {
for (i = 0u; i < (op & OP_WAIT_MASK); i++) {
if (song->rowCount == song->rowCap) {
song->rowCap = song->rowCap == 0u ? ROWS_FIRST_CAP : song->rowCap * 2u;
song->rows = (RowT *)realloc(song->rows, song->rowCap * sizeof(RowT));
if (song->rows == NULL) {
toolFail("out of memory");
}
}
song->rows[song->rowCount++] = state;
}
at++;
} else {
toolFail("%s: bad JYM1 op 0x%02X", path, (unsigned)op);
}
}
free(data);
}
// dir/name then suffix, malloc'd.
static char *pathOf(const char *dir, const char *name, const char *suffix) {
ToolBufT path;
memset(&path, 0, sizeof(path));
gateTextAdd(&path, "%s/%s%s", dir, name, suffix);
gateTextString(&path);
return (char *)path.data;
}
// pitch: the windows of Furnace's audio whose strongest pitch is one the
// JYM1 sounds there.
static int pitch(const char *wavPath, const char *jymPath) {
const RowT *state;
GateWavT wav;
GateFftT fft;
ToolBufT problem;
SongT song;
double *audio;
double *window;
double *squares;
double *piece;
double *spectrum;
double *freqs;
double loud;
double a;
double b;
double c;
double bend;
double heard;
double leastAtten;
size_t frames;
size_t bins;
size_t start;
size_t peak;
size_t k;
size_t v;
size_t h;
long row;
long tested;
long matched;
bool anyTone;
bool hit;
memset(&problem, 0, sizeof(problem));
if (!gateWavRead(wavPath, &wav, &problem)) {
toolFail("%s", gateTextString(&problem));
}
audio = gateWavMono(&wav);
frames = gateWavFrames(&wav);
jymRows(jymPath, &song);
bins = WINDOW / 2u + 1u;
window = (double *)calloc(WINDOW, sizeof(double));
piece = (double *)calloc(WINDOW, sizeof(double));
spectrum = (double *)calloc(bins, sizeof(double));
freqs = (double *)calloc(bins, sizeof(double));
squares = (double *)calloc(frames > 0u ? frames : 1u, sizeof(double));
if (window == NULL || piece == NULL || spectrum == NULL || freqs == NULL || squares == NULL) {
toolFail("out of memory");
}
for (k = 0u; k < bins; k++) {
freqs[k] = gateRfftFreq(k, WINDOW, wav.rate);
}
gateHanning(window, WINDOW);
gateFftInit(&fft, WINDOW);
for (k = 0u; k < frames; k++) {
squares[k] = audio[k] * audio[k];
}
loud = sqrt(gateMean(squares, frames)) * PITCH_LOUD_SHARE;
tested = 0;
matched = 0;
for (start = 0u; start + WINDOW < frames; start += HOP) {
if (sqrt(gateMean(squares + start, WINDOW)) < loud) {
continue;
}
row = (long)floor(((double)start + (double)WINDOW / 2.0) / (double)wav.rate * MS_PER_SECOND / (double)song.tick);
if (row >= (long)song.rowCount) {
break;
}
state = &song.rows[row];
anyTone = false;
leastAtten = 0.0;
for (v = 0u; v < TONE_VOICES; v++) {
if (state->voice[v].on && (!anyTone || (double)state->voice[v].atten < leastAtten)) {
leastAtten = (double)state->voice[v].atten;
}
anyTone = anyTone || state->voice[v].on;
}
// No tone, or the noise as loud as the loudest: nothing pitched to
// hear.
if (!anyTone || (state->voice[NOISE_VOICE].on && (double)state->voice[NOISE_VOICE].atten <= leastAtten)) {
continue;
}
for (k = 0u; k < WINDOW; k++) {
piece[k] = audio[start + k] * window[k];
}
gateFftAbs(&fft, piece, WINDOW, spectrum);
peak = 0u;
for (k = 0u; k < bins; k++) {
if (!(freqs[k] >= PITCH_BAND_LOW && freqs[k] <= PITCH_BAND_HIGH)) {
spectrum[k] = 0.0;
}
peak = spectrum[k] > spectrum[peak] ? k : peak;
}
if (peak == 0u || peak >= bins - 1u) {
continue;
}
a = spectrum[peak - 1u];
b = spectrum[peak];
c = spectrum[peak + 1u];
bend = a - 2.0 * b + c != 0.0 ? 0.5 * (a - c) / (a - 2.0 * b + c) : 0.0;
heard = freqs[peak] + bend * (freqs[1] - freqs[0]);
tested++;
hit = false;
for (v = 0u; v < TONE_VOICES; v++) {
for (h = 0u; h < GATE_COUNT_OF(kHarmonics) && state->voice[v].on; h++) {
hit = hit || fabs(CENTS_PER_OCTAVE * log2(heard / ((double)state->voice[v].value * kHarmonics[h]))) <= PITCH_CENTS;
}
}
matched += hit ? 1 : 0;
}
printf("PITCH %ld/%ld\n", matched, tested);
gateFftFree(&fft);
gateWavFree(&wav);
songFree(&song);
free(audio);
free(window);
free(piece);
free(spectrum);
free(freqs);
free(squares);
return 0;
}
// A 32-bit little-endian field.
static void putLe32(uint8_t *at, uint32_t value) {
uint32_t i;
for (i = 0u; i < VGM_FIELD_BYTES; i++) {
at[i] = (uint8_t)((value >> (i * BYTE_BITS)) & BYTE_MASK);
}
}
// The refusals and warnings: an unknown command, two chips without
// --chip, another chip's writes, and a second SN chip's.
static void refusalCases(const char *repo, const char *work, ChecksT *checks) {
ToolBufT commands;
ToolBufT data;
ToolBufT err;
SongT song;
memset(&commands, 0, sizeof(commands));
memset(&data, 0, sizeof(data));
memset(&err, 0, sizeof(err));
toolBufByte(&commands, CMD_UNKNOWN);
toolBufByte(&commands, CMD_WAIT_NTSC);
vgm(&commands, SN_CLOCK, 0u, VGM_NO_LOOP, NTSC_UPDATE_HZ, &data);
bake(repo, work, "unknown", &data, NULL, &song, &err);
expectFlag(checks, "unknown command refused", strstr(gateTextString(&err), "unknown VGM command") != NULL);
songFree(&song);
commands.len = 0u;
data.len = 0u;
snTone(&commands, 0u, SN_TONE0_PERIOD, 0u);
ay(&commands, AY_VOLUME_A, REFUSE_AY_VOLUME);
toolBufByte(&commands, CMD_WAIT_NTSC);
vgm(&commands, SN_CLOCK, AY_CLOCK, VGM_NO_LOOP, NTSC_UPDATE_HZ, &data);
bake(repo, work, "both", &data, NULL, &song, &err);
expectFlag(checks, "two chips need --chip", strstr(gateTextString(&err), "--chip") != NULL);
songFree(&song);
commands.len = 0u;
data.len = 0u;
snTone(&commands, 0u, SN_TONE0_PERIOD, 0u);
toolBufByte(&commands, CMD_YM2413);
toolBufByte(&commands, YM2413_REG);
toolBufByte(&commands, YM2413_VALUE);
toolBufByte(&commands, CMD_SN2);
toolBufByte(&commands, SN2_VALUE);
toolBufByte(&commands, CMD_WAIT_NTSC);
vgm(&commands, SN_CLOCK, 0u, VGM_NO_LOOP, NTSC_UPDATE_HZ, &data);
bake(repo, work, "others", &data, NULL, &song, &err);
expectFlag(checks, "other chip warned", song.ok && strstr(gateTextString(&err), "other chips") != NULL);
songFree(&song);
commands.len = 0u;
data.len = 0u;
snTone(&commands, 0u, SN_TONE0_PERIOD, 0u);
toolBufByte(&commands, CMD_SN2);
toolBufByte(&commands, SN_TONE0_SILENT);
toolBufByte(&commands, CMD_WAIT_NTSC);
vgm(&commands, SN_CLOCK, 0u, VGM_NO_LOOP, NTSC_UPDATE_HZ, &data);
bake(repo, work, "second", &data, NULL, &song, &err);
expectFlag(checks, "second chip warned", song.ok && strstr(gateTextString(&err), "second chip") != NULL);
songFree(&song);
toolBufFree(&commands);
toolBufFree(&data);
toolBufFree(&err);
}
// The row a VGM sample falls in at tickMs rows.
static long rowOf(long sample, uint32_t tickMs) {
return (long)floor((double)sample / ((double)(tickMs * VGM_RATE) / MS_PER_SECOND));
}
static bool rowSame(const RowT *a, const RowT *b) {
uint32_t v;
for (v = 0u; v < VOICES; v++) {
if (!voiceSame(a->voice[v], b->voice[v])) {
return false;
}
}
return true;
}
// A row's voices, each "(value, attenuation)" or "off".
static void rowText(ToolBufT *out, const RowT *row) {
uint32_t v;
for (v = 0u; v < VOICES; v++) {
gateTextAdd(out, "%s", v > 0u ? " " : "");
voiceText(out, row->voice[v]);
}
}
// math.ceil of samples / a row's samples: how many rows they fill.
static long rowTotal(long samples, uint32_t tickMs) {
return (long)ceil((double)samples / ((double)(tickMs * VGM_RATE) / MS_PER_SECOND));
}
// One of make/tools.mk's tools, run: its stdout dropped, its stderr
// appended to err; true when it exits 0. A tool that is not there is
// fatal.
static bool runTool(const char *path, char *const *args, ToolBufT *err) {
int pipeEnds[2];
int status;
int nullFd;
pid_t child;
ssize_t got;
uint8_t chunk[READ_CHUNK];
if (access(path, X_OK) != 0) {
toolFail("cannot run %s: %s", path, strerror(errno));
}
if (pipe(pipeEnds) != 0) {
toolFail("cannot make a pipe");
}
child = fork();
if (child < 0) {
toolFail("cannot run %s", path);
}
if (child == 0) {
nullFd = open("/dev/null", O_WRONLY);
dup2(nullFd, STDOUT_FILENO);
dup2(pipeEnds[1], STDERR_FILENO);
close(pipeEnds[0]);
close(pipeEnds[1]);
execv(path, args);
_exit(CHILD_EXEC_FAILED);
}
close(pipeEnds[1]);
while ((got = read(pipeEnds[0], chunk, sizeof(chunk))) > 0) {
toolBufAdd(err, chunk, (size_t)got);
}
close(pipeEnds[0]);
if (waitpid(child, &status, 0) < 0) {
toolFail("cannot wait for %s", path);
}
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
// An SN76489 write.
static void sn(ToolBufT *commands, uint32_t value) {
toolBufByte(commands, CMD_SN);
toolBufByte(commands, value);
}
// The SN76489: tones, white and periodic noise, every wait form, a loop,
// skipped commands, .vgz, --tick, and the TI-99/4A's clock (the defines
// above say what each write is).
static void snCase(const char *repo, const char *work, ChecksT *checks) {
ToolBufT commands;
ToolBufT data;
ToolBufT packed;
ToolBufT err;
SongT song;
SongT other;
RowT want;
char what[WHAT_MAX];
long loopAt;
size_t i;
memset(&commands, 0, sizeof(commands));
memset(&data, 0, sizeof(data));
memset(&packed, 0, sizeof(packed));
memset(&err, 0, sizeof(err));
snTone(&commands, 0u, SN_TONE0_PERIOD, SN_TONE0_ATTEN);
sn(&commands, SN_NOISE_WHITE_1024);
sn(&commands, SN_NOISE_ATTEN_5);
vgmWaits(&commands, CMD_WAIT_NTSC, SN_OPENING_FRAMES);
snTone(&commands, 1u, SN_TONE1_PERIOD, SN_TONE1_ATTEN);
sn(&commands, SN_TONE0_SILENT);
vgmWait(&commands, FRAME);
vgmWait(&commands, FRAME - SN_DAC_WAIT);
toolBufByte(&commands, SN_DAC_WAIT_16);
vgmWait(&commands, FRAME);
loopAt = (long)commands.len;
snTone(&commands, 2u, SN_TONE2_PERIOD, SN_TONE2_ATTEN);
sn(&commands, SN_NOISE_PERIODIC_T2);
sn(&commands, SN_NOISE_ATTEN_4);
toolBufByte(&commands, CMD_SHORT_WAIT + SN_SHORT_WAIT_15);
vgmWait(&commands, FRAME - SN_SHORT_WAIT_SAMPLES);
vgmWait(&commands, FRAME);
vgmWait(&commands, FRAME);
toolBufByte(&commands, CMD_GG_STEREO);
toolBufByte(&commands, GG_STEREO_ALL);
toolBufByte(&commands, CMD_DATA_BLOCK);
toolBufByte(&commands, DATA_BLOCK_MARK);
toolBufByte(&commands, DATA_BLOCK_TYPE);
toolBufLe32(&commands, DATA_BLOCK_BYTES);
for (i = 1u; i <= DATA_BLOCK_BYTES; i++) {
toolBufByte(&commands, (uint32_t)i);
}
vgmWaits(&commands, CMD_WAIT_NTSC, SN_CLOSING_FRAMES);
vgm(&commands, SN_CLOCK, 0u, loopAt, NTSC_UPDATE_HZ, &data);
bake(repo, work, "sn", &data, NULL, &song, &err);
if (!song.ok) {
check(checks, false, "sn: did not convert: %s", trimmed(&err));
return;
}
expectNumber(checks, "sn: tick (60 Hz updates)", true, (long)song.tick, SN_TICK_MS);
expectNumber(checks, "sn: loop row", song.hasLoop, (long)song.loopRow, rowOf(SN_LOOP_FRAME * FRAME, SN_TICK_MS));
expectNumber(checks, "sn: rows", true, (long)song.rowCount, rowTotal(SN_FRAMES * FRAME, SN_TICK_MS));
for (i = 0u; i < GATE_COUNT_OF(kSnFrames); i++) {
snprintf(what, sizeof(what), "sn: frame %ld", kSnFrames[i].frame);
expectRow(checks, what, &song, rowOf(kSnFrames[i].frame * FRAME, SN_TICK_MS), &kSnFrames[i].row);
}
want = kSnFrames[0].row;
expectVoice(checks, "sn: the frame before 3", &song, rowOf(SN_BEFORE_FRAME * FRAME, SN_TICK_MS) - 1, 0u, want.voice[0]);
// The same file compressed (.vgz) bakes the same; at --tick 33 it keeps
// its tick and its length.
gzipOf(&data, &packed);
bake(repo, work, "snGz", &packed, NULL, &other, &err);
if (other.ok) {
check(checks, songSame(&other, &song), "sn: .vgz: not the song the .vgm bakes");
} else {
check(checks, false, "sn: .vgz: did not convert: %s", trimmed(&err));
}
songFree(&other);
bake(repo, work, "snSlow", &data, SN_SLOW_TICK, &other, &err);
expectNumber(checks, "sn: --tick 33", other.ok, (long)other.tick, SN_SLOW_TICK_MS);
expectNumber(checks, "sn: --tick 33 rows", other.ok, (long)other.rowCount, rowTotal(SN_FRAMES * FRAME, SN_SLOW_TICK_MS));
songFree(&other);
// The TI-99/4A's chip: 447443 Hz, no /8 prescaler: period 254 is still
// 447443 / (4 * 254) = 440.4 Hz.
commands.len = 0u;
data.len = 0u;
snTone(&commands, 0u, SN_TONE0_PERIOD, TI_TONE_ATTEN);
toolBufByte(&commands, CMD_WAIT_NTSC);
vgm(&commands, TI_CLOCK, 0u, VGM_NO_LOOP, NTSC_UPDATE_HZ, &data);
bake(repo, work, "ti", &data, NULL, &other, &err);
want.voice[0].atten = TI_TONE_ATTEN;
if (other.ok) {
expectVoice(checks, "sn: 447 kHz tone", &other, 0, 0u, want.voice[0]);
} else {
check(checks, false, "sn: 447 kHz tone: did not convert: %s", trimmed(&err));
}
songFree(&other);
songFree(&song);
toolBufFree(&commands);
toolBufFree(&data);
toolBufFree(&packed);
toolBufFree(&err);
}
// An SN76489 tone: the channel's period (latch and data bytes) and its
// attenuation.
static void snTone(ToolBufT *commands, uint32_t channel, uint32_t period, uint32_t atten) {
sn(commands, SN_LATCH | (channel << SN_CHANNEL_SHIFT) | (period & SN_LOW_MASK));
sn(commands, (period >> SN_HIGH_SHIFT) & SN_HIGH_MASK);
sn(commands, SN_VOLUME | (channel << SN_CHANNEL_SHIFT) | atten);
}
static void songFree(SongT *song) {
free(song->rows);
memset(song, 0, sizeof(*song));
}
// Two bakes of a song alike: tick, loop, and every row.
static bool songSame(const SongT *a, const SongT *b) {
size_t i;
if (a->tick != b->tick || a->hasLoop != b->hasLoop || (a->hasLoop && a->loopRow != b->loopRow) || a->rowCount != b->rowCount) {
return false;
}
for (i = 0u; i < a->rowCount; i++) {
if (!rowSame(&a->rows[i], &b->rows[i])) {
return false;
}
}
return true;
}
// synthetic: every case, its failures, and the count.
static int synthetic(const char *repo, const char *work) {
ChecksT checks;
const char *at;
size_t i;
memset(&checks, 0, sizeof(checks));
snCase(repo, work, &checks);
ayCase(repo, work, &checks);
fitCase(repo, work, &checks);
refusalCases(repo, work, &checks);
at = (const char *)checks.failures.data;
for (i = 0u; i < checks.failureCount; i++) {
printf("vgmCheck: FAIL %s\n", at);
at += strlen(at) + 1u;
}
printf("SYNTHETIC %zu/%zu\n", checks.count - checks.failureCount, checks.count);
toolBufFree(&checks.failures);
return checks.failureCount > 0u ? FAIL_EXIT : 0;
}
// text without its leading and trailing white space, as a C string.
static const char *trimmed(ToolBufT *text) {
const char *start;
while (text->len > 0u && isspace(text->data[text->len - 1u])) {
text->len--;
}
start = gateTextString(text);
while (isspace((unsigned char)*start)) {
start++;
}
return start;
}
// A VGM 1.71 file of the commands (then the end command), with the
// loop at byte loopAt of them (VGM_NO_LOOP for none); rate is its update
// rate, as Furnace writes it.
static void vgm(const ToolBufT *commands, uint32_t snClock, uint32_t ayClock, long loopAt, uint32_t rate, ToolBufT *out) {
uint8_t header[VGM_HEADER];
memset(header, 0, sizeof(header));
memcpy(header, VGM_IDENT, VGM_IDENT_BYTES);
putLe32(header + VGM_EOF_AT, (uint32_t)(VGM_HEADER + commands->len + 1u - VGM_EOF_AT));
putLe32(header + VGM_VERSION_AT, VGM_VERSION);
putLe32(header + VGM_SN_CLOCK_AT, snClock);
putLe32(header + VGM_RATE_AT, rate);
header[VGM_SN_FEEDBACK_AT] = VGM_SN_FEEDBACK & BYTE_MASK;
header[VGM_SN_FEEDBACK_AT + 1u] = VGM_SN_FEEDBACK >> BYTE_BITS;
header[VGM_SN_WIDTH_AT] = VGM_SN_WIDTH;
putLe32(header + VGM_DATA_AT, VGM_HEADER - VGM_DATA_AT);
putLe32(header + VGM_AY_CLOCK_AT, ayClock);
if (loopAt != VGM_NO_LOOP) {
putLe32(header + VGM_LOOP_AT, (uint32_t)(VGM_HEADER + (size_t)loopAt - VGM_LOOP_AT));
}
out->len = 0u;
toolBufAdd(out, header, sizeof(header));
toolBufAdd(out, commands->data, commands->len);
toolBufByte(out, CMD_END);
}
// A wait of samples (0x61).
static void vgmWait(ToolBufT *commands, uint32_t samples) {
toolBufByte(commands, CMD_WAIT);
toolBufByte(commands, samples & BYTE_MASK);
toolBufByte(commands, samples >> BYTE_BITS);
}
// count one-byte commands in a row.
static void vgmWaits(ToolBufT *commands, uint32_t command, uint32_t count) {
uint32_t i;
for (i = 0u; i < count; i++) {
toolBufByte(commands, command);
}
}
// Both silent, or the same value at the same attenuation.
static bool voiceSame(VoiceT a, VoiceT b) {
if (a.on != b.on) {
return false;
}
return !a.on || (a.value == b.value && a.atten == b.atten);
}
static void voiceText(ToolBufT *out, VoiceT voice) {
if (!voice.on) {
gateTextAdd(out, "off");
return;
}
gateTextAdd(out, "(%u, %u)", (unsigned)voice.value, (unsigned)voice.atten);
}
int main(int argc, char **argv) {
toolFileName(TOOL_NAME);
if (argc == MODE_ARGS && strcmp(argv[MODE_ARG], "synthetic") == 0) {
return synthetic(argv[FIRST_ARG], argv[FIRST_ARG + 1]);
}
if (argc == MODE_ARGS && strcmp(argv[MODE_ARG], "pitch") == 0) {
return pitch(argv[FIRST_ARG], argv[FIRST_ARG + 1]);
}
fputs(kUsage, stderr);
return GATE_USAGE_EXIT;
}