Files
VGMPLAY.WMF/src_sdcc/scripts/gen_freq_tables.js
T

466 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* gen_freq_tables.js — Генерация LUT таблиц масштабирования частот PSG
*
* Использование:
* node scripts/gen_freq_tables.js [--binary <output.bin>] [--asm <output.s>] [--info]
*
* Каждая таблица: 4096 entries × uint16_t LE = 8192 байт (8 КБ).
*
* Покрывает:
* - AY/YM2149: 12-bit tone period (regs 0x00–0x05), 0..4095
* - Noise: 5-bit (reg 0x06), 0..31 — первые 32 записи
*
* Формула масштабирования PSG:
* F = CLK / (16 × period) → scaled = period × (target / source)
*
* FM F-Number масштабирование (YM2203) требует ОБРАТНОГО ratio (source/target)
* и выполняется runtime через дробные коэффициенты (fm_frac_t в C-хедере).
*
* Layout: 2 ratios per 16 КБ page:
* 0x0000–0x1FFF : PSG таблица A (8 КБ)
* 0x2000–0x3FFF : PSG таблица B (8 КБ)
*
* Итого: 3 pages × 16 КБ = 48 КБ (3 доп. страницы)
*/
'use strict';
const fs = require('fs');
const path = require('path');
/* ── Целевые частоты нашего железа (TSConfig MultiSound) ─────────────── */
const HW_CLK_AY = 1750000; /* AY/YM2149: 1.75 МГц */
const HW_CLK_YM2203 = 3500000; /* YM2203 OPN: 3.5 МГц */
/* ── Порог округления при сравнении частот ────────────────────────────
* Если VGM clock отличается от канонического ≤ MATCH_TOLERANCE_PCT,
* считаем что это тот же коэффициент. Отдельно: если ratio ≈ 1.0
* (±BYPASS_PCT), масштабирование не нужно вообще. */
const MATCH_TOLERANCE_PCT = 5.0; /* % — порог совпадения таблиц */
const BYPASS_PCT = 2.0; /* % — порог bypass (≈1:1) */
/* ── Предопределённые варианты VGM-клоков ─────────────────────────────
* Для YM2203: PSG (SSG) = fclk/2, FM = fclk
* Для AY8910: PSG = fclk
*
* PSG ratio = HW_CLK_AY / srcPsgHz (target/source → period scaling)
* FM ratio = srcYM2203 / HW_CLK_YM2203 (source/target → fnum scaling, INVERSE!)
*
* PSG ratio и FM ratio НЕ равны друг другу — нужны ОТДЕЛЬНЫЕ таблицы!
* PSG: scaled_period = period × (HW_CLK_AY / srcPsgHz)
* FM: scaled_fnum = fnum × (srcYM2203 / HW_CLK_YM2203)
*
* FM масштабирование: все используемые FM ratios сводятся к N/7,
* поэтому fm_scale хранит множитель N (0=bypass, 3, 5, 6, 8).
* В ASM на Z80: fnum × N через shifts+adds, затем /7 binary division.
* NTSC (45/44 ≈ 1.023) — bypass, погрешность 2.3% в рамках 5% допуска.
*
* Близкие частоты (≤ MATCH_TOLERANCE_PCT) объединяются:
* 1996800 Hz ≈ 2000000 Hz (0.16%) → используют одну таблицу '4MHz_2000'
* 3993600 Hz ≈ 4000000 Hz (0.16%) → то же самое
*/
const RATIOS = [
{
name: 'NTSC_1790',
srcPsgHz: 1789773, /* AY8910 @ 1789773 или YM2203 @ 3579545 (SSG=1789773) */
srcYM2203: 3579545,
fm_scale: 0, /* 45/44 ≈ 1.023 — bypass (2.3% в рамках 5% допуска) */
desc: 'NTSC-derived (NES, many JP computers)',
},
{
name: '3MHz_1500',
srcPsgHz: 1500000, /* AY8910 @ 1.5M или YM2203 @ 3M */
srcYM2203: 3000000,
fm_scale: 6, /* 6/7 — fnum × 6 / 7 */
desc: 'PC-88, MSX (3 MHz YM2203)',
},
{
name: '4MHz_2000',
srcPsgHz: 2000000, /* AY8910 @ 2M или YM2203 @ 4M (≈1996800/3993600) */
srcYM2203: 4000000,
fm_scale: 8, /* 8/7 — fnum × 8 / 7 */
desc: 'Arcade boards (4 MHz YM2203, covers 3.9936 MHz)',
},
{
name: 'CPC_1000',
srcPsgHz: 1000000, /* Amstrad CPC AY @ 1 MHz */
srcYM2203: 0, /* нет FM */
desc: 'Amstrad CPC (1 MHz AY)',
},
{
name: '1250',
srcPsgHz: 1250000,
srcYM2203: 2500000,
fm_scale: 5, /* 5/7 — fnum × 5 / 7 */
desc: 'Some boards (1.25 MHz AY / 2.5 MHz YM2203)',
},
{
name: '1500_750',
srcPsgHz: 750000, /* YM2203 @ 1.5M → SSG = 750 kHz */
srcYM2203: 1500000,
fm_scale: 3, /* 3/7 — fnum × 3 / 7 */
desc: 'Arcade/custom boards (1.5 MHz YM2203, SSG=750 kHz)',
},
];
/**
* matchTable(psgClkHz) → { index, ratio, bypass }
*
* Ищет ближайшую таблицу для заданного PSG clock.
* Если расхождение ratio от 1.0 ≤ BYPASS_PCT → bypass (масштабирование не нужно).
* Если ближайшая таблица ≤ MATCH_TOLERANCE_PCT → возвращает её индекс.
* Иначе → index = -1 (нужна runtime-генерация или таблицы нет).
*/
function matchTable(psgClkHz) {
const ratio = psgClkHz / HW_CLK_AY;
/* Bypass: частоты почти совпадают с HW */
if (Math.abs(ratio - 1.0) * 100 <= BYPASS_PCT) {
return { index: -1, ratio, bypass: true, name: 'BYPASS' };
}
let bestIdx = -1;
let bestDiffPct = Infinity;
for (let i = 0; i < RATIOS.length; i++) {
const diffPct = Math.abs(psgClkHz - RATIOS[i].srcPsgHz) / RATIOS[i].srcPsgHz * 100;
if (diffPct < bestDiffPct) {
bestDiffPct = diffPct;
bestIdx = i;
}
}
if (bestDiffPct <= MATCH_TOLERANCE_PCT) {
return {
index: bestIdx,
ratio: RATIOS[bestIdx].srcPsgHz / HW_CLK_AY,
bypass: false,
name: RATIOS[bestIdx].name,
diffPct: bestDiffPct,
};
}
/* Нет подходящей таблицы — runtime generation needed */
return { index: -1, ratio, bypass: false, name: 'RUNTIME' };
}
const TABLE_ENTRIES = 4096; /* 12-bit PSG range */
const TABLE_BYTES = TABLE_ENTRIES * 2; /* uint16_t LE = 8 КБ */
const PAGE_SIZE = 16384; /* 16 КБ */
/* ── Генерация PSG таблицы ───────────────────────────────────────────
* ratio = target / source (period scales up/down to preserve frequency)
* PSG: F = CLK / (16 × period) → scaled_period = period × (target/source)
*/
function generateTable(srcPsgHz, targetHz) {
const buf = Buffer.alloc(TABLE_BYTES);
const ratio = targetHz / srcPsgHz;
for (let i = 0; i < TABLE_ENTRIES; i++) {
let scaled = Math.round(i * ratio);
if (scaled > 0xFFFF) scaled = 0xFFFF; /* clamp uint16 */
buf.writeUInt16LE(scaled, i * 2);
}
return buf;
}
/* ── Вывод информации о таблицах ─────────────────────────────────────── */
function printInfo() {
console.log('=== Frequency Scale LUT Tables ===');
console.log(`Target PSG: ${HW_CLK_AY} Hz`);
console.log(`PSG table: ${TABLE_ENTRIES} entries × 2 bytes = ${TABLE_BYTES} bytes`);
console.log(`Layout: 2 ratios per 16 КБ page`);
console.log(`Total: ${Math.ceil(RATIOS.length / 2)} pages × 16 КБ = ${Math.ceil(RATIOS.length / 2) * PAGE_SIZE} bytes`);
console.log('');
for (let r = 0; r < RATIOS.length; r++) {
const rt = RATIOS[r];
const psgRatio = HW_CLK_AY / rt.srcPsgHz;
const page = Math.floor(r / 2) + PAGE_BASE;
const slot = r % 2;
const offset = slot * TABLE_BYTES;
console.log(`[${r}] ${rt.name}: PSG ${rt.srcPsgHz} Hz → ratio = ${psgRatio.toFixed(6)}`);
console.log(` ${rt.desc}`);
console.log(` Page ${page}, slot ${slot} (offset 0x${offset.toString(16).toUpperCase()})`);
if (rt.srcYM2203) {
const fmRatio = rt.srcYM2203 / HW_CLK_YM2203;
const fmDesc = rt.fm_scale ? `×${rt.fm_scale}/7` : 'bypass';
console.log(` FM: YM2203 @ ${rt.srcYM2203} Hz → fm_ratio = ${fmRatio.toFixed(6)} (${fmDesc})`);
}
/* Проверка нескольких ключевых значений */
const checks = [
{ name: 'PSG tone 440 Hz equiv', val: Math.round(rt.srcPsgHz / (16 * 440)) },
{ name: 'PSG max 12-bit', val: 4095 },
{ name: 'Noise max 5-bit', val: 31 },
];
for (const c of checks) {
if (c.val >= TABLE_ENTRIES) continue;
const scaled = Math.round(c.val * psgRatio);
console.log(` PSG ${c.name}: ${c.val} → ${scaled}`);
}
console.log('');
}
/* ── Тест: сопоставление «диких» частот из реальных VGM ────────── */
console.log('--- Clock matching test (bypass ' + BYPASS_PCT + '%, tolerance ' + MATCH_TOLERANCE_PCT + '%) ---');
const testClocks = [
/* PSG clocks (AY8910 fclk, или YM2203 SSG = fclk/2) */
{ name: 'TSconf HW (bypass)', hz: 1750000 },
{ name: 'Close to HW (bypass)', hz: 1773400 },
{ name: 'NTSC exact', hz: 1789773 },
{ name: 'NTSC rounded', hz: 1789750 },
{ name: 'YM2203 3.58MHz SSG', hz: 1789772 },
{ name: 'PAL-derived', hz: 1773448 },
{ name: 'MSX 3MHz SSG', hz: 1500000 },
{ name: 'MSX variant', hz: 1536000 },
{ name: '4MHz SSG exact', hz: 2000000 },
{ name: '3.9936MHz SSG', hz: 1996800 },
{ name: '4.028MHz SSG', hz: 2014000 },
{ name: 'CPC exact', hz: 1000000 },
{ name: 'CPC-ish', hz: 1023000 },
{ name: '2.5MHz SSG', hz: 1250000 },
{ name: 'Weird 1.3MHz', hz: 1300000 },
{ name: 'Weird 1.6MHz', hz: 1600000 },
{ name: 'Weird 900kHz', hz: 900000 },
{ name: 'YM2203 1.5MHz SSG', hz: 750000 },
{ name: 'ZX 128 (1.7734 MHz)', hz: 1773400 },
];
for (const t of testClocks) {
const m = matchTable(t.hz);
const tag = m.bypass ? 'BYPASS' :
m.index >= 0 ? `table[${m.index}]=${m.name} (${m.diffPct.toFixed(2)}%)` :
'NO MATCH → runtime';
console.log(` ${t.hz.toString().padStart(7)} Hz ${t.name.padEnd(26)} → ${tag}`);
}
/* Подсказка по Z80 маппингу */
console.log('');
console.log('--- Z80 matching: compare clock_khz ±5% ---');
for (let r = 0; r < RATIOS.length; r++) {
const khz = Math.round(RATIOS[r].srcPsgHz / 1000);
const tol = Math.round(khz * MATCH_TOLERANCE_PCT / 100);
const page = Math.floor(r / 2) + PAGE_BASE;
const slot = r % 2;
console.log(` [${r}] ${RATIOS[r].name.padEnd(12)} ${khz} kHz ± ${tol} kHz → page ${page}, slot ${slot}`);
}
}
/* ── Генерация бинарного файла ───────────────────────────────────────── *
* Layout: 2 ratios per 16 КБ page.
*
* Page N+0: PSG[0] (0x0000-0x1FFF) + PSG[1] (0x2000-0x3FFF)
* Page N+1: PSG[2] (0x0000-0x1FFF) + PSG[3] (0x2000-0x3FFF)
* ...
*
* Итого: ceil(RATIOS.length / 2) × 16 КБ
*/
function generateBinary(outPath) {
const numPages = Math.ceil(RATIOS.length / 2);
const totalSize = numPages * PAGE_SIZE;
const out = Buffer.alloc(totalSize, 0x00);
for (let r = 0; r < RATIOS.length; r++) {
const page = Math.floor(r / 2);
const slot = r % 2;
const offset = page * PAGE_SIZE + slot * TABLE_BYTES;
const table = generateTable(RATIOS[r].srcPsgHz, HW_CLK_AY);
table.copy(out, offset);
}
fs.writeFileSync(outPath, out);
console.log(`Written: ${outPath} (${out.length} bytes, ${numPages} pages)`);
/* Вывод маппинга для кода */
console.log('\nC mapping (for vgm.c):');
console.log('// After determining VGM PSG clock, select table:');
for (let r = 0; r < RATIOS.length; r++) {
const page = Math.floor(r / 2) + PAGE_BASE;
const offset = (r % 2) * TABLE_BYTES;
console.log(`// ${RATIOS[r].srcPsgHz} Hz → wc_mng0_pl(${page}), offset = 0x${offset.toString(16)}`);
}
}
/* ── Генерация ASM include (для верификации) ─────────────────────────── */
function generateAsm(outPath) {
let asm = '; Auto-generated by gen_freq_tables.js\n';
asm += '; Frequency scale LUT tables for PSG/FM\n';
asm += `; Target: PSG=${HW_CLK_AY} Hz, FM=${HW_CLK_YM2203} Hz\n`;
asm += `; ${TABLE_ENTRIES} entries × 2 bytes = ${TABLE_BYTES} bytes per table\n\n`;
for (let r = 0; r < RATIOS.length; r++) {
const rt = RATIOS[r];
const ratio = rt.srcPsgHz / HW_CLK_AY;
asm += `; Table ${r}: ${rt.name} (${rt.srcPsgHz} Hz, ratio=${ratio.toFixed(6)})\n`;
asm += `_freq_lut_${r}::\n`;
const table = generateTable(rt.srcPsgHz, HW_CLK_AY);
for (let i = 0; i < TABLE_ENTRIES; i += 8) {
const values = [];
for (let j = 0; j < 8 && (i + j) < TABLE_ENTRIES; j++) {
values.push('0x' + table.readUInt16LE((i + j) * 2).toString(16).padStart(4, '0'));
}
asm += ` .dw ${values.join(', ')}\n`;
}
asm += '\n';
}
fs.writeFileSync(outPath, asm);
console.log(`Written: ${outPath}`);
}
/* ── Генерация C-хедера для Z80 маппинга ─────────────────────────────
* Содержит таблицу {clk_khz, tol_khz, page, offset} для линейного
* поиска ближайшей LUT при парсинге VGM заголовка.
*
* Layout: 2 ratios per page — PSG_A@0x0000 (8 КБ) + PSG_B@0x2000 (8 КБ).
* FM масштабирование — runtime через fm_frac_t (дробные коэффициенты).
*
* Алгоритм на Z80 (в vgm.c):
* 1. bypass: |psg_khz - 1750| ≤ 35 → нет масштабирования
* 2. Линейный поиск: |psg_khz - entry.clk_khz| ≤ entry.tol_khz → match
* 3. Нет match → native (без масштабирования)
*
* Все сравнения — 16 бит (uint16_t kHz), дёшево на Z80.
*/
function generateCHeader(outPath) {
let h = '';
h += '/* Auto-generated by gen_freq_tables.js — DO NOT EDIT */\n';
h += '#ifndef FREQ_LUT_MAP_H\n';
h += '#define FREQ_LUT_MAP_H\n\n';
h += '#include "types.h"\n\n';
/* Count YM2203 entries (those with srcYM2203 > 0) */
const ymEntries = RATIOS.filter(r => r.srcYM2203 > 0);
h += `/* AY/YM2149: bypass |psg_khz - ${Math.round(HW_CLK_AY/1000)}| <= ${Math.round(HW_CLK_AY/1000 * BYPASS_PCT / 100)} */\n`;
h += `#define FREQ_LUT_HW_KHZ ${Math.round(HW_CLK_AY/1000)}u\n`;
h += `#define FREQ_LUT_BYPASS_TOL ${Math.round(HW_CLK_AY/1000 * BYPASS_PCT / 100)}u /* ${BYPASS_PCT}% */\n`;
h += `/* YM2203: bypass |ym_khz - ${Math.round(HW_CLK_YM2203/1000)}| <= ${Math.round(HW_CLK_YM2203/1000 * BYPASS_PCT / 100)} */\n`;
h += `#define FREQ_LUT_HW_YM_KHZ ${Math.round(HW_CLK_YM2203/1000)}u\n`;
h += `#define FREQ_LUT_BYPASS_TOL_YM ${Math.round(HW_CLK_YM2203/1000 * BYPASS_PCT / 100)}u /* ${BYPASS_PCT}% */\n\n`;
h += `#define FREQ_LUT_AY_COUNT ${RATIOS.length}u\n`;
h += `#define FREQ_LUT_YM_COUNT ${ymEntries.length}u\n`;
h += `#define FREQ_LUT_PSG_ENTRIES ${TABLE_ENTRIES}u /* 12-bit PSG */\n`;
h += `#define FREQ_LUT_PSG_BYTES ${TABLE_BYTES}u /* per PSG table */\n\n`;
h += 'typedef struct {\n';
h += ' uint16_t clk_khz; /* canonical clock in kHz (PSG or YM master) */\n';
h += ' uint16_t tol_khz; /* \u00b1tolerance in kHz (5%) */\n';
h += ' uint8_t page; /* plugin page (PAGE_BASE-based) */\n';
h += ' uint16_t offset; /* PSG table byte offset within page */\n';
h += '} freq_lut_entry_t;\n\n';
/* AY map — PSG only */
h += `/* AY/YM2149 PSG table map (tolerance = ${MATCH_TOLERANCE_PCT}% of canonical PSG clock)\n`;
h += ` * Layout: 2 ratios per page, PSG_A@0x0000, PSG_B@0x${TABLE_BYTES.toString(16).toUpperCase()} */\n`;
h += 'static const freq_lut_entry_t freq_lut_map_ay[FREQ_LUT_AY_COUNT] = {\n';
for (let r = 0; r < RATIOS.length; r++) {
const khz = Math.round(RATIOS[r].srcPsgHz / 1000);
const tol = Math.round(khz * MATCH_TOLERANCE_PCT / 100);
const page = Math.floor(r / 2) + PAGE_BASE;
const offset = (r % 2) * TABLE_BYTES;
const comma = (r < RATIOS.length - 1) ? ',' : ' ';
h += ` { ${khz.toString().padStart(4)}u, ${tol.toString().padStart(3)}u, ${page}, 0x${offset.toString(16).toUpperCase().padStart(4, '0')} }${comma} /* ${RATIOS[r].name} */\n`;
}
h += '};\n\n';
/* YM2203 map — uses same PSG table as AY, plus FM frac for runtime */
h += '/* YM2203 table map (full master clock, PSG table = same as AY entry) */\n';
h += 'static const freq_lut_entry_t freq_lut_map_ym[FREQ_LUT_YM_COUNT] = {\n';
let ymIdx = 0;
for (let r = 0; r < RATIOS.length; r++) {
if (!RATIOS[r].srcYM2203) continue;
const khz = Math.round(RATIOS[r].srcYM2203 / 1000);
const tol = Math.round(khz * MATCH_TOLERANCE_PCT / 100);
const page = Math.floor(r / 2) + PAGE_BASE;
const offset = (r % 2) * TABLE_BYTES;
ymIdx++;
const comma = (ymIdx < ymEntries.length) ? ',' : ' ';
h += ` { ${khz.toString().padStart(4)}u, ${tol.toString().padStart(3)}u, ${page}, 0x${offset.toString(16).toUpperCase().padStart(4, '0')} }${comma} /* ${RATIOS[r].name} (YM2203@${khz}kHz) */\n`;
}
h += '};\n\n';
/* FM multiplier table — indexed same as freq_lut_map_ym[] */
h += '/* FM F-Number \u043c\u043d\u043e\u0436\u0438\u0442\u0435\u043b\u044c (YM2203): scaled = fnum \u00d7 fm_mul / 7.\n';
h += ' * 0 = bypass (\u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043d\u0435 \u043d\u0443\u0436\u043d\u043e).\n';
h += ' * ASM \u043d\u0430 Z80: \u0443\u043c\u043d\u043e\u0436\u0435\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437 shifts+adds, \u0434\u0435\u043b\u0435\u043d\u0438\u0435 \u043d\u0430 7 binary division */\n';
h += 'static const uint8_t freq_lut_fm_mul[FREQ_LUT_YM_COUNT] = {\n';
ymIdx = 0;
for (let r = 0; r < RATIOS.length; r++) {
if (!RATIOS[r].srcYM2203) continue;
const rt = RATIOS[r];
const fmRatio = rt.srcYM2203 / HW_CLK_YM2203;
ymIdx++;
const comma = (ymIdx < ymEntries.length) ? ',' : ' ';
const fmDesc = rt.fm_scale ? `\u00d7${rt.fm_scale}/7` : 'bypass (2.3%)';
h += ` ${rt.fm_scale}${comma} /* ${rt.name}: ${fmRatio.toFixed(6)} = ${fmDesc} */\n`;
}
h += '};\n\n';
h += '/*\n';
h += ' * Usage in vgm.c (parse_header):\n';
h += ' *\n';
h += ' * 1. Bypass check: |clock_khz - HW| <= BYPASS_TOL \u2192 native\n';
h += ' * 2. Find matching table \u2192 set page, PSG base = offset\n';
h += ' * 3. For YM2203: fm_mul = freq_lut_fm_mul[j]\n';
h += ' *\n';
h += ' * PSG: scaled_period = freq_lut_base[period_12bit]\n';
h += ' * FM: asm_scale_fm() : fb_scaled = fnum \u00d7 fm_mul / 7\n';
h += ' * if (fb_scaled > 2047) block++, fb_scaled >>= 1\n';
h += ' */\n\n';
h += '#endif /* FREQ_LUT_MAP_H */\n';
fs.writeFileSync(outPath, h);
console.log(`Written: ${outPath}`);
}
/* ── Номер первой plugin-страницы с LUT данными ─────────────────────
* Зависит от числа страниц кода: code_pages = ceil(code_size / 16384).
* Передаётся через --page-base N. По умолчанию 2 (типичный случай:
* CODE+DATA ≈ 15-16 КБ, занимает 2 страницы #8000-#FFFF). */
let PAGE_BASE = 2;
/* ── CLI ─────────────────────────────────────────────────────────────── */
function main() {
const args = process.argv.slice(2);
let doBinary = null;
let doAsm = null;
let doCheader = null;
let doInfo = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--binary' && args[i + 1]) { doBinary = args[++i]; }
else if (args[i] === '--asm' && args[i + 1]) { doAsm = args[++i]; }
else if (args[i] === '--cheader' && args[i + 1]) { doCheader = args[++i]; }
else if (args[i] === '--page-base' && args[i + 1]) { PAGE_BASE = parseInt(args[++i], 10); }
else if (args[i] === '--info') { doInfo = true; }
else {
console.error(`Unknown arg: ${args[i]}`);
console.error('Usage: node gen_freq_tables.js [--binary out.bin] [--asm out.s] [--cheader out.h] [--page-base N] [--info]');
process.exit(1);
}
}
if (!doBinary && !doAsm && !doCheader && !doInfo) {
doInfo = true; /* default: print info */
}
if (doInfo) printInfo();
if (doBinary) generateBinary(doBinary);
if (doAsm) generateAsm(doAsm);
if (doCheader) generateCHeader(doCheader);
}
main();