From 44a25b4b2cb102a55d82b1a88300e54b87c4f2e6 Mon Sep 17 00:00:00 2001 From: Ru Guevara Date: Mon, 15 Jun 2026 09:45:35 +0200 Subject: [PATCH 1/4] Test A/B sync-buzzer export emits a linked alternating chain Regression coverage for the waveform export bugfix: a two-shape syncbuzzer must export both shapes as a 2-event chain alternating A->B->A (not a single self-looping event with shape B dropped). Also covers single-shape self-loop and a parse round-trip. Co-Authored-By: Claude Opus 4.8 --- tests/lib/services/file/tmr-encoder.test.ts | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/lib/services/file/tmr-encoder.test.ts b/tests/lib/services/file/tmr-encoder.test.ts index 972500af..ce2ed5d7 100644 --- a/tests/lib/services/file/tmr-encoder.test.ts +++ b/tests/lib/services/file/tmr-encoder.test.ts @@ -20,6 +20,7 @@ import { createDisabledTimerCaptureStates, toneRegisterApplyMask, envelopePeriodRegisterApplyMask, + envelopeShapeRegisterApplyMask, type SongCaptureFrame } from '@/lib/services/file/ay-export-utils'; import { computeFmTonePeriod, computeTimerPwmPeriods } from '@/lib/chips/ay/instrument'; @@ -425,6 +426,86 @@ describe('tmr encoder', () => { ); }); + it('starts timer 2 with a single-shape sync-buzzer event (self-looping)', () => { + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + period: 1000, + waveform: [0x0d], + waveformLoop: 0 + }; + + const encoded = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + const shapeMask = envelopeShapeRegisterApplyMask(); + const secondTimerOffset = TMR_HEADER_SIZE + 8; + expect(readU32LE(encoded.tmr, secondTimerOffset)).toBe(storedTimerHz(1000)); + expect(readU16LE(encoded.tmr, secondTimerOffset + 4)).toBe(0); + // one event item, R13 = shape, mask = envelope shape on slot 1, next = self (0) + expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + TMR_ITEM_SIZE); + expect(readU8(encoded.eventList, TEL_HEADER_SIZE + 13)).toBe(0x0d); + expect(readU16LE(encoded.eventList, TEL_HEADER_SIZE + 14)).toBe( + encodeEventPsgApplyMask(shapeMask, 1) + ); + expect(readU16LE(encoded.eventList, TEL_HEADER_SIZE + 20)).toBe(0); + }); + + it('emits an A/B two-shape sync-buzzer as a linked alternating chain', () => { + // Regression: the exporter used to keep only waveform[0], dropping shape B + // and self-looping a single event. An A/B sync-buzzer must export both + // shapes as a 2-event chain that alternates (A -> B -> A). + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + period: 6960, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + + const encoded = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + const shapeMask = envelopeShapeRegisterApplyMask(); + const secondTimerOffset = TMR_HEADER_SIZE + 8; + expect(readU32LE(encoded.tmr, secondTimerOffset)).toBe(storedTimerHz(6960)); + expect(readU16LE(encoded.tmr, secondTimerOffset + 4)).toBe(0); // chain head = event 0 + + // two event items + expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 2 * TMR_ITEM_SIZE); + const ev0 = TEL_HEADER_SIZE; + const ev1 = TEL_HEADER_SIZE + TMR_ITEM_SIZE; + // event 0: shape A, next -> event 1 + expect(readU8(encoded.eventList, ev0 + 13)).toBe(0x0d); + expect(readU16LE(encoded.eventList, ev0 + 14)).toBe(encodeEventPsgApplyMask(shapeMask, 1)); + expect(readU16LE(encoded.eventList, ev0 + 20)).toBe(1); + // event 1: shape B, next -> event 0 (loop) + expect(readU8(encoded.eventList, ev1 + 13)).toBe(0x09); + expect(readU16LE(encoded.eventList, ev1 + 14)).toBe(encodeEventPsgApplyMask(shapeMask, 1)); + expect(readU16LE(encoded.eventList, ev1 + 20)).toBe(0); + }); + + it('round-trips a two-shape sync-buzzer chain through parse', () => { + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + period: 6960, + waveform: [0x07, 0x0a], + waveformLoop: 0 + }; + + const merged = parseEncoded([frame]); + expect(merged.eventItems).toHaveLength(2); + expect(merged.eventItems[0]!.psgData[13]).toBe(0x07); + expect(merged.eventItems[0]!.timerEventIndex).toBe(1); + expect(merged.eventItems[1]!.psgData[13]).toBe(0x0a); + expect(merged.eventItems[1]!.timerEventIndex).toBe(0); + }); + it('emits timer stop when FM turns off', () => { const onFrame = disabledSidFrame(); onFrame.fm[0] = { From 1aba6b1fbe66c37cc8558262729a06047645939a Mon Sep 17 00:00:00 2001 From: Ru Guevara Date: Fri, 19 Jun 2026 15:10:51 +0200 Subject: [PATCH 2/4] Sync-buzzer export bugfix --- src/lib/services/file/ay-export-utils.ts | 8 +- src/lib/services/file/psg-export.ts | 20 ++--- src/lib/services/file/tmr-encoder.ts | 82 ++++++++++++++++++--- src/lib/services/file/tmr-export.ts | 14 ++-- tests/lib/services/file/tmr-encoder.test.ts | 45 +++++++++++ 5 files changed, 141 insertions(+), 28 deletions(-) diff --git a/src/lib/services/file/ay-export-utils.ts b/src/lib/services/file/ay-export-utils.ts index 05bdb4ba..6f56e27a 100644 --- a/src/lib/services/file/ay-export-utils.ts +++ b/src/lib/services/file/ay-export-utils.ts @@ -37,7 +37,9 @@ export type HardwareSidState = { export type HardwareSyncBuzzerState = { enabled: boolean; + pwm: boolean; period: number; + periodLow: number; waveform: number[]; waveformLoop: number; }; @@ -212,7 +214,7 @@ function createDisabledSidState(): HardwareSidState { } function createDisabledSyncBuzzerState(): HardwareSyncBuzzerState { - return { enabled: false, period: 0, waveform: [0], waveformLoop: 0 }; + return { enabled: false, pwm: false, period: 0, periodLow: 0, waveform: [0], waveformLoop: 0 }; } function createDisabledFmState(): HardwareFmState { @@ -294,7 +296,9 @@ export function extractHardwareSyncBuzzerStates(registerState: { !!timerEffect?.enabled && timerEffect.kind === TIMER_EFFECT_KIND_ENVELOPE_SHAPE; result.push({ enabled, + pwm: enabled && timerEffect.pwmMode === TIMER_PWM_MODE_BY_DUTY_INDEX, period: timerEffect?.period ?? 0, + periodLow: timerEffect?.periodLow ?? timerEffect?.period ?? 0, waveform: [...(timerEffect?.waveform ?? [0])], waveformLoop: timerEffect?.waveformLoop ?? 0 }); @@ -380,7 +384,9 @@ export function syncBuzzerStatesEqual( ): boolean { return ( a.enabled === b.enabled && + a.pwm === b.pwm && a.period === b.period && + a.periodLow === b.periodLow && a.waveformLoop === b.waveformLoop && a.waveform.length === b.waveform.length && a.waveform.every((value, index) => value === b.waveform[index]) diff --git a/src/lib/services/file/psg-export.ts b/src/lib/services/file/psg-export.ts index b47804fa..68f414c1 100644 --- a/src/lib/services/file/psg-export.ts +++ b/src/lib/services/file/psg-export.ts @@ -331,16 +331,16 @@ class PsgExportService { onProgress?.(10, 'Loading processor modules...'); const baseUrl = import.meta.env.BASE_URL; - const { default: AyumiState } = await import(`${baseUrl}ayumi-state.js`); + const { default: AyumiState } = await import(/* @vite-ignore */ `${baseUrl}ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + /* @vite-ignore */ `${baseUrl}tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); + const { default: AYAudioDriver } = await import(/* @vite-ignore */ `${baseUrl}ay-audio-driver.js`); const { default: AYChipRegisterState } = await import( - `${baseUrl}ay-chip-register-state.js` + /* @vite-ignore */ `${baseUrl}ay-chip-register-state.js` ); const { default: VirtualChannelMixer } = await import( - `${baseUrl}virtual-channel-mixer.js` + /* @vite-ignore */ `${baseUrl}virtual-channel-mixer.js` ); const modules: PsgExportModules = { @@ -456,16 +456,16 @@ export async function captureSongRegisterFrames( modules = options.modules; } else { const baseUrl = import.meta.env.BASE_URL; - const { default: AyumiState } = await import(`${baseUrl}ayumi-state.js`); + const { default: AyumiState } = await import(/* @vite-ignore */ `${baseUrl}ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + /* @vite-ignore */ `${baseUrl}tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); + const { default: AYAudioDriver } = await import(/* @vite-ignore */ `${baseUrl}ay-audio-driver.js`); const { default: AYChipRegisterState } = await import( - `${baseUrl}ay-chip-register-state.js` + /* @vite-ignore */ `${baseUrl}ay-chip-register-state.js` ); const { default: VirtualChannelMixer } = await import( - `${baseUrl}virtual-channel-mixer.js` + /* @vite-ignore */ `${baseUrl}virtual-channel-mixer.js` ); modules = { AyumiState, diff --git a/src/lib/services/file/tmr-encoder.ts b/src/lib/services/file/tmr-encoder.ts index 0fa1d360..3a0ea150 100644 --- a/src/lib/services/file/tmr-encoder.ts +++ b/src/lib/services/file/tmr-encoder.ts @@ -166,6 +166,21 @@ export function encodeSidEventTimerFrequency( 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); } @@ -288,7 +303,9 @@ export function encodeTMR( })); const previousSyncbuzzer: HardwareSyncBuzzerState[] = Array.from({ length: 3 }, () => ({ enabled: false, + pwm: false, period: 0, + periodLow: 0, waveform: [0], waveformLoop: 0 })); @@ -328,11 +345,17 @@ export function encodeTMR( periodLow: sid.periodLow > 0 ? sid.periodLow : sid.period } : sid; - const syncbuzzer = frame.syncbuzzer?.[channelIndex] ?? { + const syncbuzzer: HardwareSyncBuzzerState = frame.syncbuzzer?.[channelIndex] ?? { enabled: false, + pwm: false, period: 0, - shape: 0 + periodLow: 0, + waveform: [0], + waveformLoop: 0 }; + const effectiveSyncbuzzer: HardwareSyncBuzzerState = syncbuzzer.enabled + ? normalizePwmPeriods(syncbuzzer) + : syncbuzzer; const fm = frame.fm?.[channelIndex] ?? { enabled: false, pwm: false, @@ -361,27 +384,49 @@ export function encodeTMR( if (syncbuzzer.enabled) { const syncbuzzerWaveformChanged = !prevSyncbuzzer.enabled || - !syncBuzzerWaveformConfigEqual(prevSyncbuzzer, syncbuzzer); + !syncBuzzerWaveformConfigEqual(prevSyncbuzzer, effectiveSyncbuzzer); + const syncbuzzerPeriodChanged = + prevSyncbuzzer.period !== effectiveSyncbuzzer.period || + prevSyncbuzzer.periodLow !== effectiveSyncbuzzer.periodLow; if (syncbuzzerWaveformChanged) { const eventIndex = getOrCreateSyncBuzzerEventChain( eventItems, chainStartByKey, channelIndex, - syncbuzzer + effectiveSyncbuzzer, + options + ); + timers.push({ + frequency: encodeExportTimerFrequencyHz( + syncBuzzerStartPeriod(effectiveSyncbuzzer), + options + ), + eventIndex + }); + } else if (syncbuzzerPeriodChanged && isPwmDutySweep(prevSyncbuzzer, effectiveSyncbuzzer)) { + const eventIndex = appendSyncBuzzerEventChain( + eventItems, + channelIndex, + effectiveSyncbuzzer, + options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(syncbuzzer.period, options), + frequency: encodeExportTimerFrequencyHz( + syncBuzzerStartPeriod(effectiveSyncbuzzer), + options + ), eventIndex }); - } else if (prevSyncbuzzer.period !== syncbuzzer.period) { + } else if (syncbuzzerPeriodChanged) { const eventIndex = getOrCreateSyncBuzzerEventChain( eventItems, chainStartByKey, channelIndex, - syncbuzzer + effectiveSyncbuzzer, + options ); timers.push({ - frequency: encodeExportTimerFrequencyHz(syncbuzzer.period, options), + frequency: encodeExportTimerFrequencyHz(effectiveSyncbuzzer.period, options), eventIndex }); } else { @@ -545,7 +590,9 @@ export function encodeTMR( }; previousSyncbuzzer[channelIndex] = { enabled: syncbuzzer.enabled, - period: syncbuzzer.period, + pwm: effectiveSyncbuzzer.pwm, + period: effectiveSyncbuzzer.period, + periodLow: effectiveSyncbuzzer.periodLow, waveform: [...syncbuzzer.waveform], waveformLoop: syncbuzzer.waveformLoop }; @@ -662,7 +709,8 @@ function getOrCreateSyncBuzzerEventChain( eventItems: EventItem[], chainStartByKey: Map, channelIndex: number, - syncbuzzer: HardwareSyncBuzzerState + syncbuzzer: HardwareSyncBuzzerState, + options: TmrEncodeOptions ): number { const key = syncBuzzerEventChainKey(channelIndex, syncbuzzer); const existing = chainStartByKey.get(key); @@ -670,8 +718,18 @@ function getOrCreateSyncBuzzerEventChain( return existing; } - const startIndex = eventItems.length; + const startIndex = appendSyncBuzzerEventChain(eventItems, channelIndex, syncbuzzer, options); chainStartByKey.set(key, startIndex); + return startIndex; +} + +function appendSyncBuzzerEventChain( + eventItems: EventItem[], + channelIndex: number, + syncbuzzer: HardwareSyncBuzzerState, + options: TmrEncodeOptions +): number { + const startIndex = eventItems.length; const shapeMask = envelopeShapeRegisterApplyMask(); for (let stepIndex = 0; stepIndex < syncbuzzer.waveform.length; stepIndex++) { @@ -681,7 +739,7 @@ function getOrCreateSyncBuzzerEventChain( eventItems.push({ psgData, psgMask: encodeEventPsgApplyMask(shapeMask, channelIndex), - timerFrequency: 0, + timerFrequency: encodeSyncBuzzerEventTimerFrequency(stepIndex, syncbuzzer, options), timerEventIndex: startIndex + nextIndex }); } diff --git a/src/lib/services/file/tmr-export.ts b/src/lib/services/file/tmr-export.ts index 6d4c190c..72cff779 100644 --- a/src/lib/services/file/tmr-export.ts +++ b/src/lib/services/file/tmr-export.ts @@ -10,13 +10,17 @@ import { encodeTMR, type EncodedTmrFiles } from './tmr-encoder'; async function loadPsgExportModules(): Promise { const baseUrl = import.meta.env.BASE_URL; - const { default: AyumiState } = await import(`${baseUrl}ayumi-state.js`); + const { default: AyumiState } = await import(/* @vite-ignore */ `${baseUrl}ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + /* @vite-ignore */ `${baseUrl}tracker-pattern-processor.js` + ); + const { default: AYAudioDriver } = await import(/* @vite-ignore */ `${baseUrl}ay-audio-driver.js`); + const { default: AYChipRegisterState } = await import( + /* @vite-ignore */ `${baseUrl}ay-chip-register-state.js` + ); + const { default: VirtualChannelMixer } = await import( + /* @vite-ignore */ `${baseUrl}virtual-channel-mixer.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); - const { default: AYChipRegisterState } = await import(`${baseUrl}ay-chip-register-state.js`); - const { default: VirtualChannelMixer } = await import(`${baseUrl}virtual-channel-mixer.js`); return { AyumiState, TrackerPatternProcessor, diff --git a/tests/lib/services/file/tmr-encoder.test.ts b/tests/lib/services/file/tmr-encoder.test.ts index ce2ed5d7..aac1e5a0 100644 --- a/tests/lib/services/file/tmr-encoder.test.ts +++ b/tests/lib/services/file/tmr-encoder.test.ts @@ -506,6 +506,51 @@ describe('tmr encoder', () => { expect(merged.eventItems[1]!.timerEventIndex).toBe(0); }); + it('emits per-event timer frequencies for a duty sync-buzzer (PWM skew)', () => { + // Bassline first note: a sync-buzzer with a non-50% duty. Like SID PWM, + // each waveform step rides a different timer period (high vs low phase), + // so the .tel chain must carry BOTH timer frequencies -- not 0/inherit. + // Regression: getOrCreateSyncBuzzerEventChain hardcoded timerFrequency 0, + // flattening the duty to a single period (the .tmr slot period only). + const periodHigh = 6960; + const periodLow = 3480; // duty != 50% -> high phase != low phase + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + pwm: true, + period: periodHigh, + periodLow, + waveform: [0x0d, 0x09], // 2 steps: step0 high phase, step1 low phase + waveformLoop: 0 + }; + + const encoded = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + const shapeMask = envelopeShapeRegisterApplyMask(); + const secondTimerOffset = TMR_HEADER_SIZE + 8; + // .tmr Start carries the high-phase period (waveform[0]). + expect(readU32LE(encoded.tmr, secondTimerOffset)).toBe(storedTimerHz(periodHigh)); + expect(readU16LE(encoded.tmr, secondTimerOffset + 4)).toBe(0); + + // two event items, each re-pitching the timer to its step period. + expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 2 * TMR_ITEM_SIZE); + const ev0 = TEL_HEADER_SIZE; + const ev1 = TEL_HEADER_SIZE + TMR_ITEM_SIZE; + // event 0: shape A, high-phase freq, next -> event 1 + expect(readU8(encoded.eventList, ev0 + 13)).toBe(0x0d); + expect(readU16LE(encoded.eventList, ev0 + 14)).toBe(encodeEventPsgApplyMask(shapeMask, 1)); + expect(readU32LE(encoded.eventList, ev0 + 16)).toBe(storedTimerHz(periodHigh)); + expect(readU16LE(encoded.eventList, ev0 + 20)).toBe(1); + // event 1: shape B, low-phase freq, next -> event 0 (loop) + expect(readU8(encoded.eventList, ev1 + 13)).toBe(0x09); + expect(readU16LE(encoded.eventList, ev1 + 14)).toBe(encodeEventPsgApplyMask(shapeMask, 1)); + expect(readU32LE(encoded.eventList, ev1 + 16)).toBe(storedTimerHz(periodLow)); + expect(readU16LE(encoded.eventList, ev1 + 20)).toBe(0); + }); + it('emits timer stop when FM turns off', () => { const onFrame = disabledSidFrame(); onFrame.fm[0] = { From 5c5d1b63fdc1f64d69a1c1a8efd2b93775d13c4a Mon Sep 17 00:00:00 2001 From: Ru Grantez Date: Fri, 26 Jun 2026 09:53:19 +0200 Subject: [PATCH 3/4] Fix merged TMR timer chain export --- src/lib/services/file/tmr-encoder.ts | 494 ++++++++++++++++---- tests/lib/services/file/tmr-encoder.test.ts | 231 +++++++++ 2 files changed, 629 insertions(+), 96 deletions(-) diff --git a/src/lib/services/file/tmr-encoder.ts b/src/lib/services/file/tmr-encoder.ts index 3a0ea150..48e2c050 100644 --- a/src/lib/services/file/tmr-encoder.ts +++ b/src/lib/services/file/tmr-encoder.ts @@ -1,5 +1,6 @@ import { AY_REGISTER_COUNT, + ENVELOPE_SHAPE_REGISTER, envelopeShapeRegisterApplyMask, envelopePeriodRegisterApplyMask, registerApplyMask, @@ -135,10 +136,6 @@ function sidStartPeriod(sid: HardwareSidState): number { return timerPwmStepPeriod(sid.waveform[0] ?? 0, sid.period, sid.periodLow); } -function normalizeSidPeriods(sid: HardwareSidState): HardwareSidState { - return normalizePwmPeriods(sid); -} - export function isSidPwmDutySweep(prev: HardwareSidState, next: HardwareSidState): boolean { if (!prev.enabled || !next.enabled) { return false; @@ -189,86 +186,369 @@ function envFmStartPeriod(envFm: HardwareEnvFmState): number { return pwmEventStepPeriod(envFm, 0); } -function appendSidEventChain( - eventItems: EventItem[], +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 -): number { - const startIndex = eventItems.length; +): TimerEffectStepSource { const volumeReg = volumeRegisterIndex(channelIndex); - const volumeMask = registerApplyMask(volumeReg); + 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) + }; +} - for (let stepIndex = 0; stepIndex < sid.waveform.length; stepIndex++) { - const psgData = new Array(AY_REGISTER_COUNT).fill(0); - psgData[volumeReg] = sidVolumeLevel(sid.waveform[stepIndex]!, sid.baseVolume); - const nextIndex = resolveNextWaveformIndex(stepIndex, sid); - eventItems.push({ - psgData, - psgMask: encodeEventPsgApplyMask(volumeMask, channelIndex), - timerFrequency: encodeSidEventTimerFrequency(stepIndex, sid, options), - timerEventIndex: startIndex + nextIndex - }); +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), + waveformLoop: source.loop + }); +} + +function sourceStepStateKey(sourceSteps: number[]): string { + return sourceSteps.join(','); +} + +function buildEffectChainSteps(sources: TimerEffectStepSource[]): EffectChainStep[] { + if (sources.some((source) => source.length <= 0)) { + return []; } - return startIndex; + const steps: EffectChainStep[] = []; + const indexByState = new Map(); + let sourceSteps = sources.map(() => 0); + + while (true) { + const key = sourceStepStateKey(sourceSteps); + const existingIndex = indexByState.get(key); + if (existingIndex !== undefined) { + if (steps.length > 0) { + steps[steps.length - 1]!.nextIndex = existingIndex; + } + break; + } + + const stepIndex = steps.length; + if (steps.length > 0) { + steps[steps.length - 1]!.nextIndex = stepIndex; + } + + indexByState.set(key, stepIndex); + steps.push({ sourceSteps: [...sourceSteps], nextIndex: stepIndex }); + sourceSteps = sourceSteps.map((sourceStep, sourceIndex) => + sourceNextStepIndex(sourceStep, sources[sourceIndex]!) + ); + } + + return steps; } -function appendFmEventChain( +function appendEffectStepSources( eventItems: EventItem[], channelIndex: number, - fm: HardwareFmState, - options: TmrEncodeOptions + sources: TimerEffectStepSource[] ): number { const startIndex = eventItems.length; - const toneMask = toneRegisterApplyMask(channelIndex); + const chainSteps = buildEffectChainSteps(sources); - for (let stepIndex = 0; stepIndex < fm.waveform.length; stepIndex++) { + for (const chainStep of chainSteps) { const psgData = new Array(AY_REGISTER_COUNT).fill(0); - const tonePeriod = computeFmTonePeriod( - fm.baseTonePeriod, - fm.waveform[stepIndex]!, - fm.fmOffsetMode - ); - writeTonePeriodToPsgData(psgData, channelIndex, tonePeriod); - const nextIndex = resolveNextWaveformIndex(stepIndex, fm); + let registerMask = 0; + let timerFrequency = 0; + + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) { + const source = sources[sourceIndex]!; + const sourceStep = chainStep.sourceSteps[sourceIndex]!; + registerMask |= source.registerMask; + for (const write of source.writesAtStep(sourceStep)) { + psgData[write.register] = write.value; + } + const stepFrequency = source.stepTimerFrequency(sourceStep); + if (timerFrequency === 0 && stepFrequency !== 0) { + timerFrequency = stepFrequency; + } + } + eventItems.push({ psgData, - psgMask: encodeEventPsgApplyMask(toneMask, channelIndex), - timerFrequency: encodePwmEventTimerFrequency(stepIndex, fm, options), - timerEventIndex: startIndex + nextIndex + psgMask: encodeEventPsgApplyMask(registerMask, channelIndex), + timerFrequency, + timerEventIndex: startIndex + chainStep.nextIndex }); } return startIndex; } +function appendSidEventChain( + eventItems: EventItem[], + channelIndex: number, + sid: HardwareSidState, + options: TmrEncodeOptions +): number { + return appendEffectStepSources(eventItems, channelIndex, [ + sidStepSource(channelIndex, sid, options) + ]); +} + +function appendFmEventChain( + eventItems: EventItem[], + channelIndex: number, + fm: HardwareFmState, + options: TmrEncodeOptions +): number { + return appendEffectStepSources(eventItems, channelIndex, [ + fmStepSource(channelIndex, fm, options) + ]); +} + function appendEnvFmEventChain( eventItems: EventItem[], channelIndex: number, envFm: HardwareEnvFmState, options: TmrEncodeOptions ): number { - const startIndex = eventItems.length; - const envelopeMask = envelopePeriodRegisterApplyMask(); + return appendEffectStepSources(eventItems, channelIndex, [ + envFmStepSource(channelIndex, envFm, options) + ]); +} - for (let stepIndex = 0; stepIndex < envFm.waveform.length; stepIndex++) { - const psgData = new Array(AY_REGISTER_COUNT).fill(0); - const envelopePeriod = computeEnvFmEnvelopePeriod( - envFm.baseEnvelopePeriod, - envFm.waveform[stepIndex]!, - envFm.fmOffsetMode - ); - writeEnvelopePeriodToPsgData(psgData, envelopePeriod); - const nextIndex = resolveNextWaveformIndex(stepIndex, envFm); - eventItems.push({ - psgData, - psgMask: encodeEventPsgApplyMask(envelopeMask, channelIndex), - timerFrequency: encodePwmEventTimerFrequency(stepIndex, envFm, options), - timerEventIndex: startIndex + nextIndex +type ChannelEffect = { + source: TimerEffectStepSource; + configKey: string; + startPeriod: number; + periodKey: string; + timingKey: string; +}; + +function timerPeriodKey(state: PwmTimerState): string { + return `${state.period}:${state.periodLow}`; +} + +function eventChainHasTimerFrequencies( + state: WaveformChainState, + stepPeriod: (stepIndex: number) => number +): boolean { + for (let stepIndex = 0; stepIndex < state.waveform.length; stepIndex++) { + if (stepPeriod(stepIndex) !== stepPeriod(previousWaveformStepIndex(stepIndex, state))) { + return true; + } + } + return false; +} + +function sidEventChainTimingKey(sid: HardwareSidState): string { + return eventChainHasTimerFrequencies(sid, (stepIndex) => sidEventStepPeriod(sid, stepIndex)) + ? timerPeriodKey(sid) + : ''; +} + +function pwmEventChainTimingKey(state: PwmTimerState): string { + return eventChainHasTimerFrequencies(state, (stepIndex) => pwmEventStepPeriod(state, stepIndex)) + ? timerPeriodKey(state) + : ''; +} + +function eventChainCacheKey(configKey: string, timingKey: string): string { + return timingKey ? `${configKey}:timer:${timingKey}` : configKey; +} + +function sidEventChainCacheKey(channelIndex: number, sid: HardwareSidState): string { + return eventChainCacheKey(sidEventChainKey(channelIndex, sid), sidEventChainTimingKey(sid)); +} + +function syncBuzzerEventChainCacheKey( + channelIndex: number, + syncbuzzer: HardwareSyncBuzzerState +): string { + return eventChainCacheKey( + syncBuzzerEventChainKey(channelIndex, syncbuzzer), + pwmEventChainTimingKey(syncbuzzer) + ); +} + +function fmEventChainCacheKey(channelIndex: number, fm: HardwareFmState): string { + return eventChainCacheKey(fmEventChainKey(channelIndex, fm), pwmEventChainTimingKey(fm)); +} + +function envFmEventChainCacheKey(channelIndex: number, envFm: HardwareEnvFmState): string { + return eventChainCacheKey(envFmEventChainKey(channelIndex, envFm), pwmEventChainTimingKey(envFm)); +} + +function buildChannelEffects( + channelIndex: number, + states: { + syncbuzzer: HardwareSyncBuzzerState; + sid: HardwareSidState; + fm: HardwareFmState; + envFm: HardwareEnvFmState; + }, + options: TmrEncodeOptions +): ChannelEffect[] { + const effects: ChannelEffect[] = []; + const { syncbuzzer, sid, fm, envFm } = states; + if (syncbuzzer.enabled) { + effects.push({ + source: syncBuzzerStepSource(channelIndex, syncbuzzer, options), + configKey: syncBuzzerEventChainKey(channelIndex, syncbuzzer), + startPeriod: syncBuzzerStartPeriod(syncbuzzer), + periodKey: timerPeriodKey(syncbuzzer), + timingKey: pwmEventChainTimingKey(syncbuzzer) + }); + } + if (sid.enabled) { + effects.push({ + source: sidStepSource(channelIndex, sid, options), + configKey: sidEventChainKey(channelIndex, sid), + startPeriod: sidStartPeriod(sid), + periodKey: timerPeriodKey(sid), + timingKey: sidEventChainTimingKey(sid) + }); + } + if (fm.enabled) { + effects.push({ + source: fmStepSource(channelIndex, fm, options), + configKey: fmEventChainKey(channelIndex, fm), + startPeriod: fmStartPeriod(fm), + periodKey: timerPeriodKey(fm), + timingKey: pwmEventChainTimingKey(fm) + }); + } + if (envFm.enabled) { + effects.push({ + source: envFmStepSource(channelIndex, envFm, options), + configKey: envFmEventChainKey(channelIndex, envFm), + startPeriod: envFmStartPeriod(envFm), + periodKey: timerPeriodKey(envFm), + timingKey: pwmEventChainTimingKey(envFm) }); } + return effects; +} + +function channelEffectSetKey(effects: ChannelEffect[]): string { + return effects.map((effect) => effect.configKey).join('|'); +} + +function channelEffectTimingKey(effects: ChannelEffect[]): string { + return effects.map((effect) => effect.timingKey).join('|'); +} + +function channelEffectPeriodKey(effects: ChannelEffect[]): string { + return effects.map((effect) => effect.periodKey).join('|'); +} +function channelEffectCacheKey(effects: ChannelEffect[]): string { + return eventChainCacheKey(channelEffectSetKey(effects), channelEffectTimingKey(effects)); +} + +function getOrCreateMergedEventChain( + eventItems: EventItem[], + chainStartByKey: Map, + channelIndex: number, + effects: ChannelEffect[] +): number { + const key = channelEffectCacheKey(effects); + const existing = chainStartByKey.get(key); + if (existing !== undefined) { + return existing; + } + const startIndex = appendEffectStepSources( + eventItems, + channelIndex, + effects.map((effect) => effect.source) + ); + chainStartByKey.set(key, startIndex); return startIndex; } @@ -329,6 +609,13 @@ export function encodeTMR( waveform: [0, 7], waveformLoop: 0 })); + const previousMerged: Array< + { setKey: string; timingKey: string; periodKey: string } | undefined + > = [ + undefined, + undefined, + undefined + ]; let previousRegisters = new Array(AY_REGISTER_COUNT).fill(0); for (const frame of frames) { @@ -381,8 +668,55 @@ export function encodeTMR( const prevFm = previousFm[channelIndex]!; const prevEnvFm = previousEnvFm[channelIndex]!; - if (syncbuzzer.enabled) { + const effectiveFm: HardwareFmState = fm.enabled && fm.pwm ? normalizePwmPeriods(fm) : fm; + const effectiveEnvFm: HardwareEnvFmState = + envFm.enabled && envFm.pwm ? normalizePwmPeriods(envFm) : envFm; + + const activeEffectCount = + (syncbuzzer.enabled ? 1 : 0) + + (sid.enabled ? 1 : 0) + + (fm.enabled ? 1 : 0) + + (envFm.enabled ? 1 : 0); + + const prevMergedState = previousMerged[channelIndex]; + previousMerged[channelIndex] = undefined; + + if (activeEffectCount >= 2) { + const effects = buildChannelEffects( + channelIndex, + { + syncbuzzer: effectiveSyncbuzzer, + sid: effectiveSid, + fm: effectiveFm, + envFm: effectiveEnvFm + }, + options + ); + const setKey = channelEffectSetKey(effects); + const timingKey = channelEffectTimingKey(effects); + const periodKey = channelEffectPeriodKey(effects); + const setChanged = !prevMergedState || prevMergedState.setKey !== setKey; + const timingChanged = !prevMergedState || prevMergedState.timingKey !== timingKey; + const periodChanged = !!prevMergedState && prevMergedState.periodKey !== periodKey; + + if (setChanged || timingChanged || periodChanged) { + const eventIndex = getOrCreateMergedEventChain( + eventItems, + chainStartByKey, + channelIndex, + effects + ); + timers.push({ + frequency: encodeExportTimerFrequencyHz(effects[0]!.startPeriod, options), + eventIndex + }); + } else { + timers.push({ frequency: 0, eventIndex: 0 }); + } + previousMerged[channelIndex] = { setKey, timingKey, periodKey }; + } else if (syncbuzzer.enabled) { const syncbuzzerWaveformChanged = + !!prevMergedState || !prevSyncbuzzer.enabled || !syncBuzzerWaveformConfigEqual(prevSyncbuzzer, effectiveSyncbuzzer); const syncbuzzerPeriodChanged = @@ -434,7 +768,7 @@ export function encodeTMR( } } else if (sid.enabled) { const sidWaveformChanged = - !prevSid.enabled || !sidWaveformConfigEqual(prevSid, effectiveSid); + !!prevMergedState || !prevSid.enabled || !sidWaveformConfigEqual(prevSid, effectiveSid); const sidPeriodChanged = prevSid.period !== effectiveSid.period || prevSid.periodLow !== effectiveSid.periodLow; @@ -477,10 +811,8 @@ export function encodeTMR( timers.push({ frequency: 0, eventIndex: 0 }); } } else if (fm.enabled) { - const effectiveFm: HardwareFmState = fm.pwm - ? normalizePwmPeriods(fm) - : fm; - const fmWaveformChanged = !prevFm.enabled || !fmWaveformConfigEqual(prevFm, effectiveFm); + const fmWaveformChanged = + !!prevMergedState || !prevFm.enabled || !fmWaveformConfigEqual(prevFm, effectiveFm); const fmPeriodChanged = prevFm.period !== effectiveFm.period || prevFm.periodLow !== effectiveFm.periodLow; if (fmWaveformChanged) { @@ -522,10 +854,8 @@ export function encodeTMR( timers.push({ frequency: 0, eventIndex: 0 }); } } else if (envFm.enabled) { - const effectiveEnvFm: HardwareEnvFmState = envFm.pwm - ? normalizePwmPeriods(envFm) - : envFm; const envFmWaveformChanged = + !!prevMergedState || !prevEnvFm.enabled || !envFmWaveformConfigEqual(prevEnvFm, effectiveEnvFm); const envFmPeriodChanged = prevEnvFm.period !== effectiveEnvFm.period || @@ -712,7 +1042,7 @@ function getOrCreateSyncBuzzerEventChain( syncbuzzer: HardwareSyncBuzzerState, options: TmrEncodeOptions ): number { - const key = syncBuzzerEventChainKey(channelIndex, syncbuzzer); + const key = syncBuzzerEventChainCacheKey(channelIndex, syncbuzzer); const existing = chainStartByKey.get(key); if (existing !== undefined) { return existing; @@ -729,22 +1059,9 @@ function appendSyncBuzzerEventChain( syncbuzzer: HardwareSyncBuzzerState, options: TmrEncodeOptions ): number { - const startIndex = eventItems.length; - const shapeMask = envelopeShapeRegisterApplyMask(); - - for (let stepIndex = 0; stepIndex < syncbuzzer.waveform.length; stepIndex++) { - const psgData = new Array(AY_REGISTER_COUNT).fill(0); - psgData[13] = (syncbuzzer.waveform[stepIndex] ?? 0) & 0xf; - const nextIndex = resolveNextWaveformIndex(stepIndex, syncbuzzer); - eventItems.push({ - psgData, - psgMask: encodeEventPsgApplyMask(shapeMask, channelIndex), - timerFrequency: encodeSyncBuzzerEventTimerFrequency(stepIndex, syncbuzzer, options), - timerEventIndex: startIndex + nextIndex - }); - } - - return startIndex; + return appendEffectStepSources(eventItems, channelIndex, [ + syncBuzzerStepSource(channelIndex, syncbuzzer, options) + ]); } function getOrCreateSidEventChain( @@ -754,29 +1071,14 @@ function getOrCreateSidEventChain( sid: HardwareSidState, options: TmrEncodeOptions ): number { - const key = sidEventChainKey(channelIndex, sid); + const key = sidEventChainCacheKey(channelIndex, sid); const existing = chainStartByKey.get(key); if (existing !== undefined) { return existing; } - const startIndex = eventItems.length; + const startIndex = appendSidEventChain(eventItems, channelIndex, sid, options); chainStartByKey.set(key, startIndex); - const volumeReg = volumeRegisterIndex(channelIndex); - const volumeMask = registerApplyMask(volumeReg); - - for (let stepIndex = 0; stepIndex < sid.waveform.length; stepIndex++) { - const psgData = new Array(AY_REGISTER_COUNT).fill(0); - psgData[volumeReg] = sidVolumeLevel(sid.waveform[stepIndex]!, sid.baseVolume); - const nextIndex = resolveNextWaveformIndex(stepIndex, sid); - eventItems.push({ - psgData, - psgMask: encodeEventPsgApplyMask(volumeMask, channelIndex), - timerFrequency: encodeSidEventTimerFrequency(stepIndex, sid, options), - timerEventIndex: startIndex + nextIndex - }); - } - return startIndex; } @@ -787,7 +1089,7 @@ function getOrCreateFmEventChain( fm: HardwareFmState, options: TmrEncodeOptions ): number { - const key = fmEventChainKey(channelIndex, fm); + const key = fmEventChainCacheKey(channelIndex, fm); const existing = chainStartByKey.get(key); if (existing !== undefined) { return existing; @@ -805,7 +1107,7 @@ function getOrCreateEnvFmEventChain( envFm: HardwareEnvFmState, options: TmrEncodeOptions ): number { - const key = envFmEventChainKey(channelIndex, envFm); + const key = envFmEventChainCacheKey(channelIndex, envFm); const existing = chainStartByKey.get(key); if (existing !== undefined) { return existing; diff --git a/tests/lib/services/file/tmr-encoder.test.ts b/tests/lib/services/file/tmr-encoder.test.ts index aac1e5a0..d07029c5 100644 --- a/tests/lib/services/file/tmr-encoder.test.ts +++ b/tests/lib/services/file/tmr-encoder.test.ts @@ -571,6 +571,237 @@ describe('tmr encoder', () => { 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', () => { + // Regression: the channel loop used an if/else-if chain across + // syncbuzzer -> sid -> fm -> envFm, so only the first active effect was + // exported and the rest were silently dropped. A single hardware timer per + // channel must drive ONE chain that writes every active register group per + // step. With a 2-step sync-buzzer (R13) and a 3-step Env+FM (R11/R12) the + // merged chain has LCM(2,3) = 6 steps. + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + frame.envFm[1] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + baseEnvelopePeriod: 1000, + fmOffsetMode: 'period', + waveform: [0, 16, 32], + waveformLoop: 0 + }; + + const { eventItems } = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + expect(eventItems).toHaveLength(6); + const shapeMask = envelopeShapeRegisterApplyMask(); + const envelopeMask = envelopePeriodRegisterApplyMask(); + const combinedMask = encodeEventPsgApplyMask(shapeMask | envelopeMask, 1); + + for (let step = 0; step < 6; step++) { + const item = eventItems[step]!; + expect(item.psgMask).toBe(combinedMask); + expect(item.timerEventIndex).toBe((step + 1) % 6); + expect(item.psgData[13]).toBe(step % 2 === 0 ? 0x0d : 0x09); + } + + const envPeriods = eventItems.map((item) => item.psgData[11]! | (item.psgData[12]! << 8)); + expect(envPeriods[0]).toBe(envPeriods[3]); + expect(envPeriods[1]).toBe(envPeriods[4]); + expect(envPeriods[2]).toBe(envPeriods[5]); + expect(new Set(envPeriods.slice(0, 3)).size).toBe(3); + }); + + it('preserves nonzero waveform loop points in merged chains', () => { + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + frame.envFm[1] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + baseEnvelopePeriod: 1000, + fmOffsetMode: 'period', + waveform: [0, 16, 32], + waveformLoop: 1 + }; + + const { eventItems } = encodeTMR([frame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + expect(eventItems).toHaveLength(3); + expect(eventItems.map((item) => item.psgData[13])).toEqual([0x0d, 0x09, 0x0d]); + const envPeriods = eventItems.map((item) => item.psgData[11]! | (item.psgData[12]! << 8)); + expect(new Set(envPeriods).size).toBe(3); + expect(eventItems.map((item) => item.timerEventIndex)).toEqual([1, 2, 1]); + }); + + it('reuses merged event chain when only non-PWM period changes', () => { + const makeFrame = (period: number): SongCaptureFrame => { + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + pwm: false, + period, + periodLow: period, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + frame.envFm[1] = { + enabled: true, + pwm: false, + period, + periodLow: period, + baseEnvelopePeriod: 1000, + fmOffsetMode: 'period', + waveform: [0, 16, 32], + waveformLoop: 0 + }; + return frame; + }; + + const encoded = encodeTMR([makeFrame(800), makeFrame(900)], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + 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(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 12)).toBe(0); + }); + + it('switches from merged chain back to single-effect chain', () => { + const mergedFrame = disabledSidFrame(); + mergedFrame.syncbuzzer[1] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + mergedFrame.envFm[1] = { + enabled: true, + pwm: false, + period: 1000, + periodLow: 1000, + baseEnvelopePeriod: 1000, + fmOffsetMode: 'period', + waveform: [0, 16, 32], + waveformLoop: 0 + }; + const singleFrame = disabledSidFrame(); + singleFrame.syncbuzzer[1] = { ...mergedFrame.syncbuzzer[1]! }; + + const encoded = encodeTMR([mergedFrame, singleFrame], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 8 * TMR_ITEM_SIZE); + expect(readU32LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 8)).toBe( + storedTimerHz(1000) + ); + expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 12)).toBe(6); + }); + + it('restarts the merged timer when the shared period sweeps', () => { + // project14 case: a duty sync-buzzer whose period sweeps every frame while + // Env+FM rides the same timer. Each sweep must re-emit a fresh merged chain + // (per-step timer frequencies are baked at chain build time) and restart the + // timer, instead of inheriting (0) and freezing the sweep. + const makeFrame = (period: number, periodLow: number): SongCaptureFrame => { + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + pwm: true, + period, + periodLow, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + frame.envFm[1] = { + enabled: true, + pwm: false, + period, + periodLow: period, + baseEnvelopePeriod: 1000, + fmOffsetMode: 'period', + waveform: [0, 16, 32], + waveformLoop: 0 + }; + return frame; + }; + + const frames = [makeFrame(435, 401), makeFrame(468, 368), makeFrame(502, 334)]; + const encoded = encodeTMR(frames, { chipFrequency: 1773400, interruptFrequency: 50 }); + + const slotOffset = TMR_HEADER_SIZE + 8; // channel B timer slot + // First frame launches the chain head. + expect(readU16LE(encoded.tmr, slotOffset + 4)).toBe(0); + expect(readU32LE(encoded.tmr, slotOffset + 2)).not.toBe(0); + // Subsequent sweeps restart the timer at a fresh chain head (not inherit 0). + for (let f = 1; f < frames.length; f++) { + const off = TMR_HEADER_SIZE + f * TMR_FRAME_SIZE + 8; + expect(readU32LE(encoded.tmr, off + 2)).not.toBe(0); + expect(readU16LE(encoded.tmr, off + 4)).not.toBe(TMR_TIMER_EVENT_STOP); + } + // Three distinct 6-step merged chains were appended. + expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 3 * 6 * TMR_ITEM_SIZE); + }); + + it('rebuilds merged PWM chain when only the low period changes', () => { + const makeFrame = (periodLow: number): SongCaptureFrame => { + const frame = disabledSidFrame(); + frame.syncbuzzer[1] = { + enabled: true, + pwm: true, + period: 435, + periodLow, + waveform: [0x0d, 0x09], + waveformLoop: 0 + }; + frame.envFm[1] = { + enabled: true, + pwm: false, + period: 435, + periodLow: 435, + baseEnvelopePeriod: 1000, + fmOffsetMode: 'period', + waveform: [0, 16, 32], + waveformLoop: 0 + }; + return frame; + }; + + const encoded = encodeTMR([makeFrame(401), makeFrame(368)], { + chipFrequency: 1773400, + interruptFrequency: 50 + }); + + expect(encoded.eventList.byteLength).toBe(TEL_HEADER_SIZE + 2 * 6 * TMR_ITEM_SIZE); + expect(readU16LE(encoded.tmr, TMR_HEADER_SIZE + TMR_FRAME_SIZE + 12)).toBe(6); + }); }); describe('tmr split files', () => { From c8fd3d856d83187f5e8788b0250c5ea56c2430ac Mon Sep 17 00:00:00 2001 From: Ru Grantez Date: Fri, 26 Jun 2026 09:53:43 +0200 Subject: [PATCH 4/4] Serve public module imports in dev --- vite.config.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index ca308ffb..32a859bd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,11 @@ -import type { ResolvedConfig } from 'vite'; +import type { ResolvedConfig, ViteDevServer } from 'vite'; import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import tailwindcss from '@tailwindcss/vite'; import Icons from 'unplugin-icons/vite'; import { execSync } from 'child_process'; -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; +import { createReadStream, existsSync, readFileSync, writeFileSync } from 'fs'; +import { isAbsolute, join, relative, resolve } from 'path'; function getGitCommitHash() { try { @@ -35,6 +35,50 @@ function serviceWorkerCacheVersion() { }; } +function servePublicModuleImportsInDev() { + return { + name: 'serve-public-module-imports-dev', + apply: 'serve' as const, + configureServer(server: ViteDevServer) { + server.middlewares.use((req, res, next) => { + const requestUrl = req.url ?? ''; + const queryIndex = requestUrl.indexOf('?'); + const pathname = queryIndex === -1 ? requestUrl : requestUrl.slice(0, queryIndex); + const query = queryIndex === -1 ? '' : requestUrl.slice(queryIndex + 1); + + if (!pathname.endsWith('.js') || !new URLSearchParams(query).has('import')) { + next(); + return; + } + + let decodedPathname: string; + try { + decodedPathname = decodeURIComponent(pathname); + } catch { + next(); + return; + } + + const publicDir = resolve(server.config.publicDir); + const filePath = resolve(publicDir, `.${decodedPathname}`); + const relativePath = relative(publicDir, filePath); + if ( + relativePath.startsWith('..') || + isAbsolute(relativePath) || + !existsSync(filePath) + ) { + next(); + return; + } + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/javascript'); + createReadStream(filePath).pipe(res); + }); + } + }; +} + // https://vite.dev/config/ export default defineConfig({ base: '/', @@ -54,7 +98,8 @@ export default defineConfig({ Icons({ compiler: 'svelte' }), - serviceWorkerCacheVersion() + serviceWorkerCacheVersion(), + servePublicModuleImportsInDev() ], define: { 'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(getGitCommitHash()),