diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..422406d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# CLAUDE.md + +Bitphase is a web-based chiptune tracker for retro sound chips (currently AY-3-8910 / YM2149F), built with Svelte 5, TypeScript, Vite, and Tailwind 4. + +## Authoritative rules + +The coding rules in `.cursor/rules/` are the source of truth and apply to all work here. Read and follow them: + +- `.cursor/rules/general.mdc` — architecture, directory layout, chip-abstraction rules, code style. +- `.cursor/rules/svelte5.mdc` — Svelte 5 runes API (`$state`, `$derived`, `$effect`, `$props`, snippets). Svelte 5 syntax only. + +Key non-negotiables from those rules: + +- **No lint errors, ever.** Past production bugs traced to lint errors. Fix any you see. +- **No comments** — write self-documenting code. +- **No chip-specific code in generic parts.** AY-specific logic stays in `src/lib/chips/ay/`; generic code uses `src/lib/chips/base/`. The architecture must stay ready for additional chips. +- **State via Svelte 5 runes in `.svelte.ts` files**, not writable stores. + +## Commands + +- `pnpm dev` — build WASM, then start Vite dev server (HMR). +- `pnpm build` — build WASM, then production build. +- `pnpm build:wasm` — build only the Ayumi WASM module (requires Emscripten / `emcc` on PATH; output goes to `public/ayumi.wasm`). +- `pnpm check` — type-check (`svelte-check` + `tsc`). Run before considering work done. +- `pnpm test` — vitest watch mode. `pnpm test:run` — run once. +- `pnpm btp-to-wav` — CLI export of a `.btp` to WAV. + +## Notes + +- `@` path alias maps to `./src` (vite + vitest). +- Tests live in `tests/`, mirroring `src/` structure; vitest uses jsdom. +- The Ayumi emulator C source is a git submodule at `external/ayumi`; clone with `--recurse-submodules`. +- Stale `public/ayumi.wasm` causes silent no-audio — rebuild WASM after changing the C source or when audio is unexpectedly silent. +- `public/` holds runtime AudioWorklet scripts (`tracker-*.js`, `ay-*.js`, `bitphase-audio-processor.js`) that are served as-is, not bundled. + +See `README.md` for full feature list and project structure. diff --git a/cli/btp-to-psg.ts b/cli/btp-to-psg.ts new file mode 100644 index 00000000..cb568cd9 --- /dev/null +++ b/cli/btp-to-psg.ts @@ -0,0 +1,120 @@ +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { loadBtpFromFile } from './btp-loader'; +import { FileSystemResourceLoader } from './resource-loader-node'; +import { ensureCoreRegistry } from '../src/lib/chips/registry-core'; +import type { PsgExportModules } from '../src/lib/services/file/ay/psg-export'; +import { generatePSGBuffer } from '../src/lib/services/file/ay/psg-export'; +import type { Project } from '../src/lib/models/project'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.join(__dirname, '..'); +const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public'); + +function printUsage(): void { + console.error(`Usage: btp-to-psg [output-base] +Converts a Bitphase project (.btp) to a Bulba .psg register dump. + + input.btp Path to the BTP file to convert + output-base Optional output base path (default: input name without extension). + Produces .psg; for multi-AY-song projects, produces + _ayN.psg per AY song.`); +} + +async function loadModulesFromPublic( + resourceLoader: FileSystemResourceLoader +): Promise { + const [ + ayumiState, + trackerPatternProcessor, + ayAudioDriver, + ayChipRegisterState, + virtualChannelMixer + ] = await Promise.all([ + resourceLoader.loadModule<{ default: PsgExportModules['AyumiState'] }>('ay/ayumi-state.js'), + resourceLoader.loadModule<{ default: PsgExportModules['TrackerPatternProcessor'] }>( + 'tracker/tracker-pattern-processor.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['AYAudioDriver'] }>('ay/ay-audio-driver.js'), + resourceLoader.loadModule<{ default: PsgExportModules['AYChipRegisterState'] }>( + 'ay/ay-chip-register-state.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['VirtualChannelMixer'] }>( + 'ay/virtual-channel-mixer.js' + ) + ]); + return { + AyumiState: ayumiState.default, + TrackerPatternProcessor: trackerPatternProcessor.default, + AYAudioDriver: ayAudioDriver.default, + AYChipRegisterState: ayChipRegisterState.default, + VirtualChannelMixer: virtualChannelMixer.default + }; +} + +function getAYSongIndices(project: Project): number[] { + const aySongIndices: number[] = []; + for (let index = 0; index < project.songs.length; index++) { + const song = project.songs[index]; + if (song && (!song.chipType || song.chipType === 'ay')) { + aySongIndices.push(index); + } + } + return aySongIndices; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length < 1) { + printUsage(); + process.exit(1); + } + + const inputPath = path.resolve(process.cwd(), args[0]); + const outputBase = + args[1] !== undefined + ? path.resolve(process.cwd(), args[1]) + : inputPath.replace(/\.btp$/i, ''); + + if (!fs.existsSync(inputPath)) { + console.error(`Error: Input file not found: ${inputPath}`); + process.exit(1); + } + + if (!fs.existsSync(PUBLIC_DIR)) { + console.error(`Error: Public directory not found: ${PUBLIC_DIR}`); + console.error('Run this command from the project root.'); + process.exit(1); + } + + const resourceLoader = new FileSystemResourceLoader(PUBLIC_DIR); + + try { + await ensureCoreRegistry(); + const project = loadBtpFromFile(inputPath); + const modules = await loadModulesFromPublic(resourceLoader); + const aySongIndices = getAYSongIndices(project); + + if (aySongIndices.length === 0) { + console.error('Error: Project has no AY songs to export.'); + process.exit(1); + } + + const multipleSongs = aySongIndices.length > 1; + + for (let index = 0; index < aySongIndices.length; index++) { + const songIndex = aySongIndices[index]!; + process.stderr.write(`\r[${index + 1}/${aySongIndices.length}] Generating PSG... `); + const buffer = await generatePSGBuffer(project, songIndex, { modules }); + const base = multipleSongs ? `${outputBase}_ay${index + 1}` : outputBase; + fs.writeFileSync(`${base}.psg`, Buffer.from(buffer)); + console.error(`\nWrote: ${base}.psg`); + } + } catch (error) { + console.error('\nError:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main(); diff --git a/cli/btp-to-taym.ts b/cli/btp-to-taym.ts new file mode 100644 index 00000000..2461b200 --- /dev/null +++ b/cli/btp-to-taym.ts @@ -0,0 +1,133 @@ +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { loadBtpFromFile } from './btp-loader'; +import { FileSystemResourceLoader } from './resource-loader-node'; +import { ensureCoreRegistry } from '../src/lib/chips/registry-core'; +import type { PsgExportModules } from '../src/lib/services/file/ay/psg-export'; +import { generateTaymFile } from '../src/lib/services/file/taym/taym-export'; +import type { TaymTimerMode } from '../src/lib/services/file/taym/taym-export-timers'; +import type { Project } from '../src/lib/models/project'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.join(__dirname, '..'); +const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public'); + +function printUsage(): void { + console.error(`Usage: btp-to-taym [output-base] [--hz] +Converts a Bitphase project (.btp) to a TAYM file. + + input.btp Path to the BTP file to convert + output-base Optional output base path (default: input name without extension). + Produces .taym; for multi-AY-song projects, produces + _ayN.taym per AY song. + --hz Encode timers as ABS_RATE_HZ (16.16 Hz lanes) instead of the + default CHIP_PERIOD (literal AY-period lanes).`); +} + +async function loadModulesFromPublic( + resourceLoader: FileSystemResourceLoader +): Promise { + const [ + ayumiState, + trackerPatternProcessor, + ayAudioDriver, + ayChipRegisterState, + virtualChannelMixer, + samplePlayback + ] = await Promise.all([ + resourceLoader.loadModule<{ default: PsgExportModules['AyumiState'] }>('ay/ayumi-state.js'), + resourceLoader.loadModule<{ default: PsgExportModules['TrackerPatternProcessor'] }>( + 'tracker/tracker-pattern-processor.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['AYAudioDriver'] }>('ay/ay-audio-driver.js'), + resourceLoader.loadModule<{ default: PsgExportModules['AYChipRegisterState'] }>( + 'ay/ay-chip-register-state.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['VirtualChannelMixer'] }>( + 'ay/virtual-channel-mixer.js' + ), + resourceLoader.loadModule<{ + instrumentHasSample: NonNullable; + advanceSamplePosition: NonNullable; + }>('ay/ay-sample-playback.js') + ]); + return { + AyumiState: ayumiState.default, + TrackerPatternProcessor: trackerPatternProcessor.default, + AYAudioDriver: ayAudioDriver.default, + AYChipRegisterState: ayChipRegisterState.default, + VirtualChannelMixer: virtualChannelMixer.default, + instrumentHasSample: samplePlayback.instrumentHasSample, + advanceSamplePosition: samplePlayback.advanceSamplePosition + }; +} + +function getAYSongIndices(project: Project): number[] { + const aySongIndices: number[] = []; + for (let index = 0; index < project.songs.length; index++) { + const song = project.songs[index]; + if (song && (!song.chipType || song.chipType === 'ay')) { + aySongIndices.push(index); + } + } + return aySongIndices; +} + +async function main(): Promise { + const allArgs = process.argv.slice(2); + const timerMode: TaymTimerMode = allArgs.includes('--hz') ? 'abs-rate-hz' : 'chip-period'; + const args = allArgs.filter((arg) => arg !== '--hz'); + if (args.length < 1) { + printUsage(); + process.exit(1); + } + + const inputPath = path.resolve(process.cwd(), args[0]); + const outputBase = ( + args[1] !== undefined + ? path.resolve(process.cwd(), args[1]) + : inputPath.replace(/\.btp$/i, '') + ).replace(/\.taym$/i, ''); + + if (!fs.existsSync(inputPath)) { + console.error(`Error: Input file not found: ${inputPath}`); + process.exit(1); + } + + if (!fs.existsSync(PUBLIC_DIR)) { + console.error(`Error: Public directory not found: ${PUBLIC_DIR}`); + console.error('Run this command from the project root.'); + process.exit(1); + } + + const resourceLoader = new FileSystemResourceLoader(PUBLIC_DIR); + + try { + await ensureCoreRegistry(); + const project = loadBtpFromFile(inputPath); + const modules = await loadModulesFromPublic(resourceLoader); + const aySongIndices = getAYSongIndices(project); + + if (aySongIndices.length === 0) { + console.error('Error: Project has no AY songs to export.'); + process.exit(1); + } + + const multipleSongs = aySongIndices.length > 1; + + for (let index = 0; index < aySongIndices.length; index++) { + const songIndex = aySongIndices[index]!; + process.stderr.write(`\r[${index + 1}/${aySongIndices.length}] Generating TAYM... `); + const taym = await generateTaymFile(project, songIndex, { modules, timerMode }); + const base = multipleSongs ? `${outputBase}_ay${index + 1}` : outputBase; + fs.writeFileSync(`${base}.taym`, Buffer.from(taym)); + console.error(`\nWrote: ${base}.taym`); + } + } catch (error) { + console.error('\nError:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main(); diff --git a/cli/btp-to-tmr.ts b/cli/btp-to-tmr.ts new file mode 100644 index 00000000..ee5057d7 --- /dev/null +++ b/cli/btp-to-tmr.ts @@ -0,0 +1,135 @@ +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { loadBtpFromFile } from './btp-loader'; +import { FileSystemResourceLoader } from './resource-loader-node'; +import { ensureCoreRegistry } from '../src/lib/chips/registry-core'; +import type { PsgExportModules } from '../src/lib/services/file/ay/psg-export'; +import { encodeTMR } from '../src/lib/services/file/tmr/tmr-encoder'; +import { captureSharedAyProject } from '../src/lib/services/file/vgm/vgm-shared-capture'; +import type { Project } from '../src/lib/models/project'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.join(__dirname, '..'); +const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public'); + +function printUsage(): void { + console.error(`Usage: btp-to-tmr [output-base] +Converts a Bitphase project (.btp) to TMR + TEL files. + + input.btp Path to the BTP file to convert + output-base Optional output base path (default: input name without extension). + Produces .tmr and .tel; for multi-AY-song projects, + produces _ayN.tmr / _ayN.tel per AY song.`); +} + +async function loadModulesFromPublic( + resourceLoader: FileSystemResourceLoader +): Promise { + const [ + ayumiState, + trackerPatternProcessor, + ayAudioDriver, + ayChipRegisterState, + virtualChannelMixer + ] = await Promise.all([ + resourceLoader.loadModule<{ default: PsgExportModules['AyumiState'] }>('ay/ayumi-state.js'), + resourceLoader.loadModule<{ default: PsgExportModules['TrackerPatternProcessor'] }>( + 'tracker/tracker-pattern-processor.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['AYAudioDriver'] }>( + 'ay/ay-audio-driver.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['AYChipRegisterState'] }>( + 'ay/ay-chip-register-state.js' + ), + resourceLoader.loadModule<{ default: PsgExportModules['VirtualChannelMixer'] }>( + 'ay/virtual-channel-mixer.js' + ) + ]); + return { + AyumiState: ayumiState.default, + TrackerPatternProcessor: trackerPatternProcessor.default, + AYAudioDriver: ayAudioDriver.default, + AYChipRegisterState: ayChipRegisterState.default, + VirtualChannelMixer: virtualChannelMixer.default + }; +} + +function getAYSongIndices(project: Project): number[] { + const aySongIndices: number[] = []; + for (let index = 0; index < project.songs.length; index++) { + const song = project.songs[index]; + if (song && (!song.chipType || song.chipType === 'ay')) { + aySongIndices.push(index); + } + } + return aySongIndices; +} + +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length < 1) { + printUsage(); + process.exit(1); + } + + const inputPath = path.resolve(process.cwd(), args[0]); + const outputBase = + args[1] !== undefined + ? path.resolve(process.cwd(), args[1]) + : inputPath.replace(/\.btp$/i, ''); + + if (!fs.existsSync(inputPath)) { + console.error(`Error: Input file not found: ${inputPath}`); + process.exit(1); + } + + if (!fs.existsSync(PUBLIC_DIR)) { + console.error(`Error: Public directory not found: ${PUBLIC_DIR}`); + console.error('Run this command from the project root.'); + process.exit(1); + } + + const resourceLoader = new FileSystemResourceLoader(PUBLIC_DIR); + + try { + await ensureCoreRegistry(); + const project = loadBtpFromFile(inputPath); + const modules = await loadModulesFromPublic(resourceLoader); + const aySongIndices = getAYSongIndices(project); + + if (aySongIndices.length === 0) { + console.error('Error: Project has no AY songs to export.'); + process.exit(1); + } + + const multipleSongs = aySongIndices.length > 1; + process.stderr.write(`\rCapturing ${aySongIndices.length} AY song(s)... `); + const captured = await captureSharedAyProject(project, aySongIndices, { + ayModules: modules + }); + + for (let index = 0; index < captured.ayCaptures.length; index++) { + process.stderr.write( + `\r[${index + 1}/${captured.ayCaptures.length}] Generating TMR... ` + ); + const capture = captured.ayCaptures[index]!; + const encoded = encodeTMR(capture.frames, { + chipFrequency: capture.chipFrequency, + interruptFrequency: capture.interruptFrequency, + isYm: capture.isYm, + chipIndex: multipleSongs ? index : undefined + }); + const base = multipleSongs ? `${outputBase}_ay${index + 1}` : outputBase; + fs.writeFileSync(`${base}.tmr`, Buffer.from(encoded.tmr)); + fs.writeFileSync(`${base}.tel`, Buffer.from(encoded.eventList)); + console.error(`\nWrote: ${base}.tmr and ${base}.tel`); + } + } catch (error) { + console.error('\nError:', error instanceof Error ? error.message : error); + process.exit(1); + } +} + +main(); diff --git a/cli/btp-to-wav.ts b/cli/btp-to-wav.ts index c258e5d9..37158208 100644 --- a/cli/btp-to-wav.ts +++ b/cli/btp-to-wav.ts @@ -13,15 +13,18 @@ const PROJECT_ROOT = path.join(__dirname, '..'); const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public'); function printUsage(): void { - console.error(`Usage: btp-to-wav [output.wav] + console.error(`Usage: btp-to-wav [output.wav] [--no-dc] Converts a Bitphase project (.btp) to WAV format. input.btp Path to the BTP file to convert - output.wav Optional output path (default: input name with .wav extension)`); + output.wav Optional output path (default: input name with .wav extension) + --no-dc Bypass Ayumi's DC-blocking filter (raw DAC output)`); } async function main(): Promise { - const args = process.argv.slice(2); + const allArgs = process.argv.slice(2); + const disableDcFilter = allArgs.includes('--no-dc'); + const args = allArgs.filter((arg) => arg !== '--no-dc'); if (args.length < 1) { printUsage(); process.exit(1); @@ -59,6 +62,7 @@ async function main(): Promise { }, undefined, { resourceLoader, getChip: getChipByType, + disableDcFilter, onOutput: (buffer, filename) => { const outPath = filename.endsWith('.zip') ? path.join(path.dirname(outputPath), filename) diff --git a/external/ayumi b/external/ayumi index 692f0940..9220a554 160000 --- a/external/ayumi +++ b/external/ayumi @@ -1 +1 @@ -Subproject commit 692f0940682b354aaa5a236af33d2b109d3ef6d7 +Subproject commit 9220a5549364fc27cc71d447fa21bf5ab9095f63 diff --git a/package.json b/package.json index 04553c3b..4b502caf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", "test": "vitest", "test:run": "vitest run", - "btp-to-wav": "tsx cli/btp-to-wav.ts" + "btp-to-wav": "tsx cli/btp-to-wav.ts", + "btp-to-tmr": "tsx cli/btp-to-tmr.ts", + "btp-to-taym": "tsx cli/btp-to-taym.ts", + "btp-to-psg": "tsx cli/btp-to-psg.ts" }, "devDependencies": { "@iconify/json": "^2.2.380", diff --git a/public/ay/ayumi-constants.js b/public/ay/ayumi-constants.js index 32600c6f..87aced84 100644 --- a/public/ay/ayumi-constants.js +++ b/public/ay/ayumi-constants.js @@ -1,4 +1,4 @@ -export const AYUMI_STRUCT_SIZE = 25056; +export const AYUMI_STRUCT_SIZE = 26080; export const AYUMI_STRUCT_LEFT_OFFSET = AYUMI_STRUCT_SIZE - 40; export const AYUMI_STRUCT_RIGHT_OFFSET = AYUMI_STRUCT_SIZE - 32; export const AYUMI_STRUCT_CHANNEL_OUT_OFFSET = AYUMI_STRUCT_SIZE - 24; diff --git a/src/lib/chips/ay/ayumi-constants.ts b/src/lib/chips/ay/ayumi-constants.ts index c3ed5347..06fffa62 100644 --- a/src/lib/chips/ay/ayumi-constants.ts +++ b/src/lib/chips/ay/ayumi-constants.ts @@ -1,4 +1,4 @@ -export const AYUMI_STRUCT_SIZE = 25056; +export const AYUMI_STRUCT_SIZE = 26080; export const AYUMI_STRUCT_LEFT_OFFSET = AYUMI_STRUCT_SIZE - 40; export const AYUMI_STRUCT_RIGHT_OFFSET = AYUMI_STRUCT_SIZE - 32; export const AYUMI_STRUCT_CHANNEL_OUT_OFFSET = AYUMI_STRUCT_SIZE - 24; diff --git a/src/lib/chips/ay/renderer.ts b/src/lib/chips/ay/renderer.ts index 4b37ea44..2af857ce 100644 --- a/src/lib/chips/ay/renderer.ts +++ b/src/lib/chips/ay/renderer.ts @@ -257,7 +257,8 @@ export class AYChipRenderer implements ChipRenderer { patterns: Pattern[], loopCount: number, onProgress?: (progress: number, message: string) => void, - separateChannels?: boolean + separateChannels?: boolean, + disableDcFilter?: boolean ): Promise { const leftSamples: number[] = []; const rightSamples: number[] = []; @@ -358,7 +359,9 @@ export class AYChipRenderer implements ChipRenderer { (channelIndex: number) => this.resolveSampleAyumiChannel(mixer, channelIndex) ); ayumiEngine.process(); - ayumiEngine.removeDC(); + if (!disableDcFilter) { + ayumiEngine.removeDC(); + } if (separateChannels) { for (let ch = 0; ch < TONE_CHANNELS; ch++) { @@ -405,7 +408,8 @@ export class AYChipRenderer implements ChipRenderer { patternOrder: number[], loopCount: number, onProgress?: (progress: number, message: string) => void, - separateChannels?: boolean + separateChannels?: boolean, + disableDcFilter?: boolean ): Promise { const leftByChip: number[][] = contexts.map(() => []); const rightByChip: number[][] = contexts.map(() => []); @@ -513,7 +517,9 @@ export class AYChipRenderer implements ChipRenderer { (channelIndex: number) => this.resolveSampleAyumiChannel(ctx.mixer, channelIndex) ); ctx.ayumiEngine.process(); - ctx.ayumiEngine.removeDC(); + if (!disableDcFilter) { + ctx.ayumiEngine.removeDC(); + } } for (let ci = 0; ci < contexts.length; ci++) { @@ -671,7 +677,8 @@ export class AYChipRenderer implements ChipRenderer { patternOrder, loopCount, onProgress, - separateChannels + separateChannels, + options?.disableDcFilter ?? false ); for (const p of ptrs) { @@ -787,7 +794,8 @@ export class AYChipRenderer implements ChipRenderer { patterns, loopCount, onProgress, - separateChannels + separateChannels, + options?.disableDcFilter ?? false ); wasm.free(ayumiPtr); diff --git a/src/lib/chips/ay/sample-region.ts b/src/lib/chips/ay/sample-region.ts index f3c1b24d..52853102 100644 --- a/src/lib/chips/ay/sample-region.ts +++ b/src/lib/chips/ay/sample-region.ts @@ -103,6 +103,23 @@ export function computeSamplePitchScale(referencePeriod: number, effectiveTone: return referencePeriod / effectiveTone; } +export function resolveSamplePlaybackRate( + sampleRate: number | undefined, + fallbackRate: number +): number { + if (typeof sampleRate === 'number' && sampleRate > 0) { + return sampleRate; + } + return fallbackRate > 0 ? fallbackRate : 44100; +} + +export function computeSampleSidPeriod(clockHz: number, sampleRate: number): number { + if (!sampleRate || sampleRate <= 0 || !clockHz || clockHz <= 0) { + return 1; + } + return Math.max(1, Math.round(clockHz / (8 * sampleRate))); +} + export function clampSamplePlaybackPosition(bounds: SamplePlaybackBounds, position: number): number { return Math.max(bounds.start, Math.min(bounds.end, Math.floor(position))); } diff --git a/src/lib/chips/ay/sid-waveform-volume.ts b/src/lib/chips/ay/sid-waveform-volume.ts index 77e0819b..db72084e 100644 --- a/src/lib/chips/ay/sid-waveform-volume.ts +++ b/src/lib/chips/ay/sid-waveform-volume.ts @@ -21,18 +21,36 @@ function dacTable(variant: AyChipVariant): readonly number[] { return variant === 'YM' ? YM_DAC_TABLE : AY_DAC_TABLE; } -export function sidRegisterVolume(waveformStep: number, baseVolume: number): number { +function nearestRegisterVolume(amplitude: number, variant: AyChipVariant): number { + const table = dacTable(variant); + let best = 0; + let bestDistance = Math.abs((table[1] ?? 0) - amplitude); + for (let code = 0; code <= 15; code++) { + const distance = Math.abs((table[code * 2 + 1] ?? 0) - amplitude); + if (distance <= bestDistance) { + best = code; + bestDistance = distance; + } + } + return best; +} + +export function sidRegisterVolume( + waveformStep: number, + baseVolume: number, + variant: AyChipVariant = 'AY' +): number { const w = waveformStep & 0xf; if (w === 0) { return 0; } - return Math.min(15, Math.floor((w * baseVolume + 14) / 15)); + const table = dacTable(variant); + const volume = table[(baseVolume & 0xf) * 2 + 1] ?? 0; + const step = table[w * 2 + 1] ?? 0; + return nearestRegisterVolume(volume * step, variant); } -export function registerVolumeToAmplitude( - registerVolume: number, - variant: AyChipVariant -): number { +export function registerVolumeToAmplitude(registerVolume: number, variant: AyChipVariant): number { return lutVolumeLevelToAmplitude(registerVolume, variant); } @@ -43,7 +61,7 @@ export function sidStepToAmplitude( baseVolume: number, variant: AyChipVariant ): number { - return registerVolumeToAmplitude(sidRegisterVolume(waveformStep, baseVolume), variant); + return registerVolumeToAmplitude(sidRegisterVolume(waveformStep, baseVolume, variant), variant); } export function amplitudeToNearestSidStep( diff --git a/src/lib/chips/base/renderer.ts b/src/lib/chips/base/renderer.ts index ce09e4f9..a80b9fa1 100644 --- a/src/lib/chips/base/renderer.ts +++ b/src/lib/chips/base/renderer.ts @@ -4,6 +4,7 @@ export interface RenderOptions { separateChannels?: boolean; startPatternOrderIndex?: number; loopCount?: number; + disableDcFilter?: boolean; } export type SharedTimelineExportSlot = { diff --git a/src/lib/components/Modal/ProgressModal.svelte b/src/lib/components/Modal/ProgressModal.svelte index 71ecba26..c280d03d 100644 --- a/src/lib/components/Modal/ProgressModal.svelte +++ b/src/lib/components/Modal/ProgressModal.svelte @@ -5,6 +5,7 @@ import { exportToWAV } from '../../services/file/wav/wav-export'; import { exportToPSG } from '../../services/file/ay/psg-export'; import { exportToTMR } from '../../services/file/tmr/tmr-export'; + import { exportToTaym } from '../../services/file/taym/taym-export'; import { exportToSNDH } from '../../services/file/ay/sndh-export'; import { exportToVGM } from '../../services/file/vgm/vgm-export'; import type { Project } from '../../models/project'; @@ -18,7 +19,7 @@ dismiss } = $props<{ project: Project; - exportType?: 'wav' | 'psg' | 'sndh' | 'tmr' | 'vgm'; + exportType?: 'wav' | 'psg' | 'sndh' | 'tmr' | 'taym' | 'vgm'; wavSettings?: WavExportSettings; resolve?: (value?: any) => void; dismiss?: (error?: any) => void; @@ -59,6 +60,16 @@ }, abortController.signal ); + } else if (exportType === 'taym') { + await exportToTaym( + project, + 0, + (progressValue, messageValue) => { + progress = progressValue; + message = messageValue; + }, + abortController.signal + ); } else if (exportType === 'sndh') { await exportToSNDH( project, diff --git a/src/lib/config/export-formats.ts b/src/lib/config/export-formats.ts index b6b6d6ae..1d116bbb 100644 --- a/src/lib/config/export-formats.ts +++ b/src/lib/config/export-formats.ts @@ -12,8 +12,10 @@ const EXPORT_FORMATS: ExportFormat[] = [ { label: 'WAV', action: 'export-wav', isAvailable: () => true }, { label: 'PSG', action: 'export-psg', isAvailable: (c) => c['ay'] === 1 }, { label: 'TMR', action: 'export-tmr', isAvailable: (c) => c['ay'] === 1 }, + { label: 'TAYM', action: 'export-taym', isAvailable: (c) => c['ay'] === 1 }, { label: 'PSG (ZIP)', action: 'export-psg-zip', isAvailable: (c) => (c['ay'] ?? 0) > 1 }, { label: 'TMR (ZIP)', action: 'export-tmr-zip', isAvailable: (c) => (c['ay'] ?? 0) > 1 }, + { label: 'TAYM (ZIP)', action: 'export-taym-zip', isAvailable: (c) => (c['ay'] ?? 0) > 1 }, { label: 'SNDH', action: 'export-sndh', isAvailable: (c) => c['ay'] === 1 }, { label: 'VGM', diff --git a/src/lib/services/app/menu-action-handler.ts b/src/lib/services/app/menu-action-handler.ts index 257c6970..a2f6928d 100644 --- a/src/lib/services/app/menu-action-handler.ts +++ b/src/lib/services/app/menu-action-handler.ts @@ -324,6 +324,14 @@ export function createMenuActionHandler(ctx: MenuActionContext) { return; } + if (data.action === 'export-taym' || data.action === 'export-taym-zip') { + await ctx.open(ProgressModal, { + project: ctx.getCurrentProject(), + exportType: 'taym' + }); + return; + } + if (data.action === 'export-psg-zip') { await ctx.open(ProgressModal, { project: ctx.getCurrentProject(), diff --git a/src/lib/services/file/ay/ay-export-utils.ts b/src/lib/services/file/ay/ay-export-utils.ts index 88f4ef8a..45f2203d 100644 --- a/src/lib/services/file/ay/ay-export-utils.ts +++ b/src/lib/services/file/ay/ay-export-utils.ts @@ -1,3 +1,14 @@ +import { + clampSamplePlaybackPosition, + computeSamplePitchScale, + normalizeSamplePlaybackBounds, + resolveSampleLoopEnabled, + resolveSamplePitchReferencePeriod, + resolveSamplePlaybackRate, + type SamplePlaybackBounds +} from '../../../chips/ay/sample-region'; +import { sidRegisterVolume, type AyChipVariant } from '../../../chips/ay/sid-waveform-volume'; + export const AY_REGISTER_COUNT = 14; export const DEFAULT_AY_REGISTERS: readonly number[] = Array.from( { length: AY_REGISTER_COUNT }, @@ -9,8 +20,8 @@ const TIMER_EFFECT_KIND_VOLUME = 1; const TIMER_EFFECT_KIND_ENVELOPE_SHAPE = 2; const TIMER_EFFECT_KIND_TONE = 3; const TIMER_EFFECT_KIND_ENVELOPE_PERIOD = 4; -const TIMER_PWM_MODE_BY_STEP_VALUE = 1; const TIMER_PWM_MODE_BY_DUTY_INDEX = 2; +const SAMPLE_CAPTURE_FALLBACK_RATE_HZ = 44100; type TimerEffectRegisterState = { enabled?: boolean; @@ -86,6 +97,15 @@ export type CapturedAySampleInstrument = { sampleLoopEnabled?: boolean; }; +export type HardwareTaymSampleState = { + enabled: boolean; + instanceId: number; + sampleBytes: number[]; + loopIndex: number; + rateHz: number; + volume: number; +}; + export type SongCaptureFrame = { registers: number[]; sid: HardwareSidState[]; @@ -93,6 +113,7 @@ export type SongCaptureFrame = { fm: HardwareFmState[]; envFm: HardwareEnvFmState[]; sample: HardwareSampleState[]; + samples?: HardwareTaymSampleState[]; }; export function convertRegisterStateToAYRegisters(registerState: { @@ -205,13 +226,12 @@ export function writeEnvelopePeriodToPsgData(psgData: number[], period: number): psgData[12] = (envelopePeriod >> 8) & 0xff; } -export function sidVolumeLevel(waveformStep: number, baseVolume: number): number { - const w = waveformStep & 0xf; - if (w === 0) { - return 0; - } - const vol = Math.floor((w * baseVolume + 14) / 15); - return Math.min(15, vol); +export function sidVolumeLevel( + waveformStep: number, + baseVolume: number, + variant: AyChipVariant = 'AY' +): number { + return sidRegisterVolume(waveformStep, baseVolume, variant); } export { isTimerWaveformLowPhase, timerPwmStepPeriod } from '../../../chips/ay/instrument'; @@ -264,6 +284,23 @@ function createDisabledEnvFmState(): HardwareEnvFmState { }; } +export const SAMPLE_NO_LOOP = -1; + +function createDisabledTaymSampleState(): HardwareTaymSampleState { + return { + enabled: false, + instanceId: 0, + sampleBytes: [], + loopIndex: SAMPLE_NO_LOOP, + rateHz: 0, + volume: 0 + }; +} + +export function createDisabledTaymSampleStates(): HardwareTaymSampleState[] { + return Array.from({ length: TONE_CHANNELS }, createDisabledTaymSampleState); +} + export function createDisabledTimerCaptureStates(): { sid: HardwareSidState[]; syncbuzzer: HardwareSyncBuzzerState[]; @@ -287,6 +324,214 @@ export function createDisabledTimerCaptureStates(): { }; } +type SampleCaptureChannelState = { + enabled: boolean; + instrumentIndex: number; + regionKey: string; + instanceId: number; + sampleBytes: number[]; + loopIndex: number; +}; + +export type TaymSampleCaptureTracker = { + channels: SampleCaptureChannelState[]; + nextInstanceId: number; +}; + +export function createTaymSampleCaptureTracker(): TaymSampleCaptureTracker { + return { + channels: Array.from({ length: TONE_CHANNELS }, () => ({ + enabled: false, + instrumentIndex: -1, + regionKey: '', + instanceId: 0, + sampleBytes: [], + loopIndex: SAMPLE_NO_LOOP + })), + nextInstanceId: 1 + }; +} + +type SampleCaptureEngineState = { + channelInstruments: number[]; + channelSoundEnabled: boolean[]; + channelMuted: boolean[]; + channelCurrentNotes: number[]; + currentTuningTable: number[]; + channelToneSliding?: number[]; + channelVibratoSliding?: number[]; + channelDetune?: number[]; + channelSamplePositions?: number[]; + channelSamplePhase?: number[]; + instruments: Array<{ + sampleData?: number[]; + sampleRate?: number; + sampleStart?: number; + sampleEnd?: number; + sampleLoopStart?: number; + sampleLength?: number; + sampleLoop?: number; + sampleLoopEnabled?: boolean; + }>; + aymFrequency: number; +}; + +type SampleCaptureRegisterState = { + channels: Array<{ + timerEffects?: { sid?: { enabled?: boolean; kind?: number; baseVolume?: number } }; + }>; +}; + +function captureEffectiveTone(state: SampleCaptureEngineState, channelIndex: number): number { + const noteIndex = state.channelCurrentNotes[channelIndex] ?? -1; + if (noteIndex < 0 || noteIndex >= state.currentTuningTable.length) { + return 0; + } + const baseTone = state.currentTuningTable[noteIndex] ?? 0; + if (baseTone <= 0) { + return 0; + } + const toneSliding = state.channelToneSliding?.[channelIndex] ?? 0; + const vibratoSliding = state.channelVibratoSliding?.[channelIndex] ?? 0; + const detune = state.channelDetune?.[channelIndex] ?? 0; + return (baseTone + toneSliding + vibratoSliding + detune) & 0xfff; +} + +function samplePositionForCapture( + state: SampleCaptureEngineState, + channelIndex: number, + bounds: SamplePlaybackBounds +): number { + const position = state.channelSamplePositions?.[channelIndex]; + if (typeof position !== 'number' || !Number.isFinite(position)) { + return bounds.start; + } + return clampSamplePlaybackPosition(bounds, position); +} + +function appendSampleBytes(out: number[], sampleData: number[], start: number, end: number): void { + for (let position = start; position <= end; position++) { + out.push((sampleData[position] ?? 0) & 0xff); + } +} + +function buildSampleBytesFromPosition( + instrument: { sampleData?: number[] }, + bounds: SamplePlaybackBounds, + startPosition: number, + loopEnabled: boolean +): { sampleBytes: number[]; loopIndex: number } { + const sampleData = instrument.sampleData ?? []; + const sampleBytes: number[] = []; + appendSampleBytes(sampleBytes, sampleData, startPosition, bounds.end); + + if (!loopEnabled) { + return { sampleBytes, loopIndex: SAMPLE_NO_LOOP }; + } + + if (bounds.loopStart < startPosition) { + const loopIndex = sampleBytes.length; + appendSampleBytes(sampleBytes, sampleData, bounds.loopStart, bounds.end); + return { sampleBytes, loopIndex }; + } + + return { sampleBytes, loopIndex: bounds.loopStart - startPosition }; +} + +export function extractHardwareTaymSampleStates( + state: SampleCaptureEngineState, + registerState: SampleCaptureRegisterState, + tracker: TaymSampleCaptureTracker, + chipFrequency: number, + sampleRestartFlags: boolean[] +): HardwareTaymSampleState[] { + const clockHz = chipFrequency > 0 ? chipFrequency : 1773400; + const referencePeriod = resolveSamplePitchReferencePeriod(clockHz); + const result: HardwareTaymSampleState[] = []; + + for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { + const track = tracker.channels[channelIndex]!; + const instrumentIndex = state.channelInstruments[channelIndex] ?? -1; + const instrument = instrumentIndex >= 0 ? state.instruments[instrumentIndex] : undefined; + const bounds = instrument ? normalizeSamplePlaybackBounds(instrument) : null; + const sidEffect = registerState.channels[channelIndex]?.timerEffects?.sid; + const effectiveTone = captureEffectiveTone(state, channelIndex); + + const playing = + !!bounds && + !state.channelMuted[channelIndex] && + !!state.channelSoundEnabled[channelIndex] && + !!sidEffect?.enabled && + sidEffect.kind === TIMER_EFFECT_KIND_VOLUME && + effectiveTone > 0; + + if (!playing || !bounds || !instrument) { + track.enabled = false; + track.instrumentIndex = -1; + track.regionKey = ''; + track.sampleBytes = []; + track.loopIndex = SAMPLE_NO_LOOP; + result.push(createDisabledTaymSampleState()); + continue; + } + + const loopEnabled = resolveSampleLoopEnabled(instrument); + const regionKey = `${instrumentIndex}:${bounds.start}:${bounds.end}:${bounds.loopStart}:${loopEnabled ? 1 : 0}`; + const isNewInstance = + !track.enabled || + track.instrumentIndex !== instrumentIndex || + track.regionKey !== regionKey || + !!sampleRestartFlags[channelIndex]; + const startPosition = track.instanceId !== 0 && isNewInstance + ? bounds.start + : samplePositionForCapture(state, channelIndex, bounds); + if (isNewInstance) { + track.instanceId = tracker.nextInstanceId++; + const sample = buildSampleBytesFromPosition( + instrument, + bounds, + startPosition, + loopEnabled + ); + track.sampleBytes = sample.sampleBytes; + track.loopIndex = sample.loopIndex; + } + track.enabled = true; + track.instrumentIndex = instrumentIndex; + track.regionKey = regionKey; + + const baseRate = resolveSamplePlaybackRate( + instrument.sampleRate, + SAMPLE_CAPTURE_FALLBACK_RATE_HZ + ); + const pitchScale = computeSamplePitchScale(referencePeriod, effectiveTone); + const rateHz = baseRate * pitchScale; + const volume = (sidEffect?.baseVolume ?? 0) & 0x0f; + + result.push({ + enabled: true, + instanceId: track.instanceId, + sampleBytes: track.sampleBytes, + loopIndex: track.loopIndex, + rateHz, + volume + }); + } + + return result; +} + +export function suppressSidForTaymSampleChannels( + sid: HardwareSidState[], + samples: HardwareTaymSampleState[] +): void { + for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { + if (samples[channelIndex]?.enabled) { + sid[channelIndex] = createDisabledSidState(); + } + } +} + export function extractHardwareSidStates(registerState: { channels: Array<{ timerEffects?: { @@ -297,11 +542,10 @@ export function extractHardwareSidStates(registerState: { const result: HardwareSidState[] = []; for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { const timerEffect = registerState.channels[channelIndex]?.timerEffects?.sid; - const enabled = - !!timerEffect?.enabled && timerEffect.kind === TIMER_EFFECT_KIND_VOLUME; + const enabled = !!timerEffect?.enabled && timerEffect.kind === TIMER_EFFECT_KIND_VOLUME; result.push({ enabled, - pwm: enabled && timerEffect.pwmMode === TIMER_PWM_MODE_BY_STEP_VALUE, + pwm: enabled && timerEffect.pwmMode === TIMER_PWM_MODE_BY_DUTY_INDEX, period: timerEffect?.period ?? 0, periodLow: timerEffect?.periodLow ?? timerEffect?.period ?? 0, baseVolume: timerEffect?.baseVolume ?? 0, diff --git a/src/lib/services/file/ay/ay-timer-effects.ts b/src/lib/services/file/ay/ay-timer-effects.ts new file mode 100644 index 00000000..cc9574bf --- /dev/null +++ b/src/lib/services/file/ay/ay-timer-effects.ts @@ -0,0 +1,239 @@ +import { + AY_REGISTER_COUNT, + ENVELOPE_SHAPE_REGISTER, + envelopePeriodRegisterApplyMask, + envelopeShapeRegisterApplyMask, + registerApplyMask, + sidVolumeLevel, + toneRegisterApplyMask, + volumeRegisterIndex, + writeEnvelopePeriodToPsgData, + writeTonePeriodToPsgData, + type HardwareEnvFmState, + type HardwareFmState, + type HardwareSidState, + type HardwareSyncBuzzerState +} from './ay-export-utils'; +import { computeEnvFmEnvelopePeriod, computeFmTonePeriod } from '../../../chips/ay/instrument'; +import type { AyChipVariant } from '../../../chips/ay/ay-sample-lut'; + +export type StepRegisterWrite = { register: number; value: number }; + +export type TimerEffectStepSource = { + registerMask: number; + length: number; + loop: number; + writesAtStep(stepIndex: number): StepRegisterWrite[]; + stepPeriod(stepIndex: number): number; +}; + +export type MergedEffectStep = { + writes: StepRegisterWrite[]; + registerMask: number; + period: number; + nextIndex: number; +}; + +type WaveformChainState = { + waveform: number[]; + waveformLoop: number; +}; + +type PwmTimerState = WaveformChainState & { + pwm: boolean; + period: number; + periodLow: number; +}; + +export function resolveNextWaveformIndex(stepIndex: number, state: WaveformChainState): number { + const nextStep = stepIndex + 1; + if (nextStep < state.waveform.length) { + return nextStep; + } + if (state.waveformLoop >= 0 && state.waveformLoop < state.waveform.length) { + return state.waveformLoop; + } + return 0; +} + +export function previousWaveformStepIndex(stepIndex: number, state: WaveformChainState): number { + if (stepIndex > 0) { + return stepIndex - 1; + } + for (let index = state.waveform.length - 1; index >= 0; index--) { + if (resolveNextWaveformIndex(index, state) === stepIndex) { + return index; + } + } + return state.waveform.length - 1; +} + +export function normalizePwmPeriods(state: T): T { + return { + ...state, + periodLow: state.periodLow > 0 ? state.periodLow : state.period + }; +} + +export function isPwmActive(state: PwmTimerState): boolean { + const normalized = normalizePwmPeriods(state); + return normalized.pwm || normalized.period !== normalized.periodLow; +} + +export function pwmStepPeriod(state: PwmTimerState, stepIndex: number): number { + const normalized = normalizePwmPeriods(state); + if (isPwmActive(normalized) && normalized.waveform.length >= 2) { + return stepIndex % 2 === 0 ? normalized.period : normalized.periodLow; + } + return normalized.period; +} + +export function sidStepPeriod(sid: HardwareSidState, stepIndex: number): number { + return pwmStepPeriod(sid, stepIndex); +} + +export function sidStartPeriod(sid: HardwareSidState): number { + return pwmStepPeriod(sid, 0); +} + +export function pwmStartPeriod(state: PwmTimerState): number { + return pwmStepPeriod(state, 0); +} + +export function sidStepSource( + channelIndex: number, + sid: HardwareSidState, + variant: AyChipVariant = 'AY' +): TimerEffectStepSource { + const volumeReg = volumeRegisterIndex(channelIndex); + return { + registerMask: registerApplyMask(volumeReg), + length: sid.waveform.length, + loop: sid.waveformLoop, + writesAtStep: (stepIndex) => [ + { + register: volumeReg, + value: sidVolumeLevel(sid.waveform[stepIndex]!, sid.baseVolume, variant) + } + ], + stepPeriod: (stepIndex) => sidStepPeriod(sid, stepIndex) + }; +} + +export function fmStepSource(channelIndex: number, fm: HardwareFmState): TimerEffectStepSource { + const toneReg = channelIndex * 2; + return { + registerMask: toneRegisterApplyMask(channelIndex), + length: fm.waveform.length, + loop: fm.waveformLoop, + writesAtStep: (stepIndex) => { + const psgData = new Array(AY_REGISTER_COUNT).fill(0); + const tonePeriod = computeFmTonePeriod( + fm.baseTonePeriod, + fm.waveform[stepIndex]!, + fm.fmOffsetMode + ); + writeTonePeriodToPsgData(psgData, channelIndex, tonePeriod); + return [ + { register: toneReg, value: psgData[toneReg]! }, + { register: toneReg + 1, value: psgData[toneReg + 1]! } + ]; + }, + stepPeriod: (stepIndex) => pwmStepPeriod(fm, stepIndex) + }; +} + +export function envFmStepSource(envFm: HardwareEnvFmState): TimerEffectStepSource { + return { + registerMask: envelopePeriodRegisterApplyMask(), + length: envFm.waveform.length, + loop: envFm.waveformLoop, + writesAtStep: (stepIndex) => { + const psgData = new Array(AY_REGISTER_COUNT).fill(0); + const envelopePeriod = computeEnvFmEnvelopePeriod( + envFm.baseEnvelopePeriod, + envFm.waveform[stepIndex]!, + envFm.fmOffsetMode + ); + writeEnvelopePeriodToPsgData(psgData, envelopePeriod); + return [ + { register: 11, value: psgData[11]! }, + { register: 12, value: psgData[12]! } + ]; + }, + stepPeriod: (stepIndex) => pwmStepPeriod(envFm, stepIndex) + }; +} + +export function syncBuzzerStepSource(syncbuzzer: HardwareSyncBuzzerState): TimerEffectStepSource { + return { + registerMask: envelopeShapeRegisterApplyMask(), + length: syncbuzzer.waveform.length, + loop: syncbuzzer.waveformLoop, + writesAtStep: (stepIndex) => [ + { + register: ENVELOPE_SHAPE_REGISTER, + value: (syncbuzzer.waveform[stepIndex] ?? 0) & 0xf + } + ], + stepPeriod: (stepIndex) => pwmStepPeriod(syncbuzzer, stepIndex) + }; +} + +function sourceNextStepIndex(stepIndex: number, source: TimerEffectStepSource): number { + return resolveNextWaveformIndex(stepIndex, { + waveform: new Array(source.length), + waveformLoop: source.loop + }); +} + +export function buildMergedEffectSteps(sources: TimerEffectStepSource[]): MergedEffectStep[] { + if (sources.length === 0 || sources.some((source) => source.length <= 0)) { + return []; + } + + const stepStates: number[][] = []; + const indexByState = new Map(); + let sourceSteps = sources.map(() => 0); + let loopIndex = 0; + + while (true) { + const key = sourceSteps.join(','); + const existingIndex = indexByState.get(key); + if (existingIndex !== undefined) { + loopIndex = existingIndex; + break; + } + indexByState.set(key, stepStates.length); + stepStates.push([...sourceSteps]); + sourceSteps = sourceSteps.map((sourceStep, sourceIndex) => + sourceNextStepIndex(sourceStep, sources[sourceIndex]!) + ); + } + + return stepStates.map((states, stepIndex) => { + const psgData = new Array(AY_REGISTER_COUNT).fill(0); + let registerMask = 0; + let period = 0; + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) { + const source = sources[sourceIndex]!; + const sourceStep = states[sourceIndex]!; + registerMask |= source.registerMask; + for (const write of source.writesAtStep(sourceStep)) { + psgData[write.register] = write.value; + } + const stepPeriod = source.stepPeriod(sourceStep); + if (period === 0 && stepPeriod !== 0) { + period = stepPeriod; + } + } + const writes: StepRegisterWrite[] = []; + for (let register = 0; register < AY_REGISTER_COUNT; register++) { + if (registerMask & registerApplyMask(register)) { + writes.push({ register, value: psgData[register]! }); + } + } + const nextIndex = stepIndex + 1 < stepStates.length ? stepIndex + 1 : loopIndex; + return { writes, registerMask, period, nextIndex }; + }); +} diff --git a/src/lib/services/file/ay/psg-export.ts b/src/lib/services/file/ay/psg-export.ts index 57ef83a5..d84da61e 100644 --- a/src/lib/services/file/ay/psg-export.ts +++ b/src/lib/services/file/ay/psg-export.ts @@ -1,12 +1,18 @@ import type { Project } from '../../../models/project'; +import { EffectType } from '../../../models/song'; import { downloadFile, sanitizeFilename } from '../../../utils/file-download'; import { getTotalVirtualChannelCount } from '../../../models/virtual-channels'; import JSZip from 'jszip'; import { + AY_REGISTER_COUNT, convertRegisterStateToAYRegisters, + createDisabledTaymSampleStates, + createTaymSampleCaptureTracker, + suppressSidForTaymSampleChannels, extractHardwareEnvFmStates, extractHardwareFmStates, extractHardwareSampleStates, + extractHardwareTaymSampleStates, extractHardwareSidStates, extractHardwareSyncBuzzerStates, TONE_CHANNELS, @@ -51,7 +57,7 @@ export type CaptureRegisterOptions = { captureDigiSamples?: boolean; }; -function encodePSG(registerFrames: number[][]): ArrayBuffer { +export function encodePSG(registerFrames: number[][]): ArrayBuffer { const headerSize = 16; const data: number[] = []; @@ -64,16 +70,17 @@ function encodePSG(registerFrames: number[][]): ArrayBuffer { data.push(0); } - const currentRegs = new Array(14).fill(0); + const currentRegs = new Array(AY_REGISTER_COUNT).fill(0); for (const frameRegs of registerFrames) { data.push(0xff); - for (let reg = 0; reg < 14; reg++) { - if (frameRegs[reg] !== currentRegs[reg]) { + for (let reg = 0; reg < AY_REGISTER_COUNT; reg++) { + const value = frameRegs[reg]; + if (value !== currentRegs[reg]) { data.push(reg); - data.push(frameRegs[reg]); - currentRegs[reg] = frameRegs[reg]; + data.push(value); + currentRegs[reg] = value; } } } @@ -123,6 +130,34 @@ class PsgExportService { return totalRows; } + private rowHasPortamentoCommand(row: any): boolean { + return ( + row?.effects?.some((effect: any) => effect?.effect === EffectType.Portamento) ?? false + ); + } + + private readSampleRestartFlags(state: any): boolean[] { + const flags = new Array(TONE_CHANNELS).fill(false); + if (state.timeline.currentTick !== 0 || !state.currentPattern) { + return flags; + } + const rowIndex = state.timeline.currentRow; + const channels = state.currentPattern.channels ?? []; + for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { + const row = channels[channelIndex]?.rows?.[rowIndex]; + if ( + row && + row.note && + row.note.name >= 2 && + !this.rowHasPortamentoCommand(row) && + !state.channelPortamentoActive?.[channelIndex] + ) { + flags[channelIndex] = true; + } + } + return flags; + } + private async captureRegisterStates( state: any, patternProcessor: any, @@ -134,10 +169,12 @@ class PsgExportService { patterns: any[], modules: PsgExportModules, captureOptions: CaptureRegisterOptions, + chipFrequency: number, onProgress?: (progress: number, message: string) => void ): Promise<{ frames: SongCaptureFrame[]; orderIndices: number[] }> { const captureFrames: SongCaptureFrame[] = []; const orderIndices: number[] = []; + const taymSampleTracker = createTaymSampleCaptureTracker(); let totalTicks = 0; const maxTicks = 1000000; const captureDigiSamples = captureOptions.captureDigiSamples === true; @@ -196,6 +233,7 @@ class PsgExportService { patternProcessor.processEffectTables(); audioDriver.processInstruments(state, registerState); patternProcessor.processVibrato(); + const sampleRestartFlags = this.readSampleRestartFlags(state); patternProcessor.processSlides(); const stateToConvert = mixer.hasVirtualChannels() @@ -219,13 +257,27 @@ class PsgExportService { phase: 0, effectiveTone: 0 })); + const samples = captureDigiSamples && !mixer.hasVirtualChannels() + ? extractHardwareTaymSampleStates( + state, + registerState, + taymSampleTracker, + chipFrequency, + sampleRestartFlags + ) + : createDisabledTaymSampleStates(); + const sid = extractHardwareSidStates(stateToConvert); + if (captureDigiSamples) { + suppressSidForTaymSampleChannels(sid, samples); + } captureFrames.push({ registers: [...ayRegisters], - sid: extractHardwareSidStates(stateToConvert), + sid, syncbuzzer: extractHardwareSyncBuzzerStates(stateToConvert), fm: extractHardwareFmStates(stateToConvert), envFm: extractHardwareEnvFmStates(stateToConvert), - sample + sample, + samples }); orderIndices.push(state.timeline.currentPatternOrderIndex); if (mixer.hasVirtualChannels()) { @@ -266,7 +318,8 @@ class PsgExportService { } } - const isLastPattern = state.timeline.currentPatternOrderIndex >= state.timeline.patternOrder.length - 1; + const isLastPattern = + state.timeline.currentPatternOrderIndex >= state.timeline.patternOrder.length - 1; const isLastRow = state.timeline.currentRow >= state.currentPattern.length - 1; const isLastTick = state.timeline.currentTick >= state.timeline.currentSpeed - 1; @@ -379,6 +432,7 @@ class PsgExportService { patterns, modules, captureOptions, + chipFrequency, onProgress ); diff --git a/src/lib/services/file/taym/codec.ts b/src/lib/services/file/taym/codec.ts new file mode 100644 index 00000000..bb1c421a --- /dev/null +++ b/src/lib/services/file/taym/codec.ts @@ -0,0 +1,463 @@ +import type { Actn, Chip, Lane, Mods, Taym, Timr, Tlan, Trak } from './model'; +import * as spec from './spec'; + +export class TaymCodecError extends Error {} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('ascii'); +const utf8Decoder = new TextDecoder('utf-8'); + +class ByteWriter { + private chunks: Uint8Array[] = []; + private length = 0; + + bytes(data: Uint8Array): void { + this.chunks.push(data); + this.length += data.length; + } + + u8(value: number): void { + this.bytes(new Uint8Array([value & 0xff])); + } + + u16(value: number): void { + const buffer = new Uint8Array(2); + new DataView(buffer.buffer).setUint16(0, value & 0xffff, true); + this.bytes(buffer); + } + + u32(value: number): void { + const buffer = new Uint8Array(4); + new DataView(buffer.buffer).setUint32(0, value >>> 0, true); + this.bytes(buffer); + } + + get size(): number { + return this.length; + } + + toUint8Array(): Uint8Array { + const out = new Uint8Array(this.length); + let offset = 0; + for (const chunk of this.chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; + } +} + +function tagBytes(tag: string): Uint8Array { + const bytes = textEncoder.encode(tag); + if (bytes.length !== 4) { + throw new TaymCodecError(`chunk tag must be 4 ASCII chars: ${tag}`); + } + return bytes; +} + +function fixedAscii(value: string, size: number): Uint8Array { + const out = new Uint8Array(size); + const encoded = textEncoder.encode(value).slice(0, size); + out.set(encoded); + return out; +} + +function packTrak(trak: Trak, chipCount: number, timerCount: number): Uint8Array { + const writer = new ByteWriter(); + writer.u32(spec.toFix16(trak.frameRateHz)); + writer.u32(trak.frameCount); + writer.u32(trak.loopFrame); + writer.u8(chipCount); + writer.u8(timerCount); + writer.u16(0); + return writer.toUint8Array(); +} + +function packChip(chip: Chip): Uint8Array { + const writer = new ByteWriter(); + writer.u32(chip.clockHz); + writer.u8(chip.chipTypeId); + writer.u8(chip.variant); + writer.u16(0); + writer.bytes(fixedAscii(chip.name, spec.CHIP_NAME_SIZE)); + if (chip.frameDataTag) { + writer.bytes(tagBytes(chip.frameDataTag)); + } else { + writer.bytes(new Uint8Array(spec.CHIP_TAG_SIZE)); + } + writer.u32(chip.config); + return writer.toUint8Array(); +} + +function packTimr(timr: Timr): Uint8Array { + const writer = new ByteWriter(); + writer.u16(timr.clockDivider); + writer.u8(timr.chipIndex); + writer.u8(timr.clockMode); + writer.u16(0); + return writer.toUint8Array(); +} + +function packMods(mods: Mods): Uint8Array { + const writer = new ByteWriter(); + if (mods.command === spec.CMD_EMPTY || mods.command === spec.CMD_STOP) { + writer.u32(0); + writer.u32(0); + writer.u32(0); + writer.u8(0); + writer.u8(mods.command); + writer.u16(0); + return writer.toUint8Array(); + } + writer.u32(mods.baseTimerValue); + writer.u32(mods.timerLaneRef); + writer.u32(mods.firstAction); + writer.u8(mods.actionCount); + writer.u8(mods.command); + writer.u16(0); + return writer.toUint8Array(); +} + +function packActn(actn: Actn): Uint8Array { + const writer = new ByteWriter(); + writer.u32(actn.operand); + writer.u8(actn.targetId); + writer.u8(actn.sourceMode); + return writer.toUint8Array(); +} + +function packLane(lane: Lane): Uint8Array { + const writer = new ByteWriter(); + writer.u32(lane.valueOffset); + writer.u32(lane.length); + writer.u32(lane.loopIndex); + writer.u8(lane.valueType); + writer.bytes(new Uint8Array(3)); + return writer.toUint8Array(); +} + +function packTlan(tlan: Tlan): Uint8Array { + const writer = new ByteWriter(); + writer.u32(tlan.valueOffset); + writer.u32(tlan.length); + writer.u32(tlan.loopIndex); + writer.u8(tlan.timingMode); + writer.bytes(new Uint8Array(3)); + return writer.toUint8Array(); +} + +function packInfo(info: Record): Uint8Array { + const entries = Object.entries(info); + if (entries.length === 0) { + return new Uint8Array(0); + } + const writer = new ByteWriter(); + for (const [key, value] of entries) { + writer.bytes(textEncoder.encode(`${key}=${value}`)); + writer.u8(0); + } + writer.u8(0); + return writer.toUint8Array(); +} + +function packPool16(values: number[]): Uint8Array { + const out = new Uint8Array(values.length * 2); + const view = new DataView(out.buffer); + for (let i = 0; i < values.length; i++) { + view.setUint16(i * 2, values[i] & 0xffff, true); + } + return out; +} + +function packPool32(values: number[]): Uint8Array { + const out = new Uint8Array(values.length * 4); + const view = new DataView(out.buffer); + for (let i = 0; i < values.length; i++) { + view.setUint32(i * 4, values[i] >>> 0, true); + } + return out; +} + +function concatRecords(records: Uint8Array[]): Uint8Array { + const writer = new ByteWriter(); + for (const record of records) { + writer.bytes(record); + } + return writer.toUint8Array(); +} + +function chunk(tag: string, payload: Uint8Array): Uint8Array { + const writer = new ByteWriter(); + writer.bytes(tagBytes(tag)); + writer.u32(payload.length); + writer.bytes(payload); + return writer.toUint8Array(); +} + +export function writeTaym(taym: Taym): ArrayBuffer { + const payloads: Array<[string, Uint8Array]> = [ + ['TRAK', packTrak(taym.trak, taym.chips.length, taym.timers.length)] + ]; + + const infoPayload = packInfo(taym.info); + if (infoPayload.length > 0) { + payloads.push(['INFO', infoPayload]); + } + + payloads.push(['CHIP', concatRecords(taym.chips.map(packChip))]); + payloads.push(['TIMR', concatRecords(taym.timers.map(packTimr))]); + payloads.push(['MODS', concatRecords(taym.mods.map(packMods))]); + payloads.push(['ACTN', concatRecords(taym.actions.map(packActn))]); + payloads.push(['LANE', concatRecords(taym.lanes.map(packLane))]); + payloads.push(['TLAN', concatRecords(taym.tlanes.map(packTlan))]); + payloads.push(['VU08', Uint8Array.from(taym.vu08, (value) => value & 0xff)]); + payloads.push(['VU16', packPool16(taym.vu16)]); + payloads.push(['VU32', packPool32(taym.vu32)]); + + for (const chip of taym.chips) { + if (chip.frameDataTag) { + const payload = taym.frameData[chip.frameDataTag]; + if (!payload) { + throw new TaymCodecError( + `chip references frame_data_tag ${chip.frameDataTag} with no payload` + ); + } + payloads.push([chip.frameDataTag, payload]); + } + } + + const body = new ByteWriter(); + for (const [tag, payload] of payloads) { + body.bytes(chunk(tag, payload)); + } + const chunkBytes = body.toUint8Array(); + + const out = new ByteWriter(); + out.bytes(tagBytes(spec.MAGIC)); + out.u16(spec.VERSION); + out.u16(spec.HEADER_SIZE); + out.u32(taym.flags); + out.u32(chunkBytes.length); + out.bytes(chunkBytes); + + const result = out.toUint8Array(); + const buffer = new ArrayBuffer(result.byteLength); + new Uint8Array(buffer).set(result); + return buffer; +} + +interface SplitResult { + chunks: Map; + version: number; + flags: number; +} + +function splitChunks(data: Uint8Array): SplitResult { + if (data.length < spec.HEADER_SIZE) { + throw new TaymCodecError('file shorter than header'); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const magic = textDecoder.decode(data.subarray(0, 4)); + if (magic !== spec.MAGIC) { + throw new TaymCodecError(`bad magic ${magic}`); + } + const version = view.getUint16(4, true); + const headerSize = view.getUint16(6, true); + const flags = view.getUint32(8, true); + const chunkBytes = view.getUint32(12, true); + if (headerSize !== spec.HEADER_SIZE) { + throw new TaymCodecError(`bad header_size ${headerSize}`); + } + const end = headerSize + chunkBytes; + if (end !== data.length) { + throw new TaymCodecError(`chunk_bytes says file ends at ${end}, got ${data.length}`); + } + const chunks = new Map(); + let p = headerSize; + while (p < end) { + if (p + spec.CHUNK_HEADER_SIZE > end) { + throw new TaymCodecError(`truncated chunk header at ${p}`); + } + const tag = textDecoder.decode(data.subarray(p, p + 4)); + const size = view.getUint32(p + 4, true); + p += spec.CHUNK_HEADER_SIZE; + if (p + size > end) { + throw new TaymCodecError(`chunk ${tag} payload runs past end`); + } + if (chunks.has(tag)) { + throw new TaymCodecError(`duplicate chunk tag ${tag}`); + } + chunks.set(tag, data.subarray(p, p + size)); + p += size; + } + return { chunks, version, flags }; +} + +function records(payload: Uint8Array, stride: number, tag: string): Uint8Array[] { + if (payload.length % stride !== 0) { + throw new TaymCodecError(`${tag} size ${payload.length} not a multiple of stride ${stride}`); + } + const out: Uint8Array[] = []; + for (let off = 0; off < payload.length; off += stride) { + out.push(payload.subarray(off, off + stride)); + } + return out; +} + +function viewOf(record: Uint8Array): DataView { + return new DataView(record.buffer, record.byteOffset, record.byteLength); +} + +function parseInfo(payload: Uint8Array): Record { + const info: Record = {}; + if (payload.length === 0) { + return info; + } + let end = payload.length; + while (end > 0 && payload[end - 1] === 0) { + end -= 1; + } + const text = utf8Decoder.decode(payload.subarray(0, end)); + for (const entry of text.split('\0')) { + if (!entry) continue; + const eq = entry.indexOf('='); + if (eq === -1) { + info[entry] = ''; + } else { + info[entry.slice(0, eq)] = entry.slice(eq + 1); + } + } + return info; +} + +export function readTaym(data: ArrayBuffer | Uint8Array): Taym { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const { chunks, flags } = splitChunks(bytes); + + const need = (tag: string): Uint8Array => { + const payload = chunks.get(tag); + if (payload === undefined) { + throw new TaymCodecError(`missing core chunk ${tag}`); + } + return payload; + }; + + const trakView = viewOf(need('TRAK')); + const trak: Trak = { + frameRateHz: spec.fromFix16(trakView.getUint32(0, true)), + frameCount: trakView.getUint32(4, true), + loopFrame: trakView.getUint32(8, true) + }; + + const chips: Chip[] = records(need('CHIP'), spec.CHIP_SIZE, 'CHIP').map((record) => { + const recordView = viewOf(record); + const name = textDecoder.decode(record.subarray(8, 24)).split('\0', 1)[0]; + const tagBytesRaw = record.subarray(24, 28); + const frameDataTag = tagBytesRaw.every((value) => value === 0) + ? '' + : textDecoder.decode(tagBytesRaw); + return { + clockHz: recordView.getUint32(0, true), + chipTypeId: recordView.getUint8(4), + variant: recordView.getUint8(5), + name, + frameDataTag, + config: recordView.getUint32(28, true) + }; + }); + + const timers: Timr[] = records(need('TIMR'), spec.TIMR_SIZE, 'TIMR').map((record) => { + const recordView = viewOf(record); + return { + clockDivider: recordView.getUint16(0, true), + chipIndex: recordView.getUint8(2), + clockMode: recordView.getUint8(3) + }; + }); + + const mods: Mods[] = records(need('MODS'), spec.MODS_SIZE, 'MODS').map((record) => { + const recordView = viewOf(record); + return { + baseTimerValue: recordView.getUint32(0, true), + timerLaneRef: recordView.getUint32(4, true), + firstAction: recordView.getUint32(8, true), + actionCount: recordView.getUint8(12), + command: recordView.getUint8(13) + }; + }); + + const actions: Actn[] = records(need('ACTN'), spec.ACTN_SIZE, 'ACTN').map((record) => { + const recordView = viewOf(record); + return { + operand: recordView.getUint32(0, true), + targetId: recordView.getUint8(4), + sourceMode: recordView.getUint8(5) + }; + }); + + const lanes: Lane[] = records(need('LANE'), spec.LANE_SIZE, 'LANE').map((record) => { + const recordView = viewOf(record); + return { + valueOffset: recordView.getUint32(0, true), + length: recordView.getUint32(4, true), + loopIndex: recordView.getUint32(8, true), + valueType: recordView.getUint8(12) + }; + }); + + const tlanes: Tlan[] = records(need('TLAN'), spec.TLAN_SIZE, 'TLAN').map((record) => { + const recordView = viewOf(record); + return { + valueOffset: recordView.getUint32(0, true), + length: recordView.getUint32(4, true), + loopIndex: recordView.getUint32(8, true), + timingMode: recordView.getUint8(12) + }; + }); + + const vu08 = Array.from(need('VU08')); + const p16 = need('VU16'); + if (p16.length % 2 !== 0) { + throw new TaymCodecError('VU16 size not a multiple of 2'); + } + const p16View = viewOf(p16); + const vu16: number[] = []; + for (let i = 0; i < p16.length; i += 2) { + vu16.push(p16View.getUint16(i, true)); + } + const p32 = need('VU32'); + if (p32.length % 4 !== 0) { + throw new TaymCodecError('VU32 size not a multiple of 4'); + } + const p32View = viewOf(p32); + const vu32: number[] = []; + for (let i = 0; i < p32.length; i += 4) { + vu32.push(p32View.getUint32(i, true)); + } + + const info = parseInfo(chunks.get('INFO') ?? new Uint8Array(0)); + + const core = new Set([...spec.CORE_ONCE, 'INFO']); + const frameData: Record = {}; + for (const [tag, payload] of chunks) { + if (!core.has(tag)) { + frameData[tag] = payload; + } + } + + return { + trak, + chips, + timers, + mods, + actions, + lanes, + tlanes, + vu08, + vu16, + vu32, + info, + frameData, + flags + }; +} diff --git a/src/lib/services/file/taym/foreground-psg.ts b/src/lib/services/file/taym/foreground-psg.ts new file mode 100644 index 00000000..f08f6f4e --- /dev/null +++ b/src/lib/services/file/taym/foreground-psg.ts @@ -0,0 +1,67 @@ +import { AY_REGISTER_COUNT } from '../ay/ay-export-utils'; + +/** + * Encodes the foreground PSG stream embedded in TAYM frame data. + * + * TAYM timers can own AY registers between player frames. Foreground PSG writes + * for owned registers must be suppressed while the timer owns them, then emitted + * on the first unowned frame so playback hands the register back to the captured + * base state. + */ +export function encodeForegroundPsgFrameData( + registerFrames: number[][], + ownedRegistersPerFrame: number[][] +): ArrayBuffer { + const headerSize = 16; + const data: number[] = []; + + data.push(0x50); + data.push(0x53); + data.push(0x47); + data.push(0x1a); + + for (let i = 0; i < 12; i++) { + data.push(0); + } + + const currentRegs = new Array(AY_REGISTER_COUNT).fill(0); + let prevOwned = new Array(AY_REGISTER_COUNT).fill(false); + + for (let frameIndex = 0; frameIndex < registerFrames.length; frameIndex++) { + const frameRegs = registerFrames[frameIndex]; + const owned = ownedRegistersPerFrame[frameIndex]; + const ownedSet = new Set(owned ?? []); + data.push(0xff); + + for (let reg = 0; reg < AY_REGISTER_COUNT; reg++) { + const value = frameRegs[reg]; + if (ownedSet.has(reg)) { + continue; + } + const releasedFromTimer = prevOwned[reg]; + if (releasedFromTimer || value !== currentRegs[reg]) { + data.push(reg); + data.push(value); + currentRegs[reg] = value; + } + } + + const nextOwned = new Array(AY_REGISTER_COUNT).fill(false); + for (const reg of ownedSet) { + if (reg >= 0 && reg < AY_REGISTER_COUNT) { + nextOwned[reg] = true; + } + } + prevOwned = nextOwned; + } + + data.push(0xfd); + + const buffer = new ArrayBuffer(headerSize + data.length); + const view = new Uint8Array(buffer); + for (let i = 0; i < data.length; i++) { + view[i] = data[i]; + } + + return buffer; +} diff --git a/src/lib/services/file/taym/model.ts b/src/lib/services/file/taym/model.ts new file mode 100644 index 00000000..733dd05a --- /dev/null +++ b/src/lib/services/file/taym/model.ts @@ -0,0 +1,159 @@ +import { + CHIP_CONFIG_DEFAULT, + CHIP_TYPE_AY, + CHIP_VARIANT_DEFAULT, + CLOCK_ABS_RATE_HZ, + NO_LOOP, + TLAN_NONE, + VT_U8, + VT_U16, + VT_U32 +} from './spec'; + +export interface Trak { + frameRateHz: number; + frameCount: number; + loopFrame: number; +} + +export interface Chip { + clockHz: number; + chipTypeId: number; + name: string; + frameDataTag: string; + variant: number; + config: number; +} + +export interface Timr { + chipIndex: number; + clockMode: number; + clockDivider: number; +} + +export interface Actn { + targetId: number; + sourceMode: number; + operand: number; +} + +export interface Lane { + valueType: number; + valueOffset: number; + length: number; + loopIndex: number; +} + +export interface Tlan { + timingMode: number; + valueOffset: number; + length: number; + loopIndex: number; +} + +export interface Mods { + command: number; + baseTimerValue: number; + timerLaneRef: number; + firstAction: number; + actionCount: number; +} + +export interface Taym { + trak: Trak; + chips: Chip[]; + timers: Timr[]; + mods: Mods[]; + actions: Actn[]; + lanes: Lane[]; + tlanes: Tlan[]; + vu08: number[]; + vu16: number[]; + vu32: number[]; + info: Record; + frameData: Record; + flags: number; +} + +export function makeTrak(frameRateHz: number, frameCount: number, loopFrame = NO_LOOP): Trak { + return { frameRateHz, frameCount, loopFrame }; +} + +export function makeChip( + clockHz: number, + options: Partial> = {} +): Chip { + return { + clockHz, + chipTypeId: options.chipTypeId ?? CHIP_TYPE_AY, + name: options.name ?? '', + frameDataTag: options.frameDataTag ?? '', + variant: options.variant ?? CHIP_VARIANT_DEFAULT, + config: options.config ?? CHIP_CONFIG_DEFAULT + }; +} + +export function makeTimr( + chipIndex: number, + clockMode = CLOCK_ABS_RATE_HZ, + clockDivider = 0 +): Timr { + return { chipIndex, clockMode, clockDivider }; +} + +export function makeActn(targetId: number, sourceMode: number, operand: number): Actn { + return { targetId, sourceMode, operand }; +} + +export function makeLane( + valueType: number, + valueOffset: number, + length: number, + loopIndex = NO_LOOP +): Lane { + return { valueType, valueOffset, length, loopIndex }; +} + +export function makeTlan( + timingMode: number, + valueOffset: number, + length: number, + loopIndex = NO_LOOP +): Tlan { + return { timingMode, valueOffset, length, loopIndex }; +} + +export function makeMods(command: number, options: Partial> = {}): Mods { + return { + command, + baseTimerValue: options.baseTimerValue ?? 0, + timerLaneRef: options.timerLaneRef ?? TLAN_NONE, + firstAction: options.firstAction ?? 0, + actionCount: options.actionCount ?? 0 + }; +} + +export function makeTaym(trak: Trak, parts: Partial> = {}): Taym { + return { + trak, + chips: parts.chips ?? [], + timers: parts.timers ?? [], + mods: parts.mods ?? [], + actions: parts.actions ?? [], + lanes: parts.lanes ?? [], + tlanes: parts.tlanes ?? [], + vu08: parts.vu08 ?? [], + vu16: parts.vu16 ?? [], + vu32: parts.vu32 ?? [], + info: parts.info ?? {}, + frameData: parts.frameData ?? {}, + flags: parts.flags ?? 0 + }; +} + +export function poolFor(taym: Taym, valueType: number): number[] { + if (valueType === VT_U8) return taym.vu08; + if (valueType === VT_U16) return taym.vu16; + if (valueType === VT_U32) return taym.vu32; + throw new Error(`invalid value_type ${valueType}`); +} diff --git a/src/lib/services/file/taym/spec.ts b/src/lib/services/file/taym/spec.ts new file mode 100644 index 00000000..46905860 --- /dev/null +++ b/src/lib/services/file/taym/spec.ts @@ -0,0 +1,133 @@ +export const MAGIC = 'TAYM'; +export const MAGIC_BYTES = [0x54, 0x41, 0x59, 0x4d]; +export const VERSION = 1; +export const HEADER_SIZE = 16; +export const CHUNK_HEADER_SIZE = 8; + +export const CHUNK_ORDER = [ + 'TRAK', + 'INFO', + 'CHIP', + 'TIMR', + 'MODS', + 'ACTN', + 'LANE', + 'TLAN', + 'VU08', + 'VU16', + 'VU32' +] as const; + +export const CORE_ONCE = [ + 'TRAK', + 'CHIP', + 'TIMR', + 'MODS', + 'ACTN', + 'LANE', + 'TLAN', + 'VU08', + 'VU16', + 'VU32' +] as const; + +export const NO_LOOP = 0xffffffff; +export const TLAN_NONE = 0xffffffff; +export const TLAN_UNCHANGED = 0xfffffffe; + +export const CLOCK_ABS_RATE_HZ = 0; +export const CLOCK_CHIP_PERIOD = 1; +export const CLOCK_MODES = [CLOCK_ABS_RATE_HZ, CLOCK_CHIP_PERIOD]; + +export const VT_INVALID = 0; +export const VT_U8 = 1; +export const VT_U16 = 2; +export const VT_U32 = 3; +export const VALUE_TYPES = [VT_U8, VT_U16, VT_U32]; + +export const TM_ABSOLUTE = 0; +export const TM_RELATIVE = 1; +export const TIMING_MODES = [TM_ABSOLUTE, TM_RELATIVE]; + +export const SRC_INLINE_VALUE = 0; +export const SRC_BIND_LANE = 1; +export const SOURCE_MODES = [SRC_INLINE_VALUE, SRC_BIND_LANE]; + +export const CMD_EMPTY = 0; +export const CMD_START = 1; +export const CMD_MODULATE = 2; +export const CMD_STOP = 3; +export const COMMANDS = [CMD_EMPTY, CMD_START, CMD_MODULATE, CMD_STOP]; + +export const CHIP_TYPE_INVALID = 0x00; +export const CHIP_TYPE_AY = 0x01; + +export const CHIP_VARIANT_DEFAULT = 0x00; +export const AY_VARIANT_AY = 0x00; +export const AY_VARIANT_YM = 0x01; + +export const CHIP_CONFIG_DEFAULT = 0x00000000; + +export const AY_CFG_STEREO_MASK = 0x00000007; +export const AY_LAYOUT_MONO = 0x00; +export const AY_LAYOUT_ABC = 0x01; +export const AY_LAYOUT_ACB = 0x02; +export const AY_LAYOUT_BAC = 0x03; +export const AY_LAYOUT_BCA = 0x04; +export const AY_LAYOUT_CAB = 0x05; +export const AY_LAYOUT_CBA = 0x06; +export const AY_LAYOUT_ST_MONO = 0x07; +export const AY_LAYOUTS = [ + AY_LAYOUT_MONO, + AY_LAYOUT_ABC, + AY_LAYOUT_ACB, + AY_LAYOUT_BAC, + AY_LAYOUT_BCA, + AY_LAYOUT_CAB, + AY_LAYOUT_CBA, + AY_LAYOUT_ST_MONO +]; + +export function ayStereoLayout(config: number): number { + return config & AY_CFG_STEREO_MASK; +} + +export const TGT_SAMPLE_AMPLITUDE = 0x80; +export const TGT_FMT_VIRTUAL_DEFINED = [TGT_SAMPLE_AMPLITUDE]; + +export const AY_TARGET_MAX = 0x0d; +export const AY_R13_SHAPE = 0x0d; +export const AY_AMP_REGS = [0x08, 0x09, 0x0a]; + +export const TRAK_SIZE = 16; +export const CHIP_SIZE = 32; +export const TIMR_SIZE = 6; +export const MODS_SIZE = 16; +export const ACTN_SIZE = 6; +export const LANE_SIZE = 16; +export const TLAN_SIZE = 16; + +export const CHIP_NAME_SIZE = 16; +export const CHIP_TAG_SIZE = 4; + +export const FIX16_ONE = 65536; +export const FIX16_MAX = 0xffffffff; + +export function toFix16(value: number): number { + return Math.round(value * FIX16_ONE); +} + +export function fromFix16(encoded: number): number { + return encoded / FIX16_ONE; +} + +export function fitsFix16(value: number): boolean { + const encoded = toFix16(value); + return encoded >= 0 && encoded <= FIX16_MAX; +} + +export const VALUE_TYPE_POOL: Record = { + [VT_U8]: { tag: 'VU08', width: 1 }, + [VT_U16]: { tag: 'VU16', width: 2 }, + [VT_U32]: { tag: 'VU32', width: 4 } +}; diff --git a/src/lib/services/file/taym/taym-builder.ts b/src/lib/services/file/taym/taym-builder.ts new file mode 100644 index 00000000..d7cca5ff --- /dev/null +++ b/src/lib/services/file/taym/taym-builder.ts @@ -0,0 +1,214 @@ +import type { SongCaptureResult } from '../ay/psg-export'; +import { encodeForegroundPsgFrameData } from './foreground-psg'; +import { buildTaymTimerTables, type TaymTimerMode } from './taym-export-timers'; +import { buildTaymSampleTables } from './taym-samples'; +import type { Actn, Lane, Mods, Timr, Tlan } from './model'; +import { makeChip, makeMods, makeTaym, makeTrak, type Taym } from './model'; +import { CMD_EMPTY, SRC_BIND_LANE, TLAN_NONE, TLAN_UNCHANGED } from './spec'; +import { + AY_CFG_STEREO_MASK, + AY_LAYOUT_ABC, + AY_LAYOUT_ACB, + AY_LAYOUT_CAB, + AY_LAYOUT_MONO, + AY_LAYOUT_ST_MONO, + AY_VARIANT_AY, + AY_VARIANT_YM, + CHIP_TYPE_AY +} from './spec'; + +const FRAME_DATA_TAG = 'PSG0'; + +export const ST_MONO_LAYOUT = 'st-mono'; + +const STEREO_LAYOUTS: Record = { + ABC: AY_LAYOUT_ABC, + ACB: AY_LAYOUT_ACB, + CAB: AY_LAYOUT_CAB, + mono: AY_LAYOUT_MONO, + [ST_MONO_LAYOUT]: AY_LAYOUT_ST_MONO +}; + +export function ayStereoConfig(stereoLayout: string | undefined): number { + return (STEREO_LAYOUTS[stereoLayout ?? 'ABC'] ?? AY_LAYOUT_ABC) & AY_CFG_STEREO_MASK; +} + +export interface TaymMetadata { + title?: string; + author?: string; + stereoLayout?: string; + tuningTable?: string; + instruments?: string[]; +} + +export interface BuildTaymOptions { + chipName?: string; + metadata?: TaymMetadata; + timerMode?: TaymTimerMode; +} + +function buildInfo(metadata: TaymMetadata | undefined): Record { + const info: Record = {}; + if (!metadata) { + return info; + } + if (metadata.title) info.title = metadata.title; + if (metadata.author) info.author = metadata.author; + if (metadata.tuningTable) info.tuning = metadata.tuningTable; + const instruments = (metadata.instruments ?? []).filter((name) => name.length > 0); + if (instruments.length > 0) { + info.instruments = instruments.join(', '); + } + return info; +} + +type TimerTables = { + timers: Timr[]; + mods: Mods[]; + actions: Actn[]; + lanes: Lane[]; + tlanes: Tlan[]; + vu08: number[]; + vu32: number[]; + ownedRegistersPerFrame: number[][]; +}; + +function remapTimerLaneRef(timerLaneRef: number, tlanOffset: number): number { + if (timerLaneRef === TLAN_NONE || timerLaneRef === TLAN_UNCHANGED) { + return timerLaneRef; + } + return timerLaneRef + tlanOffset; +} + +function remapSampleMods(mods: Mods, offsets: { action: number; tlan: number }): Mods { + return makeMods(mods.command, { + baseTimerValue: mods.baseTimerValue, + timerLaneRef: remapTimerLaneRef(mods.timerLaneRef, offsets.tlan), + firstAction: mods.firstAction + offsets.action, + actionCount: mods.actionCount + }); +} + +function remapSampleAction(action: Actn, laneOffset: number): Actn { + if (action.sourceMode !== SRC_BIND_LANE) { + return action; + } + return { ...action, operand: action.operand + laneOffset }; +} + +function remapSampleLane(lane: Lane, valueOffset: number): Lane { + return { ...lane, valueOffset: lane.valueOffset + valueOffset }; +} + +function remapSampleTlan(tlan: Tlan, valueOffset: number): Tlan { + return { ...tlan, valueOffset: tlan.valueOffset + valueOffset }; +} + +function mergeTimerTables( + frameCount: number, + effect: TimerTables, + sample: TimerTables +): TimerTables { + if (sample.timers.length === 0) { + return effect; + } + if (effect.timers.length === 0) { + return sample; + } + + const effectCount = effect.timers.length; + const sampleCount = sample.timers.length; + const combinedCount = effectCount + sampleCount; + + const offsets = { + action: effect.actions.length, + lane: effect.lanes.length, + tlan: effect.tlanes.length, + vu08: effect.vu08.length, + vu32: effect.vu32.length + }; + + const mods: Mods[] = new Array(frameCount * combinedCount); + for (let frame = 0; frame < frameCount; frame++) { + for (let i = 0; i < effectCount; i++) { + mods[frame * combinedCount + i] = + effect.mods[frame * effectCount + i] ?? makeMods(CMD_EMPTY); + } + for (let i = 0; i < sampleCount; i++) { + const src = sample.mods[frame * sampleCount + i] ?? makeMods(CMD_EMPTY); + mods[frame * combinedCount + effectCount + i] = remapSampleMods(src, { + action: offsets.action, + tlan: offsets.tlan + }); + } + } + + const ownedRegistersPerFrame = effect.ownedRegistersPerFrame.map((owned, frame) => [ + ...owned, + ...(sample.ownedRegistersPerFrame[frame] ?? []) + ]); + + return { + timers: [...effect.timers, ...sample.timers], + mods, + actions: [ + ...effect.actions, + ...sample.actions.map((action) => remapSampleAction(action, offsets.lane)) + ], + lanes: [ + ...effect.lanes, + ...sample.lanes.map((lane) => remapSampleLane(lane, offsets.vu08)) + ], + tlanes: [ + ...effect.tlanes, + ...sample.tlanes.map((tlan) => remapSampleTlan(tlan, offsets.vu32)) + ], + vu08: [...effect.vu08, ...sample.vu08], + vu32: [...effect.vu32, ...sample.vu32], + ownedRegistersPerFrame + }; +} + +export function buildTaymFromCapture( + capture: SongCaptureResult, + options: BuildTaymOptions = {} +): Taym { + const frameCount = capture.frames.length; + const effectTables = buildTaymTimerTables(capture.frames, { + timerMode: options.timerMode, + chipClockHz: capture.chipFrequency, + chipVariant: capture.isYm ? 'YM' : 'AY' + }); + const sampleTables = buildTaymSampleTables(capture.frames, { + timerMode: options.timerMode, + chipClockHz: capture.chipFrequency + }); + const timerTables = mergeTimerTables(frameCount, effectTables, sampleTables); + const psg = new Uint8Array( + encodeForegroundPsgFrameData( + capture.frames.map((frame) => frame.registers), + timerTables.ownedRegistersPerFrame + ) + ); + + return makeTaym(makeTrak(capture.interruptFrequency, frameCount), { + chips: [ + makeChip(capture.chipFrequency, { + chipTypeId: CHIP_TYPE_AY, + variant: capture.isYm ? AY_VARIANT_YM : AY_VARIANT_AY, + config: ayStereoConfig(options.metadata?.stereoLayout), + name: options.chipName ?? 'AY', + frameDataTag: FRAME_DATA_TAG + }) + ], + timers: timerTables.timers, + mods: timerTables.mods, + actions: timerTables.actions, + lanes: timerTables.lanes, + tlanes: timerTables.tlanes, + vu08: timerTables.vu08, + vu32: timerTables.vu32, + info: buildInfo(options.metadata), + frameData: { [FRAME_DATA_TAG]: psg } + }); +} diff --git a/src/lib/services/file/taym/taym-export-timers.ts b/src/lib/services/file/taym/taym-export-timers.ts new file mode 100644 index 00000000..ed9b0d9f --- /dev/null +++ b/src/lib/services/file/taym/taym-export-timers.ts @@ -0,0 +1,333 @@ +import { + buildMergedEffectSteps, + envFmStepSource, + fmStepSource, + normalizePwmPeriods, + sidStepSource, + syncBuzzerStepSource, + type MergedEffectStep, + type TimerEffectStepSource +} from '../ay/ay-timer-effects'; +import { TONE_CHANNELS, type SongCaptureFrame } from '../ay/ay-export-utils'; +import type { AyChipVariant } from '../../../chips/ay/ay-sample-lut'; +import { makeActn, makeLane, makeMods, makeTimr, makeTlan } from './model'; +import type { Actn, Lane, Mods, Timr, Tlan } from './model'; +import { + CLOCK_ABS_RATE_HZ, + CLOCK_CHIP_PERIOD, + CMD_EMPTY, + CMD_MODULATE, + CMD_START, + CMD_STOP, + NO_LOOP, + SRC_BIND_LANE, + TLAN_NONE, + TM_ABSOLUTE, + toFix16, + VT_U8 +} from './spec'; + +export const TAYM_TIMER_DIVIDER = 8; + +export type TaymTimerMode = 'chip-period' | 'abs-rate-hz'; + +export const DEFAULT_TAYM_TIMER_MODE: TaymTimerMode = 'chip-period'; + +export interface TaymTimerOptions { + timerMode?: TaymTimerMode; + chipClockHz?: number; + chipVariant?: AyChipVariant; +} + +export type TaymTimerTables = { + timers: Timr[]; + mods: Mods[]; + actions: Actn[]; + lanes: Lane[]; + tlanes: Tlan[]; + vu08: number[]; + vu32: number[]; + ownedRegistersPerFrame: number[][]; +}; + +type ChannelEffectConfig = { + steps: MergedEffectStep[]; + ownedRegisters: number[]; + setKey: string; + periodKey: string; +}; + +type Pools = { + vu08: number[]; + vu32: number[]; + actions: Actn[]; + lanes: Lane[]; + tlanes: Tlan[]; + laneByKey: Map; + tlanByKey: Map; + actionSliceByKey: Map; + encodeTimerValue(period: number): number; +}; + +function makeTimerValueEncoder( + timerMode: TaymTimerMode, + chipClockHz: number +): (period: number) => number { + if (timerMode === 'abs-rate-hz') { + return (period) => toFix16(chipClockHz / (TAYM_TIMER_DIVIDER * Math.max(period, 1))); + } + return (period) => period; +} + +function collectStepSources( + channelIndex: number, + frame: SongCaptureFrame, + chipVariant: AyChipVariant +): { sources: TimerEffectStepSource[]; setKey: string; periodKey: string } { + const sources: TimerEffectStepSource[] = []; + const setKeys: string[] = []; + const periodKeys: string[] = []; + + const syncbuzzer = frame.syncbuzzer?.[channelIndex]; + if (syncbuzzer?.enabled) { + const state = normalizePwmPeriods(syncbuzzer); + sources.push(syncBuzzerStepSource(state)); + setKeys.push(`sync:${state.waveform.join(',')}:${state.waveformLoop}`); + periodKeys.push(`${state.period}:${state.periodLow}`); + } + + const sid = frame.sid?.[channelIndex]; + if (sid?.enabled) { + const state = normalizePwmPeriods(sid); + sources.push(sidStepSource(channelIndex, state, chipVariant)); + setKeys.push(`sid:${state.baseVolume}:${state.waveform.join(',')}:${state.waveformLoop}`); + periodKeys.push(`${state.period}:${state.periodLow}`); + } + + const fm = frame.fm?.[channelIndex]; + if (fm?.enabled) { + const state = fm.pwm ? normalizePwmPeriods(fm) : fm; + sources.push(fmStepSource(channelIndex, state)); + setKeys.push( + `fm:${state.baseTonePeriod}:${state.fmOffsetMode}:${state.waveform.join(',')}:${state.waveformLoop}` + ); + periodKeys.push(`${state.period}:${state.periodLow}`); + } + + const envFm = frame.envFm?.[channelIndex]; + if (envFm?.enabled) { + const state = envFm.pwm ? normalizePwmPeriods(envFm) : envFm; + sources.push(envFmStepSource(state)); + setKeys.push( + `envfm:${state.baseEnvelopePeriod}:${state.fmOffsetMode}:${state.waveform.join(',')}:${state.waveformLoop}` + ); + periodKeys.push(`${state.period}:${state.periodLow}`); + } + + return { + sources, + setKey: setKeys.join('|'), + periodKey: periodKeys.join('|') + }; +} + +function buildChannelConfig( + channelIndex: number, + frame: SongCaptureFrame, + chipVariant: AyChipVariant +): ChannelEffectConfig | undefined { + const { sources, setKey, periodKey } = collectStepSources(channelIndex, frame, chipVariant); + if (sources.length === 0) { + return undefined; + } + const steps = buildMergedEffectSteps(sources); + if (steps.length === 0) { + return undefined; + } + const ownedRegisters: number[] = []; + for (let register = 0; register < 14; register++) { + if (steps.some((step) => step.writes.some((write) => write.register === register))) { + ownedRegisters.push(register); + } + } + return { steps, ownedRegisters, setKey, periodKey }; +} + +function internValueLane(pools: Pools, values: number[], loopIndex: number): number { + const key = `${values.join(',')}#${loopIndex}`; + const existing = pools.laneByKey.get(key); + if (existing !== undefined) { + return existing; + } + const lane = makeLane(VT_U8, pools.vu08.length, values.length, loopIndex); + pools.vu08.push(...values); + const index = pools.lanes.length; + pools.lanes.push(lane); + pools.laneByKey.set(key, index); + return index; +} + +function internTimerLane(pools: Pools, periods: number[], loopIndex: number): number { + const values = periods.map((period) => pools.encodeTimerValue(period)); + const key = `${values.join(',')}#${loopIndex}`; + const existing = pools.tlanByKey.get(key); + if (existing !== undefined) { + return existing; + } + const tlan = makeTlan(TM_ABSOLUTE, pools.vu32.length, values.length, loopIndex); + pools.vu32.push(...values); + const index = pools.tlanes.length; + pools.tlanes.push(tlan); + pools.tlanByKey.set(key, index); + return index; +} + +function internActionSlice( + pools: Pools, + config: ChannelEffectConfig +): { firstAction: number; actionCount: number } { + const loopIndex = config.steps.length > 0 ? config.steps[config.steps.length - 1]!.nextIndex : 0; + const laneRefs: Array<{ targetId: number; laneIndex: number }> = []; + for (const register of config.ownedRegisters) { + const values = config.steps.map( + (step) => step.writes.find((write) => write.register === register)?.value ?? 0 + ); + laneRefs.push({ targetId: register, laneIndex: internValueLane(pools, values, loopIndex) }); + } + const sliceKey = laneRefs.map((ref) => `${ref.targetId}:${ref.laneIndex}`).join(','); + const existing = pools.actionSliceByKey.get(sliceKey); + if (existing !== undefined) { + return { firstAction: existing, actionCount: laneRefs.length }; + } + const firstAction = pools.actions.length; + for (const ref of laneRefs) { + pools.actions.push(makeActn(ref.targetId, SRC_BIND_LANE, ref.laneIndex)); + } + pools.actionSliceByKey.set(sliceKey, firstAction); + return { firstAction, actionCount: laneRefs.length }; +} + +function startMods(pools: Pools, config: ChannelEffectConfig): Mods { + const slice = internActionSlice(pools, config); + const loopIndex = config.steps.length > 0 ? config.steps[config.steps.length - 1]!.nextIndex : 0; + const periods = config.steps.map((step) => step.period || 1); + const timerLaneRef = internTimerLane(pools, periods, loopIndex); + return makeMods(CMD_START, { + baseTimerValue: pools.encodeTimerValue(periods[0] || 1), + timerLaneRef, + firstAction: slice.firstAction, + actionCount: slice.actionCount + }); +} + +export function buildTaymTimerTables( + frames: SongCaptureFrame[], + options: TaymTimerOptions = {} +): TaymTimerTables { + const timerMode = options.timerMode ?? DEFAULT_TAYM_TIMER_MODE; + const chipClockHz = options.chipClockHz ?? 0; + const chipVariant = options.chipVariant ?? 'AY'; + const encodeTimerValue = makeTimerValueEncoder(timerMode, chipClockHz); + const frameCount = frames.length; + + const channelConfigs: Array> = []; + const channelUsed: boolean[] = []; + for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { + const configs = frames.map((frame) => buildChannelConfig(channelIndex, frame, chipVariant)); + channelConfigs.push(configs); + channelUsed.push(configs.some((config) => config !== undefined)); + } + + const activeChannels = []; + for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { + if (channelUsed[channelIndex]) { + activeChannels.push(channelIndex); + } + } + + const ownedRegistersPerFrame: number[][] = Array.from({ length: frameCount }, () => []); + if (activeChannels.length === 0) { + return { + timers: [], + mods: [], + actions: [], + lanes: [], + tlanes: [], + vu08: [], + vu32: [], + ownedRegistersPerFrame + }; + } + + const pools: Pools = { + vu08: [], + vu32: [], + actions: [], + lanes: [], + tlanes: [], + laneByKey: new Map(), + tlanByKey: new Map(), + actionSliceByKey: new Map(), + encodeTimerValue + }; + + const clockMode = timerMode === 'abs-rate-hz' ? CLOCK_ABS_RATE_HZ : CLOCK_CHIP_PERIOD; + const clockDivider = timerMode === 'abs-rate-hz' ? 0 : TAYM_TIMER_DIVIDER; + const timers: Timr[] = activeChannels.map(() => makeTimr(0, clockMode, clockDivider)); + const timerCount = activeChannels.length; + const mods: Mods[] = new Array(frameCount * timerCount); + + for (let timerIndex = 0; timerIndex < timerCount; timerIndex++) { + const channelIndex = activeChannels[timerIndex]!; + const configs = channelConfigs[channelIndex]!; + let prevSetKey: string | undefined; + let prevPeriodKey: string | undefined; + + for (let frame = 0; frame < frameCount; frame++) { + const config = configs[frame]; + const modsIndex = frame * timerCount + timerIndex; + + if (!config) { + mods[modsIndex] = makeMods(prevSetKey !== undefined ? CMD_STOP : CMD_EMPTY); + prevSetKey = undefined; + prevPeriodKey = undefined; + continue; + } + + for (const register of config.ownedRegisters) { + ownedRegistersPerFrame[frame]!.push(register); + } + + const setChanged = prevSetKey === undefined || prevSetKey !== config.setKey; + const periodChanged = prevPeriodKey !== undefined && prevPeriodKey !== config.periodKey; + + if (setChanged) { + mods[modsIndex] = startMods(pools, config); + } else if (periodChanged) { + const loopIndex = config.steps[config.steps.length - 1]!.nextIndex; + const periods = config.steps.map((step) => step.period || 1); + const timerLaneRef = internTimerLane(pools, periods, loopIndex); + mods[modsIndex] = makeMods(CMD_MODULATE, { + baseTimerValue: pools.encodeTimerValue(periods[0] || 1), + timerLaneRef + }); + } else { + mods[modsIndex] = makeMods(CMD_EMPTY); + } + + prevSetKey = config.setKey; + prevPeriodKey = config.periodKey; + } + } + + return { + timers, + mods, + actions: pools.actions, + lanes: pools.lanes, + tlanes: pools.tlanes, + vu08: pools.vu08, + vu32: pools.vu32, + ownedRegistersPerFrame + }; +} diff --git a/src/lib/services/file/taym/taym-export.ts b/src/lib/services/file/taym/taym-export.ts new file mode 100644 index 00000000..402504b3 --- /dev/null +++ b/src/lib/services/file/taym/taym-export.ts @@ -0,0 +1,144 @@ +import type { Project } from '../../../models/project'; +import { downloadFile, sanitizeFilename } from '../../../utils/file-download'; +import JSZip from 'jszip'; +import { + captureSongRegisterFrames, + type GenerateCaptureOptions, + type PsgExportModules +} from '../ay/psg-export'; +import { buildTaymFromCapture } from './taym-builder'; +import { buildTaymMetadata } from './taym-metadata'; +import { writeTaym } from './codec'; +import type { TaymTimerMode } from './taym-export-timers'; + +export interface TaymExportOptions extends GenerateCaptureOptions { + timerMode?: TaymTimerMode; +} + +async function loadPsgExportModules(): Promise { + const baseUrl = import.meta.env.BASE_URL; + const { default: AyumiState } = await import(/* @vite-ignore */ `${baseUrl}ay/ayumi-state.js`); + const { default: TrackerPatternProcessor } = await import( + /* @vite-ignore */ `${baseUrl}tracker/tracker-pattern-processor.js` + ); + const { default: AYAudioDriver } = await import( + /* @vite-ignore */ `${baseUrl}ay/ay-audio-driver.js` + ); + const { default: AYChipRegisterState } = await import( + /* @vite-ignore */ `${baseUrl}ay/ay-chip-register-state.js` + ); + const { default: VirtualChannelMixer } = await import( + /* @vite-ignore */ `${baseUrl}ay/virtual-channel-mixer.js` + ); + const samplePlayback = await import( + /* @vite-ignore */ `${baseUrl}ay/ay-sample-playback.js` + ); + return { + AyumiState, + TrackerPatternProcessor, + AYAudioDriver, + AYChipRegisterState, + VirtualChannelMixer, + instrumentHasSample: samplePlayback.instrumentHasSample, + advanceSamplePosition: samplePlayback.advanceSamplePosition + }; +} + +function getAYSongIndices(project: Project): number[] { + const aySongIndices: number[] = []; + for (let index = 0; index < project.songs.length; index++) { + const song = project.songs[index]; + if (song && (!song.chipType || song.chipType === 'ay')) { + aySongIndices.push(index); + } + } + return aySongIndices; +} + +export function resolveSingleAyTaymSongIndex(project: Project, songIndex: number = 0): number { + const aySongIndices = getAYSongIndices(project); + return aySongIndices.length === 1 ? aySongIndices[0]! : songIndex; +} + +export async function generateTaymFile( + project: Project, + songIndex: number = 0, + options?: TaymExportOptions +): Promise { + const capture = await captureSongRegisterFrames(project, songIndex, { + ...options, + captureDigiSamples: true + }); + const song = project.songs[songIndex]; + const metadata = song ? buildTaymMetadata(project, song) : undefined; + return writeTaym(buildTaymFromCapture(capture, { metadata, timerMode: options?.timerMode })); +} + +export async function exportToTaym( + project: Project, + songIndex: number = 0, + onProgress?: (progress: number, message: string) => void, + abortSignal?: AbortSignal +): Promise { + try { + onProgress?.(0, 'Preparing TAYM export...'); + if (abortSignal?.aborted) { + throw new Error('Export cancelled'); + } + + const aySongIndices = getAYSongIndices(project); + if (aySongIndices.length === 0) { + throw new Error('Project has no AY songs to export'); + } + + onProgress?.(10, 'Loading processor modules...'); + const modules = await loadPsgExportModules(); + const filename = project.name || 'export'; + const sanitizedFilename = sanitizeFilename(filename); + + if (aySongIndices.length > 1) { + const zip = new JSZip(); + for (let index = 0; index < aySongIndices.length; index++) { + if (abortSignal?.aborted) { + throw new Error('Export cancelled'); + } + const currentSongIndex = aySongIndices[index]!; + const startProgress = 10 + (index / aySongIndices.length) * 80; + onProgress?.(startProgress, `Generating TAYM ${index + 1}/${aySongIndices.length}...`); + const taym = await generateTaymFile(project, currentSongIndex, { modules }); + zip.file(`${sanitizedFilename}_ay${index + 1}.taym`, taym); + } + + onProgress?.(95, 'Creating ZIP archive...'); + const zipBlob = await zip.generateAsync({ type: 'blob' }); + onProgress?.(99, 'Downloading...'); + downloadFile(zipBlob, `${sanitizedFilename}_taym.zip`); + onProgress?.(100, 'Complete!'); + return; + } + + const exportSongIndex = resolveSingleAyTaymSongIndex(project, songIndex); + const song = project.songs[exportSongIndex]; + if (!song || song.patterns.length === 0) { + throw new Error('Song is empty'); + } + + onProgress?.(50, 'Capturing register frames...'); + const taym = await generateTaymFile(project, exportSongIndex, { modules }); + if (abortSignal?.aborted) { + throw new Error('Export cancelled'); + } + + onProgress?.(99, 'Downloading...'); + downloadFile(new Blob([taym], { type: 'application/octet-stream' }), `${sanitizedFilename}.taym`); + onProgress?.(100, 'Complete!'); + } catch (error) { + if (error instanceof Error && error.message === 'Export cancelled') { + onProgress?.(0, 'Export cancelled'); + throw error; + } + console.error('Failed to export TAYM:', error); + onProgress?.(0, `Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } +} diff --git a/src/lib/services/file/taym/taym-metadata.ts b/src/lib/services/file/taym/taym-metadata.ts new file mode 100644 index 00000000..3ac0ed58 --- /dev/null +++ b/src/lib/services/file/taym/taym-metadata.ts @@ -0,0 +1,38 @@ +import type { Project } from '../../../models/project'; +import type { Song } from '../../../models/song'; +import { ST_MONO_LAYOUT, type TaymMetadata } from './taym-builder'; + +function resolveTuningTableLabel(song: Song): string | undefined { + const index = (song as { tuningTableIndex?: number }).tuningTableIndex; + if (index === undefined) { + return undefined; + } + const setting = song.getSchema()?.settings?.find((entry) => entry.key === 'tuningTableIndex'); + const option = setting?.options?.find((entry) => entry.value === index); + if (option) { + return option.label; + } + if (setting?.dynamicOption && setting.dynamicOption.value === index) { + return setting.dynamicOption.label({ + chipFrequency: (song as { chipFrequency?: number }).chipFrequency + }); + } + return undefined; +} + +function resolveStereoLayout(song: Song): string { + if ((song as { stMixing?: boolean }).stMixing) { + return ST_MONO_LAYOUT; + } + return (song as { stereoLayout?: string }).stereoLayout ?? 'ABC'; +} + +export function buildTaymMetadata(project: Project, song: Song): TaymMetadata { + return { + title: project.name || undefined, + author: project.author || undefined, + stereoLayout: resolveStereoLayout(song), + tuningTable: resolveTuningTableLabel(song), + instruments: project.instruments.map((instrument) => instrument.name) + }; +} diff --git a/src/lib/services/file/taym/taym-samples.ts b/src/lib/services/file/taym/taym-samples.ts new file mode 100644 index 00000000..f42c48c9 --- /dev/null +++ b/src/lib/services/file/taym/taym-samples.ts @@ -0,0 +1,220 @@ +import { + SAMPLE_NO_LOOP, + TONE_CHANNELS, + volumeRegisterIndex, + type HardwareTaymSampleState, + type SongCaptureFrame +} from '../ay/ay-export-utils'; +import { makeActn, makeLane, makeMods, makeTimr } from './model'; +import type { Actn, Lane, Mods, Timr, Tlan } from './model'; +import { + CLOCK_ABS_RATE_HZ, + CLOCK_CHIP_PERIOD, + CMD_EMPTY, + CMD_MODULATE, + CMD_START, + CMD_STOP, + NO_LOOP, + SRC_BIND_LANE, + SRC_INLINE_VALUE, + TGT_SAMPLE_AMPLITUDE, + TLAN_NONE, + TLAN_UNCHANGED, + toFix16, + VT_U8 +} from './spec'; +import { TAYM_TIMER_DIVIDER, type TaymTimerMode } from './taym-export-timers'; + +export const DEFAULT_TAYM_SAMPLE_TIMER_MODE: TaymTimerMode = 'abs-rate-hz'; + +export type TaymSampleOptions = { + timerMode?: TaymTimerMode; + chipClockHz?: number; +}; + +export type TaymSampleTables = { + timers: Timr[]; + mods: Mods[]; + actions: Actn[]; + lanes: Lane[]; + tlanes: Tlan[]; + vu08: number[]; + vu32: number[]; + ownedRegistersPerFrame: number[][]; +}; + +type SamplePools = { + vu08: number[]; + vu32: number[]; + actions: Actn[]; + lanes: Lane[]; + tlanes: Tlan[]; + laneByKey: Map; + sliceByKey: Map; + encodeTimerValue(period: number): number; +}; + +function makeTimerValueEncoder( + timerMode: TaymTimerMode, + chipClockHz: number +): (rateHz: number) => number { + if (timerMode === 'abs-rate-hz') { + return (rateHz) => toFix16(Math.max(rateHz, 0)); + } + return (rateHz) => + Math.max(1, Math.round(chipClockHz / (TAYM_TIMER_DIVIDER * Math.max(rateHz, 1)))); +} + +function internSampleLane(pools: SamplePools, values: number[], loopIndex: number): number { + const laneLoop = loopIndex >= 0 ? loopIndex : NO_LOOP; + const key = `${values.join(',')}#${laneLoop}`; + const existing = pools.laneByKey.get(key); + if (existing !== undefined) { + return existing; + } + const lane = makeLane(VT_U8, pools.vu08.length, values.length, laneLoop); + pools.vu08.push(...values); + const index = pools.lanes.length; + pools.lanes.push(lane); + pools.laneByKey.set(key, index); + return index; +} + +function internSampleSlice( + pools: SamplePools, + channelIndex: number, + sample: HardwareTaymSampleState +): number { + const laneIndex = internSampleLane(pools, sample.sampleBytes, sample.loopIndex); + const ampReg = volumeRegisterIndex(channelIndex); + const volume = sample.volume & 0x0f; + const key = `${ampReg}:${volume}|${TGT_SAMPLE_AMPLITUDE}:${laneIndex}`; + const existing = pools.sliceByKey.get(key); + if (existing !== undefined) { + return existing; + } + const firstAction = pools.actions.length; + pools.actions.push(makeActn(ampReg, SRC_INLINE_VALUE, volume)); + pools.actions.push(makeActn(TGT_SAMPLE_AMPLITUDE, SRC_BIND_LANE, laneIndex)); + pools.sliceByKey.set(key, firstAction); + return firstAction; +} + +function sampleSlotMods( + pools: SamplePools, + command: number, + channelIndex: number, + sample: HardwareTaymSampleState, + pitchChanged: boolean +): Mods { + const firstAction = internSampleSlice(pools, channelIndex, sample); + return makeMods(command, { + baseTimerValue: pitchChanged ? pools.encodeTimerValue(sample.rateHz) : 0, + timerLaneRef: pitchChanged ? TLAN_NONE : TLAN_UNCHANGED, + firstAction, + actionCount: 2 + }); +} + +function channelPlaysSample(frames: SongCaptureFrame[], channelIndex: number): boolean { + return frames.some((frame) => frame.samples?.[channelIndex]?.enabled); +} + +export function buildTaymSampleTables( + frames: SongCaptureFrame[], + options: TaymSampleOptions = {} +): TaymSampleTables { + const timerMode = options.timerMode ?? DEFAULT_TAYM_SAMPLE_TIMER_MODE; + const chipClockHz = options.chipClockHz ?? 0; + const encodeTimerValue = makeTimerValueEncoder(timerMode, chipClockHz); + const frameCount = frames.length; + const ownedRegistersPerFrame: number[][] = Array.from({ length: frameCount }, () => []); + + const sampleChannels: number[] = []; + for (let channelIndex = 0; channelIndex < TONE_CHANNELS; channelIndex++) { + if (channelPlaysSample(frames, channelIndex)) { + sampleChannels.push(channelIndex); + } + } + + if (sampleChannels.length === 0) { + return { + timers: [], + mods: [], + actions: [], + lanes: [], + tlanes: [], + vu08: [], + vu32: [], + ownedRegistersPerFrame + }; + } + + const pools: SamplePools = { + vu08: [], + vu32: [], + actions: [], + lanes: [], + tlanes: [], + laneByKey: new Map(), + sliceByKey: new Map(), + encodeTimerValue + }; + + const clockMode = timerMode === 'abs-rate-hz' ? CLOCK_ABS_RATE_HZ : CLOCK_CHIP_PERIOD; + const clockDivider = timerMode === 'abs-rate-hz' ? 0 : TAYM_TIMER_DIVIDER; + const timers: Timr[] = sampleChannels.map(() => makeTimr(0, clockMode, clockDivider)); + const timerCount = sampleChannels.length; + const mods: Mods[] = new Array(frameCount * timerCount); + + for (let timerIndex = 0; timerIndex < timerCount; timerIndex++) { + const channelIndex = sampleChannels[timerIndex]!; + let activeInstanceId = 0; + let prevTimerValue = -1; + let prevVolume = -1; + + for (let frame = 0; frame < frameCount; frame++) { + const sample = frames[frame]!.samples?.[channelIndex]; + const modsIndex = frame * timerCount + timerIndex; + + if (!sample?.enabled) { + mods[modsIndex] = makeMods(activeInstanceId !== 0 ? CMD_STOP : CMD_EMPTY); + activeInstanceId = 0; + prevTimerValue = -1; + prevVolume = -1; + continue; + } + + ownedRegistersPerFrame[frame]!.push(volumeRegisterIndex(channelIndex)); + + const timerValue = pools.encodeTimerValue(sample.rateHz); + const pitchChanged = timerValue !== prevTimerValue; + const volumeChanged = sample.volume !== prevVolume; + + if (sample.instanceId !== activeInstanceId) { + mods[modsIndex] = sampleSlotMods(pools, CMD_START, channelIndex, sample, true); + } else if (pitchChanged || volumeChanged) { + mods[modsIndex] = sampleSlotMods(pools, CMD_MODULATE, channelIndex, sample, pitchChanged); + } else { + mods[modsIndex] = makeMods(CMD_EMPTY); + } + + activeInstanceId = sample.instanceId; + prevTimerValue = timerValue; + prevVolume = sample.volume; + } + } + + return { + timers, + mods, + actions: pools.actions, + lanes: pools.lanes, + tlanes: pools.tlanes, + vu08: pools.vu08, + vu32: pools.vu32, + ownedRegistersPerFrame + }; +} + +export { SAMPLE_NO_LOOP }; diff --git a/src/lib/services/file/taym/validate.ts b/src/lib/services/file/taym/validate.ts new file mode 100644 index 00000000..ee57f213 --- /dev/null +++ b/src/lib/services/file/taym/validate.ts @@ -0,0 +1,398 @@ +import type { Mods, Taym } from './model'; +import { poolFor } from './model'; +import * as spec from './spec'; + +export class TaymValidationError extends Error {} + +const PSG_MAGIC = [0x50, 0x53, 0x47, 0x1a]; + +export function validate(taym: Taym): string[] { + const problems: string[] = []; + validateTrak(taym, problems); + validateChips(taym, problems); + validateTimers(taym, problems); + validateLanes(taym, problems); + validateTlanes(taym, problems); + validateActions(taym, problems); + validateMods(taym, problems); + validateFrameData(taym, problems); + return problems; +} + +export function check(taym: Taym): void { + const problems = validate(taym); + if (problems.length > 0) { + throw new TaymValidationError(problems[0]); + } +} + +function validateTrak(taym: Taym, problems: string[]): void { + const trak = taym.trak; + if (trak.frameCount === 0) { + problems.push('S4: TRAK.frame_count is zero'); + } + if (!spec.fitsFix16(trak.frameRateHz) || spec.toFix16(trak.frameRateHz) === 0) { + problems.push('S4: TRAK.frame_rate must be nonzero and fit unsigned 16.16'); + } + if (trak.loopFrame !== spec.NO_LOOP && trak.loopFrame >= trak.frameCount) { + problems.push(`S4: TRAK.loop_frame ${trak.loopFrame} >= frame_count ${trak.frameCount}`); + } + if (taym.mods.length !== trak.frameCount * taym.timers.length) { + problems.push( + `S4/S12: MODS has ${taym.mods.length} records, expected frame_count*timer_count = ${ + trak.frameCount * taym.timers.length + }` + ); + } + if (taym.chips.length > 0xff) { + problems.push('S4: chip_count exceeds u8'); + } + if (taym.timers.length > 0xff) { + problems.push('S4: timer_count exceeds u8'); + } +} + +function validateChips(taym: Taym, problems: string[]): void { + const seenTags = new Set(); + const coreTags = new Set([...spec.CORE_ONCE, 'INFO']); + taym.chips.forEach((chip, index) => { + if (chip.chipTypeId === spec.CHIP_TYPE_INVALID) { + problems.push(`S6: CHIP[${index}] chip_type_id 0x00 is invalid`); + } + if ( + chip.chipTypeId === spec.CHIP_TYPE_AY && + chip.variant !== spec.AY_VARIANT_AY && + chip.variant !== spec.AY_VARIANT_YM + ) { + problems.push(`A.1: CHIP[${index}] AY variant ${chip.variant} undefined (0=AY, 1=YM)`); + } + if (chip.chipTypeId === spec.CHIP_TYPE_AY) { + const layout = spec.ayStereoLayout(chip.config); + if (!spec.AY_LAYOUTS.includes(layout)) { + problems.push(`A.1: CHIP[${index}] AY config stereo layout ${layout} undefined (0..6)`); + } + const reserved = chip.config & ~spec.AY_CFG_STEREO_MASK; + if (reserved) { + problems.push( + `A.1: CHIP[${index}] AY config sets reserved bits 0x${(reserved >>> 0) + .toString(16) + .padStart(8, '0') + .toUpperCase()}` + ); + } + } + if (chip.frameDataTag) { + if (seenTags.has(chip.frameDataTag)) { + problems.push(`S6.1: repeated frame_data_tag ${chip.frameDataTag}`); + } + seenTags.add(chip.frameDataTag); + if (coreTags.has(chip.frameDataTag)) { + problems.push(`S6.1: frame_data_tag ${chip.frameDataTag} reuses a core/INFO tag`); + } + } + }); +} + +function validateTimers(taym: Taym, problems: string[]): void { + taym.timers.forEach((timer, index) => { + if (!spec.CLOCK_MODES.includes(timer.clockMode)) { + problems.push(`S7: TIMR[${index}] clock_mode ${timer.clockMode} invalid`); + return; + } + if (timer.chipIndex >= taym.chips.length) { + problems.push(`S7: TIMR[${index}] chip_index ${timer.chipIndex} out of range`); + return; + } + if (timer.clockMode === spec.CLOCK_ABS_RATE_HZ) { + if (timer.clockDivider !== 0) { + problems.push(`S7: TIMR[${index}] ABS_RATE_HZ requires clock_divider==0`); + } + } else { + if (timer.clockDivider === 0) { + problems.push(`S7: TIMR[${index}] CHIP_PERIOD requires nonzero clock_divider`); + } + if (taym.chips[timer.chipIndex].clockHz === 0) { + problems.push(`S7: TIMR[${index}] CHIP_PERIOD chip has zero clock_hz`); + } + } + }); +} + +function validateLanes(taym: Taym, problems: string[]): void { + taym.lanes.forEach((lane, index) => { + if (!spec.VALUE_TYPES.includes(lane.valueType)) { + problems.push(`S9: LANE[${index}] value_type ${lane.valueType} invalid`); + return; + } + if (lane.length === 0) { + problems.push(`S9: LANE[${index}] zero length`); + return; + } + const pool = poolFor(taym, lane.valueType); + if (lane.valueOffset + lane.length > pool.length) { + problems.push( + `S9: LANE[${index}] slice [${lane.valueOffset},+${lane.length}] outside ${ + spec.VALUE_TYPE_POOL[lane.valueType].tag + } (len ${pool.length})` + ); + } + if (lane.loopIndex !== spec.NO_LOOP && lane.loopIndex >= lane.length) { + problems.push(`S9: LANE[${index}] loop_index ${lane.loopIndex} >= length ${lane.length}`); + } + }); +} + +function validateTlanes(taym: Taym, problems: string[]): void { + taym.tlanes.forEach((tlan, index) => { + if (!spec.TIMING_MODES.includes(tlan.timingMode)) { + problems.push(`S10: TLAN[${index}] timing_mode ${tlan.timingMode} invalid`); + } + if (tlan.length === 0) { + problems.push(`S10: TLAN[${index}] zero length`); + return; + } + if (tlan.valueOffset + tlan.length > taym.vu32.length) { + problems.push( + `S10: TLAN[${index}] slice [${tlan.valueOffset},+${tlan.length}] outside VU32 (len ${taym.vu32.length})` + ); + } + if (tlan.loopIndex !== spec.NO_LOOP && tlan.loopIndex >= tlan.length) { + problems.push(`S10: TLAN[${index}] loop_index ${tlan.loopIndex} >= length ${tlan.length}`); + } + }); +} + +function isFmtVirtualTarget(targetId: number): boolean { + return targetId >= 0x80 && targetId <= 0xbf; +} + +function isDefinedFmtVirtualTarget(targetId: number): boolean { + return spec.TGT_FMT_VIRTUAL_DEFINED.includes(targetId); +} + +function validTarget(targetId: number): boolean { + if (isFmtVirtualTarget(targetId)) { + return isDefinedFmtVirtualTarget(targetId); + } + return true; +} + +function ayTargetOk(targetId: number): boolean { + if (targetId <= spec.AY_TARGET_MAX) { + return true; + } + if (targetId <= 0x7f) { + return false; + } + return isDefinedFmtVirtualTarget(targetId); +} + +function validateActions(taym: Taym, problems: string[]): void { + taym.actions.forEach((action, index) => { + if (!spec.SOURCE_MODES.includes(action.sourceMode)) { + problems.push(`S11: ACTN[${index}] source_mode ${action.sourceMode} invalid`); + } + if (!validTarget(action.targetId)) { + problems.push( + `S11: ACTN[${index}] target_id 0x${action.targetId.toString(16)} is reserved/invalid` + ); + } + if (action.sourceMode === spec.SRC_BIND_LANE && action.operand >= taym.lanes.length) { + problems.push(`S11: ACTN[${index}] BIND_LANE operand ${action.operand} out of LANE range`); + } + }); +} + +function actionSlice(taym: Taym, mods: Mods): typeof taym.actions { + return taym.actions.slice(mods.firstAction, mods.firstAction + mods.actionCount); +} + +function checkActionsSlice( + taym: Taym, + mods: Mods, + frame: number, + timerIndex: number, + problems: string[] +): void { + if (mods.actionCount === 0) { + return; + } + if (mods.firstAction + mods.actionCount > taym.actions.length) { + problems.push( + `S12: MODS frame ${frame} timer ${timerIndex} action slice [${mods.firstAction},+${mods.actionCount}] out of ACTN range` + ); + return; + } + const timer = taym.timers[timerIndex]; + const chipType = + timer && timer.chipIndex < taym.chips.length + ? taym.chips[timer.chipIndex].chipTypeId + : undefined; + let prev = -1; + for (const action of actionSlice(taym, mods)) { + if (action.targetId <= prev) { + problems.push( + `S11: MODS frame ${frame} timer ${timerIndex} action slice not strictly sorted / duplicate target 0x${action.targetId.toString( + 16 + )}` + ); + } + prev = action.targetId; + if (chipType === spec.CHIP_TYPE_AY && !ayTargetOk(action.targetId)) { + problems.push( + `AppA: MODS frame ${frame} timer ${timerIndex} target 0x${action.targetId.toString( + 16 + )} invalid for AY chip` + ); + } + if (action.sourceMode === spec.SRC_BIND_LANE && action.operand < taym.lanes.length) { + const lane = taym.lanes[action.operand]; + if ( + chipType === spec.CHIP_TYPE_AY && + action.targetId <= spec.AY_TARGET_MAX && + lane.valueType !== spec.VT_U8 + ) { + problems.push( + `S9/AppA: MODS frame ${frame} timer ${timerIndex} AY reg 0x${action.targetId.toString( + 16 + )} bound to non-U8 lane` + ); + } + } + } + + if (chipType === spec.CHIP_TYPE_AY && mods.actionCount > 0) { + const slice = actionSlice(taym, mods); + if (slice.some((action) => action.targetId === spec.TGT_SAMPLE_AMPLITUDE)) { + const ampCount = slice.filter((action) => + spec.AY_AMP_REGS.includes(action.targetId) + ).length; + if (ampCount !== 1) { + problems.push( + `S11.1: MODS frame ${frame} timer ${timerIndex} sample amplitude 0x80 needs exactly one paired AY amp reg (R8/R9/R10), got ${ampCount}` + ); + } + } + } +} + +function checkStart( + taym: Taym, + mods: Mods, + frame: number, + timerIndex: number, + problems: string[] +): void { + if (mods.baseTimerValue === 0) { + problems.push(`S12.2: MODS frame ${frame} timer ${timerIndex} START base_timer_value is zero`); + } + if (mods.timerLaneRef === spec.TLAN_UNCHANGED) { + problems.push( + `S12.2: MODS frame ${frame} timer ${timerIndex} START timer_lane_ref UNCHANGED invalid` + ); + } else if (mods.timerLaneRef !== spec.TLAN_NONE && mods.timerLaneRef >= taym.tlanes.length) { + problems.push( + `S12.2: MODS frame ${frame} timer ${timerIndex} timer_lane_ref ${mods.timerLaneRef} out of TLAN range` + ); + } + if (mods.actionCount < 1) { + problems.push(`S12.2: MODS frame ${frame} timer ${timerIndex} START with no actions`); + } + checkActionsSlice(taym, mods, frame, timerIndex, problems); +} + +function validateMods(taym: Taym, problems: string[]): void { + const timerCount = taym.timers.length; + if (timerCount === 0) { + return; + } + if (taym.mods.length !== taym.trak.frameCount * timerCount) { + return; + } + const active: Array<'active' | null> = new Array(timerCount).fill(null); + + for (let frame = 0; frame < taym.trak.frameCount; frame++) { + const startsThisFrame = new Map(); + for (let timerIndex = 0; timerIndex < timerCount; timerIndex++) { + const mods = taym.mods[frame * timerCount + timerIndex]; + const command = mods.command; + if (!spec.COMMANDS.includes(command)) { + problems.push(`S12: MODS frame ${frame} timer ${timerIndex} command ${command} invalid`); + continue; + } + if (command === spec.CMD_START) { + checkStart(taym, mods, frame, timerIndex, problems); + active[timerIndex] = 'active'; + const chip = taym.timers[timerIndex].chipIndex; + for (const action of actionSlice(taym, mods)) { + if (action.targetId === spec.TGT_SAMPLE_AMPLITUDE) { + continue; + } + const key = `${chip}:${action.targetId}`; + if (startsThisFrame.has(key)) { + problems.push( + `S13.2: frame ${frame} two STARTs claim chip ${chip} target 0x${action.targetId.toString( + 16 + )}` + ); + } + startsThisFrame.set(key, timerIndex); + } + } else if (command === spec.CMD_MODULATE) { + if (active[timerIndex] !== 'active') { + problems.push( + `S12.3: MODS frame ${frame} timer ${timerIndex} MODULATE on inactive timer` + ); + } + if ( + mods.timerLaneRef !== spec.TLAN_NONE && + mods.timerLaneRef !== spec.TLAN_UNCHANGED && + mods.timerLaneRef >= taym.tlanes.length + ) { + problems.push( + `S12: MODS frame ${frame} timer ${timerIndex} timer_lane_ref ${mods.timerLaneRef} out of TLAN range` + ); + } + checkActionsSlice(taym, mods, frame, timerIndex, problems); + } else if (command === spec.CMD_STOP) { + active[timerIndex] = null; + } + } + } + + const loopFrame = taym.trak.loopFrame; + if ( + loopFrame !== spec.NO_LOOP && + loopFrame < taym.trak.frameCount && + loopFrame * timerCount + timerCount <= taym.mods.length + ) { + for (let timerIndex = 0; timerIndex < timerCount; timerIndex++) { + const mods = taym.mods[loopFrame * timerCount + timerIndex]; + if (mods.command !== spec.CMD_START && mods.command !== spec.CMD_STOP) { + problems.push( + `S4: timer ${timerIndex} at loop_frame ${loopFrame} is neither START nor STOP` + ); + } + } + } +} + +function validateFrameData(taym: Taym, problems: string[]): void { + taym.chips.forEach((chip, index) => { + if (!chip.frameDataTag) { + return; + } + const payload = taym.frameData[chip.frameDataTag]; + if (payload === undefined) { + problems.push(`S6.2: CHIP[${index}] frame data ${chip.frameDataTag} missing payload`); + return; + } + if (chip.chipTypeId === spec.CHIP_TYPE_AY) { + const headerOk = + payload.length >= 4 && PSG_MAGIC.every((byte, offset) => payload[offset] === byte); + if (!headerOk) { + problems.push(`S6.2: CHIP[${index}] frame data ${chip.frameDataTag} lacks PSG header`); + } + } + }); +} diff --git a/src/lib/services/file/tmr/tmr-encoder.ts b/src/lib/services/file/tmr/tmr-encoder.ts index 31500943..ab1c05a0 100644 --- a/src/lib/services/file/tmr/tmr-encoder.ts +++ b/src/lib/services/file/tmr/tmr-encoder.ts @@ -1,23 +1,26 @@ import { AY_REGISTER_COUNT, - ENVELOPE_SHAPE_REGISTER, - envelopeShapeRegisterApplyMask, - envelopePeriodRegisterApplyMask, - registerApplyMask, registersChangedMask, - sidVolumeLevel, - timerPwmStepPeriod, - toneRegisterApplyMask, - volumeRegisterIndex, - writeEnvelopePeriodToPsgData, - writeTonePeriodToPsgData, type HardwareEnvFmState, type HardwareFmState, type HardwareSidState, type HardwareSyncBuzzerState, type SongCaptureFrame } from '../ay/ay-export-utils'; -import { computeEnvFmEnvelopePeriod, computeFmTonePeriod } from '../../../chips/ay/instrument'; +import { + envFmStepSource, + fmStepSource, + normalizePwmPeriods, + previousWaveformStepIndex, + pwmStartPeriod, + pwmStepPeriod, + resolveNextWaveformIndex, + sidStartPeriod, + sidStepPeriod, + sidStepSource, + syncBuzzerStepSource, + type TimerEffectStepSource +} from '../ay/ay-timer-effects'; import { encodeEventList } from './tmr-event-list'; import { encodeEventPsgApplyMask, @@ -28,6 +31,7 @@ import { TMR_TIMER_EVENT_STOP, type TmrEventItemRecord } from './tmr-format'; +import type { AyChipVariant } from '../../../chips/ay/ay-sample-lut'; export { encodeEventPsgApplyMask, @@ -53,6 +57,10 @@ export type TmrEncodeOptions = { chipIndex?: number; }; +function chipVariantFromOptions(options: Pick): AyChipVariant { + return options.isYm ? 'YM' : 'AY'; +} + function encodeExportTimerFrequencyHz(ymPeriod: number, options: TmrEncodeOptions): number { return exportTimerFrequencyStoredFromYmPeriod(ymPeriod, options.chipFrequency); } @@ -68,13 +76,6 @@ type PwmTimerState = WaveformChainState & { periodLow: number; }; -function normalizePwmPeriods(state: T): T { - return { - ...state, - periodLow: state.periodLow > 0 ? state.periodLow : state.period - }; -} - function isPwmActive(state: PwmTimerState): boolean { const normalized = normalizePwmPeriods(state); return normalized.pwm || normalized.period !== normalized.periodLow; @@ -95,47 +96,26 @@ function isPwmDutySweep(prev: PwmTimerState, next: PwmTimerState): boolean { if (previous.period === current.period && previous.periodLow === current.periodLow) { return false; } - return pwmDutyRatioFromPeriods(previous.period, previous.periodLow) !== - pwmDutyRatioFromPeriods(current.period, current.periodLow); -} - -function pwmEventStepPeriod(state: PwmTimerState, stepIndex: number): number { - const normalized = normalizePwmPeriods(state); - if (isPwmActive(normalized) && normalized.waveform.length >= 2) { - return stepIndex % 2 === 0 ? normalized.period : normalized.periodLow; - } - return normalized.period; -} - -function previousWaveformStepIndex(stepIndex: number, state: WaveformChainState): number { - if (stepIndex > 0) { - return stepIndex - 1; - } - for (let index = state.waveform.length - 1; index >= 0; index--) { - if (resolveNextWaveformIndex(index, state) === stepIndex) { - return index; - } - } - return state.waveform.length - 1; + return ( + pwmDutyRatioFromPeriods(previous.period, previous.periodLow) !== + pwmDutyRatioFromPeriods(current.period, current.periodLow) + ); } -function encodePwmEventTimerFrequency( +function stepFrequencyFromPeriod( + source: TimerEffectStepSource, stepIndex: number, - state: PwmTimerState, options: TmrEncodeOptions ): number { - const currentPeriod = pwmEventStepPeriod(state, stepIndex); - const previousPeriod = pwmEventStepPeriod(state, previousWaveformStepIndex(stepIndex, state)); + const state = { waveform: new Array(source.length), waveformLoop: source.loop }; + const currentPeriod = source.stepPeriod(stepIndex); + const previousPeriod = source.stepPeriod(previousWaveformStepIndex(stepIndex, state)); if (currentPeriod === previousPeriod) { return 0; } return encodeExportTimerFrequencyHz(currentPeriod, options); } -function sidStartPeriod(sid: HardwareSidState): number { - return timerPwmStepPeriod(sid.waveform[0] ?? 0, sid.period, sid.periodLow); -} - export function isSidPwmDutySweep(prev: HardwareSidState, next: HardwareSidState): boolean { if (!prev.enabled || !next.enabled) { return false; @@ -146,148 +126,11 @@ export function isSidPwmDutySweep(prev: HardwareSidState, next: HardwareSidState return isPwmDutySweep(prev, next); } -function sidEventStepPeriod(sid: HardwareSidState, stepIndex: number): number { - return timerPwmStepPeriod(sid.waveform[stepIndex] ?? 0, sid.period, sid.periodLow); -} - -export function encodeSidEventTimerFrequency( - stepIndex: number, - sid: HardwareSidState, - options: TmrEncodeOptions -): number { - const currentPeriod = sidEventStepPeriod(sid, stepIndex); - const previousPeriod = sidEventStepPeriod(sid, previousWaveformStepIndex(stepIndex, sid)); - if (currentPeriod === previousPeriod) { - return 0; - } - return encodeExportTimerFrequencyHz(currentPeriod, options); -} - -export function encodeSyncBuzzerEventTimerFrequency( - stepIndex: number, - syncbuzzer: HardwareSyncBuzzerState, - options: TmrEncodeOptions -): number { - // A duty sync-buzzer skews its retrigger period per waveform step (high vs - // low phase), exactly like FM/SID PWM. Without duty (period == periodLow) - // every step resolves to the same period, so all but the entry inherit (0). - return encodePwmEventTimerFrequency(stepIndex, syncbuzzer, options); -} - -function syncBuzzerStartPeriod(syncbuzzer: HardwareSyncBuzzerState): number { - return pwmEventStepPeriod(syncbuzzer, 0); -} - -function fmStartPeriod(fm: HardwareFmState): number { - return pwmEventStepPeriod(fm, 0); -} - -function envFmStartPeriod(envFm: HardwareEnvFmState): number { - return pwmEventStepPeriod(envFm, 0); -} - -type StepRegisterWrite = { register: number; value: number }; - -type TimerEffectStepSource = { - registerMask: number; - length: number; - loop: number; - writesAtStep(stepIndex: number): StepRegisterWrite[]; - stepTimerFrequency(stepIndex: number): number; -}; - type EffectChainStep = { sourceSteps: number[]; nextIndex: number; }; -function sidStepSource( - channelIndex: number, - sid: HardwareSidState, - options: TmrEncodeOptions -): TimerEffectStepSource { - const volumeReg = volumeRegisterIndex(channelIndex); - return { - registerMask: registerApplyMask(volumeReg), - length: sid.waveform.length, - loop: sid.waveformLoop, - writesAtStep: (stepIndex) => [ - { register: volumeReg, value: sidVolumeLevel(sid.waveform[stepIndex]!, sid.baseVolume) } - ], - stepTimerFrequency: (stepIndex) => encodeSidEventTimerFrequency(stepIndex, sid, options) - }; -} - -function fmStepSource( - channelIndex: number, - fm: HardwareFmState, - options: TmrEncodeOptions -): TimerEffectStepSource { - const toneReg = channelIndex * 2; - return { - registerMask: toneRegisterApplyMask(channelIndex), - length: fm.waveform.length, - loop: fm.waveformLoop, - writesAtStep: (stepIndex) => { - const psgData = new Array(AY_REGISTER_COUNT).fill(0); - const tonePeriod = computeFmTonePeriod( - fm.baseTonePeriod, - fm.waveform[stepIndex]!, - fm.fmOffsetMode - ); - writeTonePeriodToPsgData(psgData, channelIndex, tonePeriod); - return [ - { register: toneReg, value: psgData[toneReg]! }, - { register: toneReg + 1, value: psgData[toneReg + 1]! } - ]; - }, - stepTimerFrequency: (stepIndex) => encodePwmEventTimerFrequency(stepIndex, fm, options) - }; -} - -function envFmStepSource( - _channelIndex: number, - envFm: HardwareEnvFmState, - options: TmrEncodeOptions -): TimerEffectStepSource { - return { - registerMask: envelopePeriodRegisterApplyMask(), - length: envFm.waveform.length, - loop: envFm.waveformLoop, - writesAtStep: (stepIndex) => { - const psgData = new Array(AY_REGISTER_COUNT).fill(0); - const envelopePeriod = computeEnvFmEnvelopePeriod( - envFm.baseEnvelopePeriod, - envFm.waveform[stepIndex]!, - envFm.fmOffsetMode - ); - writeEnvelopePeriodToPsgData(psgData, envelopePeriod); - return [ - { register: 11, value: psgData[11]! }, - { register: 12, value: psgData[12]! } - ]; - }, - stepTimerFrequency: (stepIndex) => encodePwmEventTimerFrequency(stepIndex, envFm, options) - }; -} - -function syncBuzzerStepSource( - _channelIndex: number, - syncbuzzer: HardwareSyncBuzzerState, - options: TmrEncodeOptions -): TimerEffectStepSource { - return { - registerMask: envelopeShapeRegisterApplyMask(), - length: syncbuzzer.waveform.length, - loop: syncbuzzer.waveformLoop, - writesAtStep: (stepIndex) => [ - { register: ENVELOPE_SHAPE_REGISTER, value: (syncbuzzer.waveform[stepIndex] ?? 0) & 0xf } - ], - stepTimerFrequency: (stepIndex) => - encodeSyncBuzzerEventTimerFrequency(stepIndex, syncbuzzer, options) - }; -} - function sourceNextStepIndex(stepIndex: number, source: TimerEffectStepSource): number { return resolveNextWaveformIndex(stepIndex, { waveform: new Array(source.length), @@ -336,7 +179,8 @@ function buildEffectChainSteps(sources: TimerEffectStepSource[]): EffectChainSte function appendEffectStepSources( eventItems: EventItem[], channelIndex: number, - sources: TimerEffectStepSource[] + sources: TimerEffectStepSource[], + options: TmrEncodeOptions ): number { const startIndex = eventItems.length; const chainSteps = buildEffectChainSteps(sources); @@ -353,7 +197,7 @@ function appendEffectStepSources( for (const write of source.writesAtStep(sourceStep)) { psgData[write.register] = write.value; } - const stepFrequency = source.stepTimerFrequency(sourceStep); + const stepFrequency = stepFrequencyFromPeriod(source, sourceStep, options); if (timerFrequency === 0 && stepFrequency !== 0) { timerFrequency = stepFrequency; } @@ -376,9 +220,12 @@ function appendSidEventChain( sid: HardwareSidState, options: TmrEncodeOptions ): number { - return appendEffectStepSources(eventItems, channelIndex, [ - sidStepSource(channelIndex, sid, options) - ]); + return appendEffectStepSources( + eventItems, + channelIndex, + [sidStepSource(channelIndex, sid, chipVariantFromOptions(options))], + options + ); } function appendFmEventChain( @@ -387,9 +234,12 @@ function appendFmEventChain( fm: HardwareFmState, options: TmrEncodeOptions ): number { - return appendEffectStepSources(eventItems, channelIndex, [ - fmStepSource(channelIndex, fm, options) - ]); + return appendEffectStepSources( + eventItems, + channelIndex, + [fmStepSource(channelIndex, fm)], + options + ); } function appendEnvFmEventChain( @@ -398,9 +248,7 @@ function appendEnvFmEventChain( envFm: HardwareEnvFmState, options: TmrEncodeOptions ): number { - return appendEffectStepSources(eventItems, channelIndex, [ - envFmStepSource(channelIndex, envFm, options) - ]); + return appendEffectStepSources(eventItems, channelIndex, [envFmStepSource(envFm)], options); } type ChannelEffect = { @@ -428,13 +276,13 @@ function eventChainHasTimerFrequencies( } function sidEventChainTimingKey(sid: HardwareSidState): string { - return eventChainHasTimerFrequencies(sid, (stepIndex) => sidEventStepPeriod(sid, stepIndex)) + return eventChainHasTimerFrequencies(sid, (stepIndex) => sidStepPeriod(sid, stepIndex)) ? timerPeriodKey(sid) : ''; } function pwmEventChainTimingKey(state: PwmTimerState): string { - return eventChainHasTimerFrequencies(state, (stepIndex) => pwmEventStepPeriod(state, stepIndex)) + return eventChainHasTimerFrequencies(state, (stepIndex) => pwmStepPeriod(state, stepIndex)) ? timerPeriodKey(state) : ''; } @@ -462,7 +310,10 @@ function fmEventChainCacheKey(channelIndex: number, fm: HardwareFmState): string } function envFmEventChainCacheKey(channelIndex: number, envFm: HardwareEnvFmState): string { - return eventChainCacheKey(envFmEventChainKey(channelIndex, envFm), pwmEventChainTimingKey(envFm)); + return eventChainCacheKey( + envFmEventChainKey(channelIndex, envFm), + pwmEventChainTimingKey(envFm) + ); } function buildChannelEffects( @@ -479,16 +330,16 @@ function buildChannelEffects( const { syncbuzzer, sid, fm, envFm } = states; if (syncbuzzer.enabled) { effects.push({ - source: syncBuzzerStepSource(channelIndex, syncbuzzer, options), + source: syncBuzzerStepSource(syncbuzzer), configKey: syncBuzzerEventChainKey(channelIndex, syncbuzzer), - startPeriod: syncBuzzerStartPeriod(syncbuzzer), + startPeriod: pwmStartPeriod(syncbuzzer), periodKey: timerPeriodKey(syncbuzzer), timingKey: pwmEventChainTimingKey(syncbuzzer) }); } if (sid.enabled) { effects.push({ - source: sidStepSource(channelIndex, sid, options), + source: sidStepSource(channelIndex, sid, chipVariantFromOptions(options)), configKey: sidEventChainKey(channelIndex, sid), startPeriod: sidStartPeriod(sid), periodKey: timerPeriodKey(sid), @@ -497,18 +348,18 @@ function buildChannelEffects( } if (fm.enabled) { effects.push({ - source: fmStepSource(channelIndex, fm, options), + source: fmStepSource(channelIndex, fm), configKey: fmEventChainKey(channelIndex, fm), - startPeriod: fmStartPeriod(fm), + startPeriod: pwmStartPeriod(fm), periodKey: timerPeriodKey(fm), timingKey: pwmEventChainTimingKey(fm) }); } if (envFm.enabled) { effects.push({ - source: envFmStepSource(channelIndex, envFm, options), + source: envFmStepSource(envFm), configKey: envFmEventChainKey(channelIndex, envFm), - startPeriod: envFmStartPeriod(envFm), + startPeriod: pwmStartPeriod(envFm), periodKey: timerPeriodKey(envFm), timingKey: pwmEventChainTimingKey(envFm) }); @@ -536,7 +387,8 @@ function getOrCreateMergedEventChain( eventItems: EventItem[], chainStartByKey: Map, channelIndex: number, - effects: ChannelEffect[] + effects: ChannelEffect[], + options: TmrEncodeOptions ): number { const key = channelEffectCacheKey(effects); const existing = chainStartByKey.get(key); @@ -546,7 +398,8 @@ function getOrCreateMergedEventChain( const startIndex = appendEffectStepSources( eventItems, channelIndex, - effects.map((effect) => effect.source) + effects.map((effect) => effect.source), + options ); chainStartByKey.set(key, startIndex); return startIndex; @@ -565,10 +418,7 @@ export type EncodedTmrFiles = { eventItems: EventItem[]; }; -export function encodeTMR( - frames: SongCaptureFrame[], - options: TmrEncodeOptions -): EncodedTmrFiles { +export function encodeTMR(frames: SongCaptureFrame[], options: TmrEncodeOptions): EncodedTmrFiles { const eventItems: EventItem[] = []; const chainStartByKey = new Map(); const tmrFrames: Array<{ psgMask: number; timers: TimerCommand[] }> = []; @@ -611,11 +461,7 @@ export function encodeTMR( })); const previousMerged: Array< { setKey: string; timingKey: string; periodKey: string } | undefined - > = [ - undefined, - undefined, - undefined - ]; + > = [undefined, undefined, undefined]; let previousRegisters = new Array(AY_REGISTER_COUNT).fill(0); for (const frame of frames) { @@ -625,13 +471,7 @@ export function encodeTMR( for (let channelIndex = 0; channelIndex < 3; channelIndex++) { const sid = frame.sid[channelIndex]!; - const effectiveSid: HardwareSidState = sid.enabled - ? { - ...sid, - pwm: true, - periodLow: sid.periodLow > 0 ? sid.periodLow : sid.period - } - : sid; + const effectiveSid: HardwareSidState = sid.enabled ? normalizePwmPeriods(sid) : sid; const syncbuzzer: HardwareSyncBuzzerState = frame.syncbuzzer?.[channelIndex] ?? { enabled: false, pwm: false, @@ -668,7 +508,8 @@ export function encodeTMR( const prevFm = previousFm[channelIndex]!; const prevEnvFm = previousEnvFm[channelIndex]!; - const effectiveFm: HardwareFmState = fm.enabled && fm.pwm ? normalizePwmPeriods(fm) : fm; + const effectiveFm: HardwareFmState = + fm.enabled && fm.pwm ? normalizePwmPeriods(fm) : fm; const effectiveEnvFm: HardwareEnvFmState = envFm.enabled && envFm.pwm ? normalizePwmPeriods(envFm) : envFm; @@ -704,7 +545,8 @@ export function encodeTMR( eventItems, chainStartByKey, channelIndex, - effects + effects, + options ); timers.push({ frequency: encodeExportTimerFrequencyHz(effects[0]!.startPeriod, options), @@ -732,12 +574,15 @@ export function encodeTMR( ); timers.push({ frequency: encodeExportTimerFrequencyHz( - syncBuzzerStartPeriod(effectiveSyncbuzzer), + pwmStartPeriod(effectiveSyncbuzzer), options ), eventIndex }); - } else if (syncbuzzerPeriodChanged && isPwmDutySweep(prevSyncbuzzer, effectiveSyncbuzzer)) { + } else if ( + syncbuzzerPeriodChanged && + isPwmDutySweep(prevSyncbuzzer, effectiveSyncbuzzer) + ) { const eventIndex = appendSyncBuzzerEventChain( eventItems, channelIndex, @@ -746,7 +591,7 @@ export function encodeTMR( ); timers.push({ frequency: encodeExportTimerFrequencyHz( - syncBuzzerStartPeriod(effectiveSyncbuzzer), + pwmStartPeriod(effectiveSyncbuzzer), options ), eventIndex @@ -760,7 +605,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(effectiveSyncbuzzer.period, options), + frequency: encodeExportTimerFrequencyHz( + effectiveSyncbuzzer.period, + options + ), eventIndex }); } else { @@ -768,7 +616,9 @@ export function encodeTMR( } } else if (sid.enabled) { const sidWaveformChanged = - !!prevMergedState || !prevSid.enabled || !sidWaveformConfigEqual(prevSid, effectiveSid); + !!prevMergedState || + !prevSid.enabled || + !sidWaveformConfigEqual(prevSid, effectiveSid); const sidPeriodChanged = prevSid.period !== effectiveSid.period || prevSid.periodLow !== effectiveSid.periodLow; @@ -781,7 +631,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(sidStartPeriod(effectiveSid), options), + frequency: encodeExportTimerFrequencyHz( + sidStartPeriod(effectiveSid), + options + ), eventIndex }); } else if (sidPeriodChanged && isSidPwmDutySweep(prevSid, effectiveSid)) { @@ -792,7 +645,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(sidStartPeriod(effectiveSid), options), + frequency: encodeExportTimerFrequencyHz( + sidStartPeriod(effectiveSid), + options + ), eventIndex }); } else if (sidPeriodChanged) { @@ -812,9 +668,12 @@ export function encodeTMR( } } else if (fm.enabled) { const fmWaveformChanged = - !!prevMergedState || !prevFm.enabled || !fmWaveformConfigEqual(prevFm, effectiveFm); + !!prevMergedState || + !prevFm.enabled || + !fmWaveformConfigEqual(prevFm, effectiveFm); const fmPeriodChanged = - prevFm.period !== effectiveFm.period || prevFm.periodLow !== effectiveFm.periodLow; + prevFm.period !== effectiveFm.period || + prevFm.periodLow !== effectiveFm.periodLow; if (fmWaveformChanged) { const eventIndex = getOrCreateFmEventChain( eventItems, @@ -824,7 +683,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(fmStartPeriod(effectiveFm), options), + frequency: encodeExportTimerFrequencyHz( + pwmStartPeriod(effectiveFm), + options + ), eventIndex }); } else if (fmPeriodChanged && isPwmDutySweep(prevFm, effectiveFm)) { @@ -835,7 +697,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(fmStartPeriod(effectiveFm), options), + frequency: encodeExportTimerFrequencyHz( + pwmStartPeriod(effectiveFm), + options + ), eventIndex }); } else if (fmPeriodChanged) { @@ -856,7 +721,8 @@ export function encodeTMR( } else if (envFm.enabled) { const envFmWaveformChanged = !!prevMergedState || - !prevEnvFm.enabled || !envFmWaveformConfigEqual(prevEnvFm, effectiveEnvFm); + !prevEnvFm.enabled || + !envFmWaveformConfigEqual(prevEnvFm, effectiveEnvFm); const envFmPeriodChanged = prevEnvFm.period !== effectiveEnvFm.period || prevEnvFm.periodLow !== effectiveEnvFm.periodLow; @@ -869,7 +735,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(envFmStartPeriod(effectiveEnvFm), options), + frequency: encodeExportTimerFrequencyHz( + pwmStartPeriod(effectiveEnvFm), + options + ), eventIndex }); } else if (envFmPeriodChanged && isPwmDutySweep(prevEnvFm, effectiveEnvFm)) { @@ -880,7 +749,10 @@ export function encodeTMR( options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(envFmStartPeriod(effectiveEnvFm), options), + frequency: encodeExportTimerFrequencyHz( + pwmStartPeriod(effectiveEnvFm), + options + ), eventIndex }); } else if (envFmPeriodChanged) { @@ -1059,9 +931,12 @@ function appendSyncBuzzerEventChain( syncbuzzer: HardwareSyncBuzzerState, options: TmrEncodeOptions ): number { - return appendEffectStepSources(eventItems, channelIndex, [ - syncBuzzerStepSource(channelIndex, syncbuzzer, options) - ]); + return appendEffectStepSources( + eventItems, + channelIndex, + [syncBuzzerStepSource(syncbuzzer)], + options + ); } function getOrCreateSidEventChain( @@ -1118,17 +993,6 @@ function getOrCreateEnvFmEventChain( return startIndex; } -function resolveNextWaveformIndex(stepIndex: number, state: WaveformChainState): number { - const nextStep = stepIndex + 1; - if (nextStep < state.waveform.length) { - return nextStep; - } - if (state.waveformLoop >= 0 && state.waveformLoop < state.waveform.length) { - return state.waveformLoop; - } - return 0; -} - function writeHeader(view: DataView, frameCount: number, options: TmrEncodeOptions): void { view.setUint8(0, 0x54); view.setUint8(1, 0x4d); diff --git a/src/lib/services/file/tmr/tmr-export.ts b/src/lib/services/file/tmr/tmr-export.ts index 67d415aa..d02f9798 100644 --- a/src/lib/services/file/tmr/tmr-export.ts +++ b/src/lib/services/file/tmr/tmr-export.ts @@ -45,7 +45,9 @@ function downloadTmrPair(baseName: string, encoded: EncodedTmrFiles): void { ); } -export interface GenerateTMRBufferOptions extends GenerateCaptureOptions {} +export interface GenerateTMRBufferOptions extends GenerateCaptureOptions { + chipIndex?: number; +} export async function generateTMRFiles( project: Project, @@ -56,7 +58,8 @@ export async function generateTMRFiles( return encodeTMR(capture.frames, { chipFrequency: capture.chipFrequency, interruptFrequency: capture.interruptFrequency, - isYm: capture.isYm + isYm: capture.isYm, + chipIndex: options?.chipIndex }); } diff --git a/src/lib/services/file/vgm/vgm-shared-capture.ts b/src/lib/services/file/vgm/vgm-shared-capture.ts index 7f26e5d7..1f19e0e8 100644 --- a/src/lib/services/file/vgm/vgm-shared-capture.ts +++ b/src/lib/services/file/vgm/vgm-shared-capture.ts @@ -43,6 +43,13 @@ export type VgmProjectCapture = { orderIndices: number[]; }; +export type VgmProjectCaptureOptions = { + onProgress?: (progress: number, message: string) => void; + abortSignal?: AbortSignal; + ayModules?: PsgExportModules; + nesModules?: NesExportModules; +}; + function getPatterns(song: { patterns: Array<{ id: number }> }, patternOrder: number[]) { const patterns: Array<{ id: number; length: number }> = []; for (const patternId of patternOrder) { @@ -84,7 +91,7 @@ function resolveAyIsYm(song: { ); } -function assertCompatibleExportSongs( +function assertCompatibleInterruptFrequencies( project: Project, ayIndices: number[], nesIndices: number[] @@ -100,10 +107,20 @@ function assertCompatibleExportSongs( const interruptFrequency = resolveInterruptFrequency(songs[0]!); for (const song of songs) { if (resolveInterruptFrequency(song) !== interruptFrequency) { - throw new Error('VGM export requires all songs to use the same interrupt frequency'); + throw new Error('Shared export requires all songs to use the same interrupt frequency'); } } + return interruptFrequency; +} + +function assertCompatibleExportSongs( + project: Project, + ayIndices: number[], + nesIndices: number[] +): number { + const interruptFrequency = assertCompatibleInterruptFrequencies(project, ayIndices, nesIndices); + if (ayIndices.length > 1) { const first = project.songs[ayIndices[0]!]!; const second = project.songs[ayIndices[1]!]!; @@ -468,20 +485,20 @@ function createNesCaptureSlot( }; } -export async function captureVgmProject( +async function captureSharedProject( project: Project, ayIndices: number[], nesIndices: number[], - options?: { - onProgress?: (progress: number, message: string) => void; - abortSignal?: AbortSignal; - } + options: VgmProjectCaptureOptions | undefined, + requireVgmChipCompatibility: boolean ): Promise { if (ayIndices.length === 0 && nesIndices.length === 0) { throw new Error('No AY or NES songs to export'); } - const interruptFrequency = assertCompatibleExportSongs(project, ayIndices, nesIndices); + const interruptFrequency = requireVgmChipCompatibility + ? assertCompatibleExportSongs(project, ayIndices, nesIndices) + : assertCompatibleInterruptFrequencies(project, ayIndices, nesIndices); const patternOrder = project.patternOrder || [0]; const samplesPerInterrupt = Math.max( 1, @@ -489,8 +506,9 @@ export async function captureVgmProject( ); options?.onProgress?.(10, 'Loading capture modules...'); - const ayModules = ayIndices.length > 0 ? await loadAyModules() : null; - const nesModules = nesIndices.length > 0 ? await loadNesModules() : null; + const ayModules = ayIndices.length > 0 ? (options?.ayModules ?? (await loadAyModules())) : null; + const nesModules = + nesIndices.length > 0 ? (options?.nesModules ?? (await loadNesModules())) : null; if (options?.abortSignal?.aborted) { throw new Error('Export cancelled'); @@ -648,3 +666,20 @@ export async function captureVgmProject( orderIndices }; } + +export function captureSharedAyProject( + project: Project, + ayIndices: number[], + options?: VgmProjectCaptureOptions +): Promise { + return captureSharedProject(project, ayIndices, [], options, false); +} + +export function captureVgmProject( + project: Project, + ayIndices: number[], + nesIndices: number[], + options?: VgmProjectCaptureOptions +): Promise { + return captureSharedProject(project, ayIndices, nesIndices, options, true); +} diff --git a/src/lib/services/file/wav/wav-export.ts b/src/lib/services/file/wav/wav-export.ts index aad58fff..00ff1a8e 100644 --- a/src/lib/services/file/wav/wav-export.ts +++ b/src/lib/services/file/wav/wav-export.ts @@ -20,6 +20,7 @@ export type WavExportOptions = { onOutput?: (buffer: ArrayBuffer, filename: string) => void | Promise; resourceLoader?: ResourceLoader; getChip?: (chipType: string) => Chip | null; + disableDcFilter?: boolean; }; type ExportChannelDescriptor = { @@ -187,6 +188,8 @@ function encodeWAV( export type { ChipRenderer } from '../../../chips/base/renderer'; class WavExportService { + private disableDcFilter = false; + private async tryRenderSharedTimelineSlots( project: Project, nonempty: number[], @@ -228,7 +231,8 @@ class WavExportService { onProgress?.(2, 'Rendering songs with shared project playback timeline...'); const parts = await renderShared.call(renderer, project, sharedSlots, onProgress, { separateChannels, - loopCount: loops + loopCount: loops, + disableDcFilter: this.disableDcFilter }); return new Map(parts.map((p) => [p.songIndex, p.channels] as const)); } @@ -346,7 +350,8 @@ class WavExportService { }, { separateChannels: separateChannels ?? false, - loopCount: loops + loopCount: loops, + disableDcFilter: this.disableDcFilter } ); } @@ -404,6 +409,7 @@ class WavExportService { options?: WavExportOptions ): Promise { const { onOutput, resourceLoader, getChip } = options ?? {}; + this.disableDcFilter = options?.disableDcFilter ?? false; onProgress?.(0, 'Preparing export...'); if (abortSignal?.aborted) { diff --git a/src/lib/services/instrument/instrument-filter.ts b/src/lib/services/instrument/instrument-filter.ts index 9c0e9b90..ca838664 100644 --- a/src/lib/services/instrument/instrument-filter.ts +++ b/src/lib/services/instrument/instrument-filter.ts @@ -1,5 +1,5 @@ import type { Instrument, Song } from '../../models/song'; -import { getAllChips } from '../../chips/registry'; +import { CHIP_TYPES } from '../../chips/chip-registration'; export function resolveInstrumentChipType(instrument: Instrument): string { return instrument.chipType ?? 'ay'; @@ -39,7 +39,5 @@ export function getOrderedProjectChipTypes( for (const processor of chipProcessors) { types.add(processor.chip.type); } - return getAllChips() - .map((chip) => chip.type) - .filter((type) => types.has(type)); + return CHIP_TYPES.filter((type) => types.has(type)); } diff --git a/tests/lib/chips/ay/sid-waveform-volume.test.ts b/tests/lib/chips/ay/sid-waveform-volume.test.ts index 5d439f25..5a35f8b3 100644 --- a/tests/lib/chips/ay/sid-waveform-volume.test.ts +++ b/tests/lib/chips/ay/sid-waveform-volume.test.ts @@ -16,7 +16,8 @@ describe('sid waveform volume curve', () => { }); it('uses the same register volume formula as hardware export', () => { - expect(sidRegisterVolume(7, 10)).toBe(5); + expect(sidRegisterVolume(7, 10, 'AY')).toBe(4); + expect(sidRegisterVolume(7, 10, 'YM')).toBe(2); expect(sidRegisterVolume(15, 15)).toBe(15); }); diff --git a/tests/lib/config/export-formats.test.ts b/tests/lib/config/export-formats.test.ts index e89b27b0..6202cd2d 100644 --- a/tests/lib/config/export-formats.test.ts +++ b/tests/lib/config/export-formats.test.ts @@ -10,9 +10,11 @@ describe('export formats', () => { expect(labels).toContain('WAV'); expect(labels).toContain('PSG'); expect(labels).toContain('TMR'); + expect(labels).toContain('TAYM'); expect(labels).toContain('SNDH'); expect(labels).toContain('VGM'); expect(labels).not.toContain('PSG (ZIP)'); + expect(labels).not.toContain('TAYM (ZIP)'); }); it('shows PSG (ZIP) for multiple AY chips', () => { @@ -24,7 +26,9 @@ describe('export formats', () => { expect(labels).toContain('PSG (ZIP)'); expect(labels).toContain('TMR (ZIP)'); expect(labels).toContain('VGM'); + expect(labels).toContain('TAYM (ZIP)'); expect(labels).not.toContain('PSG'); + expect(labels).not.toContain('TAYM'); expect(labels).not.toContain('SNDH'); }); diff --git a/tests/lib/services/file/ay/ay-export-utils.test.ts b/tests/lib/services/file/ay/ay-export-utils.test.ts new file mode 100644 index 00000000..c39f5268 --- /dev/null +++ b/tests/lib/services/file/ay/ay-export-utils.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { + createTaymSampleCaptureTracker, + extractHardwareTaymSampleStates +} from '@/lib/services/file/ay/ay-export-utils'; +import { + computeSamplePitchScale, + resolveSamplePitchReferencePeriod +} from '@/lib/chips/ay/sample-region'; + +const CLOCK = 1_773_400; +const EFFECTIVE_TONE = 424; +const TIMER_EFFECT_KIND_VOLUME = 1; + +function registerState(baseVolume = 15) { + return { + channels: [ + { + timerEffects: { + sid: { enabled: true, kind: TIMER_EFFECT_KIND_VOLUME, baseVolume } + } + }, + { timerEffects: {} }, + { timerEffects: {} } + ] + }; +} + +function sampleState(overrides: Record = {}) { + return { + channelInstruments: [0, -1, -1], + channelSoundEnabled: [true, false, false], + channelMuted: [false, false, false], + channelCurrentNotes: [0, 0, 0], + currentTuningTable: [EFFECTIVE_TONE], + channelSamplePositions: [0, 0, 0], + aymFrequency: CLOCK, + instruments: [ + { + sampleData: [10, 20, 30, 40, 50], + sampleStart: 0, + sampleEnd: 4, + sampleLoopStart: 0, + sampleLoopEnabled: true, + sampleRate: 8_000 + } + ], + ...overrides + }; +} + +describe('extractHardwareTaymSampleStates', () => { + it('keeps the sample instance when a note does not restart sample playback', () => { + const tracker = createTaymSampleCaptureTracker(); + const state = sampleState(); + + const first = extractHardwareTaymSampleStates(state, registerState(), tracker, CLOCK, [ + true, + false, + false + ])[0]!; + state.channelSamplePositions[0] = 2; + const portamento = extractHardwareTaymSampleStates(state, registerState(), tracker, CLOCK, [ + false, + false, + false + ])[0]!; + const restart = extractHardwareTaymSampleStates(state, registerState(), tracker, CLOCK, [ + true, + false, + false + ])[0]!; + + expect(portamento.instanceId).toBe(first.instanceId); + expect(portamento.sampleBytes).toEqual(first.sampleBytes); + expect(restart.instanceId).not.toBe(first.instanceId); + expect(restart.sampleBytes).toEqual(first.sampleBytes); + }); + + it('rotates exported looped sample lanes around the current sample position', () => { + const tracker = createTaymSampleCaptureTracker(); + const state = sampleState({ + channelSamplePositions: [2, 0, 0], + instruments: [ + { + sampleData: [10, 20, 30, 40, 50], + sampleStart: 0, + sampleEnd: 4, + sampleLoopStart: 1, + sampleLoopEnabled: true, + sampleRate: 8_000 + } + ] + }); + + const sample = extractHardwareTaymSampleStates(state, registerState(), tracker, CLOCK, [ + true, + false, + false + ])[0]!; + + expect(sample.sampleBytes).toEqual([30, 40, 50, 20, 30, 40, 50]); + expect(sample.loopIndex).toBe(3); + }); + + it('uses 44.1 kHz as the export fallback for missing sample rates', () => { + const tracker = createTaymSampleCaptureTracker(); + const state = sampleState({ + instruments: [ + { + sampleData: [10, 20, 30, 40, 50], + sampleStart: 0, + sampleEnd: 4, + sampleLoopStart: 0, + sampleLoopEnabled: true + } + ] + }); + + const sample = extractHardwareTaymSampleStates(state, registerState(), tracker, CLOCK, [ + true, + false, + false + ])[0]!; + const expectedRate = + 44_100 * + computeSamplePitchScale(resolveSamplePitchReferencePeriod(CLOCK), EFFECTIVE_TONE); + + expect(sample.rateHz).toBeCloseTo(expectedRate); + }); +}); diff --git a/tests/lib/services/file/taym-export.test.ts b/tests/lib/services/file/taym-export.test.ts new file mode 100644 index 00000000..b1c6bd65 --- /dev/null +++ b/tests/lib/services/file/taym-export.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { Project } from '@/lib/models/project'; +import { Song } from '@/lib/models/song'; +import { resolveSingleAyTaymSongIndex } from '@/lib/services/file/taym/taym-export'; + +function song(chipType?: string): Song { + const result = new Song(); + result.chipType = chipType; + return result; +} + +describe('resolveSingleAyTaymSongIndex', () => { + it('uses the sole AY song index in mixed-chip projects', () => { + const project = new Project('mixed', '', [song('saa'), song('ay')]); + + expect(resolveSingleAyTaymSongIndex(project, 0)).toBe(1); + }); + + it('keeps the requested song index when there are multiple AY songs', () => { + const project = new Project('multi-ay', '', [song('ay'), song('ay')]); + + expect(resolveSingleAyTaymSongIndex(project, 0)).toBe(0); + }); +}); diff --git a/tests/lib/services/file/taym/codec.test.ts b/tests/lib/services/file/taym/codec.test.ts new file mode 100644 index 00000000..5ac718ea --- /dev/null +++ b/tests/lib/services/file/taym/codec.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { readTaym, writeTaym } from '@/lib/services/file/taym/codec'; +import { + makeActn, + makeChip, + makeLane, + makeMods, + makeTaym, + makeTimr, + makeTlan, + makeTrak, + type Taym +} from '@/lib/services/file/taym/model'; +import * as spec from '@/lib/services/file/taym/spec'; +import { check, validate } from '@/lib/services/file/taym/validate'; + +// Generated once from the Python reference: +// python -m taym sample (build() == write_taym(build_model())) +// Pins byte-for-byte compatibility with the spec's canonical witness. +const PYTHON_WITNESS = new Uint8Array([ + 84, 65, 89, 77, 1, 0, 16, 0, 0, 0, 0, 0, 214, 0, 0, 0, 84, 82, 65, 75, 16, 0, 0, 0, 0, 0, 50, 0, 2, + 0, 0, 0, 255, 255, 255, 255, 1, 1, 0, 0, 67, 72, 73, 80, 32, 0, 0, 0, 88, 15, 27, 0, 1, 0, 0, 0, + 65, 89, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 84, 73, 77, 82, 6, 0, 0, + 0, 16, 0, 0, 1, 0, 0, 77, 79, 68, 83, 32, 0, 0, 0, 25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 65, 67, 84, 78, 6, 0, 0, 0, 0, 0, 0, 0, 8, 1, + 76, 65, 78, 69, 16, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 84, 76, 65, 78, 16, + 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 86, 85, 48, 56, 2, 0, 0, 0, 15, 0, 86, + 85, 49, 54, 0, 0, 0, 0, 86, 85, 51, 50, 8, 0, 0, 0, 25, 0, 0, 0, 75, 0, 0, 0 +]); + +function buildCanonicalSample(): Taym { + return makeTaym(makeTrak(50.0, 2, spec.NO_LOOP), { + chips: [makeChip(1773400, { chipTypeId: spec.CHIP_TYPE_AY, name: 'AY' })], + timers: [makeTimr(0, spec.CLOCK_CHIP_PERIOD, 16)], + mods: [ + makeMods(spec.CMD_START, { + baseTimerValue: 25, + timerLaneRef: 0, + firstAction: 0, + actionCount: 1 + }), + makeMods(spec.CMD_STOP) + ], + actions: [makeActn(0x08, spec.SRC_BIND_LANE, 0)], + lanes: [makeLane(spec.VT_U8, 0, 2, 0)], + tlanes: [makeTlan(spec.TM_ABSOLUTE, 0, 2, 0)], + vu08: [15, 0], + vu32: [25, 75] + }); +} + +describe('taym codec', () => { + it('writes the canonical sample byte-for-byte like the Python witness', () => { + const bytes = new Uint8Array(writeTaym(buildCanonicalSample())); + expect(Array.from(bytes)).toEqual(Array.from(PYTHON_WITNESS)); + }); + + it('round-trips writeTaym(readTaym(x)) === x', () => { + const original = new Uint8Array(writeTaym(buildCanonicalSample())); + const reparsed = new Uint8Array(writeTaym(readTaym(original))); + expect(Array.from(reparsed)).toEqual(Array.from(original)); + }); + + it('parses the canonical sample into the expected model', () => { + const model = readTaym(PYTHON_WITNESS); + expect(model.trak.frameRateHz).toBe(50); + expect(model.trak.frameCount).toBe(2); + expect(model.trak.loopFrame).toBe(spec.NO_LOOP); + expect(model.chips).toHaveLength(1); + expect(model.chips[0].clockHz).toBe(1773400); + expect(model.chips[0].name).toBe('AY'); + expect(model.timers[0].clockDivider).toBe(16); + expect(model.mods[0].command).toBe(spec.CMD_START); + expect(model.mods[1].command).toBe(spec.CMD_STOP); + expect(model.actions[0].targetId).toBe(0x08); + expect(model.vu08).toEqual([15, 0]); + expect(model.vu32).toEqual([25, 75]); + }); + + it('validates the canonical sample as clean', () => { + expect(validate(buildCanonicalSample())).toEqual([]); + expect(() => check(buildCanonicalSample())).not.toThrow(); + }); + + it('preserves frame-data chunks through a round-trip', () => { + const psg = new Uint8Array([0x50, 0x53, 0x47, 0x1a, 0xff, 0xfd]); + const taym = makeTaym(makeTrak(50, 1), { + chips: [makeChip(1773400, { name: 'AY', frameDataTag: 'PSG0' })], + frameData: { PSG0: psg } + }); + const reparsed = readTaym(writeTaym(taym)); + expect(reparsed.chips[0].frameDataTag).toBe('PSG0'); + expect(Array.from(reparsed.frameData.PSG0)).toEqual(Array.from(psg)); + }); + + it('reports chips that reference missing frame-data payloads', () => { + const taym = makeTaym(makeTrak(50, 1), { + chips: [makeChip(1773400, { name: 'AY', frameDataTag: 'PSG0' })] + }); + + expect(validate(taym)).toContain('S6.2: CHIP[0] frame data PSG0 missing payload'); + expect(() => check(taym)).toThrow(/missing payload/); + }); +}); diff --git a/tests/lib/services/file/taym/foreground-psg.test.ts b/tests/lib/services/file/taym/foreground-psg.test.ts new file mode 100644 index 00000000..9280ce7f --- /dev/null +++ b/tests/lib/services/file/taym/foreground-psg.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { encodeForegroundPsgFrameData } from '@/lib/services/file/taym/foreground-psg'; +import { AY_REGISTER_COUNT } from '@/lib/services/file/ay/ay-export-utils'; + +function decodePsgFrameWrites(buffer: ArrayBuffer): Array> { + const bytes = new Uint8Array(buffer); + const frames: Array> = []; + let offset = 16; + + while (offset < bytes.length) { + const marker = bytes[offset++]!; + if (marker === 0xfd) { + break; + } + if (marker !== 0xff) { + throw new Error(`Unexpected PSG marker 0x${marker.toString(16)}`); + } + + const writes: Array<[number, number]> = []; + while (offset < bytes.length && bytes[offset] !== 0xff && bytes[offset] !== 0xfd) { + const register = bytes[offset++]!; + const value = bytes[offset++]!; + writes.push([register, value]); + } + frames.push(writes); + } + + return frames; +} + +function replayForegroundPsg(buffer: ArrayBuffer): number[][] { + const registers = new Array(AY_REGISTER_COUNT).fill(0); + return decodePsgFrameWrites(buffer).map((writes) => { + for (const [register, value] of writes) { + registers[register] = value; + } + return [...registers]; + }); +} + +function expectUnownedRegistersToMatch( + registerFrames: number[][], + ownedRegistersPerFrame: number[][] +): void { + const foregroundFrames = replayForegroundPsg( + encodeForegroundPsgFrameData(registerFrames, ownedRegistersPerFrame) + ); + + for (let frame = 0; frame < registerFrames.length; frame++) { + const owned = new Set(ownedRegistersPerFrame[frame] ?? []); + for (let register = 0; register < AY_REGISTER_COUNT; register++) { + if (!owned.has(register)) { + expect(foregroundFrames[frame][register]).toBe(registerFrames[frame][register]); + } + } + } +} + +describe('encodeForegroundPsgFrameData', () => { + it('restores a timer-owned register when it becomes unowned again', () => { + const silent = new Array(AY_REGISTER_COUNT).fill(0); + const timerOwned = [...silent]; + timerOwned[8] = 15; + const registerFrames = [silent, timerOwned, silent]; + const ownedRegistersPerFrame = [[], [8], []]; + + const frames = decodePsgFrameWrites( + encodeForegroundPsgFrameData(registerFrames, ownedRegistersPerFrame) + ); + + expect(frames[1]).not.toContainEqual([8, 15]); + expect(frames[2]).toContainEqual([8, 0]); + expectUnownedRegistersToMatch(registerFrames, ownedRegistersPerFrame); + }); + + it('re-emits a released register even when its background value is unchanged across the owned span', () => { + const base = new Array(AY_REGISTER_COUNT).fill(0); + base[8] = 15; + const registerFrames = [base, base, base]; + const ownedRegistersPerFrame = [[], [8], []]; + + const frames = decodePsgFrameWrites( + encodeForegroundPsgFrameData(registerFrames, ownedRegistersPerFrame) + ); + + expect(frames[0]).toContainEqual([8, 15]); + expect(frames[1]).not.toContainEqual([8, 15]); + expect(frames[2]).toContainEqual([8, 15]); + expectUnownedRegistersToMatch(registerFrames, ownedRegistersPerFrame); + }); + + it('keeps foreground playback aligned with captured registers whenever timer ownership is absent', () => { + const frame0 = new Array(AY_REGISTER_COUNT).fill(0); + frame0[0] = 1; + frame0[8] = 7; + const frame1 = [...frame0]; + frame1[2] = 10; + frame1[8] = 15; + const frame2 = [...frame1]; + frame2[0] = 2; + frame2[8] = 3; + const frame3 = [...frame2]; + const registerFrames = [frame0, frame1, frame2, frame3]; + const ownedRegistersPerFrame = [[], [8], [8], []]; + + expectUnownedRegistersToMatch(registerFrames, ownedRegistersPerFrame); + }); +}); diff --git a/tests/lib/services/file/taym/taym-builder.test.ts b/tests/lib/services/file/taym/taym-builder.test.ts new file mode 100644 index 00000000..15e90a73 --- /dev/null +++ b/tests/lib/services/file/taym/taym-builder.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { buildTaymFromCapture, ST_MONO_LAYOUT } from '@/lib/services/file/taym/taym-builder'; +import { readTaym, writeTaym } from '@/lib/services/file/taym/codec'; +import { readTaymFile } from '@/lib/services/file/taym/taym-reader'; +import { check } from '@/lib/services/file/taym/validate'; +import * as spec from '@/lib/services/file/taym/spec'; +import { encodePSG, type SongCaptureResult } from '@/lib/services/file/ay/psg-export'; +import { + createDisabledTimerCaptureStates, + type SongCaptureFrame +} from '@/lib/services/file/ay/ay-export-utils'; + +function frame(registers: number[]): SongCaptureFrame { + return { registers, ...createDisabledTimerCaptureStates() }; +} + +function capture(frames: SongCaptureFrame[], isYm = false): SongCaptureResult { + return { + frames, + chipFrequency: 1773400, + interruptFrequency: 50, + isYm + }; +} + +describe('buildTaymFromCapture', () => { + const frames = [ + frame([0xfd, 0x00, 0, 0, 0, 0, 0, 0b111110, 0x0f, 0, 0, 0, 0, 0]), + frame([0xfd, 0x00, 0, 0, 0, 0, 0, 0b111110, 0x00, 0, 0, 0, 0, 0]) + ]; + + it('writes a single AY chip with frame_rate, frame_count and clock from the capture', () => { + const taym = buildTaymFromCapture(capture(frames)); + expect(taym.trak.frameRateHz).toBe(50); + expect(taym.trak.frameCount).toBe(2); + expect(taym.chips).toHaveLength(1); + expect(taym.chips[0].clockHz).toBe(1773400); + expect(taym.chips[0].chipTypeId).toBe(spec.CHIP_TYPE_AY); + expect(taym.chips[0].variant).toBe(spec.AY_VARIANT_AY); + expect(taym.chips[0].frameDataTag).toBe('PSG0'); + }); + + it('selects the YM variant for YM captures', () => { + const taym = buildTaymFromCapture(capture(frames, true)); + expect(taym.chips[0].variant).toBe(spec.AY_VARIANT_YM); + }); + + it('embeds the PSG frame-data identical to encodePSG', () => { + const taym = buildTaymFromCapture(capture(frames)); + const expected = new Uint8Array(encodePSG(frames.map((f) => f.registers))); + expect(Array.from(taym.frameData.PSG0)).toEqual(Array.from(expected)); + }); + + it('produces a valid, writable TAYM file', () => { + const taym = buildTaymFromCapture(capture(frames)); + expect(() => check(taym)).not.toThrow(); + const buffer = writeTaym(taym); + expect(() => readTaymFile(buffer)).not.toThrow(); + }); + + it('maps the stereo layout into CHIP.config', () => { + expect(buildTaymFromCapture(capture(frames)).chips[0].config).toBe(spec.AY_LAYOUT_ABC); + expect( + buildTaymFromCapture(capture(frames), { metadata: { stereoLayout: 'ACB' } }).chips[0].config + ).toBe(spec.AY_LAYOUT_ACB); + expect( + buildTaymFromCapture(capture(frames), { metadata: { stereoLayout: 'CAB' } }).chips[0].config + ).toBe(spec.AY_LAYOUT_CAB); + expect( + buildTaymFromCapture(capture(frames), { metadata: { stereoLayout: 'mono' } }).chips[0].config + ).toBe(spec.AY_LAYOUT_MONO); + expect( + buildTaymFromCapture(capture(frames), { metadata: { stereoLayout: ST_MONO_LAYOUT } }).chips[0] + .config + ).toBe(spec.AY_LAYOUT_ST_MONO); + }); + + it('writes title, author, tuning table and instrument names into an INFO chunk', () => { + const taym = buildTaymFromCapture(capture(frames), { + metadata: { + title: 'My Song', + author: 'Me', + tuningTable: 'ProTracker 3.3', + instruments: ['Lead', 'Bass', ''] + } + }); + expect(taym.info).toEqual({ + title: 'My Song', + author: 'Me', + tuning: 'ProTracker 3.3', + instruments: 'Lead, Bass' + }); + expect(() => check(taym)).not.toThrow(); + const reparsed = readTaym(writeTaym(taym)); + expect(reparsed.info).toEqual(taym.info); + }); + + it('omits empty metadata entries', () => { + const taym = buildTaymFromCapture(capture(frames), { + metadata: { title: '', author: undefined, instruments: ['', ''] } + }); + expect(taym.info).toEqual({}); + }); +}); diff --git a/tests/lib/services/file/taym/taym-export-timers.test.ts b/tests/lib/services/file/taym/taym-export-timers.test.ts new file mode 100644 index 00000000..cc7e29cf --- /dev/null +++ b/tests/lib/services/file/taym/taym-export-timers.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTaymTimerTables, + TAYM_TIMER_DIVIDER +} from '@/lib/services/file/taym/taym-export-timers'; +import { buildTaymFromCapture } from '@/lib/services/file/taym/taym-builder'; +import { check } from '@/lib/services/file/taym/validate'; +import { writeTaym } from '@/lib/services/file/taym/codec'; +import * as spec from '@/lib/services/file/taym/spec'; +import type { SongCaptureResult } from '@/lib/services/file/ay/psg-export'; +import { + createDisabledTimerCaptureStates, + ENVELOPE_SHAPE_REGISTER, + volumeRegisterIndex, + type SongCaptureFrame +} from '@/lib/services/file/ay/ay-export-utils'; + +function baseFrame(): SongCaptureFrame { + return { + registers: [0, 0, 0, 0, 0, 0, 0, 0b00111111, 0, 0x1f, 0, 13, 0, 0xff], + ...createDisabledTimerCaptureStates() + }; +} + +function syncBuzzerFrame(channel: number, period: number, periodLow: number): SongCaptureFrame { + const frame = baseFrame(); + frame.syncbuzzer[channel] = { + enabled: true, + pwm: true, + period, + periodLow, + waveform: [13, 9], + waveformLoop: 0 + }; + return frame; +} + +function capture(frames: SongCaptureFrame[], isYm = false): SongCaptureResult { + return { frames, chipFrequency: 1773400, interruptFrequency: 50, isYm }; +} + +describe('buildTaymTimerTables', () => { + it('emits no timers when no channel hosts an effect', () => { + const tables = buildTaymTimerTables([baseFrame(), baseFrame()]); + expect(tables.timers).toHaveLength(0); + expect(tables.mods).toHaveLength(0); + expect(tables.ownedRegistersPerFrame).toEqual([[], []]); + }); + + it('maps a sync-buzzer channel to one CHIP_PERIOD timer owning R13', () => { + const frames = [syncBuzzerFrame(1, 869, 803), syncBuzzerFrame(1, 869, 803)]; + const tables = buildTaymTimerTables(frames); + + expect(tables.timers).toHaveLength(1); + expect(tables.timers[0].clockMode).toBe(spec.CLOCK_CHIP_PERIOD); + expect(tables.timers[0].clockDivider).toBe(TAYM_TIMER_DIVIDER); + + const start = tables.mods[0]; + expect(start.command).toBe(spec.CMD_START); + expect(start.baseTimerValue).toBe(869); + const action = tables.actions[start.firstAction]; + expect(action.targetId).toBe(ENVELOPE_SHAPE_REGISTER); + expect(action.sourceMode).toBe(spec.SRC_BIND_LANE); + const lane = tables.lanes[action.operand]; + expect(tables.vu08.slice(lane.valueOffset, lane.valueOffset + lane.length)).toEqual([ + 13, 9 + ]); + + const tlan = tables.tlanes[start.timerLaneRef]; + expect(tables.vu32.slice(tlan.valueOffset, tlan.valueOffset + tlan.length)).toEqual([ + 869, 803 + ]); + + expect(tables.ownedRegistersPerFrame[0]).toContain(ENVELOPE_SHAPE_REGISTER); + }); + + it('emits START on (re)arm, EMPTY when steady, STOP on release', () => { + const frames = [ + baseFrame(), + syncBuzzerFrame(1, 869, 803), + syncBuzzerFrame(1, 869, 803), + baseFrame() + ]; + const tables = buildTaymTimerTables(frames); + const nt = tables.timers.length; + const cmds = frames.map((_f, frame) => tables.mods[frame * nt].command); + expect(cmds).toEqual([spec.CMD_EMPTY, spec.CMD_START, spec.CMD_EMPTY, spec.CMD_STOP]); + }); + + it('emits MODULATE when only the period sweeps', () => { + const frames = [syncBuzzerFrame(1, 869, 803), syncBuzzerFrame(1, 936, 736)]; + const tables = buildTaymTimerTables(frames); + const nt = tables.timers.length; + expect(tables.mods[0].command).toBe(spec.CMD_START); + expect(tables.mods[1 * nt].command).toBe(spec.CMD_MODULATE); + expect(tables.mods[1 * nt].baseTimerValue).toBe(936); + }); + + it('owns the volume register for a SID channel', () => { + const frame = baseFrame(); + frame.sid[0] = { + enabled: true, + pwm: false, + period: 500, + periodLow: 500, + baseVolume: 15, + waveform: [15, 0], + waveformLoop: 0 + }; + const tables = buildTaymTimerTables([frame, frame]); + expect(tables.actions[0].targetId).toBe(volumeRegisterIndex(0)); + expect(tables.ownedRegistersPerFrame[0]).toContain(volumeRegisterIndex(0)); + }); + + it('quantizes SID volume lanes with the selected chip DAC curve', () => { + const frame = baseFrame(); + frame.sid[0] = { + enabled: true, + pwm: false, + period: 500, + periodLow: 500, + baseVolume: 10, + waveform: [7], + waveformLoop: 0 + }; + + const ayTables = buildTaymTimerTables([frame], { chipVariant: 'AY' }); + const ayLane = ayTables.lanes[ayTables.actions[0].operand]; + expect(ayTables.vu08.slice(ayLane.valueOffset, ayLane.valueOffset + ayLane.length)).toEqual( + [4] + ); + + const ymTables = buildTaymTimerTables([frame], { chipVariant: 'YM' }); + const ymLane = ymTables.lanes[ymTables.actions[0].operand]; + expect(ymTables.vu08.slice(ymLane.valueOffset, ymLane.valueOffset + ymLane.length)).toEqual( + [2] + ); + }); + + it('produces a valid TAYM file whose PSG omits timer-owned registers', () => { + const frames = [syncBuzzerFrame(1, 869, 803), syncBuzzerFrame(1, 936, 736)]; + const taym = buildTaymFromCapture(capture(frames)); + expect(() => check(taym)).not.toThrow(); + expect(() => writeTaym(taym)).not.toThrow(); + expect(taym.timers).toHaveLength(1); + expect(taym.chips[0].frameDataTag).toBe('PSG0'); + }); + + it('uses capture chip variant when exporting SID timer lanes', () => { + const frame = baseFrame(); + frame.sid[0] = { + enabled: true, + pwm: false, + period: 500, + periodLow: 500, + baseVolume: 10, + waveform: [7], + waveformLoop: 0 + }; + + const taym = buildTaymFromCapture(capture([frame], true)); + const lane = taym.lanes[taym.actions[0].operand]; + expect(taym.vu08.slice(lane.valueOffset, lane.valueOffset + lane.length)).toEqual([2]); + }); +}); diff --git a/tests/lib/services/file/taym/taym-samples.test.ts b/tests/lib/services/file/taym/taym-samples.test.ts new file mode 100644 index 00000000..8ad7b932 --- /dev/null +++ b/tests/lib/services/file/taym/taym-samples.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'vitest'; +import { buildTaymSampleTables } from '@/lib/services/file/taym/taym-samples'; +import { buildTaymFromCapture } from '@/lib/services/file/taym/taym-builder'; +import { check } from '@/lib/services/file/taym/validate'; +import { writeTaym } from '@/lib/services/file/taym/codec'; +import * as spec from '@/lib/services/file/taym/spec'; +import type { SongCaptureResult } from '@/lib/services/file/ay/psg-export'; +import { + createDisabledTaymSampleStates, + createDisabledTimerCaptureStates, + SAMPLE_NO_LOOP, + volumeRegisterIndex, + type HardwareTaymSampleState, + type SongCaptureFrame +} from '@/lib/services/file/ay/ay-export-utils'; + +function baseFrame(): SongCaptureFrame { + return { + registers: [0, 0, 0, 0, 0, 0, 0, 0b00111111, 0, 0, 0, 0, 0, 0], + ...createDisabledTimerCaptureStates(), + samples: createDisabledTaymSampleStates() + }; +} + +const CLOCK = 1773400; +const TIMER_DIVIDER = 8; + +function rateForPeriod(period: number): number { + return CLOCK / (TIMER_DIVIDER * period); +} + +function sampleFrame( + channel: number, + sample: Partial & Pick +): SongCaptureFrame { + const frame = baseFrame(); + frame.samples![channel] = { + enabled: true, + sampleBytes: [255, 192, 128, 64, 0], + loopIndex: 0, + rateHz: rateForPeriod(40), + volume: 15, + ...sample + }; + return frame; +} + +function capture(frames: SongCaptureFrame[]): SongCaptureResult { + return { + frames, + orderIndices: [], + instruments: [], + chipFrequency: CLOCK, + interruptFrequency: 50, + isYm: false + }; +} + +function sliceOf(tables: ReturnType, mods: { firstAction: number }) { + return tables.actions.slice(mods.firstAction, mods.firstAction + 2); +} + +describe('buildTaymSampleTables', () => { + it('emits no timers when no channel plays a sample', () => { + const tables = buildTaymSampleTables([baseFrame(), baseFrame()]); + expect(tables.timers).toHaveLength(0); + expect(tables.mods).toHaveLength(0); + expect(tables.ownedRegistersPerFrame).toEqual([[], []]); + }); + + it('maps a sample channel to a timer with a paired (amp-reg, 0x80) slice', () => { + const frames = [sampleFrame(0, { instanceId: 1 }), sampleFrame(0, { instanceId: 1 })]; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + + expect(tables.timers).toHaveLength(1); + expect(tables.timers[0].clockMode).toBe(spec.CLOCK_ABS_RATE_HZ); + expect(tables.timers[0].clockDivider).toBe(0); + + const start = tables.mods[0]; + expect(start.command).toBe(spec.CMD_START); + expect(start.baseTimerValue).toBe(spec.toFix16(rateForPeriod(40))); + expect(start.timerLaneRef).toBe(spec.TLAN_NONE); + expect(start.actionCount).toBe(2); + expect(tables.tlanes).toHaveLength(0); + expect(tables.vu32).toHaveLength(0); + + const [ampAction, ampltdAction] = sliceOf(tables, start); + expect(ampAction.targetId).toBe(volumeRegisterIndex(0)); + expect(ampAction.sourceMode).toBe(spec.SRC_INLINE_VALUE); + expect(ampAction.operand).toBe(15); + expect(ampltdAction.targetId).toBe(spec.TGT_SAMPLE_AMPLITUDE); + expect(ampltdAction.sourceMode).toBe(spec.SRC_BIND_LANE); + expect(ampAction.targetId).toBeLessThan(ampltdAction.targetId); + + const lane = tables.lanes[ampltdAction.operand]; + expect(lane.valueType).toBe(spec.VT_U8); + expect(lane.loopIndex).toBe(0); + expect(tables.vu08.slice(lane.valueOffset, lane.valueOffset + lane.length)).toEqual([ + 255, 192, 128, 64, 0 + ]); + + expect(tables.ownedRegistersPerFrame[0]).toEqual([volumeRegisterIndex(0)]); + expect(tables.ownedRegistersPerFrame[0]).not.toContain(spec.TGT_SAMPLE_AMPLITUDE); + expect(tables.mods[1].command).toBe(spec.CMD_EMPTY); + }); + + it('encodes a one-shot sample lane with no loop', () => { + const frames = [sampleFrame(0, { instanceId: 1, loopIndex: SAMPLE_NO_LOOP })]; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + const [, ampltd] = sliceOf(tables, tables.mods[0]); + expect(tables.lanes[ampltd.operand].loopIndex).toBe(spec.NO_LOOP); + }); + + it('emits START on note-on, EMPTY when held, STOP on release', () => { + const frames = [ + baseFrame(), + sampleFrame(0, { instanceId: 1 }), + sampleFrame(0, { instanceId: 1 }), + baseFrame() + ]; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + const cmds = frames.map((_f, frame) => tables.mods[frame].command); + expect(cmds).toEqual([spec.CMD_EMPTY, spec.CMD_START, spec.CMD_EMPTY, spec.CMD_STOP]); + }); + + it('re-STARTs when a new note begins (instanceId changes)', () => { + const frames = [sampleFrame(0, { instanceId: 1 }), sampleFrame(0, { instanceId: 2 })]; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + expect(tables.mods[0].command).toBe(spec.CMD_START); + expect(tables.mods[1].command).toBe(spec.CMD_START); + }); + + it('MODULATEs pitch without touching the 0x80 lane phase', () => { + const frames = [ + sampleFrame(0, { instanceId: 1, rateHz: rateForPeriod(40) }), + sampleFrame(0, { instanceId: 1, rateHz: rateForPeriod(36) }) + ]; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + expect(tables.mods[0].command).toBe(spec.CMD_START); + const mod = tables.mods[1]; + expect(mod.command).toBe(spec.CMD_MODULATE); + expect(mod.baseTimerValue).toBe(spec.toFix16(rateForPeriod(36))); + expect(mod.timerLaneRef).toBe(spec.TLAN_NONE); + expect(mod.actionCount).toBe(2); + expect(tables.tlanes).toHaveLength(0); + expect(tables.vu32).toHaveLength(0); + const [, startAmpltd] = sliceOf(tables, tables.mods[0]); + const [, modAmpltd] = sliceOf(tables, mod); + expect(modAmpltd.operand).toBe(startAmpltd.operand); + }); + + it('MODULATEs volume via the amp-reg inline, keeping pitch unchanged', () => { + const frames = [ + sampleFrame(0, { instanceId: 1, volume: 15 }), + sampleFrame(0, { instanceId: 1, volume: 8 }) + ]; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + const mod = tables.mods[1]; + expect(mod.command).toBe(spec.CMD_MODULATE); + expect(mod.timerLaneRef).toBe(spec.TLAN_UNCHANGED); + expect(mod.baseTimerValue).toBe(0); + const [amp, ampltd] = sliceOf(tables, mod); + expect(amp.targetId).toBe(volumeRegisterIndex(0)); + expect(amp.operand).toBe(8); + const [, startAmpltd] = sliceOf(tables, tables.mods[0]); + expect(ampltd.operand).toBe(startAmpltd.operand); + }); + + it('shares one lane across two channels and volumes', () => { + const frames = [baseFrame()]; + frames[0].samples![0] = { + enabled: true, + instanceId: 1, + sampleBytes: [255, 0], + loopIndex: 0, + rateHz: rateForPeriod(40), + volume: 15 + }; + frames[0].samples![2] = { + enabled: true, + instanceId: 2, + sampleBytes: [255, 0], + loopIndex: 0, + rateHz: rateForPeriod(40), + volume: 8 + }; + const tables = buildTaymSampleTables(frames, { chipClockHz: CLOCK }); + expect(tables.lanes).toHaveLength(1); + expect(tables.timers).toHaveLength(2); + }); + + it('lets two channels START 0x80 in the same frame (validates)', () => { + const frames = [baseFrame()]; + frames[0].samples![0] = { + enabled: true, + instanceId: 1, + sampleBytes: [255, 0], + loopIndex: 0, + rateHz: rateForPeriod(40), + volume: 15 + }; + frames[0].samples![2] = { + enabled: true, + instanceId: 2, + sampleBytes: [255, 0], + loopIndex: 0, + rateHz: rateForPeriod(40), + volume: 8 + }; + const taym = buildTaymFromCapture(capture(frames)); + expect(() => check(taym)).not.toThrow(); + }); +}); + +describe('buildTaymFromCapture with samples', () => { + it('produces a valid TAYM whose PSG omits the sample-owned volume register', () => { + const frames = [ + sampleFrame(0, { instanceId: 1 }), + sampleFrame(0, { instanceId: 1, rateHz: rateForPeriod(36) }), + baseFrame() + ]; + const taym = buildTaymFromCapture(capture(frames)); + expect(() => check(taym)).not.toThrow(); + expect(() => writeTaym(taym)).not.toThrow(); + expect(taym.timers).toHaveLength(1); + expect(taym.timers[0].clockMode).toBe(spec.CLOCK_ABS_RATE_HZ); + }); + + it('merges sample timers alongside effect timers with remapped references', () => { + const frames: SongCaptureFrame[] = [baseFrame(), baseFrame()]; + frames[0].syncbuzzer[1] = { + enabled: true, + pwm: true, + period: 869, + periodLow: 803, + waveform: [13, 9], + waveformLoop: 0 + }; + frames[1].syncbuzzer[1] = { ...frames[0].syncbuzzer[1] }; + frames[0].samples![0] = { + enabled: true, + instanceId: 1, + sampleBytes: [255, 0], + loopIndex: 0, + rateHz: rateForPeriod(40), + volume: 15 + }; + frames[1].samples![0] = { ...frames[0].samples![0] }; + + const taym = buildTaymFromCapture(capture(frames)); + expect(taym.timers).toHaveLength(2); + expect(() => check(taym)).not.toThrow(); + + const sampleStart = taym.mods.find( + (mods) => + mods.command === spec.CMD_START && + taym.actions[mods.firstAction]?.targetId === volumeRegisterIndex(0) + ); + expect(sampleStart).toBeDefined(); + const amp = taym.actions[sampleStart!.firstAction]; + expect(amp.targetId).toBe(volumeRegisterIndex(0)); + expect(amp.sourceMode).toBe(spec.SRC_INLINE_VALUE); + expect(amp.operand).toBe(15); + const ampltd = taym.actions[sampleStart!.firstAction + 1]; + expect(ampltd.targetId).toBe(spec.TGT_SAMPLE_AMPLITUDE); + expect(ampltd.sourceMode).toBe(spec.SRC_BIND_LANE); + const lane = taym.lanes[ampltd.operand]; + expect(taym.vu08.slice(lane.valueOffset, lane.valueOffset + lane.length)).toEqual([255, 0]); + }); +}); diff --git a/tests/lib/services/file/tmr/tmr-encoder.test.ts b/tests/lib/services/file/tmr/tmr-encoder.test.ts index 69374f75..9f9bd299 100644 --- a/tests/lib/services/file/tmr/tmr-encoder.test.ts +++ b/tests/lib/services/file/tmr/tmr-encoder.test.ts @@ -48,7 +48,10 @@ function disabledSidFrame(registers: number[] = new Array(14).fill(0)): SongCapt }; } -function parseEncoded(frames: SongCaptureFrame[], options = { chipFrequency: 1773400, interruptFrequency: 50 }) { +function parseEncoded( + frames: SongCaptureFrame[], + options = { chipFrequency: 1773400, interruptFrequency: 50 } +) { const encoded = encodeTMR(frames, options); const tmr = parseTMR(encoded.tmr); const tel = parseEventList(encoded.eventList); @@ -111,6 +114,32 @@ describe('tmr encoder', () => { ); }); + it('quantizes SID event data with the selected chip DAC curve', () => { + const frame = disabledSidFrame(); + frame.sid[0] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + baseVolume: 10, + waveform: [7], + waveformLoop: 0 + }; + + const ay = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + expect(ay.eventItems[0]!.psgData[8]).toBe(4); + + const ym = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50, + isYm: true + }); + expect(ym.eventItems[0]!.psgData[8]).toBe(2); + }); + it('emits timer stop when SID turns off', () => { const onFrame = disabledSidFrame(); onFrame.sid[0] = { @@ -212,7 +241,9 @@ describe('tmr encoder', () => { expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 2 * TMR_ITEM_SIZE); expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + 6)).toBe(0); expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + 2)).toBe(storedTimerHz(500)); - expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 2)).toBe(storedTimerHz(520)); + expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 2)).toBe( + storedTimerHz(520) + ); expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + 2 * TMR_FRAME_SIZE + 2)).toBe( storedTimerHz(540) ); @@ -255,7 +286,9 @@ describe('tmr encoder', () => { const secondDuty = duties[1]!; const { highPeriod, lowPeriod } = computeTimerPwmPeriods(1000, secondDuty); const secondChainOffset = TEL_HEADER_SIZE + 2 * TMR_ITEM_SIZE; - expect(readU32LE(encoded.eventList, secondChainOffset + 16)).toBe(storedTimerHz(highPeriod)); + expect(readU32LE(encoded.eventList, secondChainOffset + 16)).toBe( + storedTimerHz(highPeriod) + ); expect(readU32LE(encoded.eventList, secondChainOffset + TMR_ITEM_SIZE + 16)).toBe( storedTimerHz(lowPeriod) ); @@ -283,7 +316,9 @@ describe('tmr encoder', () => { expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 2 * TMR_ITEM_SIZE); expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + 6)).toBe(0); - expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 6)).toBe(TMR_TIMER_EVENT_STOP); + expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 6)).toBe( + TMR_TIMER_EVENT_STOP + ); expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + 2 * TMR_FRAME_SIZE + 6)).toBe(0); }); @@ -308,9 +343,13 @@ describe('tmr encoder', () => { expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 2 * TMR_ITEM_SIZE); expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + 6)).toBe(0); - expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 6)).toBe(TMR_TIMER_EVENT_STOP); + expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 6)).toBe( + TMR_TIMER_EVENT_STOP + ); expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + 2 * TMR_FRAME_SIZE + 6)).toBe(0); - expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + 2 * TMR_FRAME_SIZE + 2)).toBe(storedTimerHz(900)); + expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + 2 * TMR_FRAME_SIZE + 2)).toBe( + storedTimerHz(900) + ); }); it('encodes sid event items with per-step timer frequencies when duty is asymmetric', () => { @@ -569,7 +608,9 @@ describe('tmr encoder', () => { interruptFrequency: 50 }); - expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 6)).toBe(TMR_TIMER_EVENT_STOP); + expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 6)).toBe( + TMR_TIMER_EVENT_STOP + ); }); it('merges coexisting sync-buzzer and Env+FM into one LCM event chain', () => { @@ -686,7 +727,9 @@ describe('tmr encoder', () => { }); expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 6 * TMR_ITEM_SIZE); - expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 8)).toBe(storedTimerHz(900)); + expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 8)).toBe( + storedTimerHz(900) + ); expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 12)).toBe(0); }); diff --git a/tests/lib/services/file/vgm/vgm-shared-capture.test.ts b/tests/lib/services/file/vgm/vgm-shared-capture.test.ts index 57d1defd..9c07e60a 100644 --- a/tests/lib/services/file/vgm/vgm-shared-capture.test.ts +++ b/tests/lib/services/file/vgm/vgm-shared-capture.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { captureVgmProject } from '@/lib/services/file/vgm/vgm-shared-capture'; +import { + captureSharedAyProject, + captureVgmProject +} from '@/lib/services/file/vgm/vgm-shared-capture'; import type { Project } from '@/lib/models/project'; function songStub(overrides: Record = {}) { @@ -33,6 +36,24 @@ describe('vgm-shared-capture validation', () => { ); }); + it('applies the interrupt-frequency check to shared AY captures', async () => { + const project = { + name: 'test', + patternOrder: [0], + loopPointId: 0, + songs: [ + songStub({ interruptFrequency: 50 }), + songStub({ interruptFrequency: 60 }) + ], + instruments: [], + tables: [] + } as unknown as Project; + + await expect(captureSharedAyProject(project, [0, 1])).rejects.toThrow( + /same interrupt frequency/ + ); + }); + it('rejects dual AY with mismatched clocks', async () => { const project = { name: 'test', diff --git a/tests/public/ayumi-constants.test.ts b/tests/public/ayumi-constants.test.ts index 58190621..1c0ef978 100644 --- a/tests/public/ayumi-constants.test.ts +++ b/tests/public/ayumi-constants.test.ts @@ -10,19 +10,45 @@ import { getPanSettingsForLayout } from '../../public/ay/ayumi-constants.js'; +async function instantiateAyumiWasm() { + const fs = await import('node:fs'); + const path = await import('node:path'); + const wasmPath = path.join(process.cwd(), 'public/ay/ayumi.wasm'); + const wasm = fs.readFileSync(wasmPath); + return WebAssembly.instantiate(wasm, { + env: { emscripten_notify_memory_growth: () => {} } + }); +} + describe('ayumi-constants', () => { describe('constants', () => { it('AYUMI_STRUCT_SIZE matches ayumi.wasm', async () => { - const fs = await import('node:fs'); - const path = await import('node:path'); - const wasmPath = path.join(process.cwd(), 'public/ay/ayumi.wasm'); - const wasm = fs.readFileSync(wasmPath); - const { instance } = await WebAssembly.instantiate(wasm, { - env: { emscripten_notify_memory_growth: () => {} } - }); + const { instance } = await instantiateAyumiWasm(); expect(instance.exports.ayumi_struct_size()).toBe(AYUMI_STRUCT_SIZE); }); + it('native SID timer volume uses DAC-space scaling', async () => { + const { instance } = await instantiateAyumiWasm(); + const wasm = instance.exports as any; + const ay = wasm.malloc(AYUMI_STRUCT_SIZE); + const regs = wasm.malloc(14); + const waveform = wasm.malloc(4); + try { + new Int32Array(wasm.memory.buffer, waveform, 1)[0] = 7; + wasm.ayumi_configure(ay, 0, DEFAULT_AYM_FREQUENCY, 44100, 0); + wasm.ayumi_set_timer_effect(ay, 0, 1, 1, 0, 1, 1, 10, 1, 0); + wasm.ayumi_set_timer_effect_waveform(ay, 0, 0, waveform, 1, 0); + wasm.ayumi_get_registers(ay, regs); + + const out = new Uint8Array(wasm.memory.buffer, regs, 14); + expect(out[8]).toBe(4); + } finally { + wasm.free(waveform); + wasm.free(regs); + wasm.free(ay); + } + }); + it('AYUMI_STRUCT_LEFT_OFFSET is struct size minus 40', () => { expect(AYUMI_STRUCT_LEFT_OFFSET).toBe(AYUMI_STRUCT_SIZE - 40); });