// 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 #include #include #include #include #include #include #include #include #include #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; }