diff --git a/build-wasm.ps1 b/build-wasm.ps1 index 28de3c37..affab714 100644 --- a/build-wasm.ps1 +++ b/build-wasm.ps1 @@ -3,18 +3,18 @@ if ($env:EMSDK) { if (Test-Path $envScript) { & $envScript } } -$emccArgs = "-O3", +$emccArgs = "-O3", "-DNDEBUG", "-s", "WASM=1", "-s", "ALLOW_MEMORY_GROWTH=1", "-s", "INITIAL_MEMORY=16777216", "-s", "MAXIMUM_MEMORY=16777216", "-s", "ENVIRONMENT=web", "-s", "STANDALONE_WASM=1", "--no-entry" -$ayumiArgs = "external/ayumi/ayumi.c", "-o", "public/ayumi.wasm", - "-s", "EXPORTED_FUNCTIONS=`"[\`"_ayumi_configure\`", \`"_ayumi_set_pan\`", \`"_ayumi_set_tone\`", \`"_ayumi_set_noise\`", \`"_ayumi_set_mixer\`", \`"_ayumi_set_volume\`", \`"_ayumi_set_timer_effect\`", \`"_ayumi_set_timer_effect_slot\`", \`"_ayumi_set_timer_effect_waveform\`", \`"_ayumi_timer_effect_reset\`", \`"_ayumi_get_timer_effect_active_period\`", \`"_ayumi_get_registers\`", \`"_ayumi_struct_size\`", \`"_ayumi_set_envelope\`", \`"_ayumi_set_envelope_shape\`", \`"_ayumi_process\`", \`"_ayumi_remove_dc\`", \`"_malloc\`", \`"_free\`"]`"", +$ayumiArgs = "external/ayumi/ayumi.c", "-o", "public/ay/ayumi.wasm", + "-s", "EXPORTED_FUNCTIONS=`"[\`"_ayumi_configure\`", \`"_ayumi_set_pan\`", \`"_ayumi_set_tone\`", \`"_ayumi_set_noise\`", \`"_ayumi_set_mixer\`", \`"_ayumi_set_volume\`", \`"_ayumi_set_timer_effect\`", \`"_ayumi_set_timer_effect_slot\`", \`"_ayumi_set_timer_effect_waveform\`", \`"_ayumi_timer_effect_reset\`", \`"_ayumi_get_timer_effect_active_period\`", \`"_ayumi_get_registers\`", \`"_ayumi_struct_size\`", \`"_ayumi_set_envelope\`", \`"_ayumi_set_envelope_shape\`", \`"_ayumi_process\`", \`"_ayumi_remove_dc\`", \`"_malloc\`", \`"_free\`"]`"" -$nesApuArgs = "external/nsfplug/nes_apu.c", "external/nsfplug/nes_dmc.c", "-o", "public/nes_apu.wasm", - "-s", "EXPORTED_FUNCTIONS=`"[\`"_nes_apu_Init\`", \`"_nes_apu_Reset\`", \`"_nes_apu_Tick\`", \`"_nes_apu_Render\`", \`"_nes_apu_Write\`", \`"_nes_apu_SetMask\`", \`"_nes_apu_SetStereoMix\`", \`"_nes_dmc_Init\`", \`"_nes_dmc_Reset\`", \`"_nes_dmc_Tick\`", \`"_nes_dmc_Render\`", \`"_nes_dmc_Write\`", \`"_nes_dmc_SetMask\`", \`"_nes_dmc_SetStereoMix\`", \`"_nes_dmc_SetPal\`", \`"_nes_dmc_SetAPU\`", \`"_nes_dmc_SetMemory_Read\`", \`"_nes_dmc_TickFrameSequence\`"]`"" +$nesApuArgs = "external/nsfplug/nes_apu.c", "external/nsfplug/nes_dmc.c", "-o", "public/nes/nes_apu.wasm", + "-s", "EXPORTED_FUNCTIONS=`"[\`"_nes_apu_Init\`", \`"_nes_apu_Reset\`", \`"_nes_apu_Tick\`", \`"_nes_apu_Render\`", \`"_nes_apu_Write\`", \`"_nes_apu_SetMask\`", \`"_nes_apu_SetStereoMix\`", \`"_nes_apu_GetOut\`", \`"_nes_dmc_Init\`", \`"_nes_dmc_Reset\`", \`"_nes_dmc_Tick\`", \`"_nes_dmc_Render\`", \`"_nes_dmc_Write\`", \`"_nes_dmc_SetMask\`", \`"_nes_dmc_SetStereoMix\`", \`"_nes_dmc_SetPal\`", \`"_nes_dmc_SetAPU\`", \`"_nes_dmc_SetMemory_Read\`", \`"_nes_dmc_TickFrameSequence\`", \`"_nes_dmc_GetOut\`", \`"_malloc\`", \`"_free\`"]`"" -$nesMmc5Args = "external/nsfplug/nes_mmc5.c", "-o", "public/nes_mmc5.wasm", +$nesMmc5Args = "external/nsfplug/nes_mmc5.c", "-o", "public/nes/nes_mmc5.wasm", "-s", "EXPORTED_FUNCTIONS=`"[\`"_nes_mmc5_Init\`", \`"_nes_mmc5_Reset\`", \`"_nes_mmc5_Tick\`", \`"_nes_mmc5_Render\`", \`"_nes_mmc5_Write\`", \`"_nes_mmc5_SetMask\`", \`"_nes_mmc5_SetStereoMix\`", \`"_nes_mmc5_TickFrameSequence\`"]`"" $python = $null diff --git a/build-wasm.sh b/build-wasm.sh index 5ea36216..7b0ea090 100755 --- a/build-wasm.sh +++ b/build-wasm.sh @@ -12,6 +12,7 @@ fi EMCC_ARGS="\ -O3 \ + -DNDEBUG \ -s WASM=1 \ -s ALLOW_MEMORY_GROWTH=1 \ -s INITIAL_MEMORY=16777216 \ @@ -23,15 +24,15 @@ EMCC_ARGS="\ emcc ${EMCC_ARGS} \ external/ayumi/ayumi.c \ - -o public/ayumi.wasm \ + -o public/ay/ayumi.wasm \ -s EXPORTED_FUNCTIONS='["_ayumi_configure", "_ayumi_set_pan", "_ayumi_set_tone", "_ayumi_set_noise", "_ayumi_set_mixer", "_ayumi_set_volume", "_ayumi_set_timer_effect", "_ayumi_set_timer_effect_slot", "_ayumi_set_timer_effect_waveform", "_ayumi_timer_effect_reset", "_ayumi_get_timer_effect_active_period", "_ayumi_get_registers", "_ayumi_struct_size", "_ayumi_set_envelope", "_ayumi_set_envelope_shape", "_ayumi_process", "_ayumi_remove_dc", "_malloc", "_free"]' emcc ${EMCC_ARGS} \ external/nsfplug/nes_apu.c external/nsfplug/nes_dmc.c \ - -o public/nes_apu.wasm \ - -s EXPORTED_FUNCTIONS='["_nes_apu_Init", "_nes_apu_Reset", "_nes_apu_Tick", "_nes_apu_Render", "_nes_apu_Write", "_nes_apu_SetMask", "_nes_apu_SetStereoMix", "_nes_dmc_Init", "_nes_dmc_Reset", "_nes_dmc_Tick", "_nes_dmc_Render", "_nes_dmc_Write", "_nes_dmc_SetMask", "_nes_dmc_SetStereoMix", "_nes_dmc_SetPal", "_nes_dmc_SetAPU", "_nes_dmc_SetMemory_Read", "_nes_dmc_TickFrameSequence"]' + -o public/nes/nes_apu.wasm \ + -s EXPORTED_FUNCTIONS='["_nes_apu_Init", "_nes_apu_Reset", "_nes_apu_Tick", "_nes_apu_Render", "_nes_apu_Write", "_nes_apu_SetMask", "_nes_apu_SetStereoMix", "_nes_apu_GetOut", "_nes_dmc_Init", "_nes_dmc_Reset", "_nes_dmc_Tick", "_nes_dmc_Render", "_nes_dmc_Write", "_nes_dmc_SetMask", "_nes_dmc_SetStereoMix", "_nes_dmc_SetPal", "_nes_dmc_SetAPU", "_nes_dmc_SetMemory_Read", "_nes_dmc_TickFrameSequence", "_nes_dmc_GetOut", "_malloc", "_free"]' emcc ${EMCC_ARGS} \ external/nsfplug/nes_mmc5.c \ - -o public/nes_mmc5.wasm \ + -o public/nes/nes_mmc5.wasm \ -s EXPORTED_FUNCTIONS='["_nes_mmc5_Init", "_nes_mmc5_Reset", "_nes_mmc5_Tick", "_nes_mmc5_Render", "_nes_mmc5_Write", "_nes_mmc5_SetMask", "_nes_mmc5_SetStereoMix", "_nes_mmc5_TickFrameSequence"]' diff --git a/external/nsfplug/nes_apu.c b/external/nsfplug/nes_apu.c index b5fd9a59..5542c2f5 100644 --- a/external/nsfplug/nes_apu.c +++ b/external/nsfplug/nes_apu.c @@ -183,6 +183,13 @@ uint32_t nes_apu_Render (nes_apu_t *s, int32_t b[2]) return 2; } +int32_t nes_apu_GetOut (nes_apu_t *s, int channel) +{ + if (channel < 0 || channel > 1) + return 0; + return s->out[channel]; +} + void nes_apu_Init (nes_apu_t *s) { s->option[NES_APU_OPT_UNMUTE_ON_RESET] = true; diff --git a/external/nsfplug/nes_apu.h b/external/nsfplug/nes_apu.h index 46a093ff..9c2fa48f 100644 --- a/external/nsfplug/nes_apu.h +++ b/external/nsfplug/nes_apu.h @@ -67,4 +67,6 @@ void nes_apu_SetOption (nes_apu_t *s, int id, int b); void nes_apu_SetMask(nes_apu_t *s, int m); void nes_apu_SetStereoMix (nes_apu_t *s, int trk, int32_t mixl, int32_t mixr); +int32_t nes_apu_GetOut (nes_apu_t *s, int channel); + #endif diff --git a/external/nsfplug/nes_dmc.c b/external/nsfplug/nes_dmc.c index 8054e2b6..6a83e26b 100644 --- a/external/nsfplug/nes_dmc.c +++ b/external/nsfplug/nes_dmc.c @@ -375,6 +375,13 @@ uint32_t nes_dmc_Render (nes_dmc_t* s, int32_t b[2]) return 2; } +int32_t nes_dmc_GetOut (nes_dmc_t* s, int channel) +{ + if (channel < 0 || channel > 2) + return 0; + return (int32_t)s->out[channel]; +} + void nes_dmc_SetPal (nes_dmc_t* s, bool is_pal) { s->pal = (is_pal ? 1 : 0); @@ -484,16 +491,16 @@ void nes_dmc_Reset (nes_dmc_t* s) s->noise = 1; s->noise_tap = (1<<1); - if (s->option[NES_DMC_OPT_RANDOMIZE_NOISE]) - { - s->noise |= rand(); - s->counter[1] = -(rand() & 511); - } - if (s->option[NES_DMC_OPT_RANDOMIZE_TRI]) - { - s->tphase = rand() & 31; - s->counter[0] = -(rand() & 2047); - } + // if (s->option[NES_DMC_OPT_RANDOMIZE_NOISE]) + // { + // s->noise |= rand(); + // s->counter[1] = -(rand() & 511); + // } + // if (s->option[NES_DMC_OPT_RANDOMIZE_TRI]) + // { + // s->tphase = rand() & 31; + // s->counter[0] = -(rand() & 2047); + // } } void nes_dmc_SetMemory_Read (nes_dmc_t* s, read_func * r) diff --git a/external/nsfplug/nes_dmc.h b/external/nsfplug/nes_dmc.h index d2d94c1d..f3cc6a8c 100644 --- a/external/nsfplug/nes_dmc.h +++ b/external/nsfplug/nes_dmc.h @@ -101,6 +101,8 @@ bool nes_dmc_Read (nes_dmc_t* s, uint32_t adr, uint32_t* val); void nes_dmc_SetOption (nes_dmc_t* s, int id, int b); void nes_dmc_SetStereoMix (nes_dmc_t* s, int trk, int32_t mixl, int32_t mixr); +int32_t nes_dmc_GetOut (nes_dmc_t* s, int channel); + // void nes_dmc_SetCPU(nes_dmc_t* s, NES_CPU* cpu_); #endif diff --git a/public/audio-slot-registry.js b/public/audio/audio-slot-registry.js similarity index 100% rename from public/audio-slot-registry.js rename to public/audio/audio-slot-registry.js diff --git a/public/bitphase-audio-processor.js b/public/audio/bitphase-audio-processor.js similarity index 80% rename from public/bitphase-audio-processor.js rename to public/audio/bitphase-audio-processor.js index 11f5f7cc..efa952de 100644 --- a/public/bitphase-audio-processor.js +++ b/public/audio/bitphase-audio-processor.js @@ -1,7 +1,11 @@ -import SongTimeline from './song-timeline.js'; +import SongTimeline from '../tracker/song-timeline.js'; import { createAudioSlot } from './audio-slot-registry.js'; import './builtin-audio-slots.js'; +function sortPlaySlotsForQuantum(playSlots) { + return [...playSlots].sort((a, b) => (b.chipIndex ?? 0) - (a.chipIndex ?? 0)); +} + class BitphaseAudioProcessor extends AudioWorkletProcessor { constructor() { super(); @@ -78,14 +82,19 @@ class BitphaseAudioProcessor extends AudioWorkletProcessor { return true; } + const active = slots.filter((s) => s && s.canRender()); + const anyPreview = active.some((s) => s.isPreviewActive()); + const playSlots = anyPreview ? [] : active.filter((s) => s.shouldRunPlaybackAccumulation()); + const outputSlots = anyPreview + ? [] + : active.filter((s) => s.shouldAccumulateStereoOutput()); + const quantumSlots = sortPlaySlotsForQuantum(playSlots); + const leaderLen = this.leaderPatternLength(); + for (let i = 0; i < numSamples; i++) { tl.tickAccumulator += tl.tickStep; const mix = { l: 0, r: 0 }; - const active = slots.filter((s) => s && s.canRender()); - const anyPreview = active.some((s) => s.isPreviewActive()); - const playSlots = active.filter((s) => s.shouldRunPlaybackAccumulation()); - if (anyPreview) { for (const s of active) { if (s.isPreviewActive()) { @@ -94,21 +103,19 @@ class BitphaseAudioProcessor extends AudioWorkletProcessor { } } } else if (playSlots.length > 0 && tl.tickAccumulator >= 1.0) { - for (const s of playSlots) { + for (const s of quantumSlots) { s.runSharedPlaybackQuantum(); } - const leaderLen = this.leaderPatternLength(); const needsOrderWrap = tl.advancePosition(leaderLen); for (let j = 0; j < slots.length; j++) { const s = slots[j]; if (s) s.onPatternOrderAdvanced(needsOrderWrap); } tl.tickAccumulator -= 1.0; - for (const s of playSlots) { - s.accumulateStereoOutput(i, mix); - } - } else { - for (const s of playSlots) { + } + + if (!anyPreview) { + for (const s of outputSlots) { s.accumulateStereoOutput(i, mix); } } diff --git a/public/audio/builtin-audio-slots.js b/public/audio/builtin-audio-slots.js new file mode 100644 index 00000000..066d1652 --- /dev/null +++ b/public/audio/builtin-audio-slots.js @@ -0,0 +1,15 @@ +import { registerAudioSlotKind } from './audio-slot-registry.js'; +import { AyumiSlot } from '../ay/ayumi-slot.js'; +import { NesWorkletSlot } from '../nes/nes-worklet-slot.js'; + +registerAudioSlotKind('ayumi', (port, chipIndex, sharedTimeline, initData) => { + const slot = new AyumiSlot(port, chipIndex, sharedTimeline); + void slot.handleMessage({ type: 'init', wasmBuffer: initData.wasmBuffer }); + return slot; +}); + +registerAudioSlotKind('nes', (port, chipIndex, sharedTimeline, initData) => { + const slot = new NesWorkletSlot(port, chipIndex, sharedTimeline); + void slot.handleMessage({ type: 'init', wasmBuffer: initData.wasmBuffer }); + return slot; +}); diff --git a/public/ay-audio-driver.js b/public/ay/ay-audio-driver.js similarity index 94% rename from public/ay-audio-driver.js rename to public/ay/ay-audio-driver.js index 512233dc..b7fd0141 100644 --- a/public/ay-audio-driver.js +++ b/public/ay/ay-audio-driver.js @@ -1,7 +1,40 @@ import AYChipRegisterState from './ay-chip-register-state.js'; -import EffectAlgorithms from './effect-algorithms.js'; -import { PT3VolumeTable } from './pt3-volume-table.js'; -import { normalizeAyInstrumentFields, getAySidBaseVolume, computeTimerEffectPeriod, computeTimerPwmPeriods, effectiveRowTimerWaveform, effectiveRowFmWaveform, effectiveRowFmWaveformLoop, effectiveRowEnvFmWaveform, effectiveRowEnvFmWaveformLoop, resolveAyFmOffsetMode, effectiveRowTimerWaveformLoop, effectiveRowTimerPwmDuty, effectiveRowTimerPwmSweep, effectiveRowTimerPwmSweepMin, rowSupportsSidTimerPwm, rowSupportsSyncbuzzerTimerPwm, rowSupportsFmTimerPwm, rowSupportsEnvFmTimerPwm, rowSupportsTimerPwm, rowUsesSyncbuzzerPwmDuty, resolveSyncbuzzerWaveform, isPatternEnvelopeShapeSet, advanceTimerPwmSweep, DEFAULT_AY_TIMER_PWM_DUTY } from './ay-instrument-utils.js'; +import EffectAlgorithms from '../tracker/effect-algorithms.js'; +import { PT3VolumeTable } from '../tracker/pt3-volume-table.js'; +import { advanceInstrumentRowPosition } from '../tracker/tracker-audio-utils.js'; +import { + assignPatternRowInstrument, + channelHasAssignedInstrument, + getChannelInstrument, + isChannelOnOffHalted, + processChannelOnOffCounters +} from '../tracker/tracker-instrument-channel.js'; +import { + normalizeAyInstrumentFields, + getAySidBaseVolume, + computeTimerEffectPeriod, + computeTimerPwmPeriods, + effectiveRowTimerWaveform, + effectiveRowFmWaveform, + effectiveRowFmWaveformLoop, + effectiveRowEnvFmWaveform, + effectiveRowEnvFmWaveformLoop, + resolveAyFmOffsetMode, + effectiveRowTimerWaveformLoop, + effectiveRowTimerPwmDuty, + effectiveRowTimerPwmSweep, + effectiveRowTimerPwmSweepMin, + rowSupportsSidTimerPwm, + rowSupportsSyncbuzzerTimerPwm, + rowSupportsFmTimerPwm, + rowSupportsEnvFmTimerPwm, + rowSupportsTimerPwm, + rowUsesSyncbuzzerPwmDuty, + resolveSyncbuzzerWaveform, + isPatternEnvelopeShapeSet, + advanceTimerPwmSweep, + DEFAULT_AY_TIMER_PWM_DUTY +} from './ay-instrument-utils.js'; import { instrumentHasSample, computeSampleSidPeriod, @@ -23,15 +56,6 @@ import { disableTimerEffect } from './ay-timer-effect-constants.js'; -function advanceInstrumentRowPosition(position, rowsLength, loop) { - const length = rowsLength > 0 ? rowsLength : 1; - let next = position + 1; - if (next >= length) { - next = loop > 0 && loop < length ? loop : 0; - } - return next; -} - class AYAudioDriver { constructor(channelCount = 3) { this.channelMixerState = []; @@ -290,30 +314,23 @@ class AYAudioDriver { } _processInstrument(state, channelIndex, row) { - if (!state.channelInstruments || !state.instruments) return; - if (state.channelMuted[channelIndex]) return; + const assignment = assignPatternRowInstrument(state, channelIndex, row); + if (!assignment.changed) return; + if (!assignment.assigned) return; - if (row.instrument > 0) { - const instrumentIndex = state.instrumentIdToIndex.get(row.instrument); - if (instrumentIndex !== undefined && state.instruments[instrumentIndex]) { - const instrument = state.instruments[instrumentIndex]; - state.channelInstruments[channelIndex] = instrumentIndex; - const preserveSamplePlayback = this.shouldPreserveSamplePlayback(state, channelIndex, row); - if (!(preserveSamplePlayback && instrumentHasSample(instrument))) { - state.instrumentPositions[channelIndex] = 0; - if (state.channelTimerPositions) { - state.channelTimerPositions[channelIndex] = 0; - } - } - if (instrumentHasSample(instrument) && !preserveSamplePlayback) { - resetChannelSamplePlayback(state, channelIndex, instrument); - } - const preserveTimerPwmSweep = this.shouldPreserveTimerPwmSweep(state, channelIndex, row); - this.resetInstrumentAccumulators(state, channelIndex, { preserveTimerPwmSweep }); - } else { - state.channelInstruments[channelIndex] = -1; + const instrument = assignment.instrument; + const preserveSamplePlayback = this.shouldPreserveSamplePlayback(state, channelIndex, row); + if (!(preserveSamplePlayback && instrumentHasSample(instrument))) { + state.instrumentPositions[channelIndex] = 0; + if (state.channelTimerPositions) { + state.channelTimerPositions[channelIndex] = 0; } } + if (instrumentHasSample(instrument) && !preserveSamplePlayback) { + resetChannelSamplePlayback(state, channelIndex, instrument); + } + const preserveTimerPwmSweep = this.shouldPreserveTimerPwmSweep(state, channelIndex, row); + this.resetInstrumentAccumulators(state, channelIndex, { preserveTimerPwmSweep }); } rowHasPortamentoCommand(row) { @@ -871,8 +888,7 @@ class AYAudioDriver { for (let channelIndex = 0; channelIndex < state.channelInstruments.length; channelIndex++) { const isMuted = state.channelMuted[channelIndex]; const isSoundEnabled = state.channelSoundEnabled[channelIndex]; - const onOffHalted = - state.channelOnOffCounter[channelIndex] > 0 && !state.channelSoundEnabled[channelIndex]; + const onOffHalted = isChannelOnOffHalted(state, channelIndex); if (isMuted) { registerState.channels[channelIndex].volume = 0; @@ -886,8 +902,7 @@ class AYAudioDriver { continue; } - const instrumentIndex = state.channelInstruments[channelIndex]; - const instrument = state.instruments[instrumentIndex]; + const { instrumentIndex, instrument } = getChannelInstrument(state, channelIndex); if (instrumentHasSample(instrument)) { this.processSampleInstrument( @@ -901,7 +916,7 @@ class AYAudioDriver { continue; } - if (instrumentIndex < 0 || !instrument) { + if (!channelHasAssignedInstrument(state, channelIndex)) { registerState.channels[channelIndex].volume = 0; registerState.channels[channelIndex].mixer.tone = false; registerState.channels[channelIndex].mixer.noise = false; @@ -1196,18 +1211,7 @@ class AYAudioDriver { ); } - for (let channelIndex = 0; channelIndex < state.channelInstruments.length; channelIndex++) { - if (state.channelOnOffCounter[channelIndex] > 0) { - const result = EffectAlgorithms.processOnOffCounter( - state.channelOnOffCounter[channelIndex], - state.channelOnDuration[channelIndex], - state.channelOffDuration[channelIndex], - state.channelSoundEnabled[channelIndex] - ); - state.channelOnOffCounter[channelIndex] = result.counter; - state.channelSoundEnabled[channelIndex] = result.enabled; - } - } + processChannelOnOffCounters(state, state.channelInstruments.length); this.processEnvelopeOnOff(state); diff --git a/public/ay-chip-register-state.js b/public/ay/ay-chip-register-state.js similarity index 100% rename from public/ay-chip-register-state.js rename to public/ay/ay-chip-register-state.js diff --git a/public/ay-instrument-utils.js b/public/ay/ay-instrument-utils.js similarity index 100% rename from public/ay-instrument-utils.js rename to public/ay/ay-instrument-utils.js diff --git a/public/ay-sample-lut.js b/public/ay/ay-sample-lut.js similarity index 100% rename from public/ay-sample-lut.js rename to public/ay/ay-sample-lut.js diff --git a/public/ay-sample-playback.js b/public/ay/ay-sample-playback.js similarity index 100% rename from public/ay-sample-playback.js rename to public/ay/ay-sample-playback.js diff --git a/public/ay-timer-effect-constants.js b/public/ay/ay-timer-effect-constants.js similarity index 100% rename from public/ay-timer-effect-constants.js rename to public/ay/ay-timer-effect-constants.js diff --git a/public/ay/ay8910-worklet-slot.js b/public/ay/ay8910-worklet-slot.js new file mode 100644 index 00000000..9df0997d --- /dev/null +++ b/public/ay/ay8910-worklet-slot.js @@ -0,0 +1,198 @@ +import AyumiState from './ayumi-state.js'; +import TrackerPatternProcessor from '../tracker/tracker-pattern-processor.js'; +import AYAudioDriver from './ay-audio-driver.js'; +import AyumiEngine from './ayumi-engine.js'; +import AYChipRegisterState from './ay-chip-register-state.js'; +import VirtualChannelMixer from './virtual-channel-mixer.js'; +import { disableAllChannelTimerEffects, ensureChannelTimerEffects } from './ay-timer-effect-constants.js'; +import { TrackerWorkletSlot } from '../tracker/tracker-worklet-slot.js'; +import { resetChipPlaybackOutput } from '../tracker/tracker-engine-transport.js'; + +export class Ay8910WorkletSlot extends TrackerWorkletSlot { + constructor(port, chipIndex, sharedTimeline) { + super(port, chipIndex); + this.state = new AyumiState(3, sharedTimeline); + this.initialized = false; + this.audioDriver = null; + this.patternProcessor = null; + this.ayumiEngine = null; + this.registerState = new AYChipRegisterState(); + this.virtualChannelMixer = new VirtualChannelMixer(); + this.virtualChannelMap = {}; + this.hwChannelCount = 3; + } + + _slotState() { + return this.state; + } + + _isReadyForPlayback() { + return this.initialized && this.state.wasmModule && this.state.ayumiPtr; + } + + _resizeForPatternChannels(channelCount) { + this._ensureChannelCapacity(channelCount); + } + + _applyPlaybackSpeed(speed) { + if (!(speed > 0)) return; + this.state.publishPlaybackSpeed(speed); + } + + _chipEngineReady() { + return this._playbackWorkersReady() && this.ayumiEngine; + } + + _onTransportStop() { + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); + } + + _dispatchChipPortMessage(type, data) { + if (type !== 'set_virtual_channel_config') { + return false; + } + this.handleSetVirtualChannelConfig(data); + return true; + } + + applyChannelSilent(registerState, channelIndex) { + registerState.channels[channelIndex].volume = 0; + registerState.channels[channelIndex].mixer = { + tone: false, + noise: false, + envelope: false + }; + disableAllChannelTimerEffects(ensureChannelTimerEffects(registerState.channels[channelIndex])); + } + + handleSetVirtualChannelConfig({ virtualChannelMap, hwChannelCount }) { + this.virtualChannelMap = virtualChannelMap || {}; + this.hwChannelCount = hwChannelCount || 3; + this.virtualChannelMixer.configure(this.virtualChannelMap, this.hwChannelCount); + + const totalChannels = this.virtualChannelMixer.getTotalVirtualChannelCount(); + this.state.resizeChannels(totalChannels); + this.registerState.resize(totalChannels); + if (this.audioDriver) { + this.audioDriver.resizeChannels(totalChannels); + } + if (this.ayumiEngine) { + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); + } + } + + handleSetChannelMute({ channelIndex, muted }) { + const totalChannels = this.virtualChannelMixer.getTotalVirtualChannelCount(); + if (channelIndex >= 0 && channelIndex < totalChannels) { + this.state.channelMuted[channelIndex] = muted; + if (muted) { + this.applyChannelSilent(this.registerState, channelIndex); + this.state.channelEnvelopeEnabled[channelIndex] = false; + if (this.ayumiEngine) { + this._applyRegisterStateToEngine(); + } + } + } + } + + _getEngineRegisterState() { + if (this.virtualChannelMixer.hasVirtualChannels()) { + return this.virtualChannelMixer.merge(this.registerState, this.state); + } + return this.registerState; + } + + _collectHardwareRegisters() { + const wasmModule = this.state?.wasmModule; + const ayumiPtr = this.state?.ayumiPtr; + const getRegisters = wasmModule?.ayumi_get_registers; + if (typeof getRegisters === 'function' && ayumiPtr) { + if (!this._hardwareRegistersBufferPtr) { + this._hardwareRegistersBufferPtr = wasmModule.malloc(14); + } + getRegisters(ayumiPtr, this._hardwareRegistersBufferPtr); + return Array.from( + new Uint8Array(wasmModule.memory.buffer, this._hardwareRegistersBufferPtr, 14) + ); + } + return this._getEngineRegisterState().toHardwareRegisters(); + } + + _applyRegisterStateToEngine() { + if (!this.ayumiEngine) return; + if (this.virtualChannelMixer.hasVirtualChannels()) { + const hwState = this._getEngineRegisterState(); + this.ayumiEngine.applyRegisterState(hwState); + this.registerState.forceEnvelopeShapeWrite = false; + } else { + this.ayumiEngine.applyRegisterState(this.registerState); + } + } + + _applyVirtualChannelResize() { + if (!this.virtualChannelMixer.hasVirtualChannels()) return; + const totalChannels = this.virtualChannelMixer.getTotalVirtualChannelCount(); + this.state.resizeChannels(totalChannels); + this.registerState.resize(totalChannels); + if (this.audioDriver) { + this.audioDriver.resizeChannels(totalChannels); + } + } + + _ensureChannelCapacity(channelCount) { + if (channelCount <= this.registerState.channelCount) return; + this.state.resizeChannels(channelCount); + this.registerState.resize(channelCount); + if (this.audioDriver) { + this.audioDriver.resizeChannels(channelCount); + } + } + + enforceMuteState() { + const totalChannels = this.registerState.channelCount; + for (let ch = 0; ch < totalChannels; ch++) { + if (this.state.channelMuted[ch]) { + this.applyChannelSilent(this.registerState, ch); + this.state.channelEnvelopeEnabled[ch] = false; + } + } + } + + ensurePlaybackWorkers() { + if (!this.audioDriver || !this.patternProcessor || !this.ayumiEngine) { + this.audioDriver = new AYAudioDriver(); + this.ayumiEngine = new AyumiEngine(this.state.wasmModule, this.state.ayumiPtr); + this.patternProcessor = new TrackerPatternProcessor( + this.state, + this.audioDriver, + this.port + ); + this._applyVirtualChannelResize(); + } + } + + _resetEnginesForPreview() { + resetChipPlaybackOutput({ + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine + }); + } + + _silencePreviewChannel(channelIndex) { + this.applyChannelSilent(this.registerState, channelIndex); + } + + canRender() { + return Boolean(this.initialized && this.state.wasmModule && this.state.ayumiPtr); + } +} diff --git a/public/ayumi-constants.js b/public/ay/ayumi-constants.js similarity index 100% rename from public/ayumi-constants.js rename to public/ay/ayumi-constants.js diff --git a/public/ayumi-engine.js b/public/ay/ayumi-engine.js similarity index 100% rename from public/ayumi-engine.js rename to public/ay/ayumi-engine.js diff --git a/public/ayumi-slot.js b/public/ay/ayumi-slot.js similarity index 94% rename from public/ayumi-slot.js rename to public/ay/ayumi-slot.js index fe9b99b8..2b1654d5 100644 --- a/public/ayumi-slot.js +++ b/public/ay/ayumi-slot.js @@ -14,9 +14,10 @@ import { TIMER_EFFECT_SLOT_SYNCBUZZER, disableAllChannelTimerEffects } from './ay-timer-effect-constants.js'; +import { resetChipPlaybackOutput } from '../tracker/tracker-engine-transport.js'; import AYAudioDriver from './ay-audio-driver.js'; import AyumiEngine from './ayumi-engine.js'; -import TrackerPatternProcessor from './tracker-pattern-processor.js'; +import TrackerPatternProcessor from '../tracker/tracker-pattern-processor.js'; import { Ay8910WorkletSlot } from './ay8910-worklet-slot.js'; export class AyumiSlot extends Ay8910WorkletSlot { @@ -175,14 +176,12 @@ export class AyumiSlot extends Ay8910WorkletSlot { _prepareOutputForPlay() { this.fadeInSamples = Math.floor(sampleRate * this.fadeInDuration); - this.registerState.reset(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - this._applyRegisterStateToEngine(); - } + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); } resetChannelWaveformCapture() { @@ -308,7 +307,22 @@ export class AyumiSlot extends Ay8910WorkletSlot { } accumulateStereoOutput(sampleIndex, mix) { - if (this.audioDriver && this.ayumiEngine) { + if (!this.ayumiEngine) { + return; + } + const channelMuted = this.state.channelMuted; + const channelSoundEnabled = this.state.channelSoundEnabled; + let hasAudibleChannel = false; + for (let ch = 0; ch < channelMuted.length; ch++) { + if (!channelMuted[ch] && channelSoundEnabled[ch]) { + hasAudibleChannel = true; + break; + } + } + if (!hasAudibleChannel) { + return; + } + if (this.audioDriver) { const resolveAyumiChannelIndex = this.virtualChannelMixer?.hasVirtualChannels?.() ? (channelIndex) => this.virtualChannelMixer.getHardwareChannelIndex(channelIndex) diff --git a/public/ayumi-state.js b/public/ay/ayumi-state.js similarity index 83% rename from public/ayumi-state.js rename to public/ay/ayumi-state.js index 100d3408..4b59ee3a 100644 --- a/public/ayumi-state.js +++ b/public/ay/ayumi-state.js @@ -1,5 +1,11 @@ import { DEFAULT_AYM_FREQUENCY } from './ayumi-constants.js'; -import TrackerState from './tracker-state.js'; +import TrackerState from '../tracker/tracker-state.js'; +import { + buildInstrumentIdToIndex, + initChipChannelArrays, + resetChipChannelArrays, + resizeChipChannelArrays +} from '../tracker/tracker-chip-state.js'; const AY_CHANNEL_ARRAY_SPECS = [ ['channelInstruments', -1], @@ -33,9 +39,7 @@ class AyumiState extends TrackerState { this.instruments = []; this.instrumentIdToIndex = new Map(); - for (const [name, defaultVal] of AY_CHANNEL_ARRAY_SPECS) { - this[name] = Array(channelCount).fill(defaultVal); - } + initChipChannelArrays(this, channelCount, AY_CHANNEL_ARRAY_SPECS); this.envelopeSlideDelay = 0; this.envelopeSlideDelayCounter = 0; @@ -104,36 +108,18 @@ class AyumiState extends TrackerState { setInstruments(instruments) { this.instruments = instruments; - this.instrumentIdToIndex = new Map(); - instruments.forEach((instrument, index) => { - if (instrument && instrument.id !== undefined) { - let numericId; - if (typeof instrument.id === 'string') { - numericId = parseInt(instrument.id, 36); - } else { - numericId = instrument.id; - } - this.instrumentIdToIndex.set(numericId, index); - } - }); + this.instrumentIdToIndex = buildInstrumentIdToIndex(instruments); } resizeChannels(newCount) { super.resizeChannels(newCount); - for (const [name, defaultVal] of AY_CHANNEL_ARRAY_SPECS) { - const arr = this[name]; - while (arr.length < newCount) arr.push(defaultVal); - if (arr.length > newCount) arr.length = newCount; - } + resizeChipChannelArrays(this, newCount, AY_CHANNEL_ARRAY_SPECS); } reset(opts = {}) { super.reset(opts); - for (const [name, defaultVal] of AY_CHANNEL_ARRAY_SPECS) { - if (name === 'channelMuted') continue; - this[name].fill(defaultVal); - } + resetChipChannelArrays(this, AY_CHANNEL_ARRAY_SPECS); this.envelopeSlideDelay = 0; this.envelopeSlideDelayCounter = 0; diff --git a/public/virtual-channel-mixer.js b/public/ay/virtual-channel-mixer.js similarity index 100% rename from public/virtual-channel-mixer.js rename to public/ay/virtual-channel-mixer.js diff --git a/public/ay8910-worklet-slot.js b/public/ay8910-worklet-slot.js deleted file mode 100644 index a495ec3c..00000000 --- a/public/ay8910-worklet-slot.js +++ /dev/null @@ -1,428 +0,0 @@ -import AyumiState from './ayumi-state.js'; -import TrackerPatternProcessor from './tracker-pattern-processor.js'; -import AYAudioDriver from './ay-audio-driver.js'; -import AyumiEngine from './ayumi-engine.js'; -import AYChipRegisterState from './ay-chip-register-state.js'; -import VirtualChannelMixer from './virtual-channel-mixer.js'; -import { disableAllChannelTimerEffects, ensureChannelTimerEffects } from './ay-timer-effect-constants.js'; -import { WorkletSlotBase } from './worklet-slot-base.js'; - -export class Ay8910WorkletSlot extends WorkletSlotBase { - constructor(port, chipIndex, sharedTimeline) { - super(port, chipIndex); - this.state = new AyumiState(3, sharedTimeline); - this.initialized = false; - this.audioDriver = null; - this.patternProcessor = null; - this.ayumiEngine = null; - this.registerState = new AYChipRegisterState(); - this.virtualChannelMixer = new VirtualChannelMixer(); - this.virtualChannelMap = {}; - this.hwChannelCount = 3; - this.previewActiveChannels = new Set(); - this.previewTickSampleCounter = 0; - } - - _slotState() { - return this.state; - } - - _isReadyForPlayback() { - return this.initialized && this.state.wasmModule && this.state.ayumiPtr; - } - - _resizeForPatternChannels(n) { - this._ensureChannelCapacity(n); - } - - _applyPlaybackSpeed(speed) { - if (!(speed > 0)) return; - this.state.publishPlaybackSpeed(speed); - } - - _replayCatchUpSegments(catchUpSegments) { - if ( - !catchUpSegments?.length || - !this.patternProcessor || - !this.audioDriver || - !this.ayumiEngine - ) { - return; - } - for (const segment of catchUpSegments) { - if (segment.pattern?.channels?.length) { - this._ensureChannelCapacity(segment.pattern.channels.length); - } - this.state.setPattern(segment.pattern, segment.patternOrderIndex); - const numRows = segment.numRows ?? 0; - for (let r = 0; r < numRows; r++) { - this._simulateRow(this.state.currentPattern, r); - } - } - } - - _runCatchUpRows(upToRow) { - if ( - !this.state.currentPattern || - this.state.currentPattern.length === 0 || - upToRow <= 0 || - !this.patternProcessor || - !this.audioDriver || - !this.ayumiEngine - ) { - return; - } - for (let r = 0; r < upToRow; r++) { - this._simulateRow(this.state.currentPattern, r); - } - } - - _onTransportStop() { - this.registerState.reset(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - } - this._applyRegisterStateToEngine(); - } - - _afterTransportStop() { - this.handleStopPreview(); - } - - _preparePatternWorkersForPlay() { - this.ensurePlaybackWorkers(); - this.enforceMuteState(); - } - - dispatchPortMessages(type, data) { - switch (type) { - case 'play': - this.handlePlay(data); - break; - case 'play_from_row': - this.handlePlayFromRow(data); - break; - case 'play_from_position': - this.handlePlayFromPosition(data); - break; - case 'stop': - this.handleStop(); - break; - case 'init_pattern': - this.handleInitPattern(data); - break; - case 'update_order': - this.handleUpdateOrder(data); - break; - case 'set_pattern_data': - this.handleSetPatternData(data); - break; - case 'init_tuning_table': - this.handleInitTuningTable(data); - break; - case 'init_speed': - this.handleInitSpeed(data); - break; - case 'init_tables': - this.handleInitTables(data); - break; - case 'init_instruments': - this.handleInitInstruments(data); - break; - case 'change_pattern_during_playback': - this.handleChangePatternDuringPlayback(data); - break; - case 'preview_row': - this.handlePreviewRow(data); - break; - case 'stop_preview': - this.handleStopPreview(data.channel); - break; - case 'set_virtual_channel_config': - this.handleSetVirtualChannelConfig(data); - break; - case 'set_channel_mute': - this.handleSetChannelMute(data); - break; - default: - break; - } - } - - handleInitTuningTable(data) { - this.state.setTuningTable(data.tuningTable); - } - - handleInitSpeed(data) { - const speed = data.speed; - if (!(speed > 0)) return; - this.state.publishPlaybackSpeed(speed); - } - - handleInitTables(data) { - this.state.setTables(data.tables); - } - - handleInitInstruments(data) { - this.state.setInstruments(data.instruments); - } - - applyChannelSilent(registerState, channelIndex) { - registerState.channels[channelIndex].volume = 0; - registerState.channels[channelIndex].mixer = { - tone: false, - noise: false, - envelope: false - }; - disableAllChannelTimerEffects(ensureChannelTimerEffects(registerState.channels[channelIndex])); - } - - handleSetVirtualChannelConfig({ virtualChannelMap, hwChannelCount }) { - this.virtualChannelMap = virtualChannelMap || {}; - this.hwChannelCount = hwChannelCount || 3; - this.virtualChannelMixer.configure(this.virtualChannelMap, this.hwChannelCount); - - const totalChannels = this.virtualChannelMixer.getTotalVirtualChannelCount(); - this.state.resizeChannels(totalChannels); - this.registerState.resize(totalChannels); - if (this.audioDriver) { - this.audioDriver.resizeChannels(totalChannels); - } - if (this.ayumiEngine) { - this.registerState.reset(); - this.audioDriver?.resetChannelMixerState(); - this.ayumiEngine.reset(); - this._applyRegisterStateToEngine(); - } - } - - handleSetChannelMute({ channelIndex, muted }) { - const totalChannels = this.virtualChannelMixer.getTotalVirtualChannelCount(); - if (channelIndex >= 0 && channelIndex < totalChannels) { - this.state.channelMuted[channelIndex] = muted; - if (muted) { - this.applyChannelSilent(this.registerState, channelIndex); - this.state.channelEnvelopeEnabled[channelIndex] = false; - if (this.ayumiEngine) { - this._applyRegisterStateToEngine(); - } - } - } - } - - _simulateRow(pattern, rowIndex) { - this.patternProcessor.parsePatternRow(pattern, rowIndex, this.registerState); - this.patternProcessor.processSpeedTable(); - const ticksPerRow = this.state.timeline.currentSpeed; - for (let tick = 0; tick < ticksPerRow; tick++) { - this.patternProcessor.processTables(); - this.patternProcessor.processArpeggio(); - this.patternProcessor.processEffectTables(); - this.audioDriver.processInstruments(this.state, this.registerState); - this.patternProcessor.processVibrato(); - this.patternProcessor.processSlides(); - } - this._applyRegisterStateToEngine(); - } - - _getEngineRegisterState() { - if (this.virtualChannelMixer.hasVirtualChannels()) { - return this.virtualChannelMixer.merge(this.registerState, this.state); - } - return this.registerState; - } - - _collectHardwareRegisters() { - const wasmModule = this.state?.wasmModule; - const ayumiPtr = this.state?.ayumiPtr; - const getRegisters = wasmModule?.ayumi_get_registers; - if (typeof getRegisters === 'function' && ayumiPtr) { - if (!this._hardwareRegistersBufferPtr) { - this._hardwareRegistersBufferPtr = wasmModule.malloc(14); - } - getRegisters(ayumiPtr, this._hardwareRegistersBufferPtr); - return Array.from( - new Uint8Array(wasmModule.memory.buffer, this._hardwareRegistersBufferPtr, 14) - ); - } - return this._getEngineRegisterState().toHardwareRegisters(); - } - - _applyRegisterStateToEngine() { - if (!this.ayumiEngine) return; - if (this.virtualChannelMixer.hasVirtualChannels()) { - const hwState = this._getEngineRegisterState(); - this.ayumiEngine.applyRegisterState(hwState); - this.registerState.forceEnvelopeShapeWrite = false; - } else { - this.ayumiEngine.applyRegisterState(this.registerState); - } - } - - _applyVirtualChannelResize() { - if (!this.virtualChannelMixer.hasVirtualChannels()) return; - const totalChannels = this.virtualChannelMixer.getTotalVirtualChannelCount(); - this.state.resizeChannels(totalChannels); - this.registerState.resize(totalChannels); - if (this.audioDriver) { - this.audioDriver.resizeChannels(totalChannels); - } - } - - _ensureChannelCapacity(channelCount) { - if (channelCount <= this.registerState.channelCount) return; - this.state.resizeChannels(channelCount); - this.registerState.resize(channelCount); - if (this.audioDriver) { - this.audioDriver.resizeChannels(channelCount); - } - } - - enforceMuteState() { - const totalChannels = this.registerState.channelCount; - for (let ch = 0; ch < totalChannels; ch++) { - if (this.state.channelMuted[ch]) { - this.applyChannelSilent(this.registerState, ch); - this.state.channelEnvelopeEnabled[ch] = false; - } - } - this._applyRegisterStateToEngine(); - } - - ensurePlaybackWorkers() { - if (!this.audioDriver || !this.patternProcessor || !this.ayumiEngine) { - this.audioDriver = new AYAudioDriver(); - this.ayumiEngine = new AyumiEngine(this.state.wasmModule, this.state.ayumiPtr); - this.patternProcessor = new TrackerPatternProcessor( - this.state, - this.audioDriver, - this.port - ); - this._applyVirtualChannelResize(); - } - } - - handlePreviewRow({ pattern, rowIndex, instrument }) { - if (!this.initialized || !this.state.wasmModule) { - return; - } - this.paused = true; - if (!pattern || !pattern.channels || !pattern.patternRows || rowIndex < 0) { - return; - } - if (rowIndex >= pattern.length) { - return; - } - if (!this.audioDriver || !this.patternProcessor || !this.ayumiEngine) { - return; - } - if (pattern.channels.length) { - this._ensureChannelCapacity(pattern.channels.length); - } - if (instrument) { - this.state.setInstruments([instrument]); - } - - this.state.reset({ resetTimeline: false }); - this.registerState.reset(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - } - - for (let r = 0; r < rowIndex; r++) { - this._simulateRow(pattern, r); - } - this.patternProcessor.parsePatternRow(pattern, rowIndex, this.registerState); - this.patternProcessor.processTables(); - this.audioDriver.processInstruments(this.state, this.registerState); - this._applyRegisterStateToEngine(); - - const totalChannels = this.registerState.channelCount; - this.previewActiveChannels = new Set(); - for (let ch = 0; ch < totalChannels; ch++) { - this.previewActiveChannels.add(ch); - } - this.previewTickSampleCounter = 0; - } - - handleStopPreview(channel) { - if (channel !== undefined) { - this.previewActiveChannels.delete(channel); - this.applyChannelSilent(this.registerState, channel); - this.state.channelSoundEnabled[channel] = false; - this._applyRegisterStateToEngine(); - } else { - this.previewActiveChannels.clear(); - this.registerState.reset(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - } - this._applyRegisterStateToEngine(); - } - } - - canRender() { - return this.initialized && this.state.wasmModule && this.state.ayumiPtr; - } - - isPreviewActive() { - return this.previewActiveChannels.size > 0; - } - - runSharedPlaybackQuantum() { - if (!this.state.currentPattern || this.state.currentPattern.length === 0) return; - if (this.state.timeline.currentTick === 0) { - this.timelinePattern.maybeRequestPrefetchForSharedTimeline( - this.state.currentPattern, - this.state.timeline - ); - - if (this.state.currentPattern.channels) { - this._ensureChannelCapacity(this.state.currentPattern.channels.length); - } - const rowIndex = this.state.timeline.currentRow; - this.patternProcessor.parsePatternRow( - this.state.currentPattern, - rowIndex, - this.registerState - ); - this.patternProcessor.processSpeedTable(); - - this.timelinePattern.queueOrSendPositionUpdate(); - } - - this.enforceMuteState(); - - this.patternProcessor.processTables(); - this.patternProcessor.processArpeggio(); - this.patternProcessor.processEffectTables(); - this.audioDriver.processInstruments(this.state, this.registerState); - this.patternProcessor.processVibrato(); - this.patternProcessor.processSlides(); - - this.enforceMuteState(); - - this._applyRegisterStateToEngine(); - } - - runPreviewStep() { - this.previewTickSampleCounter++; - if (this.previewTickSampleCounter >= this.state.timeline.samplesPerTick) { - this.previewTickSampleCounter = 0; - this.patternProcessor.processTables(); - if (this.state.channelInstruments) { - this.audioDriver.processInstruments(this.state, this.registerState); - } - } - this._applyRegisterStateToEngine(); - } -} diff --git a/public/builtin-audio-slots.js b/public/builtin-audio-slots.js deleted file mode 100644 index 8fd3cba5..00000000 --- a/public/builtin-audio-slots.js +++ /dev/null @@ -1,8 +0,0 @@ -import { registerAudioSlotKind } from './audio-slot-registry.js'; -import { AyumiSlot } from './ayumi-slot.js'; - -registerAudioSlotKind('ayumi', (port, chipIndex, sharedTimeline, initData) => { - const slot = new AyumiSlot(port, chipIndex, sharedTimeline); - void slot.handleMessage({ type: 'init', wasmBuffer: initData.wasmBuffer }); - return slot; -}); diff --git a/public/nes/nes-apu-engine.js b/public/nes/nes-apu-engine.js new file mode 100644 index 00000000..ebcf78c2 --- /dev/null +++ b/public/nes/nes-apu-engine.js @@ -0,0 +1,441 @@ +import NesChipRegisterState from './nes-chip-register-state.js'; +import { + NES_APU_STRUCT_SIZE, + NES_DMC_STRUCT_SIZE, + NES_NTSC_CPU_FREQUENCY, + NES_SQUARE_LENGTH_NIBBLE, + NES_TRIANGLE_LINEAR_RELOAD, + NES_APU_OUTPUT_SCALE +} from './nes-constants.js'; +import { + buildNoiseSilentVolumeReg, + buildSquareSilentVolumeReg, + buildTriangleSilentLinearReg, + NES_REGISTER_UNCHANGED, + NES_SQUARE_SWEEP_DISABLED +} from './nes-instrument-utils.js'; + +const SQUARE_BASE = [0x4000, 0x4004]; +const TRIANGLE_BASE = 0x4008; +const NOISE_BASE = 0x400c; + +function buildSquareVolumeReg(volume, duty) { + return (3 << 4) | (volume & 15) | ((duty & 3) << 6); +} + +function isSquareChannelActive(channel) { + return channel.enabled && channel.period > 0; +} + +function isTriangleChannelActive(channel) { + return channel.enabled && channel.period > 0; +} + +function isNoiseChannelActive(channel) { + return channel.enabled; +} + +function buildApu4015Mask(registerState) { + let mask = 0; + if (isSquareChannelActive(registerState.channels[0])) mask |= 1; + if (isSquareChannelActive(registerState.channels[1])) mask |= 2; + return mask; +} + +function buildDmc4015Mask(registerState) { + let mask = 0; + if (isTriangleChannelActive(registerState.channels[2])) mask |= 4; + if (isNoiseChannelActive(registerState.channels[3])) mask |= 8; + return mask; +} + +function buildApuOutputMask(registerState) { + let mask = 0; + if (!isSquareChannelActive(registerState.channels[0])) mask |= 1; + if (!isSquareChannelActive(registerState.channels[1])) mask |= 2; + return mask; +} + +function buildDmcOutputMask(registerState) { + let mask = 4; + if (!isTriangleChannelActive(registerState.channels[2])) mask |= 1; + if (!isNoiseChannelActive(registerState.channels[3])) mask |= 2; + return mask; +} + +class NesApuEngine { + constructor(wasmModule, apuPtr, dmcPtr) { + this.wasmModule = wasmModule; + this.apuPtr = apuPtr; + this.dmcPtr = dmcPtr; + this.lastState = new NesChipRegisterState(); + this.cpuFrequency = NES_NTSC_CPU_FREQUENCY; + this.isPal = false; + this.clockAccumulator = 0; + this.outputPtr = wasmModule.malloc(16); + this.forceFullApply = false; + this._lastApu4015 = -1; + this._lastDmc4015 = -1; + this._lastApuOutputMask = -1; + this._lastDmcOutputMask = -1; + this._lastOutput = { left: 0, right: 0 }; + } + + setCpuFrequency(frequency) { + if (frequency > 0) { + this.cpuFrequency = frequency; + } + } + + setChipVariant(variant) { + const isPal = variant === 'PAL'; + if (this.isPal !== isPal) { + this.isPal = isPal; + this.wasmModule.nes_dmc_SetPal(this.dmcPtr, isPal ? 1 : 0); + this.forceFullApply = true; + } + } + + reset() { + this.wasmModule.nes_apu_Reset(this.apuPtr); + this.wasmModule.nes_dmc_Reset(this.dmcPtr); + this.wasmModule.nes_dmc_SetAPU(this.dmcPtr, this.apuPtr); + this.wasmModule.nes_dmc_SetPal(this.dmcPtr, this.isPal ? 1 : 0); + this.lastState.reset(); + this.forceFullApply = true; + this.clockAccumulator = 0; + this._lastApu4015 = -1; + this._lastDmc4015 = -1; + this._lastApuOutputMask = -1; + this._lastDmcOutputMask = -1; + this._lastOutput = { left: 0, right: 0 }; + } + + _applyOutputMasks(registerState, forceApply) { + const apuOutputMask = buildApuOutputMask(registerState); + const dmcOutputMask = buildDmcOutputMask(registerState); + if (forceApply || apuOutputMask !== this._lastApuOutputMask) { + this.wasmModule.nes_apu_SetMask(this.apuPtr, apuOutputMask); + this._lastApuOutputMask = apuOutputMask; + } + if (forceApply || dmcOutputMask !== this._lastDmcOutputMask) { + this.wasmModule.nes_dmc_SetMask(this.dmcPtr, dmcOutputMask); + this._lastDmcOutputMask = dmcOutputMask; + } + } + + _writeSquareSilent(channelIndex, channel) { + const last = this.lastState.channels[channelIndex]; + const base = SQUARE_BASE[channelIndex]; + const volumeReg = buildSquareSilentVolumeReg(channel.duty); + this.wasmModule.nes_apu_Write(this.apuPtr, base, volumeReg); + this.wasmModule.nes_apu_Write(this.apuPtr, base + 1, NES_SQUARE_SWEEP_DISABLED); + last.volumeReg = volumeReg; + last.volume = 0; + last.duty = channel.duty; + last.sweepReg = NES_SQUARE_SWEEP_DISABLED; + last.period = 0; + last.lengthNibble = NES_REGISTER_UNCHANGED; + last.retrigger = false; + } + + _writeTriangleSilent(channel) { + const last = this.lastState.channels[2]; + const linearReg = buildTriangleSilentLinearReg(); + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE, linearReg); + last.linearReg = linearReg; + last.period = 0; + last.lengthNibble = NES_REGISTER_UNCHANGED; + last.retrigger = false; + } + + _writeNoiseSilent(channel) { + const last = this.lastState.channels[3]; + const volumeReg = buildNoiseSilentVolumeReg(); + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE, volumeReg); + last.volumeReg = volumeReg; + last.volume = 0; + last.lengthNibble = NES_REGISTER_UNCHANGED; + last.retrigger = false; + } + + _writeSquare(channelIndex, channel, forceApply, triggerChannel) { + const last = this.lastState.channels[channelIndex]; + if (!isSquareChannelActive(channel)) { + if (forceApply || last.enabled) { + this._writeSquareSilent(channelIndex, channel); + } + last.enabled = false; + return; + } + const base = SQUARE_BASE[channelIndex]; + const volumeReg = + channel.volumeReg !== NES_REGISTER_UNCHANGED + ? channel.volumeReg + : buildSquareVolumeReg(channel.volume, channel.duty); + const lengthNibble = + channel.lengthNibble !== NES_REGISTER_UNCHANGED + ? channel.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const period = channel.period > 0 ? channel.period - 1 : 0; + const periodLow = period & 0xff; + const periodHigh = (lengthNibble << 3) | ((period >> 8) & 7); + const lastLengthNibble = + last.lengthNibble !== NES_REGISTER_UNCHANGED + ? last.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const lastPeriodHigh = (lastLengthNibble << 3) | ((last.period >> 8) & 7); + + if ( + isSquareChannelActive(channel) && + (channel.volumeReg !== NES_REGISTER_UNCHANGED || + forceApply || + !last.enabled || + volumeReg !== last.volumeReg) + ) { + this.wasmModule.nes_apu_Write(this.apuPtr, base, volumeReg); + last.volumeReg = volumeReg; + last.volume = channel.volume; + last.duty = channel.duty; + } + + const sweepReg = + channel.sweepReg === undefined || channel.sweepReg < 0 + ? NES_SQUARE_SWEEP_DISABLED + : channel.sweepReg; + const sweepChanged = last.sweepReg !== sweepReg; + const sweepRetrigger = + triggerChannel && sweepReg !== NES_SQUARE_SWEEP_DISABLED; + if (forceApply || sweepChanged || sweepRetrigger) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 1, sweepReg); + last.sweepReg = sweepReg; + } + + if (forceApply || periodLow !== (last.period & 0xff) || sweepChanged) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 2, periodLow); + } + + if ( + forceApply || + triggerChannel || + channel.retrigger || + periodHigh !== lastPeriodHigh || + sweepChanged || + (channel.lengthNibble !== NES_REGISTER_UNCHANGED && + lengthNibble !== lastLengthNibble) + ) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 3, periodHigh); + } + + last.period = period; + last.lengthNibble = channel.lengthNibble; + last.retrigger = channel.retrigger; + } + + _writeTriangle(channel, forceApply, triggerChannel) { + const last = this.lastState.channels[2]; + if (!isTriangleChannelActive(channel)) { + if (forceApply || last.enabled) { + this._writeTriangleSilent(channel); + } + last.enabled = false; + return; + } + const linearReg = + channel.linearReg !== NES_REGISTER_UNCHANGED + ? channel.linearReg + : (1 << 7) | NES_TRIANGLE_LINEAR_RELOAD; + const lengthNibble = + channel.lengthNibble !== NES_REGISTER_UNCHANGED + ? channel.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const periodLow = channel.period & 0xff; + const periodHigh = (lengthNibble << 3) | ((channel.period >> 8) & 7); + const lastLengthNibble = + last.lengthNibble !== NES_REGISTER_UNCHANGED + ? last.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const lastPeriodHigh = (lastLengthNibble << 3) | ((last.period >> 8) & 7); + + if ( + channel.linearReg !== NES_REGISTER_UNCHANGED && + (forceApply || linearReg !== last.linearReg) + ) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE, linearReg); + last.linearReg = linearReg; + } + if (forceApply || periodLow !== (last.period & 0xff)) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE + 2, periodLow); + } + if ( + forceApply || + triggerChannel || + channel.retrigger || + periodHigh !== lastPeriodHigh || + (channel.lengthNibble !== NES_REGISTER_UNCHANGED && + lengthNibble !== lastLengthNibble) + ) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE + 3, periodHigh); + } + last.period = channel.period; + last.lengthNibble = channel.lengthNibble; + last.retrigger = channel.retrigger; + } + + _writeNoise(channel, forceApply, triggerChannel) { + const last = this.lastState.channels[3]; + if (!isNoiseChannelActive(channel)) { + if (forceApply || last.enabled) { + this._writeNoiseSilent(channel); + } + last.enabled = false; + return; + } + const volumeReg = + channel.volumeReg !== NES_REGISTER_UNCHANGED + ? channel.volumeReg + : buildSquareVolumeReg(channel.volume, 0); + const lengthNibble = + channel.lengthNibble !== NES_REGISTER_UNCHANGED + ? channel.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const periodReg = (channel.noiseMode ? 0x80 : 0) | (channel.noisePeriod & 15); + + if ( + isNoiseChannelActive(channel) && + (channel.volumeReg !== NES_REGISTER_UNCHANGED || + forceApply || + !last.enabled || + volumeReg !== last.volumeReg) + ) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE, volumeReg); + last.volumeReg = volumeReg; + last.volume = channel.volume; + } + if (forceApply || periodReg !== ((last.noiseMode ? 0x80 : 0) | (last.noisePeriod & 15))) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE + 2, periodReg); + last.noisePeriod = channel.noisePeriod; + last.noiseMode = channel.noiseMode; + } + if ( + forceApply || + triggerChannel || + channel.retrigger || + (channel.lengthNibble !== NES_REGISTER_UNCHANGED && + lengthNibble !== last.lengthNibble) + ) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE + 3, lengthNibble << 3); + last.lengthNibble = channel.lengthNibble; + last.retrigger = channel.retrigger; + } + } + + applyRegisterState(registerState) { + const forceApply = this.forceFullApply; + this.forceFullApply = false; + + const apu4015 = buildApu4015Mask(registerState); + const dmc4015 = buildDmc4015Mask(registerState); + if (forceApply || apu4015 !== this._lastApu4015) { + this.wasmModule.nes_apu_Write(this.apuPtr, 0x4015, apu4015); + this._lastApu4015 = apu4015; + } + if (forceApply || dmc4015 !== this._lastDmc4015) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, 0x4015, dmc4015); + this._lastDmc4015 = dmc4015; + } + + for (let i = 0; i < 2; i++) { + const channel = registerState.channels[i]; + const last = this.lastState.channels[i]; + const isActive = isSquareChannelActive(channel); + const triggerChannel = isActive && (channel.retrigger || !last.enabled); + this._writeSquare(i, channel, forceApply, triggerChannel); + last.enabled = isActive; + } + + const triangleChannel = registerState.channels[2]; + const triangleLast = this.lastState.channels[2]; + const triangleActive = isTriangleChannelActive(triangleChannel); + const triangleTrigger = + triangleActive && (triangleChannel.retrigger || !triangleLast.enabled); + this._writeTriangle(triangleChannel, forceApply, triangleTrigger); + triangleLast.enabled = triangleActive; + + const noiseChannel = registerState.channels[3]; + const noiseLast = this.lastState.channels[3]; + const noiseActive = isNoiseChannelActive(noiseChannel); + const noiseTrigger = noiseActive && (noiseChannel.retrigger || !noiseLast.enabled); + this._writeNoise(noiseChannel, forceApply, noiseTrigger); + noiseLast.enabled = noiseActive; + + this._applyOutputMasks(registerState, forceApply); + } + + process(sampleRate) { + this.clockAccumulator += this.cpuFrequency / sampleRate; + const clocks = Math.floor(this.clockAccumulator); + if (clocks <= 0) { + return this._lastOutput; + } + this.clockAccumulator -= clocks; + + this.wasmModule.nes_dmc_TickFrameSequence(this.dmcPtr, clocks); + this.wasmModule.nes_apu_Tick(this.apuPtr, clocks); + this.wasmModule.nes_dmc_Tick(this.dmcPtr, clocks); + + const memory = this.wasmModule.memory.buffer; + this.wasmModule.nes_apu_Render(this.apuPtr, this.outputPtr); + this.wasmModule.nes_dmc_Render(this.dmcPtr, this.outputPtr + 8); + const samples = new Int32Array(memory, this.outputPtr, 4); + const left = (samples[0] + samples[2]) * NES_APU_OUTPUT_SCALE; + const right = (samples[1] + samples[3]) * NES_APU_OUTPUT_SCALE; + this._lastOutput = { left, right }; + return this._lastOutput; + } + + canReadChannelOutputs() { + return ( + typeof this.wasmModule.nes_apu_GetOut === 'function' && + typeof this.wasmModule.nes_dmc_GetOut === 'function' + ); + } + + getChannelRawOut(channelIndex) { + if (!this.canReadChannelOutputs()) return 0; + if (channelIndex <= 1) { + return this.wasmModule.nes_apu_GetOut(this.apuPtr, channelIndex); + } + if (channelIndex >= 2 && channelIndex <= 4) { + return this.wasmModule.nes_dmc_GetOut(this.dmcPtr, channelIndex - 2); + } + return 0; + } + + dispose() { + if (this.outputPtr) { + this.wasmModule.free(this.outputPtr); + this.outputPtr = 0; + } + } +} + +export default NesApuEngine; + +export function createNesApuEngine(wasmModule) { + const apuPtr = wasmModule.malloc(NES_APU_STRUCT_SIZE); + const dmcPtr = wasmModule.malloc(NES_DMC_STRUCT_SIZE); + wasmModule.nes_apu_Init(apuPtr); + wasmModule.nes_dmc_Init(dmcPtr); + wasmModule.nes_dmc_SetAPU(dmcPtr, apuPtr); + wasmModule.nes_apu_SetMask(apuPtr, 3); + wasmModule.nes_dmc_SetMask(dmcPtr, 7); + wasmModule.nes_apu_SetStereoMix(apuPtr, 0, 128, 128); + wasmModule.nes_apu_SetStereoMix(apuPtr, 1, 128, 128); + wasmModule.nes_dmc_SetStereoMix(dmcPtr, 0, 128, 128); + wasmModule.nes_dmc_SetStereoMix(dmcPtr, 1, 128, 128); + wasmModule.nes_dmc_SetStereoMix(dmcPtr, 2, 128, 128); + const engine = new NesApuEngine(wasmModule, apuPtr, dmcPtr); + engine.reset(); + return { engine, apuPtr, dmcPtr }; +} diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js new file mode 100644 index 00000000..edd7b91c --- /dev/null +++ b/public/nes/nes-audio-driver.js @@ -0,0 +1,266 @@ +import { + advanceInstrumentRowPosition, + calculatePt3Volume, + getEffectiveTuningPeriod +} from '../tracker/tracker-audio-utils.js'; +import { + assignPatternRowInstrument, + channelHasAssignedInstrument, + isChannelOnOffHalted, + processChannelOnOffCounters +} from '../tracker/tracker-instrument-channel.js'; +import { + buildLengthCounterNibble, + buildNoiseEnvelopeVolumeReg, + buildNoiseSilentVolumeReg, + buildSquareEnvelopeVolumeReg, + buildSquareSilentVolumeReg, + buildSquareSweepReg, + buildTriangleLinearReg, + buildTriangleSilentLinearReg, + ensureNesInstrumentRows, + isChannelAudible, + NES_REGISTER_UNCHANGED, + NES_SQUARE_SWEEP_DISABLED, + normalizeNesInstrumentRow, + resolveEnvelopeVolumeOrRate, + usesTriangleLinearCounter +} from './nes-instrument-utils.js'; +import { NES_CHANNEL_COUNT } from './nes-constants.js'; + +const NES_NOISE_PERIOD_COUNT = 16; + +function resolveNesNoisePeriodFromSemitoneOffset(semitoneOffset) { + const wrapped = + ((semitoneOffset % NES_NOISE_PERIOD_COUNT) + NES_NOISE_PERIOD_COUNT) % + NES_NOISE_PERIOD_COUNT; + return NES_NOISE_PERIOD_COUNT - 1 - wrapped; +} + +class NesAudioDriver { + resetChannelMixerState() {} + + resizeChannels(_newCount) {} + + processPatternRow(state, pattern, rowIndex, _patternRow, registerState) { + for (let channelIndex = 0; channelIndex < pattern.channels.length; channelIndex++) { + const row = pattern.channels[channelIndex].rows[rowIndex]; + const isMuted = state.channelMuted[channelIndex]; + + if (isMuted) { + this._silenceChannel(registerState, channelIndex); + } else { + this._processNote(state, channelIndex, row); + this._processInstrument(state, channelIndex, row); + } + } + } + + _silenceChannel(registerState, channelIndex) { + const channel = registerState.channels[channelIndex]; + if (!channel) return; + channel.enabled = false; + channel.period = 0; + channel.volume = 0; + channel.retrigger = false; + channel.lengthNibble = NES_REGISTER_UNCHANGED; + if (channelIndex <= 1) { + channel.volumeReg = buildSquareSilentVolumeReg(channel.duty); + channel.linearReg = NES_REGISTER_UNCHANGED; + channel.sweepReg = NES_SQUARE_SWEEP_DISABLED; + } else if (channelIndex === 2) { + channel.volumeReg = NES_REGISTER_UNCHANGED; + channel.linearReg = buildTriangleSilentLinearReg(); + } else if (channelIndex === 3) { + channel.volumeReg = buildNoiseSilentVolumeReg(); + channel.linearReg = NES_REGISTER_UNCHANGED; + } else { + channel.volumeReg = NES_REGISTER_UNCHANGED; + channel.linearReg = NES_REGISTER_UNCHANGED; + } + } + + _applyEnvelopeAndLength(channel, channelIndex, row, patternVolume) { + const combinedVolume = this.calculateVolume(patternVolume, row.volumeOrRate); + const volumeNibble = resolveEnvelopeVolumeOrRate( + row.envelope, + patternVolume, + row.volumeOrRate, + combinedVolume + ); + channel.volume = combinedVolume; + + if (channelIndex <= 1) { + channel.volumeReg = buildSquareEnvelopeVolumeReg( + row.pulseWidth, + row.envelope, + volumeNibble, + row.soundLength + ); + channel.duty = row.pulseWidth; + channel.lengthNibble = buildLengthCounterNibble(row.soundLength); + channel.linearReg = NES_REGISTER_UNCHANGED; + } else if (channelIndex === 2) { + channel.volumeReg = NES_REGISTER_UNCHANGED; + channel.linearReg = buildTriangleLinearReg(row.soundLength); + channel.lengthNibble = usesTriangleLinearCounter(row.soundLength) + ? NES_REGISTER_UNCHANGED + : buildLengthCounterNibble(row.soundLength); + channel.duty = 0; + } else if (channelIndex === 3) { + channel.volumeReg = buildNoiseEnvelopeVolumeReg( + row.envelope, + volumeNibble, + row.soundLength + ); + channel.noiseMode = row.pulseWidth > 0; + channel.lengthNibble = buildLengthCounterNibble(row.soundLength); + channel.linearReg = NES_REGISTER_UNCHANGED; + } + } + + _isChannelAudible(row, patternVolume, combinedVolume) { + return isChannelAudible(row.envelope, patternVolume, row.volumeOrRate, combinedVolume); + } + + _resetToneAccumulator(state, channelIndex) { + if (state.channelToneAccumulator) { + state.channelToneAccumulator[channelIndex] = 0; + } + } + + _applyToneOffset(state, channelIndex, instrumentRow, basePeriod) { + if (basePeriod <= 0) return 0; + let sampleTone = state.channelToneAccumulator[channelIndex] ?? 0; + if (instrumentRow.toneAdd !== 0) { + sampleTone += instrumentRow.toneAdd; + } + if (instrumentRow.toneAccumulation) { + state.channelToneAccumulator[channelIndex] = sampleTone; + } + const period = basePeriod + sampleTone; + if (period < 0) return 0; + if (period > 2047) return 2047; + return period; + } + + _processNote(state, channelIndex, row) { + if (state.channelMuted[channelIndex]) return; + + if (row.note.name === 1) { + state.channelSoundEnabled[channelIndex] = false; + state.instrumentPositions[channelIndex] = 0; + state.channelKeyOn[channelIndex] = false; + this._resetToneAccumulator(state, channelIndex); + } else if (row.note.name !== 0) { + state.channelSoundEnabled[channelIndex] = true; + state.instrumentPositions[channelIndex] = 0; + state.channelKeyOn[channelIndex] = true; + this._resetToneAccumulator(state, channelIndex); + } + } + + _processInstrument(state, channelIndex, row) { + const assignment = assignPatternRowInstrument(state, channelIndex, row); + if (assignment.changed) { + this._resetToneAccumulator(state, channelIndex); + } + } + + calculateVolume(patternVolume, instrumentVolume) { + return calculatePt3Volume(patternVolume, instrumentVolume); + } + + getEffectivePeriod(state, channelIndex) { + return getEffectiveTuningPeriod(state, channelIndex, 2048); + } + + resolveNoisePeriod(state, channelIndex) { + const noteIndex = state.channelCurrentNotes[channelIndex]; + const toneSliding = state.channelToneSliding?.[channelIndex] || 0; + const vibratoSliding = state.channelVibratoSliding?.[channelIndex] || 0; + const detune = state.channelDetune?.[channelIndex] || 0; + const semitoneOffset = noteIndex + toneSliding + vibratoSliding + detune; + return resolveNesNoisePeriodFromSemitoneOffset(semitoneOffset); + } + + resolveInstrumentRow(state, channelIndex) { + const instrumentIndex = state.channelInstruments[channelIndex]; + const instrument = state.instruments[instrumentIndex]; + const rows = ensureNesInstrumentRows(instrument.rows); + const loop = instrument.loop ?? 0; + const rowIndex = state.instrumentPositions[channelIndex] % rows.length; + return { + row: normalizeNesInstrumentRow(rows[rowIndex]), + rowsLength: rows.length, + loop + }; + } + + processInstruments(state, registerState) { + for (let channelIndex = 0; channelIndex < NES_CHANNEL_COUNT; channelIndex++) { + const channel = registerState.channels[channelIndex]; + if (!channel) continue; + + const isMuted = state.channelMuted[channelIndex]; + const isSoundEnabled = state.channelSoundEnabled[channelIndex]; + const onOffHalted = isChannelOnOffHalted(state, channelIndex); + + if (isMuted || !isSoundEnabled) { + this._silenceChannel(registerState, channelIndex); + continue; + } + + if (!channelHasAssignedInstrument(state, channelIndex)) { + this._silenceChannel(registerState, channelIndex); + continue; + } + + const { row, rowsLength, loop } = this.resolveInstrumentRow(state, channelIndex); + const patternVolume = state.channelPatternVolumes[channelIndex] ?? 15; + const combinedVolume = this.calculateVolume(patternVolume, row.volumeOrRate); + const basePeriod = this.getEffectivePeriod(state, channelIndex); + const period = + channelIndex <= 2 + ? this._applyToneOffset(state, channelIndex, row, basePeriod) + : basePeriod; + const keyOn = state.channelKeyOn[channelIndex]; + + this._applyEnvelopeAndLength(channel, channelIndex, row, patternVolume); + const audible = this._isChannelAudible(row, patternVolume, combinedVolume); + + if (channelIndex <= 1) { + channel.enabled = period > 0 && audible; + channel.period = period; + channel.sweepReg = buildSquareSweepReg(row.sweep, row.sweepRate, row.sweepShift); + channel.retrigger = row.retrigger || keyOn; + state.channelKeyOn[channelIndex] = false; + } else if (channelIndex === 2) { + channel.enabled = period > 0 && combinedVolume > 0; + channel.period = period; + channel.retrigger = row.retrigger || keyOn; + state.channelKeyOn[channelIndex] = false; + } else if (channelIndex === 3) { + channel.enabled = audible; + channel.noisePeriod = this.resolveNoisePeriod(state, channelIndex); + channel.retrigger = row.retrigger || keyOn; + state.channelKeyOn[channelIndex] = false; + } else { + this._silenceChannel(registerState, channelIndex); + } + + if (!onOffHalted) { + state.instrumentPositions[channelIndex] = advanceInstrumentRowPosition( + state.instrumentPositions[channelIndex], + rowsLength, + loop + ); + } + } + + processChannelOnOffCounters(state, NES_CHANNEL_COUNT); + } +} + +export default NesAudioDriver; +export { resolveNesNoisePeriodFromSemitoneOffset }; diff --git a/public/nes/nes-chip-register-state.js b/public/nes/nes-chip-register-state.js new file mode 100644 index 00000000..074d3736 --- /dev/null +++ b/public/nes/nes-chip-register-state.js @@ -0,0 +1,43 @@ +import { NES_CHANNEL_COUNT } from './nes-constants.js'; +import { NES_REGISTER_UNCHANGED, NES_SQUARE_SWEEP_DISABLED } from './nes-instrument-utils.js'; + +function createDefaultChannel() { + return { + enabled: false, + period: 0, + volume: 0, + duty: 2, + retrigger: false, + sweepReg: NES_SQUARE_SWEEP_DISABLED, + noisePeriod: 0, + noiseMode: false, + volumeReg: NES_REGISTER_UNCHANGED, + lengthNibble: NES_REGISTER_UNCHANGED, + linearReg: NES_REGISTER_UNCHANGED + }; +} + +class NesChipRegisterState { + constructor(channelCount = NES_CHANNEL_COUNT) { + this.channelCount = channelCount; + this.channels = Array.from({ length: channelCount }, () => createDefaultChannel()); + } + + reset() { + for (let i = 0; i < this.channelCount; i++) { + this.channels[i] = createDefaultChannel(); + } + } + + resize(newChannelCount) { + while (this.channels.length < newChannelCount) { + this.channels.push(createDefaultChannel()); + } + if (this.channels.length > newChannelCount) { + this.channels.length = newChannelCount; + } + this.channelCount = newChannelCount; + } +} + +export default NesChipRegisterState; diff --git a/public/nes/nes-constants.js b/public/nes/nes-constants.js new file mode 100644 index 00000000..7161a9e8 --- /dev/null +++ b/public/nes/nes-constants.js @@ -0,0 +1,13 @@ +export const NES_CHANNEL_COUNT = 5; + +export const NES_APU_STRUCT_SIZE = 512; +export const NES_DMC_STRUCT_SIZE = 512; + +export const NES_NTSC_CPU_FREQUENCY = 1_789_773; +export const NES_PAL_CPU_FREQUENCY = 1_662_607; + +export const NES_SQUARE_LENGTH_NIBBLE = 0xf; +export const NES_TRIANGLE_LINEAR_RELOAD = 0x7f; + +export const NES_APU_OUTPUT_GAIN = 3.2; +export const NES_APU_OUTPUT_SCALE = NES_APU_OUTPUT_GAIN / 8192; diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js new file mode 100644 index 00000000..c0dd890f --- /dev/null +++ b/public/nes/nes-instrument-utils.js @@ -0,0 +1,218 @@ +export const NES_PULSE_WIDTHS = [0, 1, 2, 3]; + +const TONE_ADD_MIN = -4096; +const TONE_ADD_MAX = 4095; +const SWEEP_RATE_MIN = 0; +const SWEEP_RATE_MAX = 7; +const SWEEP_SHIFT_MIN = -7; +const SWEEP_SHIFT_MAX = 7; +export const NES_SQUARE_SWEEP_DISABLED = 0x08; + +export const NES_LENGTH_COUNTER_LENGTHS = [ + 20, 508, 40, 4, 80, 8, 160, 12, 320, 16, 120, 20, 28, 24, 52, 28, 24, 32, 48, 36, 96, 40, 192, + 44, 384, 48, 144, 52, 32, 56, 64, 60 +]; + +export const NES_REGISTER_UNCHANGED = -1; + +export function buildSquareSilentVolumeReg(duty = 2) { + return ((duty & 3) << 6) | (3 << 4); +} + +export function buildNoiseSilentVolumeReg() { + return 3 << 4; +} + +export function buildTriangleSilentLinearReg() { + return 0; +} + +const SOUND_LENGTH_MIN = 0; +const SOUND_LENGTH_MAX = 511; +const VOLUME_OR_RATE_MIN = 0; +const VOLUME_OR_RATE_MAX = 15; + +function normalizeEnvelope(value) { + return Boolean(value); +} + +function normalizeSoundLength(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SOUND_LENGTH_MIN, Math.min(SOUND_LENGTH_MAX, Math.round(parsed))); +} + +function normalizeVolumeOrRate(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(VOLUME_OR_RATE_MIN, Math.min(VOLUME_OR_RATE_MAX, Math.round(parsed))); +} + +function normalizeToneAdd(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(TONE_ADD_MIN, Math.min(TONE_ADD_MAX, Math.round(parsed))); +} + +export function createDefaultNesInstrumentRow() { + return { + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: false, + volumeOrRate: 15, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }; +} + +function normalizeSweepRate(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_RATE_MIN, Math.min(SWEEP_RATE_MAX, Math.round(parsed))); +} + +function normalizeSweepShift(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_SHIFT_MIN, Math.min(SWEEP_SHIFT_MAX, Math.round(parsed))); +} + +export function buildSquareSweepReg(enabled, rate, shift) { + if (!enabled || shift === 0) { + return NES_SQUARE_SWEEP_DISABLED; + } + const amount = Math.abs(shift); + const packed = ((rate & 7) << 4) | (amount & 7); + return shift < 0 ? 0x88 | packed : 0x80 | packed; +} + +export function resolveLengthCounterIndex(soundLength) { + if (soundLength <= 0) return 0; + const target = soundLength; + let bestIndex = 0; + let bestDelta = Number.POSITIVE_INFINITY; + for (let i = 0; i < NES_LENGTH_COUNTER_LENGTHS.length; i++) { + const frames = NES_LENGTH_COUNTER_LENGTHS[i] * 2; + const delta = Math.abs(frames - target); + if (delta < bestDelta) { + bestDelta = delta; + bestIndex = i; + } + } + return bestIndex; +} + +function resolveEnvelopeLoopBit(soundLength) { + return soundLength === 0 ? 1 << 5 : 0; +} + +function resolveConstantVolumeBit(envelope) { + return envelope ? 0 : 1 << 4; +} + +export function buildSquareEnvelopeVolumeReg(duty, envelope, volumeOrRate, soundLength) { + const volume = volumeOrRate & 15; + const dutyBits = (duty & 3) << 6; + return ( + dutyBits | resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume + ); +} + +export function buildNoiseEnvelopeVolumeReg(envelope, volumeOrRate, soundLength) { + const volume = volumeOrRate & 15; + return resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume; +} + +export function buildLengthCounterNibble(soundLength) { + if (soundLength === 0) { + return NES_REGISTER_UNCHANGED; + } + return resolveLengthCounterIndex(soundLength) & 31; +} + +export function buildTriangleLinearReg(soundLength) { + if (soundLength === 0) { + return (1 << 7) | 0x7f; + } + if (soundLength > 0 && soundLength < 128) { + return soundLength & 0x7f; + } + return 0x7f; +} + +export function usesTriangleLinearCounter(soundLength) { + return soundLength > 0 && soundLength < 128; +} + +export function resolveEnvelopeVolumeOrRate( + envelope, + patternVolume, + instrumentVolumeOrRate, + combinedVolume +) { + if (!envelope) { + return combinedVolume; + } + return instrumentVolumeOrRate; +} + +export function isChannelAudible(envelope, patternVolume, volumeOrRate, combinedVolume) { + if (!envelope) { + return combinedVolume > 0; + } + return patternVolume > 0; +} + +export function normalizeNesInstrumentRow(row) { + const defaults = createDefaultNesInstrumentRow(); + const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) + ? row.pulseWidth + : defaults.pulseWidth; + return { + pulseWidth, + retrigger: row?.retrigger !== undefined ? Boolean(row.retrigger) : defaults.retrigger, + soundLength: + row?.soundLength !== undefined + ? normalizeSoundLength(row.soundLength) + : defaults.soundLength, + envelope: row?.envelope !== undefined ? normalizeEnvelope(row.envelope) : defaults.envelope, + volumeOrRate: + row?.volumeOrRate !== undefined + ? normalizeVolumeOrRate(row.volumeOrRate) + : defaults.volumeOrRate, + toneAdd: row?.toneAdd !== undefined ? normalizeToneAdd(row.toneAdd) : defaults.toneAdd, + toneAccumulation: + row?.toneAccumulation !== undefined + ? Boolean(row.toneAccumulation) + : defaults.toneAccumulation, + sweep: row?.sweep !== undefined ? Boolean(row.sweep) : defaults.sweep, + sweepRate: + row?.sweepRate !== undefined ? normalizeSweepRate(row.sweepRate) : defaults.sweepRate, + sweepShift: + row?.sweepShift !== undefined + ? normalizeSweepShift(row.sweepShift) + : defaults.sweepShift + }; +} + +export function ensureNesInstrumentRows(rows) { + if (!rows || rows.length === 0) { + return [createDefaultNesInstrumentRow()]; + } + return rows.map((row) => normalizeNesInstrumentRow(row)); +} + +export function normalizeNesInstrument(instrument) { + if (!instrument) { + return { rows: [createDefaultNesInstrumentRow()], loop: 0 }; + } + return { + ...instrument, + rows: ensureNesInstrumentRows(instrument.rows), + loop: instrument.loop ?? 0 + }; +} diff --git a/public/nes/nes-state.js b/public/nes/nes-state.js new file mode 100644 index 00000000..0c0b0fef --- /dev/null +++ b/public/nes/nes-state.js @@ -0,0 +1,70 @@ +import TrackerState from '../tracker/tracker-state.js'; +import { + buildInstrumentIdToIndex, + initChipChannelArrays, + resetChipChannelArrays, + resizeChipChannelArrays +} from '../tracker/tracker-chip-state.js'; +import { NES_CHANNEL_COUNT, NES_NTSC_CPU_FREQUENCY } from './nes-constants.js'; + +const NES_CHANNEL_ARRAY_SPECS = [ + ['channelInstruments', -1], + ['instrumentPositions', 0], + ['channelInstrumentVolumes', 0], + ['channelPatternVolumes', 15], + ['channelMuted', false], + ['channelSoundEnabled', false], + ['channelKeyOn', false], + ['channelToneAccumulator', 0] +]; + +class NesState extends TrackerState { + constructor(sharedTimeline) { + super(NES_CHANNEL_COUNT, sharedTimeline); + this.wasmModule = null; + this.wasmBuffer = null; + this.apuPtr = 0; + this.dmcPtr = 0; + this.cpuFrequency = NES_NTSC_CPU_FREQUENCY; + this.chipVariant = 'NTSC'; + + this.instruments = []; + this.instrumentIdToIndex = new Map(); + + initChipChannelArrays(this, NES_CHANNEL_COUNT, NES_CHANNEL_ARRAY_SPECS); + } + + setWasmModule(wasmModule, apuPtr, dmcPtr, wasmBuffer) { + this.wasmModule = wasmModule; + this.apuPtr = apuPtr; + this.dmcPtr = dmcPtr; + this.wasmBuffer = wasmBuffer; + } + + setCpuFrequency(frequency) { + if (frequency > 0) { + this.cpuFrequency = frequency; + } + } + + setChipVariant(variant) { + this.chipVariant = variant ?? 'NTSC'; + } + + setInstruments(instruments) { + this.instruments = instruments; + this.instrumentIdToIndex = buildInstrumentIdToIndex(instruments); + } + + resizeChannels(newCount) { + super.resizeChannels(newCount); + resizeChipChannelArrays(this, newCount, NES_CHANNEL_ARRAY_SPECS); + } + + reset(opts = {}) { + super.reset(opts); + resetChipChannelArrays(this, NES_CHANNEL_ARRAY_SPECS); + } +} + +export default NesState; diff --git a/public/nes/nes-waveform-capture.js b/public/nes/nes-waveform-capture.js new file mode 100644 index 00000000..043fb4aa --- /dev/null +++ b/public/nes/nes-waveform-capture.js @@ -0,0 +1,68 @@ +const NES_WAVEFORM_SCALE = 1; +const NES_SQUARE_NOISE_MAX = 15; +const NES_DMC_MAX = 127; + +const SQUARE_DUTY = [0.125, 0.25, 0.5, 0.75]; + +export function normalizeNesChannelWaveformSample(channelIndex, rawOut) { + if (rawOut < 0) return 0; + const max = + channelIndex === 4 ? NES_DMC_MAX : channelIndex <= 3 ? NES_SQUARE_NOISE_MAX : 0; + if (!(max > 0)) return 0; + return (rawOut / max - 0.5) * NES_WAVEFORM_SCALE; +} + +export class NesWaveformCapture { + constructor(channelCount) { + this.channelCount = channelCount; + this.phases = new Float64Array(channelCount); + } + + readChannelOutputs(apuEngine) { + const outputs = new Float32Array(this.channelCount); + if (!apuEngine?.canReadChannelOutputs?.()) { + return null; + } + for (let ch = 0; ch < this.channelCount; ch++) { + outputs[ch] = normalizeNesChannelWaveformSample( + ch, + apuEngine.getChannelRawOut(ch) + ); + } + return outputs; + } + + reset() { + this.phases.fill(0); + } + + sample(channelIndex, channel, cpuFrequency, sampleRate) { + if (!channel?.enabled) return 0; + + if (channelIndex <= 1) { + if (channel.period <= 0 || channel.volume <= 0) return 0; + const hz = cpuFrequency / (16 * (channel.period + 1)); + this.phases[channelIndex] += hz / sampleRate; + if (this.phases[channelIndex] >= 1) this.phases[channelIndex] -= 1; + const duty = SQUARE_DUTY[channel.duty] ?? 0.5; + const amplitude = channel.volume / 15; + return (this.phases[channelIndex] < duty ? 1 : -1) * amplitude * 0.5; + } + + if (channelIndex === 2) { + if (channel.period <= 0) return 0; + const hz = cpuFrequency / (16 * (channel.period + 1)); + this.phases[channelIndex] += hz / sampleRate; + if (this.phases[channelIndex] >= 1) this.phases[channelIndex] -= 1; + const phase = this.phases[channelIndex]; + return (phase < 0.5 ? phase * 4 - 1 : 3 - phase * 4) * 0.5; + } + + if (channelIndex === 3) { + if (channel.volume <= 0) return 0; + return ((Math.random() < 0.5 ? 1 : -1) * channel.volume) / 15 * 0.5; + } + + return 0; + } +} diff --git a/public/nes/nes-worklet-slot.js b/public/nes/nes-worklet-slot.js new file mode 100644 index 00000000..467b5edb --- /dev/null +++ b/public/nes/nes-worklet-slot.js @@ -0,0 +1,286 @@ +import NesState from './nes-state.js'; +import NesAudioDriver from './nes-audio-driver.js'; +import NesApuEngine, { createNesApuEngine } from './nes-apu-engine.js'; +import NesChipRegisterState from './nes-chip-register-state.js'; +import { NesWaveformCapture } from './nes-waveform-capture.js'; +import TrackerPatternProcessor from '../tracker/tracker-pattern-processor.js'; +import { TrackerWorkletSlot } from '../tracker/tracker-worklet-slot.js'; +import { resetChipPlaybackOutput } from '../tracker/tracker-engine-transport.js'; +import { NES_CHANNEL_COUNT } from './nes-constants.js'; + +export class NesWorkletSlot extends TrackerWorkletSlot { + constructor(port, chipIndex, sharedTimeline) { + super(port, chipIndex); + this.state = new NesState(sharedTimeline); + this.initialized = false; + this.audioDriver = null; + this.patternProcessor = null; + this.apuEngine = null; + this.registerState = new NesChipRegisterState(NES_CHANNEL_COUNT); + this.channelWaveformBuf = Array.from( + { length: NES_CHANNEL_COUNT }, + () => new Float32Array(512) + ); + this.channelWaveformWriteIndex = 0; + this.waveformPostCounter = 0; + this.waveformPostInterval = 6; + this.waveformCapture = new NesWaveformCapture(NES_CHANNEL_COUNT); + } + + _slotState() { + return this.state; + } + + _isReadyForPlayback() { + return this.initialized && this.state.wasmModule && this.state.apuPtr; + } + + _applyPlaybackSpeed(speed) { + if (!(speed > 0)) return; + this.state.publishPlaybackSpeed(speed); + } + + _chipEngineReady() { + return this._playbackWorkersReady() && this.apuEngine; + } + + _prepareOutputForPlay() { + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.apuEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); + } + + _onTransportStop() { + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.apuEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); + } + + _applyRegisterStateToEngine() { + if (!this.apuEngine) return; + this.enforceMuteState(); + this.apuEngine.applyRegisterState(this.registerState); + } + + ensurePlaybackWorkers() { + if (!this.audioDriver || !this.patternProcessor || !this.apuEngine) { + this.audioDriver = new NesAudioDriver(); + this.apuEngine = new NesApuEngine( + this.state.wasmModule, + this.state.apuPtr, + this.state.dmcPtr + ); + this.apuEngine.setCpuFrequency(this.state.cpuFrequency); + this.apuEngine.setChipVariant(this.state.chipVariant); + this.patternProcessor = new TrackerPatternProcessor( + this.state, + this.audioDriver, + this.port + ); + } + } + + resetChannelWaveformCapture() { + for (const buf of this.channelWaveformBuf) { + buf.fill(0); + } + this.channelWaveformWriteIndex = 0; + this.waveformPostCounter = 0; + this.waveformCapture.reset(); + } + + enforceMuteState() { + for (let ch = 0; ch < this.registerState.channelCount; ch++) { + if (this.state.channelMuted[ch]) { + this.audioDriver._silenceChannel(this.registerState, ch); + } + } + } + + async handleMessage(payload) { + if (payload == null || typeof payload !== 'object') return; + const { type, ...data } = payload; + if (type === undefined) return; + + switch (type) { + case 'init': + await this.handleInit(data); + break; + case 'update_cpu_frequency': + this.handleUpdateCpuFrequency(data); + break; + case 'update_chip_variant': + this.handleUpdateChipVariant(data); + break; + case 'update_int_frequency': + this.handleUpdateIntFrequency(data); + break; + default: + this.dispatchPortMessages(type, data); + } + } + + async handleInit({ wasmBuffer }) { + if (!wasmBuffer) return; + + if (this._isReadyForPlayback()) { + this.apuEngine?.setCpuFrequency(this.state.cpuFrequency); + this.apuEngine?.setChipVariant(this.state.chipVariant); + return; + } + + try { + const result = await WebAssembly.instantiate(wasmBuffer, { + env: { emscripten_notify_memory_growth: () => {} } + }); + const wasmModule = result.instance.exports; + const { engine, apuPtr, dmcPtr } = createNesApuEngine(wasmModule); + + this.state.setWasmModule(wasmModule, apuPtr, dmcPtr, wasmBuffer); + this.state.updateSamplesPerTick(sampleRate); + this.audioDriver = new NesAudioDriver(); + this.apuEngine = engine; + this.apuEngine.setCpuFrequency(this.state.cpuFrequency); + this.apuEngine.setChipVariant(this.state.chipVariant); + this.patternProcessor = new TrackerPatternProcessor( + this.state, + this.audioDriver, + this.port + ); + this.registerState.reset(); + this.initialized = true; + } catch (error) { + console.error('Failed to initialize NES APU:', error); + } + } + + handleUpdateCpuFrequency({ cpuFrequency }) { + if (!(cpuFrequency > 0)) return; + this.state.setCpuFrequency(cpuFrequency); + this.apuEngine?.setCpuFrequency(cpuFrequency); + } + + handleUpdateChipVariant({ chipVariant }) { + this.state.setChipVariant(chipVariant); + this.apuEngine?.setChipVariant(chipVariant); + } + + handleUpdateIntFrequency({ intFrequency }) { + this.state.setIntFrequency(intFrequency, sampleRate); + } + + handleSetChannelMute({ channelIndex, muted }) { + if (channelIndex >= 0 && channelIndex < this.state.channelMuted.length) { + this.state.channelMuted[channelIndex] = muted; + if (muted) { + this.audioDriver?._silenceChannel(this.registerState, channelIndex); + this._applyRegisterStateToEngine(); + } + } + } + + _beforePreviewRow() { + this.resetChannelWaveformCapture(); + } + + _beforeStopPreviewAll() { + this.resetChannelWaveformCapture(); + } + + _resetEnginesForPreview() { + resetChipPlaybackOutput({ chipEngine: this.apuEngine }); + } + + _silencePreviewChannel(channelIndex) { + this.audioDriver?._silenceChannel(this.registerState, channelIndex); + } + + canRender() { + return Boolean(this.initialized && this.state.wasmModule && this.state.apuPtr); + } + + _collectPlaybackHz() { + const toneHz = []; + const cpuFrequency = this.state.cpuFrequency; + for (let i = 0; i < NES_CHANNEL_COUNT; i++) { + const period = this.registerState.channels[i]?.period ?? 0; + if (period <= 0 || !this.registerState.channels[i]?.enabled) { + toneHz.push(null); + } else if (i <= 2) { + toneHz.push(cpuFrequency / (16 * (period + 1))); + } else { + toneHz.push(null); + } + } + return { toneHz }; + } + + accumulateStereoOutput(sampleIndex, mix) { + if (!this.apuEngine) return; + const { left, right } = this.apuEngine.process(sampleRate); + mix.l += left; + mix.r += right; + + const cpuFrequency = this.state.cpuFrequency; + const wi = this.channelWaveformWriteIndex; + const emulatorOutputs = this.waveformCapture.readChannelOutputs(this.apuEngine); + for (let ch = 0; ch < this.channelWaveformBuf.length; ch++) { + const channel = this.registerState.channels[ch]; + const sample = + emulatorOutputs != null + ? channel?.enabled + ? emulatorOutputs[ch] + : 0 + : this.waveformCapture.sample( + ch, + channel, + cpuFrequency, + sampleRate + ); + this.channelWaveformBuf[ch][(wi + sampleIndex) % 512] = sample; + } + } + + finishAudioBlock(numSamples) { + if (this.paused && !this.isPreviewActive()) { + this.finishAudioBlockFlushTransport(numSamples, this.paused); + return; + } + this.channelWaveformWriteIndex = (this.channelWaveformWriteIndex + numSamples) % 512; + this.waveformPostCounter++; + if (this.waveformPostCounter >= this.waveformPostInterval) { + this.waveformPostCounter = 0; + const wi = this.channelWaveformWriteIndex; + const channels = this.channelWaveformBuf.map((buf) => { + const out = new Float32Array(512); + for (let j = 0; j < 512; j++) { + out[j] = buf[(wi + j) % 512]; + } + return out; + }); + this._post({ type: 'channel_waveform', channels }); + const playbackHz = this._collectPlaybackHz(); + this._post({ + type: 'channel_tone_hz', + frequencies: playbackHz.toneHz, + sidTimerHz: [], + syncbuzzerTimerHz: [], + registers: [] + }); + } + this.finishAudioBlockFlushTransport(numSamples, this.paused); + } + + handleStop() { + this.resetChannelWaveformCapture(); + super.handleStop(); + } +} + +export default NesWorkletSlot; diff --git a/public/effect-algorithms.js b/public/tracker/effect-algorithms.js similarity index 100% rename from public/effect-algorithms.js rename to public/tracker/effect-algorithms.js diff --git a/public/pt3-volume-table.js b/public/tracker/pt3-volume-table.js similarity index 100% rename from public/pt3-volume-table.js rename to public/tracker/pt3-volume-table.js diff --git a/public/song-timeline.js b/public/tracker/song-timeline.js similarity index 100% rename from public/song-timeline.js rename to public/tracker/song-timeline.js diff --git a/public/timeline-pattern-coordinator.js b/public/tracker/timeline-pattern-coordinator.js similarity index 99% rename from public/timeline-pattern-coordinator.js rename to public/tracker/timeline-pattern-coordinator.js index be4b56d5..2f46806a 100644 --- a/public/timeline-pattern-coordinator.js +++ b/public/tracker/timeline-pattern-coordinator.js @@ -52,7 +52,6 @@ export class TimelinePatternCoordinator { pattern: data.pattern, orderIndex: data.patternOrderIndex }; - this.nextPatternRequested = false; return; } diff --git a/public/tracker/tracker-audio-utils.js b/public/tracker/tracker-audio-utils.js new file mode 100644 index 00000000..7f5a2501 --- /dev/null +++ b/public/tracker/tracker-audio-utils.js @@ -0,0 +1,30 @@ +import { PT3VolumeTable } from './pt3-volume-table.js'; + +export function advanceInstrumentRowPosition(position, rowsLength, loop) { + const length = rowsLength > 0 ? rowsLength : 1; + let next = position + 1; + if (next >= length) { + next = loop > 0 && loop < length ? loop : 0; + } + return next; +} + +export function calculatePt3Volume(patternVolume, instrumentVolume) { + const pattern = Math.max(0, Math.min(15, patternVolume)); + const instrument = Math.max(0, Math.min(15, instrumentVolume)); + return PT3VolumeTable[pattern][instrument]; +} + +export function getEffectiveTuningPeriod(state, channelIndex, maxPeriod = 2047) { + const noteIndex = state.channelCurrentNotes[channelIndex]; + if (noteIndex < 0 || noteIndex >= state.currentTuningTable.length) return 0; + let period = state.currentTuningTable[noteIndex]; + if (period <= 0) return 0; + const toneSliding = state.channelToneSliding?.[channelIndex] || 0; + const vibratoSliding = state.channelVibratoSliding?.[channelIndex] || 0; + const detune = state.channelDetune?.[channelIndex] || 0; + period += toneSliding + vibratoSliding + detune; + if (period < 0) return 0; + if (period > maxPeriod) return maxPeriod; + return period; +} diff --git a/public/tracker/tracker-chip-state.js b/public/tracker/tracker-chip-state.js new file mode 100644 index 00000000..a43b5a37 --- /dev/null +++ b/public/tracker/tracker-chip-state.js @@ -0,0 +1,42 @@ +export const TRACKER_CHANNEL_ARRAY_SKIP_ON_RESET = ['channelMuted']; + +export function buildInstrumentIdToIndex(instruments) { + const instrumentIdToIndex = new Map(); + instruments.forEach((instrument, index) => { + if (instrument && instrument.id !== undefined) { + let numericId; + if (typeof instrument.id === 'string') { + numericId = parseInt(instrument.id, 36); + } else { + numericId = instrument.id; + } + instrumentIdToIndex.set(numericId, index); + } + }); + return instrumentIdToIndex; +} + +export function initChipChannelArrays(state, channelCount, specs) { + for (const [name, defaultVal] of specs) { + state[name] = Array(channelCount).fill(defaultVal); + } +} + +export function resizeChipChannelArrays(state, newCount, specs) { + for (const [name, defaultVal] of specs) { + const arr = state[name]; + while (arr.length < newCount) arr.push(defaultVal); + if (arr.length > newCount) arr.length = newCount; + } +} + +export function resetChipChannelArrays( + state, + specs, + skipNames = TRACKER_CHANNEL_ARRAY_SKIP_ON_RESET +) { + for (const [name, defaultVal] of specs) { + if (skipNames.includes(name)) continue; + state[name].fill(defaultVal); + } +} diff --git a/public/tracker/tracker-engine-transport.js b/public/tracker/tracker-engine-transport.js new file mode 100644 index 00000000..a80a6b90 --- /dev/null +++ b/public/tracker/tracker-engine-transport.js @@ -0,0 +1,19 @@ +export function resetChipPlaybackOutput({ + registerState, + audioDriver, + chipEngine, + applyRegisterState +}) { + if (registerState) { + registerState.reset(); + } + if (audioDriver?.resetChannelMixerState) { + audioDriver.resetChannelMixerState(); + } + if (chipEngine) { + chipEngine.reset(); + } + if (applyRegisterState) { + applyRegisterState(); + } +} diff --git a/public/tracker/tracker-instrument-channel.js b/public/tracker/tracker-instrument-channel.js new file mode 100644 index 00000000..03c48be6 --- /dev/null +++ b/public/tracker/tracker-instrument-channel.js @@ -0,0 +1,67 @@ +import EffectAlgorithms from './effect-algorithms.js'; + +export function getChannelInstrument(state, channelIndex) { + const instrumentIndex = state.channelInstruments?.[channelIndex] ?? -1; + if (instrumentIndex < 0) { + return { instrumentIndex: -1, instrument: null }; + } + const instrument = state.instruments?.[instrumentIndex] ?? null; + return { instrumentIndex, instrument }; +} + +export function channelHasAssignedInstrument(state, channelIndex) { + const { instrumentIndex, instrument } = getChannelInstrument(state, channelIndex); + return instrumentIndex >= 0 && instrument != null; +} + +export function isChannelOnOffHalted(state, channelIndex) { + return ( + state.channelOnOffCounter?.[channelIndex] > 0 && !state.channelSoundEnabled[channelIndex] + ); +} + +export function processChannelOnOffCounters(state, channelCount) { + for (let channelIndex = 0; channelIndex < channelCount; channelIndex++) { + if (state.channelOnOffCounter?.[channelIndex] > 0) { + const result = EffectAlgorithms.processOnOffCounter( + state.channelOnOffCounter[channelIndex], + state.channelOnDuration[channelIndex], + state.channelOffDuration[channelIndex], + state.channelSoundEnabled[channelIndex] + ); + state.channelOnOffCounter[channelIndex] = result.counter; + state.channelSoundEnabled[channelIndex] = result.enabled; + } + } +} + +export function assignPatternRowInstrument(state, channelIndex, row) { + if (!state.channelInstruments || !state.instruments || state.channelMuted?.[channelIndex]) { + return { changed: false, assigned: false, instrument: null, instrumentIndex: -1 }; + } + + if (!row.instrument || row.instrument <= 0) { + const { instrumentIndex, instrument } = getChannelInstrument(state, channelIndex); + return { + changed: false, + assigned: instrumentIndex >= 0 && instrument != null, + instrument, + instrumentIndex + }; + } + + const instrumentIndex = state.instrumentIdToIndex?.get(row.instrument); + if (instrumentIndex === undefined || !state.instruments[instrumentIndex]) { + state.channelInstruments[channelIndex] = -1; + return { changed: true, assigned: false, instrument: null, instrumentIndex: -1 }; + } + + state.channelInstruments[channelIndex] = instrumentIndex; + state.instrumentPositions[channelIndex] = 0; + return { + changed: true, + assigned: true, + instrument: state.instruments[instrumentIndex], + instrumentIndex + }; +} diff --git a/public/tracker-pattern-processor.js b/public/tracker/tracker-pattern-processor.js similarity index 100% rename from public/tracker-pattern-processor.js rename to public/tracker/tracker-pattern-processor.js diff --git a/public/tracker-state.js b/public/tracker/tracker-state.js similarity index 100% rename from public/tracker-state.js rename to public/tracker/tracker-state.js diff --git a/public/tracker/tracker-worklet-slot.js b/public/tracker/tracker-worklet-slot.js new file mode 100644 index 00000000..1ca3845c --- /dev/null +++ b/public/tracker/tracker-worklet-slot.js @@ -0,0 +1,297 @@ +import { WorkletSlotBase } from './worklet-slot-base.js'; + +export class TrackerWorkletSlot extends WorkletSlotBase { + constructor(port, chipIndex) { + super(port, chipIndex); + this.previewActiveChannels = new Set(); + this.previewTickSampleCounter = 0; + } + + _playbackWorkersReady() { + return Boolean(this.patternProcessor && this.audioDriver); + } + + _chipEngineReady() { + return this._playbackWorkersReady(); + } + + _resizeForPatternChannels(channelCount) { + if (channelCount <= this.registerState.channelCount) return; + this.state.resizeChannels(channelCount); + this.registerState.resize(channelCount); + if (this.audioDriver) { + this.audioDriver.resizeChannels(channelCount); + } + } + + _replayCatchUpSegments(catchUpSegments) { + if (!catchUpSegments?.length || !this._chipEngineReady()) { + return; + } + for (const segment of catchUpSegments) { + if (segment.pattern?.channels?.length) { + this._resizeForPatternChannels(segment.pattern.channels.length); + } + this.state.setPattern(segment.pattern, segment.patternOrderIndex); + const numRows = segment.numRows ?? 0; + for (let row = 0; row < numRows; row++) { + this._simulateRow(this.state.currentPattern, row); + } + } + } + + _runCatchUpRows(upToRow) { + if ( + !this.state.currentPattern || + this.state.currentPattern.length === 0 || + upToRow <= 0 || + !this._chipEngineReady() + ) { + return; + } + for (let row = 0; row < upToRow; row++) { + this._simulateRow(this.state.currentPattern, row); + } + } + + _afterPlaybackPositionSet(rowIndex) { + this._primePatternRowForPlayback(rowIndex); + } + + _primePatternRowForPlayback(rowIndex) { + if (!this._chipEngineReady()) { + return; + } + const pattern = this.state.currentPattern; + if (!pattern?.length || rowIndex < 0 || rowIndex >= pattern.length) { + return; + } + if (pattern.channels?.length) { + this._resizeForPatternChannels(pattern.channels.length); + } + this.patternProcessor.parsePatternRow(pattern, rowIndex, this.registerState); + this.patternProcessor.processSpeedTable(); + this.enforceMuteState(); + this._processTrackerTick(); + this._applyRegisterStateToEngine(); + } + + _afterTransportStop() { + this.handleStopPreview(); + } + + _preparePatternWorkersForPlay() { + this.ensurePlaybackWorkers(); + this.enforceMuteState(); + } + + _simulateRow(pattern, rowIndex) { + this.patternProcessor.parsePatternRow(pattern, rowIndex, this.registerState); + this.patternProcessor.processSpeedTable(); + const ticksPerRow = this.state.timeline.currentSpeed; + for (let tick = 0; tick < ticksPerRow; tick++) { + this._processTrackerTick(); + } + this._applyRegisterStateToEngine(); + } + + _processTrackerTick() { + this.patternProcessor.processTables(); + this.patternProcessor.processArpeggio(); + this.patternProcessor.processEffectTables(); + this.audioDriver.processInstruments(this.state, this.registerState); + this.patternProcessor.processVibrato(); + this.patternProcessor.processSlides(); + } + + dispatchPortMessages(type, data) { + if (this._dispatchChipPortMessage(type, data)) { + return; + } + + switch (type) { + case 'play': + this.handlePlay(data); + break; + case 'play_from_row': + this.handlePlayFromRow(data); + break; + case 'play_from_position': + this.handlePlayFromPosition(data); + break; + case 'stop': + this.handleStop(); + break; + case 'init_pattern': + this.handleInitPattern(data); + break; + case 'update_order': + this.handleUpdateOrder(data); + break; + case 'set_pattern_data': + this.handleSetPatternData(data); + break; + case 'init_tuning_table': + this.handleInitTuningTable(data); + break; + case 'init_speed': + this.handleInitSpeed(data); + break; + case 'init_tables': + this.handleInitTables(data); + break; + case 'init_instruments': + this.handleInitInstruments(data); + break; + case 'change_pattern_during_playback': + this.handleChangePatternDuringPlayback(data); + break; + case 'preview_row': + this.handlePreviewRow(data); + break; + case 'stop_preview': + this.handleStopPreview(data.channel); + break; + case 'set_channel_mute': + this.handleSetChannelMute(data); + break; + default: + break; + } + } + + _dispatchChipPortMessage(_type, _data) { + return false; + } + + handleInitTuningTable({ tuningTable }) { + this.state.setTuningTable(tuningTable); + } + + handleInitSpeed({ speed }) { + if (!(speed > 0)) return; + if (this.chipIndex !== 0) return; + this.state.publishPlaybackSpeed(speed); + } + + handleInitTables({ tables }) { + this.state.setTables(tables); + } + + handleInitInstruments({ instruments }) { + this.state.setInstruments(instruments); + } + + _canPreview() { + return this.initialized && this.state.wasmModule; + } + + _beforePreviewRow(_data) {} + + _resetEnginesForPreview() {} + + _beforeStopPreviewAll() {} + + _silencePreviewChannel(_channelIndex) {} + + handlePreviewRow({ pattern, rowIndex, instrument }) { + if (!this._canPreview()) { + return; + } + this._beforePreviewRow({ pattern, rowIndex, instrument }); + this.paused = true; + if (!pattern?.channels || !pattern.patternRows || rowIndex < 0) { + return; + } + if (rowIndex >= pattern.length) { + return; + } + if (!this._chipEngineReady()) { + return; + } + if (pattern.channels.length) { + this._resizeForPatternChannels(pattern.channels.length); + } + if (instrument) { + this.state.setInstruments([instrument]); + } + + this.state.reset({ resetTimeline: false }); + this.registerState.reset(); + this._resetEnginesForPreview(); + + for (let row = 0; row < rowIndex; row++) { + this._simulateRow(pattern, row); + } + this.patternProcessor.parsePatternRow(pattern, rowIndex, this.registerState); + this.patternProcessor.processTables(); + this.audioDriver.processInstruments(this.state, this.registerState); + this._applyRegisterStateToEngine(); + + this.previewActiveChannels = new Set(); + for (let ch = 0; ch < this.registerState.channelCount; ch++) { + this.previewActiveChannels.add(ch); + } + this.previewTickSampleCounter = 0; + } + + handleStopPreview(channel) { + if (channel === undefined) { + this._beforeStopPreviewAll(); + } + if (channel !== undefined) { + this.previewActiveChannels.delete(channel); + this._silencePreviewChannel(channel); + this.state.channelSoundEnabled[channel] = false; + this._applyRegisterStateToEngine(); + } else { + this.previewActiveChannels.clear(); + this.registerState.reset(); + this._resetEnginesForPreview(); + this._applyRegisterStateToEngine(); + } + } + + isPreviewActive() { + return this.previewActiveChannels.size > 0; + } + + runSharedPlaybackQuantum() { + if (!this.state.currentPattern || this.state.currentPattern.length === 0) return; + if (this.state.timeline.currentTick === 0) { + this.timelinePattern.maybeRequestPrefetchForSharedTimeline( + this.state.currentPattern, + this.state.timeline + ); + + if (this.state.currentPattern.channels) { + this._resizeForPatternChannels(this.state.currentPattern.channels.length); + } + const rowIndex = this.state.timeline.currentRow; + this.patternProcessor.parsePatternRow( + this.state.currentPattern, + rowIndex, + this.registerState + ); + this.patternProcessor.processSpeedTable(); + this.timelinePattern.queueOrSendPositionUpdate(); + } + + this.enforceMuteState(); + this._processTrackerTick(); + this.enforceMuteState(); + this._applyRegisterStateToEngine(); + } + + runPreviewStep() { + this.previewTickSampleCounter++; + if (this.previewTickSampleCounter >= this.state.timeline.samplesPerTick) { + this.previewTickSampleCounter = 0; + this.patternProcessor.processTables(); + if (this.state.channelInstruments) { + this.audioDriver.processInstruments(this.state, this.registerState); + } + } + this._applyRegisterStateToEngine(); + } +} diff --git a/public/worklet-slot-base.js b/public/tracker/worklet-slot-base.js similarity index 87% rename from public/worklet-slot-base.js rename to public/tracker/worklet-slot-base.js index 6b2ce4a6..b62b3b5a 100644 --- a/public/worklet-slot-base.js +++ b/public/tracker/worklet-slot-base.js @@ -36,6 +36,11 @@ export class WorkletSlotBase { _applyPlaybackSpeed(_speed) {} + _publishLeaderPlaybackSpeed(speed) { + if (this.chipIndex !== 0 || !(speed > 0)) return; + this._applyPlaybackSpeed(speed); + } + _onTransportStop() {} _afterTransportStop() {} @@ -56,6 +61,7 @@ export class WorkletSlotBase { this._runCatchUpRows(this._slotState().timeline.currentRow); } if (!paused) { + this._afterPlaybackPositionSet(this._slotState().timeline.currentRow); this.timelinePattern.postPositionUpdate(); } } @@ -76,7 +82,8 @@ export class WorkletSlotBase { const state = this._slotState(); if (patternOrderIndex !== undefined) { - const patternOrderChanged = state.timeline.currentPatternOrderIndex !== patternOrderIndex; + const patternOrderChanged = + state.timeline.currentPatternOrderIndex !== patternOrderIndex; state.timeline.currentPatternOrderIndex = patternOrderIndex; if (pattern) { @@ -97,7 +104,7 @@ export class WorkletSlotBase { } if (speed !== undefined && speed !== null && speed > 0) { - this._applyPlaybackSpeed(speed); + this._publishLeaderPlaybackSpeed(speed); } if (row !== undefined && (patternChanged || state.currentPattern)) { @@ -124,10 +131,11 @@ export class WorkletSlotBase { state.timeline.currentPatternOrderIndex = startPatternOrderIndex; } if (initialSpeed !== undefined && initialSpeed > 0) { - this._applyPlaybackSpeed(initialSpeed); + this._publishLeaderPlaybackSpeed(initialSpeed); } this.timelinePattern.postPositionUpdate(); + this._afterPlaybackPositionSet(state.timeline.currentRow); } handlePlayFromRow({ row, patternOrderIndex, speed }) { @@ -151,11 +159,12 @@ export class WorkletSlotBase { } state.timeline.currentRow = row; if (speed !== undefined && speed !== null && speed > 0) { - this._applyPlaybackSpeed(speed); + this._publishLeaderPlaybackSpeed(speed); } this.timelinePattern.postPositionUpdate(); this._runCatchUpRows(state.timeline.currentRow); + this._afterPlaybackPositionSet(state.timeline.currentRow); } handlePlayFromPosition({ @@ -173,7 +182,7 @@ export class WorkletSlotBase { this.startPlaybackCommon(); if (speed !== undefined && speed !== null && speed > 0) { - this._applyPlaybackSpeed(speed); + this._publishLeaderPlaybackSpeed(speed); } this._replayCatchUpSegments(catchUpSegments); @@ -187,6 +196,7 @@ export class WorkletSlotBase { state.timeline.currentRow = startRow; this.timelinePattern.postPositionUpdate(); + this._afterPlaybackPositionSet(startRow); } startPlaybackCommon() { @@ -220,13 +230,15 @@ export class WorkletSlotBase { return p && p.length > 0 ? p.length : 0; } + _afterPlaybackPositionSet(_rowIndex) {} + + shouldAccumulateStereoOutput() { + return !this.paused; + } + shouldRunPlaybackAccumulation() { const state = this._slotState(); - return ( - !this.paused && - state.currentPattern && - state.currentPattern.length > 0 - ); + return !this.paused && state.currentPattern && state.currentPattern.length > 0; } finishAudioBlockFlushTransport(numSamples, paused) { diff --git a/src/App.svelte b/src/App.svelte index c81096c7..702716d9 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -132,10 +132,7 @@ const backup = await autobackupService.getAutobackup(); if (backup) { - container.audioService.clearChipProcessors(); - for (const _ of backup.songs) { - await container.audioService.addChipProcessor(AY_CHIP); - } + await projectService.restoreChipProcessorsForSongs(backup.songs); syncChipProcessors(); ProjectService.ensureChipSettingsConsistency(backup.songs); projectStore.applyProject(backup); @@ -204,7 +201,7 @@ .forEach((s) => { const value = firstSong[s.key] ?? s.defaultValue; if (value !== undefined) { - container.audioService.chipSettings.set(s.key, value); + container.audioService.chipSettings.forChip(chipType).set(s.key, value); } }); }); @@ -222,6 +219,7 @@ removeSong: (index) => { projectStore.removeSong(index); container.audioService.removeChipProcessor(index); + container.audioService.updateInstruments(projectStore.instruments); syncChipProcessors(); }, addSong: (song) => { @@ -244,7 +242,8 @@ activeSongIndex = 0; songView?.resetEditorState?.(); patternEditor?.resetToBeginning?.(); - } + }, + syncChipProcessors }; const baseHandleMenuAction = createMenuActionHandler(menuActionContext); diff --git a/src/lib/chips/ay/AYInstrumentEditor.svelte b/src/lib/chips/ay/AYInstrumentEditor.svelte index c723788a..0878c848 100644 --- a/src/lib/chips/ay/AYInstrumentEditor.svelte +++ b/src/lib/chips/ay/AYInstrumentEditor.svelte @@ -1,8 +1,5 @@ -{#snippet volumeCell(index: number, value: number, isSelected: boolean, rowSelected: boolean)} - beginDragVolume(index, value)} - onmouseover={() => dragOverVolume(index, value)} - onfocus={() => dragOverVolume(index, value)}> - {#if isSelected} - {formatNum(value)} - {:else} - {formatNum(value)} - {/if} - -{/snippet} -
-
- Name: - -
+ - {#if loopMarkerStyle} -
-
-
-
-
-
-
-
-
-
- {/if} + - {#each activeTab === 'timer' ? timerEffects.timerRows : rows as _, index (index)} - {@const selected = isRowSelected(index)} + {#each activeTab === 'timer' ? timerEffects.timerRows : mixerSync.rows as _, index (index)} + {@const selected = selection.isRowSelected(index)} -
- - {#if index < displayRowCount - 1} - - {/if} -
- - + onmousedown={(e) => selection.handleRowSelect(index, e)} /> + removeDisplayRow(index)} + onRemoveFromBottom={() => removeDisplayRowsFromBottom(index)} /> + setActiveLoop(index)} /> {#if activeTab === 'mixer'} - {@const row = rows[index]} - - - - - - - - + widthClass={isExpanded ? 'w-8 min-w-8' : 'w-8 min-w-8'} + onPaintBegin={() => + booleanDrag.begin( + () => row.retriggerEnvelope ?? false, + (value) => + updateBooleanRow(index, 'retriggerEnvelope', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'retriggerEnvelope', value))} /> - - + + booleanDrag.begin( + () => row.toneAccumulation, + (value) => + updateBooleanRow(index, 'toneAccumulation', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'toneAccumulation', value))} /> - - + + booleanDrag.begin( + () => row.noiseAccumulation, + (value) => + updateBooleanRow(index, 'noiseAccumulation', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'noiseAccumulation', value))} /> - - + + booleanDrag.begin( + () => row.envelopeAccumulation, + (value) => + updateBooleanRow(index, 'envelopeAccumulation', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'envelopeAccumulation', value))} /> @@ -1259,7 +931,7 @@ rowCount={displayRowCount} onRowCountChange={setDisplayRowCount} rowHeightPx={isExpanded ? 32 : 28} - maxRows={MAX_ROWS} /> + maxRows={ROW_EDITOR_MAX_ROWS} /> @@ -1276,7 +948,7 @@ {/each} @@ -1288,8 +960,8 @@ - {#each rows as row, index} - {@const selected = isRowSelected(index)} + {#each mixerSync.rows as row, index} + {@const selected = selection.isRowSelected(index)} {#each VOLUME_VALUES as v} - {@render volumeCell(index, v, v === row.volume, selected)} + formatRowEditorNumber(value, asHex)} + onPaintBegin={(_, value) => + volumeDrag.begin(value, (paintValue) => + updateRow(index, 'volume', paintValue))} + onPaintOver={(_, value) => + volumeDrag.dragOverWithValue(value, (paintValue) => + updateRow(index, 'volume', paintValue))} /> {/each} {/each} diff --git a/src/lib/chips/ay/AYInstrumentSamplePanel.svelte b/src/lib/chips/ay/AYInstrumentSamplePanel.svelte index d6347a8f..d0030eb5 100644 --- a/src/lib/chips/ay/AYInstrumentSamplePanel.svelte +++ b/src/lib/chips/ay/AYInstrumentSamplePanel.svelte @@ -81,7 +81,7 @@ }); $effect(() => { - const chipSettings = containerContext.audioService.chipSettings; + const chipSettings = containerContext.audioService.chipSettings.forChip('ay'); return chipSettings.subscribe('chipVariant', (value) => { chipVariant = resolveAyChipVariant(value); }); diff --git a/src/lib/chips/ay/AYPreviewRow.svelte b/src/lib/chips/ay/AYPreviewRow.svelte index 832302ad..d57377cb 100644 --- a/src/lib/chips/ay/AYPreviewRow.svelte +++ b/src/lib/chips/ay/AYPreviewRow.svelte @@ -28,6 +28,7 @@ import { ShortcutString } from '../../utils/shortcut-string'; import { ACTION_TOGGLE_PLAYBACK } from '../../config/keybindings'; import { isValidTableDisplayChar, tableDisplayCharToId } from '../../utils/table-id'; + import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; let { chip, @@ -124,7 +125,9 @@ $effect(() => { return () => { if (savedStereoLayout !== undefined) { - containerContext.audioService.chipSettings.set('stereoLayout', savedStereoLayout); + containerContext.audioService.chipSettings + .forChip(chip.type) + .set('stereoLayout', savedStereoLayout); savedStereoLayout = undefined; } }; @@ -134,7 +137,7 @@ const processors = previewProcessors as unknown as PreviewNoteSupport[]; if (processors.length === 0) return; const hasNotes = effectiveNoteStrings.length > 0; - const chipSettings = containerContext.audioService.chipSettings; + const chipSettings = containerContext.audioService.chipSettings.forChip(chip.type); if (!hasNotes) { if (hadActiveNotes) { hadActiveNotes = false; @@ -158,10 +161,12 @@ if (chipIndices.length > 0) { containerContext.audioService.setPreviewActiveForChips(chipIndices); } - const normalizedId = (instrumentId || '01').toUpperCase().padStart(2, '0'); - const currentInstrument = projectStore.instruments.find( - (i) => i.id.toUpperCase().padStart(2, '0') === normalizedId - ); + const normalizedId = instrumentId.toUpperCase().padStart(2, '0'); + const currentInstrument = instrumentId + ? filterInstrumentsForChip(projectStore.instruments, chip.type).find( + (i) => i.id.toUpperCase().padStart(2, '0') === normalizedId + ) + : undefined; processors.forEach((proc, processorIndex) => { const start = processorIndex * 3; const channelNotes = [ diff --git a/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte b/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte index e000aba9..440a213d 100644 --- a/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte +++ b/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte @@ -7,6 +7,7 @@ import { playbackToneDebugStore } from '../../stores/playback-tone-debug.svelte'; import { projectStore } from '../../stores/project.svelte'; import { editorStateStore } from '../../stores/editor-state.svelte'; + import { resolveInstrumentChipType } from '../../services/instrument/instrument-filter'; import { AY_TIMER_PWM_SWEEP_SHAPE_LABELS, AY_TIMER_PWM_SWEEP_SHAPES, @@ -43,9 +44,7 @@ let isDragging = $state(false); let isHovering = $state(false); - const minDuty = $derived( - controller.timerPwmSweep() > 0 ? controller.timerPwmSweepMin() : 0 - ); + const minDuty = $derived(controller.timerPwmSweep() > 0 ? controller.timerPwmSweepMin() : 0); const maxDuty = $derived(controller.timerPwmDuty()); const sweepSpeed = $derived(controller.timerPwmSweep()); const startPhase = $derived(controller.timerPwmSweepStartPhase()); @@ -53,7 +52,9 @@ const shapeLabel = $derived(AY_TIMER_PWM_SWEEP_SHAPE_LABELS[sweepShape]); const instrumentIndex = $derived( projectStore.instruments.findIndex( - (instrument) => instrument.id === editorStateStore.currentInstrument + (instrument) => + instrument.id === editorStateStore.getCurrentInstrument('ay') && + resolveInstrumentChipType(instrument) === 'ay' ) ); @@ -273,7 +274,9 @@ bind:this={svgEl} viewBox="0 0 {VIEW_WIDTH} {VIEW_HEIGHT}" preserveAspectRatio="none" - class="block h-full w-full touch-none {enabled ? 'cursor-crosshair' : 'cursor-not-allowed'}" + class="block h-full w-full touch-none {enabled + ? 'cursor-crosshair' + : 'cursor-not-allowed'}" role="slider" tabindex={enabled ? 0 : -1} aria-label="PWM sweep start position on automation curve" @@ -289,8 +292,14 @@ onpointerleave={handlePointerLeave}> - - + + diff --git a/src/lib/chips/ay/AYTimerWaveformEditor.svelte b/src/lib/chips/ay/AYTimerWaveformEditor.svelte index 36a47414..f062d6ed 100644 --- a/src/lib/chips/ay/AYTimerWaveformEditor.svelte +++ b/src/lib/chips/ay/AYTimerWaveformEditor.svelte @@ -56,7 +56,7 @@ let hoverStepIndex = $state(null); const chipVariant = $derived( - resolveAyChipVariant(containerContext.audioService.chipSettings.get('chipVariant')) + resolveAyChipVariant(containerContext.audioService.chipSettings.forChip('ay').get('chipVariant')) ); const waveform = $derived(controller.rowTimerWaveform(rowIndex)); diff --git a/src/lib/chips/ay/core.ts b/src/lib/chips/ay/core.ts index b9a618f1..a64a3435 100644 --- a/src/lib/chips/ay/core.ts +++ b/src/lib/chips/ay/core.ts @@ -4,12 +4,14 @@ import { AYFormatter } from './formatter'; import { AYChipRenderer } from './renderer'; import { AY_CHIP_SCHEMA } from './schema'; import { AYUMI_AUDIO_SLOT_KIND } from './audio-slot-kind'; +import { AY_PLAYBACK_DEBUG } from './playback-debug'; +import { copyAyInstrumentFields } from './instrument'; import type { Chip } from '../types'; export const AY_CHIP: Chip = { type: 'ay', name: 'AY-3-8910 / YM2149F', - wasmUrl: 'ayumi.wasm', + wasmUrl: 'ay/ayumi.wasm', audioSlotKind: AYUMI_AUDIO_SLOT_KIND, processorMap: (chip) => new AYProcessor(chip), schema: AY_CHIP_SCHEMA, @@ -17,7 +19,9 @@ export const AY_CHIP: Chip = { createFormatter: () => new AYFormatter(), createRenderer: (loader, binding) => new AYChipRenderer(loader, binding), instrumentEditor: undefined, - previewRow: undefined + previewRow: undefined, + playbackDebug: AY_PLAYBACK_DEBUG, + copyInstrumentFields: copyAyInstrumentFields }; export const CHIP = AY_CHIP; diff --git a/src/lib/chips/ay/playback-debug.ts b/src/lib/chips/ay/playback-debug.ts new file mode 100644 index 00000000..fa42b116 --- /dev/null +++ b/src/lib/chips/ay/playback-debug.ts @@ -0,0 +1,47 @@ +import { + formatPlaybackFrequencyHz, + type ChipPlaybackDebugSpec +} from '../base/playback-debug'; +import { + AY_REGISTER_COUNT, + DEFAULT_AY_REGISTERS +} from '../../services/file/ay-export-utils'; +import { AY_REGISTER_NAMES, formatTimerFrequencyHz } from '../../services/file/tmr-parser'; + +export const AY_PLAYBACK_DEBUG: ChipPlaybackDebugSpec = { + metrics: [ + { + key: 'tone', + label: 'Tone', + icon: 'tone', + accentClass: 'text-[var(--color-pattern-note)]', + readHz: (state, channelIndex) => state?.toneHz[channelIndex] ?? null, + formatHz: formatPlaybackFrequencyHz + }, + { + key: 'sid', + label: 'SID/PWM', + icon: 'sid', + accentClass: 'text-[var(--color-pattern-instrument)]', + readHz: (state, channelIndex) => state?.sidTimerHz[channelIndex] ?? null, + formatHz: (hz) => + hz === null || hz <= 0 ? '—' : formatTimerFrequencyHz(hz) + }, + { + key: 'sync', + label: 'Syncbuzzer', + icon: 'sync', + accentClass: 'text-[var(--color-pattern-envelope)]', + readHz: (state, channelIndex) => state?.syncbuzzerTimerHz[channelIndex] ?? null, + formatHz: (hz) => + hz === null || hz <= 0 ? '—' : formatTimerFrequencyHz(hz) + } + ], + registers: { + count: AY_REGISTER_COUNT, + names: AY_REGISTER_NAMES, + defaultValues: DEFAULT_AY_REGISTERS, + normalizeRegisters: (registers) => + registers?.length === AY_REGISTER_COUNT ? registers : DEFAULT_AY_REGISTERS + } +}; diff --git a/src/lib/chips/ay/processor.ts b/src/lib/chips/ay/processor.ts index 5435ead3..53d7b662 100644 --- a/src/lib/chips/ay/processor.ts +++ b/src/lib/chips/ay/processor.ts @@ -44,6 +44,7 @@ export function sanitizeInstrumentForWorklet(instrument: Instrument): WorkletIns : undefined; return { id: instrument.id, + chipType: instrument.chipType, rows: Array.from(instrument.rows).map((row) => ({ ...row })), loop: instrument.loop, name: instrument.name, diff --git a/src/lib/chips/ay/renderer.ts b/src/lib/chips/ay/renderer.ts index 7042edef..4b37ea44 100644 --- a/src/lib/chips/ay/renderer.ts +++ b/src/lib/chips/ay/renderer.ts @@ -12,6 +12,7 @@ import { AYUMI_AUDIO_SLOT_KIND } from './audio-slot-kind'; import type { ResourceLoader } from '../base/resource-loader'; import { BrowserResourceLoader } from '../base/resource-loader'; import { getTotalVirtualChannelCount } from '../../models/virtual-channels'; +import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; import { AYUMI_STRUCT_SIZE, AYUMI_STRUCT_LEFT_OFFSET, @@ -56,7 +57,7 @@ export class AYChipRenderer implements ChipRenderer { onProgress?: (progress: number, message: string) => void ): Promise<{ wasm: any; wasmBuffer: ArrayBuffer }> { onProgress?.(0, 'Loading WASM module...'); - const wasmBuffer = await this.loader.loadWasm('ayumi.wasm'); + const wasmBuffer = await this.loader.loadWasm('ay/ayumi.wasm'); onProgress?.(10, 'Instantiating WASM...'); const result = await WebAssembly.instantiate(wasmBuffer, { @@ -114,24 +115,24 @@ export class AYChipRenderer implements ChipRenderer { onProgress?.(20, 'Loading processor modules...'); const { default: AyumiState } = await this.loader.loadModule<{ default: new () => unknown; - }>('ayumi-state.js'); + }>('ay/ayumi-state.js'); onProgress?.(30, 'Loading pattern processor...'); const { default: TrackerPatternProcessor } = await this.loader.loadModule<{ default: new (a: unknown, b: unknown, c: unknown) => unknown; - }>('tracker-pattern-processor.js'); + }>('tracker/tracker-pattern-processor.js'); onProgress?.(40, 'Loading audio driver...'); const { default: AYAudioDriver } = await this.loader.loadModule<{ default: new () => unknown; - }>('ay-audio-driver.js'); + }>('ay/ay-audio-driver.js'); const { default: AyumiEngine } = await this.loader.loadModule<{ default: new (a: unknown, b: unknown) => unknown; - }>('ayumi-engine.js'); + }>('ay/ayumi-engine.js'); const { default: AYChipRegisterState } = await this.loader.loadModule<{ default: new () => unknown; - }>('ay-chip-register-state.js'); + }>('ay/ay-chip-register-state.js'); const { default: VirtualChannelMixer } = await this.loader.loadModule<{ default: new () => unknown; - }>('virtual-channel-mixer.js'); + }>('ay/virtual-channel-mixer.js'); return { AyumiState, @@ -195,7 +196,7 @@ export class AYChipRenderer implements ChipRenderer { state.setWasmModule(wasm, ayumiPtr, wasmBuffer); state.setAymFrequency(chipFrequency); state.setTuningTable(song.tuningTable); - state.setInstruments(project.instruments); + state.setInstruments(filterInstrumentsForChip(project.instruments, song.chipType ?? 'ay')); state.setTables(project.tables); if (ownsSharedPlaybackTimeline) { state.setIntFrequency(interruptFrequency, SAMPLE_RATE); @@ -563,7 +564,7 @@ export class AYChipRenderer implements ChipRenderer { const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); const { getPanSettingsForLayout } = await this.loader.loadModule<{ getPanSettingsForLayout: GetPanSettingsForLayout; - }>('ayumi-constants.js'); + }>('ay/ayumi-constants.js'); const { AyumiState, TrackerPatternProcessor, @@ -716,7 +717,7 @@ export class AYChipRenderer implements ChipRenderer { const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); const { getPanSettingsForLayout } = await this.loader.loadModule<{ getPanSettingsForLayout: GetPanSettingsForLayout; - }>('ayumi-constants.js'); + }>('ay/ayumi-constants.js'); const ayumiPtr = this.initializeAyumi(wasm, song, getPanSettingsForLayout); const { AyumiState, diff --git a/src/lib/chips/base/playback-debug.ts b/src/lib/chips/base/playback-debug.ts new file mode 100644 index 00000000..e5c31bd8 --- /dev/null +++ b/src/lib/chips/base/playback-debug.ts @@ -0,0 +1,37 @@ +import type { ChipPlaybackHzState } from '../../stores/playback-tone-debug.svelte'; + +export function formatPlaybackFrequencyHz(hz: number | null): string { + if (hz === null || !Number.isFinite(hz) || hz <= 0) { + return '—'; + } + if (hz >= 1000) { + return `${(hz / 1000).toFixed(2)} kHz`; + } + if (hz >= 100) { + return `${hz.toFixed(1)} Hz`; + } + return `${hz.toFixed(2)} Hz`; +} + +export type PlaybackDebugMetricIcon = 'tone' | 'sid' | 'sync'; + +export interface PlaybackDebugMetricSpec { + key: string; + label: string; + icon: PlaybackDebugMetricIcon; + accentClass: string; + readHz: (state: ChipPlaybackHzState | undefined, channelIndex: number) => number | null; + formatHz: (hz: number | null) => string; +} + +export interface PlaybackDebugRegisterSpec { + count: number; + names: readonly string[]; + defaultValues: readonly number[]; + normalizeRegisters: (registers: number[] | undefined) => readonly number[]; +} + +export interface ChipPlaybackDebugSpec { + metrics: PlaybackDebugMetricSpec[]; + registers?: PlaybackDebugRegisterSpec; +} diff --git a/src/lib/chips/chip-registration.ts b/src/lib/chips/chip-registration.ts index 15e0befe..028c9664 100644 --- a/src/lib/chips/chip-registration.ts +++ b/src/lib/chips/chip-registration.ts @@ -1,2 +1,2 @@ -export const CHIP_TYPES = ['ay'] as const; +export const CHIP_TYPES = ['ay', 'nes'] as const; export type ChipType = (typeof CHIP_TYPES)[number]; diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte new file mode 100644 index 00000000..9a26be9d --- /dev/null +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -0,0 +1,432 @@ + + + + + +
+ + +
setActiveLoop(index)}> - beginDragBoolean(index, 'tone')} - onmouseover={() => dragOverBoolean(index, 'tone')} - onfocus={() => dragOverBoolean(index, 'tone')}> - {row.tone ? '✓' : ''} - beginDragBoolean(index, 'noise')} - onmouseover={() => dragOverBoolean(index, 'noise')} - onfocus={() => dragOverBoolean(index, 'noise')}> - {row.noise ? '✓' : ''} - beginDragBoolean(index, 'envelope')} - onmouseover={() => dragOverBoolean(index, 'envelope')} - onfocus={() => dragOverBoolean(index, 'envelope')}> - {row.envelope ? '✓' : ''} - + booleanDrag.begin( + () => row.tone, + (value) => updateBooleanRow(index, 'tone', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'tone', value))} /> + + booleanDrag.begin( + () => row.noise, + (value) => updateBooleanRow(index, 'noise', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'noise', value))} /> + + booleanDrag.begin( + () => row.envelope, + (value) => updateBooleanRow(index, 'envelope', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'envelope', value))} /> + - beginDragBoolean(index, 'retriggerEnvelope')} - onmouseover={() => - dragOverBoolean(index, 'retriggerEnvelope')} - onfocus={() => - dragOverBoolean(index, 'retriggerEnvelope')}> - {(row.retriggerEnvelope ?? false) ? '✓' : ''} - handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => updateNumericField(index, 'toneAdd', e)} /> - beginDragBoolean(index, 'toneAccumulation')} - onmouseover={() => - dragOverBoolean(index, 'toneAccumulation')} - onfocus={() => - dragOverBoolean(index, 'toneAccumulation')}> - {row.toneAccumulation ? '↑' : ''} - handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => updateNumericField(index, 'noiseAdd', e)} /> - beginDragBoolean(index, 'noiseAccumulation')} - onmouseover={() => - dragOverBoolean(index, 'noiseAccumulation')} - onfocus={() => - dragOverBoolean(index, 'noiseAccumulation')}> - {row.noiseAccumulation ? '↑' : ''} - handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => updateNumericField(index, 'envelopeAdd', e)} /> - beginDragBoolean(index, 'envelopeAccumulation')} - onmouseover={() => - dragOverBoolean(index, 'envelopeAccumulation')} - onfocus={() => - dragOverBoolean(index, 'envelopeAccumulation')}> - {row.envelopeAccumulation ? '↑' : ''} - handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} @@ -1240,15 +920,7 @@ {/if}
-
- -
+
- {formatNum(v)} + {formatRowEditorNumber(v, asHex)}
{index}
+ + + + + + + + + + + + + + + + + + + {#each editorSync.rows as row, index (index)} + {@const selected = selection.isRowSelected(index)} + + selection.handleRowSelect(index, e)} /> + editorSync.removeRow(index)} + onRemoveFromBottom={() => editorSync.removeRowsFromBottom(index)} /> + editorSync.setLoop(index)} /> + + booleanDrag.begin( + () => row.retrigger, + (value) => updateBooleanRow(index, 'retrigger', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'retrigger', value) + )} /> + + updateRow(index, { + pulseWidth: cyclePulseWidth(row.pulseWidth) + })} /> + + + booleanDrag.begin( + () => row.toneAccumulation, + (value) => updateBooleanRow(index, 'toneAccumulation', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'toneAccumulation', value) + )} /> + + booleanDrag.begin( + () => row.sweep, + (value) => updateBooleanRow(index, 'sweep', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'sweep', value) + )} /> + + + + + booleanDrag.begin( + () => row.envelope, + (value) => updateBooleanRow(index, 'envelope', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'envelope', value) + )} /> + + + {/each} + + editorSync.addRow(createDefaultNesInstrumentRow)} + onRowCountChange={(count) => + editorSync.setRowCount( + count, + createDefaultNesInstrumentRow, + ROW_EDITOR_MAX_ROWS + )} /> +
row{isExpanded ? 'loop' : 'lp'} +
+ + + +
+
+
+ + +
+
+ rate + + shift + + {isExpanded ? 'sound len' : 'len'} + + env + +
+ + /rate +
+
+ handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => updateNumericField(index, 'toneAdd', e)} /> + + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'sweepRate', e, { + min: 0, + max: 7 + })} /> + + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'sweepShift', e, { + min: -7, + max: 7 + })} /> + + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'soundLength', e, { + min: 0, + max: 511 + })} /> + + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'volumeOrRate', e, { + min: 0, + max: 15 + })} /> +
+
+ diff --git a/src/lib/chips/nes/adapter.ts b/src/lib/chips/nes/adapter.ts new file mode 100644 index 00000000..214dfbcf --- /dev/null +++ b/src/lib/chips/nes/adapter.ts @@ -0,0 +1,109 @@ +import { Pattern as NesPattern, Note, Effect } from '../../models/song'; +import type { PatternConverter } from '../base/adapter'; +import type { Pattern } from '../../models/song'; +import type { GenericPattern, GenericRow, GenericPatternRow } from '../../models/song/generic'; +import { formatNoteFromEnum, parseNoteFromString } from '../../utils/note-utils'; +import { isEffectLike, isString, toNumber } from '../../utils/type-guards'; +import { PatternEffectHandling } from '../../services/pattern/editing/pattern-effect-handling'; +import { NES_CHIP_SCHEMA } from './schema'; + +export class NESConverter implements PatternConverter { + toGeneric(chipPattern: Pattern): GenericPattern { + const nesPattern = chipPattern as NesPattern; + const generic: GenericPattern = { + id: nesPattern.id, + length: nesPattern.length, + channels: [], + patternRows: [] + }; + + for (let i = 0; i < nesPattern.channels.length; i++) { + generic.channels.push({ rows: [] }); + } + + for (let rowIndex = 0; rowIndex < nesPattern.length; rowIndex++) { + generic.patternRows.push({}); + + for (let channelIndex = 0; channelIndex < nesPattern.channels.length; channelIndex++) { + const nesRow = nesPattern.channels[channelIndex].rows[rowIndex]; + const effectValue = nesRow.effects[0]; + const effect = isEffectLike(effectValue) ? effectValue : null; + const effectForGeneric = + effect && !PatternEffectHandling.isEmptyEffect(effect) + ? ({ + effect: effect.effect, + delay: effect.delay, + parameter: effect.parameter, + tableIndex: effect.tableIndex + } as Record) + : null; + const genericRow: GenericRow = { + note: formatNoteFromEnum(nesRow.note.name, nesRow.note.octave), + instrument: toNumber(nesRow.instrument), + volume: toNumber(nesRow.volume), + table: toNumber(nesRow.table), + effect: effectForGeneric + }; + generic.channels[channelIndex].rows.push(genericRow); + } + } + + return generic; + } + + fromGeneric(generic: GenericPattern): Pattern { + const channelLabels = NES_CHIP_SCHEMA.channelLabels ?? [ + 'Square 1', + 'Square 2', + 'Triangle', + 'Noise', + 'DPCM' + ]; + const nesPattern = new NesPattern( + generic.id, + generic.length, + NES_CHIP_SCHEMA, + channelLabels.slice(0, generic.channels.length) + ); + + for (let rowIndex = 0; rowIndex < generic.length; rowIndex++) { + const genericPatternRow = generic.patternRows[rowIndex] as GenericPatternRow; + + for (let channelIndex = 0; channelIndex < generic.channels.length; channelIndex++) { + const genericChannel = generic.channels[channelIndex]; + const genericRow = genericChannel.rows[rowIndex]; + const nesRow = nesPattern.channels[channelIndex].rows[rowIndex]; + + if (genericRow.note && isString(genericRow.note)) { + const { noteName, octave } = parseNoteFromString(genericRow.note); + nesRow.note = new Note(noteName, octave); + } + + nesRow.instrument = toNumber(genericRow.instrument); + nesRow.volume = toNumber(genericRow.volume); + nesRow.table = toNumber(genericRow.table); + + if ( + genericRow.effect && + isEffectLike(genericRow.effect) && + !PatternEffectHandling.isEmptyEffect(genericRow.effect) + ) { + nesRow.effects[0] = new Effect( + genericRow.effect.effect, + genericRow.effect.delay, + genericRow.effect.parameter, + genericRow.effect.tableIndex + ); + } else { + nesRow.effects[0] = null; + } + } + + if (genericPatternRow) { + Object.assign(nesPattern.patternRows[rowIndex], genericPatternRow); + } + } + + return nesPattern; + } +} diff --git a/src/lib/chips/nes/audio-slot-kind.ts b/src/lib/chips/nes/audio-slot-kind.ts new file mode 100644 index 00000000..d353273d --- /dev/null +++ b/src/lib/chips/nes/audio-slot-kind.ts @@ -0,0 +1 @@ +export const NES_AUDIO_SLOT_KIND = 'nes' as const; diff --git a/src/lib/chips/nes/core.ts b/src/lib/chips/nes/core.ts new file mode 100644 index 00000000..1c7fb8ed --- /dev/null +++ b/src/lib/chips/nes/core.ts @@ -0,0 +1,25 @@ +import { NESProcessor } from './processor'; +import { NESConverter } from './adapter'; +import { NESFormatter } from './formatter'; +import { NESChipRenderer } from './renderer'; +import { NES_CHIP_SCHEMA } from './schema'; +import { NES_AUDIO_SLOT_KIND } from './audio-slot-kind'; +import { NES_PLAYBACK_DEBUG } from './playback-debug'; +import type { Chip } from '../types'; + +export const NES_CHIP: Chip = { + type: 'nes', + name: '2A03 / 2A07', + wasmUrl: 'nes/nes_apu.wasm', + audioSlotKind: NES_AUDIO_SLOT_KIND, + processorMap: (chip) => new NESProcessor(chip), + schema: NES_CHIP_SCHEMA, + createConverter: () => new NESConverter(), + createFormatter: () => new NESFormatter(), + createRenderer: (loader, binding) => new NESChipRenderer(loader, binding), + instrumentEditor: undefined, + previewRow: undefined, + playbackDebug: NES_PLAYBACK_DEBUG +}; + +export const CHIP = NES_CHIP; diff --git a/src/lib/chips/nes/formatter.ts b/src/lib/chips/nes/formatter.ts new file mode 100644 index 00000000..0c462e8a --- /dev/null +++ b/src/lib/chips/nes/formatter.ts @@ -0,0 +1,3 @@ +import { BaseFormatter } from '../base/formatter'; + +export class NESFormatter extends BaseFormatter {} diff --git a/src/lib/chips/nes/index.ts b/src/lib/chips/nes/index.ts new file mode 100644 index 00000000..eb4de0df --- /dev/null +++ b/src/lib/chips/nes/index.ts @@ -0,0 +1,14 @@ +import { NES_CHIP as NES_CHIP_CORE } from './core'; +import NESInstrumentEditor from './NESInstrumentEditor.svelte'; + +export const NES_CHIP = { + ...NES_CHIP_CORE, + instrumentEditor: NESInstrumentEditor +} as const; + +export { NESProcessor } from './processor'; +export { NESConverter } from './adapter'; +export { NESFormatter } from './formatter'; +export { NESChipRenderer } from './renderer'; +export { NESInstrumentEditor }; +export { NES_CHIP_SCHEMA } from './schema'; diff --git a/src/lib/chips/nes/instrument.ts b/src/lib/chips/nes/instrument.ts new file mode 100644 index 00000000..b5eaacfa --- /dev/null +++ b/src/lib/chips/nes/instrument.ts @@ -0,0 +1,215 @@ +export const NES_PULSE_WIDTHS = [0, 1, 2, 3] as const; + +export type NesPulseWidth = (typeof NES_PULSE_WIDTHS)[number]; + +export const NES_PULSE_WIDTH_LABELS: Record = { + 0: '12.5%', + 1: '25%', + 2: '50%', + 3: '75%' +}; + +const TONE_ADD_MIN = -4096; +const TONE_ADD_MAX = 4095; +const SWEEP_RATE_MIN = 0; +const SWEEP_RATE_MAX = 7; +const SWEEP_SHIFT_MIN = -7; +const SWEEP_SHIFT_MAX = 7; +export const NES_SQUARE_SWEEP_DISABLED = 0x08; + +export const NES_LENGTH_COUNTER_LENGTHS = [ + 20, 508, 40, 4, 80, 8, 160, 12, 320, 16, 120, 20, 28, 24, 52, 28, 24, 32, 48, 36, 96, 40, 192, + 44, 384, 48, 144, 52, 32, 56, 64, 60 +] as const; + +const SOUND_LENGTH_MIN = 0; +const SOUND_LENGTH_MAX = 511; +const VOLUME_OR_RATE_MIN = 0; +const VOLUME_OR_RATE_MAX = 15; + +export type NesInstrumentRow = { + pulseWidth: NesPulseWidth; + retrigger: boolean; + soundLength: number; + envelope: boolean; + volumeOrRate: number; + toneAdd: number; + toneAccumulation: boolean; + sweep: boolean; + sweepRate: number; + sweepShift: number; +}; + +export function isNesVolumeField(envelope: boolean): boolean { + return !envelope; +} + +export function createDefaultNesInstrumentRow(): NesInstrumentRow { + return { + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: false, + volumeOrRate: 15, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }; +} + +function normalizeToneAdd(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(TONE_ADD_MIN, Math.min(TONE_ADD_MAX, Math.round(parsed))); +} + +function normalizeSweepRate(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_RATE_MIN, Math.min(SWEEP_RATE_MAX, Math.round(parsed))); +} + +function normalizeSweepShift(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_SHIFT_MIN, Math.min(SWEEP_SHIFT_MAX, Math.round(parsed))); +} + +function normalizeEnvelope(value: unknown): boolean { + return Boolean(value); +} + +function normalizeSoundLength(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SOUND_LENGTH_MIN, Math.min(SOUND_LENGTH_MAX, Math.round(parsed))); +} + +function normalizeVolumeOrRate(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(VOLUME_OR_RATE_MIN, Math.min(VOLUME_OR_RATE_MAX, Math.round(parsed))); +} + +export function buildSquareSweepReg(enabled: boolean, rate: number, shift: number): number { + if (!enabled || shift === 0) { + return NES_SQUARE_SWEEP_DISABLED; + } + const amount = Math.abs(shift); + const packed = ((rate & 7) << 4) | (amount & 7); + return shift < 0 ? 0x88 | packed : 0x80 | packed; +} + +export const NES_REGISTER_UNCHANGED = -1; + +export function resolveLengthCounterIndex(soundLength: number): number { + if (soundLength <= 0) return 0; + const target = soundLength; + let bestIndex = 0; + let bestDelta = Number.POSITIVE_INFINITY; + for (let i = 0; i < NES_LENGTH_COUNTER_LENGTHS.length; i++) { + const frames = NES_LENGTH_COUNTER_LENGTHS[i] * 2; + const delta = Math.abs(frames - target); + if (delta < bestDelta) { + bestDelta = delta; + bestIndex = i; + } + } + return bestIndex; +} + +function resolveEnvelopeLoopBit(soundLength: number): number { + return soundLength === 0 ? 1 << 5 : 0; +} + +function resolveConstantVolumeBit(envelope: boolean): number { + return envelope ? 0 : 1 << 4; +} + +export function buildSquareEnvelopeVolumeReg( + duty: NesPulseWidth, + envelope: boolean, + volumeOrRate: number, + soundLength: number +): number { + const volume = volumeOrRate & 15; + const dutyBits = (duty & 3) << 6; + return ( + dutyBits | resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume + ); +} + +export function buildNoiseEnvelopeVolumeReg( + envelope: boolean, + volumeOrRate: number, + soundLength: number +): number { + const volume = volumeOrRate & 15; + return resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume; +} + +export function buildLengthCounterNibble(soundLength: number): number { + if (soundLength === 0) { + return NES_REGISTER_UNCHANGED; + } + return resolveLengthCounterIndex(soundLength) & 31; +} + +export function buildTriangleLinearReg(soundLength: number): number { + if (soundLength === 0) { + return (1 << 7) | 0x7f; + } + if (soundLength > 0 && soundLength < 128) { + return soundLength & 0x7f; + } + return 0x7f; +} + +export function usesTriangleLinearCounter(soundLength: number): boolean { + return soundLength > 0 && soundLength < 128; +} + +export function normalizeNesInstrumentRow(row: Record): NesInstrumentRow { + const defaults = createDefaultNesInstrumentRow(); + const pulseWidth = NES_PULSE_WIDTHS.includes(row.pulseWidth as NesPulseWidth) + ? (row.pulseWidth as NesPulseWidth) + : defaults.pulseWidth; + return { + pulseWidth, + retrigger: row.retrigger !== undefined ? Boolean(row.retrigger) : defaults.retrigger, + soundLength: + row.soundLength !== undefined + ? normalizeSoundLength(row.soundLength) + : defaults.soundLength, + envelope: row.envelope !== undefined ? normalizeEnvelope(row.envelope) : defaults.envelope, + volumeOrRate: + row.volumeOrRate !== undefined + ? normalizeVolumeOrRate(row.volumeOrRate) + : defaults.volumeOrRate, + toneAdd: row.toneAdd !== undefined ? normalizeToneAdd(row.toneAdd) : defaults.toneAdd, + toneAccumulation: + row.toneAccumulation !== undefined + ? Boolean(row.toneAccumulation) + : defaults.toneAccumulation, + sweep: row.sweep !== undefined ? Boolean(row.sweep) : defaults.sweep, + sweepRate: + row.sweepRate !== undefined ? normalizeSweepRate(row.sweepRate) : defaults.sweepRate, + sweepShift: + row.sweepShift !== undefined ? normalizeSweepShift(row.sweepShift) : defaults.sweepShift + }; +} + +export function ensureNesInstrumentRows(rows: Record[]): NesInstrumentRow[] { + if (rows.length === 0) { + return [createDefaultNesInstrumentRow()]; + } + return rows.map((row) => normalizeNesInstrumentRow(row)); +} + +export function cyclePulseWidth(current: NesPulseWidth): NesPulseWidth { + const index = NES_PULSE_WIDTHS.indexOf(current); + const nextIndex = index < 0 ? 0 : (index + 1) % NES_PULSE_WIDTHS.length; + return NES_PULSE_WIDTHS[nextIndex]; +} diff --git a/src/lib/chips/nes/playback-debug.ts b/src/lib/chips/nes/playback-debug.ts new file mode 100644 index 00000000..a91785c0 --- /dev/null +++ b/src/lib/chips/nes/playback-debug.ts @@ -0,0 +1,17 @@ +import { + formatPlaybackFrequencyHz, + type ChipPlaybackDebugSpec +} from '../base/playback-debug'; + +export const NES_PLAYBACK_DEBUG: ChipPlaybackDebugSpec = { + metrics: [ + { + key: 'tone', + label: 'Tone', + icon: 'tone', + accentClass: 'text-[var(--color-pattern-note)]', + readHz: (state, channelIndex) => state?.toneHz[channelIndex] ?? null, + formatHz: formatPlaybackFrequencyHz + } + ] +}; diff --git a/src/lib/chips/nes/processor.ts b/src/lib/chips/nes/processor.ts new file mode 100644 index 00000000..df110b5f --- /dev/null +++ b/src/lib/chips/nes/processor.ts @@ -0,0 +1,260 @@ +import type { Chip } from '../types'; +import type { Pattern, Instrument } from '../../models/song'; +import type { Table } from '../../models/project'; +import type { + MixerWorkletSlotProcessor, + SettingsSubscriber, + TuningTableSupport, + InstrumentSupport, + PreviewNoteSupport +} from '../base/processor'; +import type { ChipSettings } from '../../services/audio/chip-settings'; +import type { CatchUpSegment } from '../../services/audio/play-from-position'; +import { MixerWorkletBridge } from '../../services/audio/mixer-worklet-bridge'; +import { ensureNesInstrumentRows } from './instrument'; + +function sanitizeInstrumentForWorklet(instrument: Instrument) { + return { + id: instrument.id, + chipType: instrument.chipType, + rows: ensureNesInstrumentRows(instrument.rows as Record[]), + loop: instrument.loop, + name: instrument.name + }; +} + +export class NESProcessor + implements + MixerWorkletSlotProcessor, + SettingsSubscriber, + TuningTableSupport, + InstrumentSupport, + PreviewNoteSupport +{ + chip: Chip; + private readonly bridge: MixerWorkletBridge; + private settingsUnsubscribers: (() => void)[] = []; + + constructor(chip: Chip) { + this.chip = chip; + this.bridge = new MixerWorkletBridge(chip); + } + + bindChipIndex(index: number): void { + this.bridge.bindChipIndex(index); + } + + subscribeToSettings(chipSettings: ChipSettings): void { + this.settingsUnsubscribers.push( + chipSettings.subscribe('chipFrequency', (value) => { + if (typeof value === 'number') { + this.sendUpdateCpuFrequency(value); + } + }) + ); + + this.settingsUnsubscribers.push( + chipSettings.subscribe('chipVariant', (value) => { + if (typeof value === 'string') { + this.sendUpdateChipVariant(value); + } + }) + ); + + this.settingsUnsubscribers.push( + chipSettings.subscribe('interruptFrequency', (value) => { + if (typeof value === 'number') { + this.sendUpdateIntFrequency(value); + } + }) + ); + + this.settingsUnsubscribers.push( + chipSettings.subscribe('tuningTable', (value) => { + if (Array.isArray(value) && value.length > 0) { + this.sendInitTuningTable(value as number[]); + } + }) + ); + } + + unsubscribeFromSettings(): void { + for (const unsubscribe of this.settingsUnsubscribers) { + unsubscribe(); + } + this.settingsUnsubscribers = []; + } + + initialize(wasmBuffer: ArrayBuffer, audioNode: AudioWorkletNode): void { + if (!wasmBuffer || wasmBuffer.byteLength === 0) { + throw new Error('WASM buffer not available or empty'); + } + + this.bridge.attachNode(audioNode); + this.bridge.postInitCommand({ type: 'init', wasmBuffer }); + this.bridge.flushCommandQueue(); + } + + acceptWorkletPayload(data: unknown): void { + this.bridge.acceptWorkletPayload(data); + } + + setWaveformCallback(callback: (channels: Float32Array[]) => void): void { + this.bridge.setWaveformCallback(callback); + } + + setChannelToneHzCallback( + callback: (payload: { + frequencies: (number | null)[]; + sidTimerHz: (number | null)[]; + syncbuzzerTimerHz: (number | null)[]; + timerPwmSweepPhase: (number | null)[]; + channelInstrumentIndex: number[]; + registers: number[]; + }) => void + ): void { + this.bridge.setChannelToneHzCallback(callback); + } + + setCallbacks( + onPositionUpdate: (currentRow: number, currentPatternOrderIndex?: number) => void, + onPatternRequest: (patternOrderIndex: number) => void + ): void { + this.bridge.setCallbacks(onPositionUpdate, onPatternRequest); + } + + play(initialSpeed?: number): void { + this.bridge.sendCommand({ type: 'play', initialSpeed }); + } + + playFromRow(row: number, patternOrderIndex?: number, speed?: number | null): void { + this.bridge.sendCommand({ type: 'play_from_row', row, patternOrderIndex, speed }); + } + + playFromPosition( + row: number, + patternOrderIndex: number, + speed: number | null, + catchUpSegments: CatchUpSegment[], + startPattern: Pattern + ): void { + this.bridge.sendCommand({ + type: 'play_from_position', + catchUpSegments, + startPattern, + startPatternOrderIndex: patternOrderIndex, + startRow: row, + speed + }); + } + + stop(): void { + this.bridge.sendCommand({ type: 'stop' }); + } + + updateOrder(order: number[], loopPointId: number): void { + this.bridge.sendCommand({ type: 'update_order', order: Array.from(order), loopPointId }); + } + + sendInitPattern(pattern: Pattern, patternOrderIndex: number): void { + this.bridge.sendCommand({ type: 'init_pattern', pattern, patternOrderIndex }); + } + + sendInitTuningTable(tuningTable: number[]): void { + this.bridge.sendCommand({ type: 'init_tuning_table', tuningTable }); + } + + sendInitSpeed(speed: number): void { + this.bridge.sendCommand({ type: 'init_speed', speed }); + } + + sendInitTables(tables: Table[]): void { + const sanitized: Table[] = tables.map((table) => ({ + id: table.id, + rows: Array.from(table.rows), + loop: table.loop, + name: table.name + })); + this.bridge.sendCommand({ type: 'init_tables', tables: sanitized }); + } + + sendInitInstruments(instruments: Instrument[]): void { + const sanitized = instruments.map((instrument) => + sanitizeInstrumentForWorklet(instrument) + ); + this.bridge.sendCommand({ type: 'init_instruments', instruments: sanitized }); + } + + sendRequestedPattern(pattern: Pattern, patternOrderIndex: number): void { + this.bridge.sendCommand({ type: 'set_pattern_data', pattern, patternOrderIndex }); + } + + changePatternDuringPlayback( + row: number, + patternOrderIndex: number, + pattern?: Pattern, + speed?: number | null + ): void { + this.bridge.sendCommand({ + type: 'change_pattern_during_playback', + row, + patternOrderIndex, + pattern, + speed + }); + } + + playPreviewRow(pattern: Pattern, rowIndex: number, instrument?: Instrument): void { + const sanitized = instrument ? sanitizeInstrumentForWorklet(instrument) : undefined; + this.bridge.sendCommand({ + type: 'preview_row', + pattern, + rowIndex, + instrument: sanitized + }); + } + + stopPreviewNote(channel?: number): void { + this.bridge.sendCommand({ type: 'stop_preview', channel }); + } + + sendUpdateCpuFrequency(cpuFrequency: number): void { + this.bridge.sendCommand({ type: 'update_cpu_frequency', cpuFrequency }); + } + + sendUpdateChipVariant(chipVariant: string): void { + this.bridge.sendCommand({ type: 'update_chip_variant', chipVariant }); + } + + sendUpdateIntFrequency(intFrequency: number): void { + this.bridge.sendCommand({ type: 'update_int_frequency', intFrequency }); + } + + updateParameter(parameter: string, value: unknown): void { + if (parameter.startsWith('channelMute_')) { + const channelIndex = parseInt(parameter.replace('channelMute_', ''), 10); + if (!isNaN(channelIndex) && typeof value === 'boolean') { + this.bridge.sendCommand({ type: 'set_channel_mute', channelIndex, muted: value }); + } + return; + } + + switch (parameter) { + case 'chipFrequency': + this.sendUpdateCpuFrequency(value as number); + break; + case 'interruptFrequency': + this.sendUpdateIntFrequency(value as number); + break; + case 'chipVariant': + this.sendUpdateChipVariant(value as string); + break; + default: + break; + } + } + + isAudioNodeAvailable(): boolean { + return this.bridge.isAudioNodeAvailable(); + } +} diff --git a/src/lib/chips/nes/renderer.ts b/src/lib/chips/nes/renderer.ts new file mode 100644 index 00000000..695f5364 --- /dev/null +++ b/src/lib/chips/nes/renderer.ts @@ -0,0 +1,668 @@ +import type { Project } from '../../models/project'; +import type { Pattern } from '../../models/song'; +import { + assertSharedTimelineSlotsForChip, + type ChipRenderer, + type ChipRendererBinding, + type RenderOptions, + type SharedTimelineExportResult, + type SharedTimelineExportSlot +} from '../base/renderer'; +import { NES_AUDIO_SLOT_KIND } from './audio-slot-kind'; +import type { ResourceLoader } from '../base/resource-loader'; +import { BrowserResourceLoader } from '../base/resource-loader'; +import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; +import { NES_NTSC_CPU_FREQUENCY } from './schema'; + +const SAMPLE_RATE = 44100; +const DEFAULT_SPEED = 6; +const DEFAULT_INTERRUPT_FREQUENCY = 50; + +type NesSlotLane = { + songIndex: number; + song: { patterns: Pattern[]; chipType?: string; chipVariant?: string; chipFrequency?: number; interruptFrequency?: number; initialSpeed?: number; tuningTable: number[] }; + state: { + currentPattern: Pattern | null; + timeline: { + tickAccumulator: number; + tickStep: number; + currentTick: number; + currentSpeed: number; + currentRow: number; + currentPatternOrderIndex: number; + patternOrder: number[]; + loopPointId: number; + }; + advancePosition: (leaderPatternLength?: number) => boolean; + }; + patternProcessor: { + parsePatternRow: (pattern: Pattern, rowIndex: number, registerState: unknown) => void; + processSpeedTable: () => void; + processTables: () => void; + processArpeggio: () => void; + processEffectTables: () => void; + processVibrato: () => void; + processSlides: () => void; + }; + audioDriver: { + processInstruments: (state: unknown, registerState: unknown) => void; + }; + apuEngine: { + applyRegisterState: (registerState: unknown) => void; + process: (sampleRate: number) => { left: number; right: number }; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + dispose: () => void; + }; + registerState: unknown; + patterns: Pattern[]; + apuPtr: number; + dmcPtr: number; +}; + +export class NESChipRenderer implements ChipRenderer { + private loader: ResourceLoader; + private readonly binding: ChipRendererBinding; + + constructor(loader?: ResourceLoader, binding?: ChipRendererBinding) { + this.loader = loader ?? new BrowserResourceLoader(); + this.binding = binding ?? { + chipType: 'nes', + audioSlotKind: NES_AUDIO_SLOT_KIND + }; + } + + private async loadWasmModule( + onProgress?: (progress: number, message: string) => void + ): Promise<{ wasm: Record; wasmBuffer: ArrayBuffer }> { + onProgress?.(0, 'Loading WASM module...'); + const wasmBuffer = await this.loader.loadWasm('nes/nes_apu.wasm'); + onProgress?.(10, 'Instantiating WASM...'); + const result = await WebAssembly.instantiate(wasmBuffer, { + env: { emscripten_notify_memory_growth: () => {} } + }); + return { wasm: result.instance.exports as Record, wasmBuffer }; + } + + private async loadProcessorModules( + onProgress?: (progress: number, message: string) => void + ): Promise<{ + NesState: new (sharedTimeline?: unknown) => NesSlotLane['state']; + TrackerPatternProcessor: new ( + state: unknown, + driver: unknown, + port: { postMessage?: (...args: unknown[]) => void } + ) => NesSlotLane['patternProcessor']; + NesAudioDriver: new () => NesSlotLane['audioDriver']; + createNesApuEngine: ( + wasm: Record + ) => { engine: NesSlotLane['apuEngine']; apuPtr: number; dmcPtr: number }; + NesChipRegisterState: new (channelCount?: number) => unknown; + }> { + onProgress?.(20, 'Loading processor modules...'); + const { default: NesState } = await this.loader.loadModule<{ default: new (...args: unknown[]) => unknown }>( + 'nes/nes-state.js' + ); + onProgress?.(30, 'Loading pattern processor...'); + const { default: TrackerPatternProcessor } = await this.loader.loadModule<{ + default: new (...args: unknown[]) => unknown; + }>('tracker/tracker-pattern-processor.js'); + onProgress?.(40, 'Loading audio driver...'); + const { default: NesAudioDriver } = await this.loader.loadModule<{ + default: new () => unknown; + }>('nes/nes-audio-driver.js'); + const { createNesApuEngine } = await this.loader.loadModule<{ + createNesApuEngine: (wasm: Record) => { + engine: NesSlotLane['apuEngine']; + apuPtr: number; + dmcPtr: number; + }; + }>('nes/nes-apu-engine.js'); + const { default: NesChipRegisterState } = await this.loader.loadModule<{ + default: new (channelCount?: number) => unknown; + }>('nes/nes-chip-register-state.js'); + + return { + NesState: NesState as new (sharedTimeline?: unknown) => NesSlotLane['state'], + TrackerPatternProcessor: TrackerPatternProcessor as new ( + state: unknown, + driver: unknown, + port: { postMessage?: (...args: unknown[]) => void } + ) => NesSlotLane['patternProcessor'], + NesAudioDriver: NesAudioDriver as new () => NesSlotLane['audioDriver'], + createNesApuEngine, + NesChipRegisterState + }; + } + + private setupExportState( + state: NesSlotLane['state'] & { + setWasmModule: (wasm: unknown, apuPtr: number, dmcPtr: number, wasmBuffer: ArrayBuffer) => void; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + setTuningTable: (table: number[]) => void; + setInstruments: (instruments: unknown[]) => void; + setTables: (tables: unknown[]) => void; + setIntFrequency: (frequency: number, sampleRate: number) => void; + setPatternOrder: (order: number[], loopPointId: number) => void; + setSpeed: (speed: number) => void; + updateSamplesPerTick: (sampleRate: number) => void; + }, + song: NesSlotLane['song'], + project: Project, + wasm: Record, + apuPtr: number, + dmcPtr: number, + wasmBuffer: ArrayBuffer, + ownsSharedPlaybackTimeline: boolean + ): void { + state.setWasmModule(wasm, apuPtr, dmcPtr, wasmBuffer); + state.setCpuFrequency(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + state.setChipVariant(song.chipVariant ?? 'NTSC'); + state.setTuningTable(song.tuningTable); + state.setInstruments(filterInstrumentsForChip(project.instruments, song.chipType ?? 'nes')); + state.setTables(project.tables); + if (ownsSharedPlaybackTimeline) { + state.setIntFrequency(song.interruptFrequency ?? DEFAULT_INTERRUPT_FREQUENCY, SAMPLE_RATE); + state.setPatternOrder(project.patternOrder || [0], project.loopPointId || 0); + state.setSpeed(song.initialSpeed || DEFAULT_SPEED); + state.updateSamplesPerTick(SAMPLE_RATE); + } + } + + private getPatterns(song: NesSlotLane['song'], patternOrder: number[]): Pattern[] { + const patterns: Pattern[] = []; + for (const patternId of patternOrder) { + const pattern = song.patterns.find((p) => p.id === patternId); + if (pattern) { + patterns.push(pattern); + } + } + return patterns; + } + + private calculateTotalRows(song: NesSlotLane['song'], patternOrder: number[]): number { + let totalRows = 0; + for (const patternId of patternOrder) { + const pattern = song.patterns.find((p) => p.id === patternId); + if (pattern) { + totalRows += pattern.length; + } + } + return totalRows; + } + + private calculateCurrentRow(state: NesSlotLane['state'], song: NesSlotLane['song']): number { + let currentRow = 0; + for (let i = 0; i < state.timeline.currentPatternOrderIndex; i++) { + const patternId = state.timeline.patternOrder[i]; + const pattern = song.patterns.find((p) => p.id === patternId); + if (pattern) { + currentRow += pattern.length; + } + } + if (state.currentPattern) { + currentRow += state.timeline.currentRow; + } + return currentRow; + } + + private freeWasmPointers(wasm: Record, apuPtr: number, dmcPtr: number): void { + const free = wasm.free as ((ptr: number) => void) | undefined; + if (!free) return; + try { + free(apuPtr); + free(dmcPtr); + } catch { + /* ignore */ + } + } + + private async renderAudioLoop( + lane: NesSlotLane, + song: NesSlotLane['song'], + totalRows: number, + patterns: Pattern[], + loopCount: number, + onProgress?: (progress: number, message: string) => void + ): Promise { + const leftSamples: number[] = []; + const rightSamples: number[] = []; + let totalSamples = 0; + const maxSamples = SAMPLE_RATE * 300 * Math.max(1, loopCount); + let completedLoops = 0; + let lastProgressUpdate = 0; + const progressUpdateInterval = SAMPLE_RATE * 0.1; + let lastProgressTime = Date.now(); + const minProgressUpdateMs = 100; + + onProgress?.(50, 'Starting render...'); + + while (totalSamples < maxSamples) { + const now = Date.now(); + if ( + (totalSamples - lastProgressUpdate >= progressUpdateInterval || + now - lastProgressTime >= minProgressUpdateMs) && + totalSamples > 0 + ) { + const renderProgress = (totalSamples / maxSamples) * 50; + const progress = 50 + renderProgress; + const currentRow = this.calculateCurrentRow(lane.state, song); + onProgress?.(progress, `Rendering... ${currentRow}/${totalRows} rows`); + lastProgressUpdate = totalSamples; + lastProgressTime = now; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const tl = lane.state.timeline; + tl.tickAccumulator += tl.tickStep; + + if (tl.tickAccumulator >= 1.0) { + if (tl.currentTick === 0 && lane.state.currentPattern) { + lane.patternProcessor.parsePatternRow( + lane.state.currentPattern, + tl.currentRow, + lane.registerState + ); + lane.patternProcessor.processSpeedTable(); + } + + lane.patternProcessor.processTables(); + lane.patternProcessor.processArpeggio(); + lane.patternProcessor.processEffectTables(); + lane.audioDriver.processInstruments(lane.state, lane.registerState); + lane.patternProcessor.processVibrato(); + lane.patternProcessor.processSlides(); + lane.apuEngine.applyRegisterState(lane.registerState); + + const isLastPattern = + tl.currentPatternOrderIndex >= tl.patternOrder.length - 1; + const isLastRow = + lane.state.currentPattern != null && + tl.currentRow >= lane.state.currentPattern.length - 1; + const isLastTick = tl.currentTick >= tl.currentSpeed - 1; + + if (isLastPattern && isLastRow && isLastTick) { + completedLoops++; + if (completedLoops >= loopCount) { + break; + } + } + + const needsPatternChange = lane.state.advancePosition(); + if (needsPatternChange) { + if (tl.currentPatternOrderIndex >= tl.patternOrder.length) { + break; + } + if (tl.currentPatternOrderIndex < patterns.length) { + lane.state.currentPattern = patterns[tl.currentPatternOrderIndex]!; + } else { + break; + } + } + + tl.tickAccumulator -= 1.0; + } + + const { left, right } = lane.apuEngine.process(SAMPLE_RATE); + leftSamples.push(left); + rightSamples.push(right); + totalSamples++; + } + + return [new Float32Array(leftSamples), new Float32Array(rightSamples)]; + } + + private async renderAudioLoopSharedTimeline( + contexts: NesSlotLane[], + leaderSong: NesSlotLane['song'], + totalRows: number, + loopCount: number, + onProgress?: (progress: number, message: string) => void + ): Promise { + const leftByChip: number[][] = contexts.map(() => []); + const rightByChip: number[][] = contexts.map(() => []); + let totalSamples = 0; + const maxSamples = SAMPLE_RATE * 300 * Math.max(1, loopCount); + let completedLoops = 0; + let lastProgressUpdate = 0; + const progressUpdateInterval = SAMPLE_RATE * 0.1; + let lastProgressTime = Date.now(); + const minProgressUpdateMs = 100; + const leader = contexts[0]!; + + while (totalSamples < maxSamples) { + const now = Date.now(); + if ( + (totalSamples - lastProgressUpdate >= progressUpdateInterval || + now - lastProgressTime >= minProgressUpdateMs) && + totalSamples > 0 + ) { + const renderProgress = (totalSamples / maxSamples) * 50; + const progress = 50 + renderProgress; + const currentRow = this.calculateCurrentRow(leader.state, leaderSong); + onProgress?.(progress, `Rendering... ${currentRow}/${totalRows} rows`); + lastProgressUpdate = totalSamples; + lastProgressTime = now; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const tl = leader.state.timeline; + tl.tickAccumulator += tl.tickStep; + + if (tl.tickAccumulator >= 1.0) { + for (const ctx of contexts) { + if (tl.currentTick === 0 && ctx.state.currentPattern) { + ctx.patternProcessor.parsePatternRow( + ctx.state.currentPattern, + tl.currentRow, + ctx.registerState + ); + ctx.patternProcessor.processSpeedTable(); + } + ctx.patternProcessor.processTables(); + ctx.patternProcessor.processArpeggio(); + ctx.patternProcessor.processEffectTables(); + ctx.audioDriver.processInstruments(ctx.state, ctx.registerState); + ctx.patternProcessor.processVibrato(); + ctx.patternProcessor.processSlides(); + ctx.apuEngine.applyRegisterState(ctx.registerState); + } + + const isLastPattern = tl.currentPatternOrderIndex >= tl.patternOrder.length - 1; + const isLastRow = + leader.state.currentPattern != null && + tl.currentRow >= leader.state.currentPattern.length - 1; + const isLastTick = tl.currentTick >= tl.currentSpeed - 1; + + if (isLastPattern && isLastRow && isLastTick) { + completedLoops++; + if (completedLoops >= loopCount) { + break; + } + } + + const needsPatternChange = leader.state.advancePosition(); + if (needsPatternChange) { + if (tl.currentPatternOrderIndex >= tl.patternOrder.length) { + break; + } + for (const ctx of contexts) { + const pattern = ctx.patterns[tl.currentPatternOrderIndex]; + if (pattern) { + ctx.state.currentPattern = pattern; + } + } + } + + tl.tickAccumulator -= 1.0; + } + + for (let ci = 0; ci < contexts.length; ci++) { + const { left, right } = contexts[ci]!.apuEngine.process(SAMPLE_RATE); + leftByChip[ci].push(left); + rightByChip[ci].push(right); + } + totalSamples++; + } + + return contexts.map((_, ci) => [ + new Float32Array(leftByChip[ci]), + new Float32Array(rightByChip[ci]) + ]); + } + + async renderSharedTimelineSlots( + project: Project, + slots: readonly SharedTimelineExportSlot[], + onProgress?: (progress: number, message: string) => void, + options?: RenderOptions + ): Promise { + assertSharedTimelineSlotsForChip(slots, this.binding); + const songIndices = slots.map((s) => s.songIndex); + const loopCount = Math.max(1, options?.loopCount ?? 1); + const patternOrder = project.patternOrder || [0]; + const requestedStartOrderIndex = options?.startPatternOrderIndex ?? 0; + const startOrderIndex = + requestedStartOrderIndex >= 0 && requestedStartOrderIndex < patternOrder.length + ? requestedStartOrderIndex + : 0; + + const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); + const { NesState, TrackerPatternProcessor, NesAudioDriver, createNesApuEngine, NesChipRegisterState } = + await this.loadProcessorModules(onProgress); + + const contexts: NesSlotLane[] = []; + const ptrPairs: Array<{ apuPtr: number; dmcPtr: number }> = []; + + try { + for (const songIndex of songIndices) { + const song = project.songs[songIndex]; + if (!song?.patterns?.length) { + throw new Error('Song is empty'); + } + + const { engine, apuPtr, dmcPtr } = createNesApuEngine(wasm); + ptrPairs.push({ apuPtr, dmcPtr }); + + const state = ( + contexts.length === 0 + ? new NesState() + : new NesState(contexts[0]!.state.timeline) + ) as NesSlotLane['state'] & { + setWasmModule: (...args: unknown[]) => void; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + setTuningTable: (table: number[]) => void; + setInstruments: (instruments: unknown[]) => void; + setTables: (tables: unknown[]) => void; + setIntFrequency: (frequency: number, sampleRate: number) => void; + setPatternOrder: (order: number[], loopPointId: number) => void; + setSpeed: (speed: number) => void; + updateSamplesPerTick: (sampleRate: number) => void; + }; + + this.setupExportState( + state, + song as NesSlotLane['song'], + project, + wasm, + apuPtr, + dmcPtr, + wasmBuffer, + contexts.length === 0 + ); + + const audioDriver = new NesAudioDriver(); + engine.setCpuFrequency(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + engine.setChipVariant(song.chipVariant ?? 'NTSC'); + const registerState = new NesChipRegisterState(); + const patternProcessor = new TrackerPatternProcessor(state, audioDriver, { + postMessage: () => {} + }); + + const patterns = this.getPatterns(song as NesSlotLane['song'], patternOrder); + if (patterns.length === 0) { + throw new Error('No patterns found'); + } + + state.currentPattern = patterns[startOrderIndex]!; + state.timeline.currentPatternOrderIndex = startOrderIndex; + if (contexts.length === 0) { + state.timeline.tickAccumulator = 1.0; + } + + engine.applyRegisterState(registerState); + + contexts.push({ + songIndex, + song: song as NesSlotLane['song'], + state, + patternProcessor, + audioDriver, + apuEngine: engine, + registerState, + patterns, + apuPtr, + dmcPtr + }); + } + + const leaderSong = contexts[0]!.song; + const firstPassRows = this.calculateTotalRows(leaderSong, patternOrder); + const validLoopPointId = + project.loopPointId >= 0 && project.loopPointId < patternOrder.length + ? project.loopPointId + : 0; + const loopOrderSegment = patternOrder.slice(validLoopPointId); + const loopSegmentRows = this.calculateTotalRows(leaderSong, loopOrderSegment); + const totalRows = + loopCount <= 1 ? firstPassRows : firstPassRows + loopSegmentRows * (loopCount - 1); + + const buffers = await this.renderAudioLoopSharedTimeline( + contexts, + leaderSong, + totalRows, + loopCount, + onProgress + ); + + for (const ctx of contexts) { + ctx.apuEngine.dispose(); + } + for (const { apuPtr, dmcPtr } of ptrPairs) { + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + } + + return contexts.map((ctx, i) => ({ + songIndex: ctx.songIndex, + channels: buffers[i]! + })); + } catch (error) { + for (const ctx of contexts) { + try { + ctx.apuEngine.dispose(); + } catch { + /* ignore */ + } + } + for (const { apuPtr, dmcPtr } of ptrPairs) { + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + } + throw error; + } + } + + async render( + project: Project, + songIndex: number, + onProgress?: (progress: number, message: string) => void, + options?: RenderOptions + ): Promise { + const song = project.songs[songIndex]; + if (!song || song.patterns.length === 0) { + throw new Error('Song is empty'); + } + + const loopCount = Math.max(1, options?.loopCount ?? 1); + const patternOrder = project.patternOrder || [0]; + const requestedStartOrderIndex = options?.startPatternOrderIndex ?? 0; + const startOrderIndex = + requestedStartOrderIndex >= 0 && requestedStartOrderIndex < patternOrder.length + ? requestedStartOrderIndex + : 0; + + const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); + const { NesState, TrackerPatternProcessor, NesAudioDriver, createNesApuEngine, NesChipRegisterState } = + await this.loadProcessorModules(onProgress); + + const { engine, apuPtr, dmcPtr } = createNesApuEngine(wasm); + + try { + const state = new NesState() as NesSlotLane['state'] & { + setWasmModule: (...args: unknown[]) => void; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + setTuningTable: (table: number[]) => void; + setInstruments: (instruments: unknown[]) => void; + setTables: (tables: unknown[]) => void; + setIntFrequency: (frequency: number, sampleRate: number) => void; + setPatternOrder: (order: number[], loopPointId: number) => void; + setSpeed: (speed: number) => void; + updateSamplesPerTick: (sampleRate: number) => void; + }; + + this.setupExportState( + state, + song as NesSlotLane['song'], + project, + wasm, + apuPtr, + dmcPtr, + wasmBuffer, + true + ); + + const audioDriver = new NesAudioDriver(); + engine.setCpuFrequency(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + engine.setChipVariant(song.chipVariant ?? 'NTSC'); + const registerState = new NesChipRegisterState(); + const patternProcessor = new TrackerPatternProcessor(state, audioDriver, { + postMessage: () => {} + }); + + engine.applyRegisterState(registerState); + + const patterns = this.getPatterns(song as NesSlotLane['song'], patternOrder); + if (patterns.length === 0) { + throw new Error('No patterns found'); + } + + state.currentPattern = patterns[startOrderIndex]!; + state.timeline.currentPatternOrderIndex = startOrderIndex; + state.timeline.tickAccumulator = 1.0; + + onProgress?.(50, 'Initializing renderer...'); + const firstPassRows = this.calculateTotalRows(song as NesSlotLane['song'], patternOrder); + const validLoopPointId = + project.loopPointId >= 0 && project.loopPointId < patternOrder.length + ? project.loopPointId + : 0; + const loopOrderSegment = patternOrder.slice(validLoopPointId); + const loopSegmentRows = this.calculateTotalRows(song as NesSlotLane['song'], loopOrderSegment); + const totalRows = + loopCount <= 1 ? firstPassRows : firstPassRows + loopSegmentRows * (loopCount - 1); + + const lane: NesSlotLane = { + songIndex, + song: song as NesSlotLane['song'], + state, + patternProcessor, + audioDriver, + apuEngine: engine, + registerState, + patterns, + apuPtr, + dmcPtr + }; + + const channels = await this.renderAudioLoop( + lane, + song as NesSlotLane['song'], + totalRows, + patterns, + loopCount, + onProgress + ); + + engine.dispose(); + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + onProgress?.(100, 'Rendering complete'); + return channels; + } catch (error) { + engine.dispose(); + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + throw error; + } + } +} diff --git a/src/lib/chips/nes/schema.ts b/src/lib/chips/nes/schema.ts new file mode 100644 index 00000000..d5cdc041 --- /dev/null +++ b/src/lib/chips/nes/schema.ts @@ -0,0 +1,156 @@ +import type { ChipSchema } from '../base/schema'; +import { generate12TETTuningTable } from '../../models/pt3/tuning-tables'; + +export const NES_NTSC_CPU_FREQUENCY = 1_789_773; +export const NES_PAL_CPU_FREQUENCY = 1_662_607; +export const NES_DENDY_CPU_FREQUENCY = 1_773_448; +export const NES_MAX_TUNING_PERIOD = 2047; + +export type NesSystem = 'NTSC' | 'PAL' | 'Dendy'; +export type NesApuTimingType = 'NTSC' | 'PAL'; + +export const NES_SYSTEM_CONFIG: Record< + NesSystem, + { frequency: number; apuTimingType: NesApuTimingType } +> = { + NTSC: { frequency: NES_NTSC_CPU_FREQUENCY, apuTimingType: 'NTSC' }, + PAL: { frequency: NES_PAL_CPU_FREQUENCY, apuTimingType: 'PAL' }, + Dendy: { frequency: NES_DENDY_CPU_FREQUENCY, apuTimingType: 'NTSC' } +}; + +export function resolveNesSystem(value: unknown): NesSystem { + if (value === 'PAL' || value === 'Dendy') return value; + return 'NTSC'; +} + +export function resolveNesCpuFrequency(system: unknown): number { + return NES_SYSTEM_CONFIG[resolveNesSystem(system)].frequency; +} + +export function resolveNesApuTimingType(system: unknown): NesApuTimingType { + return NES_SYSTEM_CONFIG[resolveNesSystem(system)].apuTimingType; +} + +export function resolveNesTuningTable(cpuFrequencyHz: number, a4TuningHz: number): number[] { + return generate12TETTuningTable(cpuFrequencyHz, a4TuningHz, NES_MAX_TUNING_PERIOD); +} + +export const NES_DEFAULT_TUNING_TABLE = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); + +export const NES_CHIP_SCHEMA: ChipSchema = { + chipType: 'nes', + defaultTuningTable: NES_DEFAULT_TUNING_TABLE, + defaultChipVariant: 'NTSC', + channelLabels: ['Square 1', 'Square 2', 'Triangle', 'Noise', 'DPCM'], + template: '{note} {instrument}{table}{volume} {effect}', + fields: { + note: { + key: 'note', + type: 'note', + length: 3, + color: 'patternNote', + selectable: 'atomic', + usedForBacktracking: true, + backtrackWhen: 'nonZero' + }, + instrument: { + key: 'instrument', + type: 'symbol', + length: 2, + color: 'patternInstrument', + selectable: 'character', + allowZeroValue: false, + usedForBacktracking: true, + backtrackWhen: 'nonZero' + }, + table: { + key: 'table', + type: 'symbol', + length: 1, + color: 'patternTable', + selectable: 'character', + usedForBacktracking: true, + backtrackWhen: 'any' + }, + volume: { + key: 'volume', + type: 'hex', + length: 1, + color: 'patternText', + selectable: 'character', + usedForBacktracking: true, + backtrackWhen: 'nonZero' + }, + effect: { + key: 'effect', + type: 'hex', + length: 4, + color: 'patternEffect', + selectable: 'character' + } + }, + settings: [ + { + key: 'chipVariant', + label: 'System', + type: 'toggle', + options: [ + { label: 'NTSC', value: 'NTSC' }, + { label: 'PAL', value: 'PAL' }, + { label: 'Dendy', value: 'Dendy' } + ], + defaultValue: 'NTSC', + group: 'chip', + notifyAudioService: true, + computedHint: (value) => { + const frequency = resolveNesCpuFrequency(value); + const mhz = (frequency / 1_000_000).toFixed(4); + const apuTimingType = resolveNesApuTimingType(value); + return `${mhz} MHz · ${apuTimingType} type`; + } + }, + { + key: 'interruptFrequency', + label: 'Interrupt Frequency', + type: 'select', + options: [ + { label: 'PAL (50 Hz)', value: 50 }, + { label: 'NTSC (60 Hz)', value: 60 } + ], + defaultValue: 50, + group: 'chip', + notifyAudioService: true + }, + { + key: 'a4TuningHz', + label: 'A4 (Hz)', + type: 'number', + min: 220, + max: 880, + defaultValue: 440, + group: 'chip', + notifyAudioService: true, + startNewRow: true + } + ], + resolveTuningTable(song) { + const cpuFreq = Number(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + const a4 = Math.min(880, Math.max(220, Number(song.a4TuningHz ?? 440))); + return resolveNesTuningTable(cpuFreq, a4); + }, + tuningTableSettingKeys: ['a4TuningHz', 'chipFrequency'], + applySettingSideEffects(key, value) { + if (key === 'chipVariant') { + return [{ key: 'chipFrequency', value: resolveNesCpuFrequency(value) }]; + } + return []; + }, + normalizeSettings(record) { + const system = resolveNesSystem(record.chipVariant); + return { + ...record, + chipVariant: system, + chipFrequency: resolveNesCpuFrequency(system) + }; + } +}; diff --git a/src/lib/chips/registry.ts b/src/lib/chips/registry.ts index 938ffbb3..a5887140 100644 --- a/src/lib/chips/registry.ts +++ b/src/lib/chips/registry.ts @@ -2,9 +2,11 @@ import type { Chip } from './types'; import type { ResourceLoader } from './base/resource-loader'; import type { ChipType } from './chip-registration'; import { AY_CHIP } from './ay'; +import { NES_CHIP } from './nes'; const CHIPS = { - ay: AY_CHIP + ay: AY_CHIP, + nes: NES_CHIP } satisfies Record; export function getChipByType(chipType: string): Chip | null { diff --git a/src/lib/chips/types.ts b/src/lib/chips/types.ts index 1895fac6..ea714c18 100644 --- a/src/lib/chips/types.ts +++ b/src/lib/chips/types.ts @@ -5,6 +5,9 @@ import type { PatternFormatter } from './base/formatter-interface'; import type { ChipRenderer, ChipRendererBinding } from './base/renderer'; import type { ResourceLoader } from './base/resource-loader'; import type { Component } from 'svelte'; +import type { ChipPlaybackDebugSpec } from './base/playback-debug'; + +import type { Instrument } from '../models/song'; export interface Chip { type: string; @@ -18,4 +21,6 @@ export interface Chip { createRenderer: (loader?: ResourceLoader, binding?: ChipRendererBinding) => ChipRenderer; instrumentEditor?: Component; previewRow?: Component; + playbackDebug?: ChipPlaybackDebugSpec; + copyInstrumentFields?: (source: Instrument, target: Instrument) => void; } diff --git a/src/lib/components/Details/DetailsView.svelte b/src/lib/components/Details/DetailsView.svelte index 90214c40..ade03b0f 100644 --- a/src/lib/components/Details/DetailsView.svelte +++ b/src/lib/components/Details/DetailsView.svelte @@ -129,7 +129,14 @@ ] ); if (setting.notifyAudioService) { - services.audioService.chipSettings.set(key, value); + for (const group of chipsByType) { + const notifies = group.chip.schema.settings?.some( + (s) => s.key === key && s.notifyAudioService + ); + if (notifies) { + services.audioService.chipSettings.forChip(group.type).set(key, normalized); + } + } } } @@ -144,7 +151,7 @@ for (const s of songsOfType) { s.tuningTable = table; } - services.audioService.chipSettings.set('tuningTable', [...table]); + services.audioService.chipSettings.forChip(chipType).set('tuningTable', [...table]); } function getChipSettingValue(chipType: string, key: string): unknown { @@ -188,7 +195,7 @@ const songsOfType = songs.filter((s) => s.chipType === chipType); for (const [updateKey, updateValue] of Object.entries(updates)) { const currentValue = getChipSettingValue(chipType, updateKey); - const audioValue = services.audioService.chipSettings.get(updateKey); + const audioValue = services.audioService.chipSettings.forChip(chipType).get(updateKey); const updateSetting = chipSettings.find((s) => s.key === updateKey); const needsSongUpdate = songsOfType.some( (song) => (song as unknown as Record)[updateKey] !== updateValue @@ -211,7 +218,7 @@ for (const processor of processors) { processor.updateParameter(updateKey, updateValue); } - services.audioService.chipSettings.set(updateKey, updateValue); + services.audioService.chipSettings.forChip(chipType).set(updateKey, updateValue); } } } @@ -274,7 +281,7 @@ for (const processor of processors) { processor.updateParameter(key, normalized); } - services.audioService.chipSettings.set(key, normalized); + services.audioService.chipSettings.forChip(chipType).set(key, normalized); if (chip) { const context = buildChipContext(chipType, chipSettings); diff --git a/src/lib/components/Instruments/InstrumentsView.svelte b/src/lib/components/Instruments/InstrumentsView.svelte index 3798362f..172a85d6 100644 --- a/src/lib/components/Instruments/InstrumentsView.svelte +++ b/src/lib/components/Instruments/InstrumentsView.svelte @@ -21,7 +21,9 @@ import EditableIdField from '../EditableIdField/EditableIdField.svelte'; import { getContext, tick, untrack } from 'svelte'; import type { AudioService } from '../../services/audio/audio-service'; - import type { Chip } from '../../chips/types'; + import type { ChipProcessor } from '../../chips/base/processor'; + import { getChipByType } from '../../chips/registry'; + import PillTabs, { type PillTab } from '../PillTabs/PillTabs.svelte'; import { isValidInstrumentId, normalizeInstrumentId, @@ -30,7 +32,11 @@ MAX_INSTRUMENT_ID_NUM } from '../../utils/instrument-id'; import { migrateInstrumentIdInSong } from '../../services/project/id-migration'; - import { copyAyInstrumentFields, type AyInstrumentFields } from '../../chips/ay/instrument'; + import { + filterInstrumentsForChip, + getOrderedProjectChipTypes, + resolveInstrumentChipType + } from '../../services/instrument/instrument-filter'; import { editorStateStore } from '../../stores/editor-state.svelte'; import { projectStore } from '../../stores/project.svelte'; import { computeGridRows } from '../../utils/compute-grid-rows'; @@ -48,13 +54,32 @@ let { isExpanded = $bindable(false), - chip + chipProcessors, + syncChipType, + activeEditorIndex = 0 }: { isExpanded: boolean; - chip: Chip; + chipProcessors: ChipProcessor[]; + syncChipType?: string; + activeEditorIndex?: number; } = $props(); - let instruments = $derived(projectStore.instruments); + let allInstruments = $derived(projectStore.instruments); + const chipTypeTabs = $derived.by((): PillTab[] => { + return getOrderedProjectChipTypes(chipProcessors).flatMap((chipType) => { + const chip = getChipByType(chipType); + return chip ? [{ id: chipType, label: chip.name }] : []; + }); + }); + let selectedChipType = $state(''); + let lastSyncedEditorIndex = $state(-1); + const chip = $derived.by(() => { + const chipType = selectedChipType || chipTypeTabs[0]?.id || syncChipType || 'ay'; + return getChipByType(chipType); + }); + const chipInstruments = $derived( + chip ? filterInstrumentsForChip(allInstruments, chip.type) : [] + ); const songs = $derived(projectStore.songs); const instrumentListResize = createPersistedResizableListHeight({ @@ -66,7 +91,7 @@ const instrumentGridRows = $derived.by(() => computeGridRows( - instruments?.length ?? 0, + chipInstruments?.length ?? 0, instrumentListResize.listHeight, ITEM_ROW_HEIGHT, ITEM_BUTTON_BAR_HEIGHT @@ -79,20 +104,61 @@ let instrumentListScrollRef: HTMLDivElement | null = $state(null); $effect(() => { + if (chipTypeTabs.length === 0) return; + if (!selectedChipType || !chipTypeTabs.some((tab) => tab.id === selectedChipType)) { + selectedChipType = syncChipType ?? chipTypeTabs[0].id; + } + }); + + $effect(() => { + const editorIndex = activeEditorIndex; + if (editorIndex === lastSyncedEditorIndex) return; + lastSyncedEditorIndex = editorIndex; + if (syncChipType) { + selectedChipType = syncChipType; + } + }); + + $effect(() => { + const request = editorStateStore.selectInstrumentRequest; + if (!request) return; + if (request.chipType) { + selectedChipType = request.chipType; + return; + } + const instrument = allInstruments.find((inst) => inst.id === request.instrumentId); + if (instrument) { + selectedChipType = resolveInstrumentChipType(instrument); + } + }); + + $effect(() => { + chip?.type; if (editorStateStore.selectInstrumentRequest) return; - if (instruments.length > 0 && instruments[selectedInstrumentIndex]) { - const instrumentId = instruments[selectedInstrumentIndex].id; + if (chipInstruments.length > 0 && chipInstruments[selectedInstrumentIndex]) { + const instrumentId = chipInstruments[selectedInstrumentIndex].id; untrack(() => { - editorStateStore.setCurrentInstrument(instrumentId); + editorStateStore.setCurrentInstrumentForChip( + chipInstruments[selectedInstrumentIndex].chipType, + instrumentId + ); }); } }); $effect(() => { - const targetId = editorStateStore.currentInstrument; - const idx = instruments.findIndex((inst) => inst.id === targetId); + const targetId = chip ? editorStateStore.getCurrentInstrument(chip.type) : null; + const idx = chipInstruments.findIndex((inst) => inst.id === targetId); if (idx >= 0 && idx !== selectedInstrumentIndex) { selectedInstrumentIndex = idx; + } else if (idx < 0 && chipInstruments.length > 0) { + selectedInstrumentIndex = 0; + untrack(() => { + editorStateStore.setCurrentInstrumentForChip( + chipInstruments[0].chipType, + chipInstruments[0].id + ); + }); } if (editorStateStore.selectInstrumentRequest) { editorStateStore.clearSelectInstrumentRequest(); @@ -110,7 +176,7 @@ }); }); - const InstrumentEditor = $derived(chip.instrumentEditor); + const InstrumentEditor = $derived(chip?.instrumentEditor); const hexIcon = $derived(asHex ? IconCarbonHexagonSolid : IconCarbonHexagonOutline); const expandIcon = $derived(isExpanded ? IconCarbonMinimize : IconCarbonMaximize); @@ -135,13 +201,14 @@ } function sortInstrumentsAndSyncSelection(selectedId?: string): void { - const sorted = [...instruments].sort(compareInstrumentIds); - const needsSort = sorted.some((inst, i) => inst !== instruments[i]); + const sorted = [...allInstruments].sort(compareInstrumentIds); + const needsSort = sorted.some((inst, i) => inst !== allInstruments[i]); if (needsSort) { projectStore.instruments = sorted; } - if (selectedId !== undefined) { - const newIndex = sorted.findIndex((inst) => inst.id === selectedId); + if (selectedId !== undefined && chip) { + const filtered = filterInstrumentsForChip(projectStore.instruments, chip.type); + const newIndex = filtered.findIndex((inst) => inst.id === selectedId); if (newIndex >= 0) selectedInstrumentIndex = newIndex; } } @@ -201,10 +268,10 @@ function handleInstrumentChange(instrument: Instrument): void { const id = instrument.id; - const idx = instruments.findIndex((inst) => inst.id === id); + const idx = allInstruments.findIndex((inst) => inst.id === id); if (idx >= 0) { const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); - const updated = [...instruments]; + const updated = [...allInstruments]; updated[idx] = { ...instrument }; projectStore.instruments = updated; scheduleInstrumentUpdateHistory(beforeInstruments, `Edit instrument ${id}`); @@ -214,21 +281,28 @@ async function addInstrument(): Promise { flushInstrumentUpdateHistory(); - const existingIds = instruments.map((inst) => inst.id); + const existingIds = allInstruments.map((inst) => inst.id); const newId = getNextAvailableInstrumentId(existingIds); if (!newId) return; - const newInstrument = new InstrumentModel(newId, [], 0, `Instrument ${newId}`); + if (!chip) return; + const newInstrument = new InstrumentModel(newId, [], 0, `Instrument ${newId}`, chip.type); const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); - projectStore.instruments = [...instruments, newInstrument]; + projectStore.instruments = [...allInstruments, newInstrument]; sortInstrumentsAndSyncSelection(newId); - editorStateStore.setCurrentInstrument(newId); + editorStateStore.setCurrentInstrumentForChip(chip.type, newId); projectStore.recordHistory( { type: 'instrument.add', label: `Add instrument ${newId}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); services.audioService.updateInstruments(projectStore.instruments); await tick(); @@ -239,12 +313,12 @@ function removeInstrument(index: number): void { flushInstrumentUpdateHistory(); - const toRemove = instruments[index]; - if (!toRemove || instruments.length <= 1) return; + const toRemove = chipInstruments[index]; + if (!toRemove || chipInstruments.length <= 1) return; const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); - projectStore.instruments = instruments.filter((inst) => inst.id !== toRemove.id); - if (selectedInstrumentIndex >= projectStore.instruments.length) { - selectedInstrumentIndex = Math.max(0, projectStore.instruments.length - 1); + projectStore.instruments = allInstruments.filter((inst) => inst.id !== toRemove.id); + if (selectedInstrumentIndex >= chipInstruments.length - 1) { + selectedInstrumentIndex = Math.max(0, chipInstruments.length - 2); } projectStore.recordHistory( { @@ -252,16 +326,22 @@ label: `Remove instrument ${toRemove.id}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); services.audioService.updateInstruments(projectStore.instruments); } async function copyInstrument(copiedIndex: number): Promise { flushInstrumentUpdateHistory(); - const instrument = instruments[copiedIndex]; - if (!instrument) return; - const existingIds = instruments.map((inst) => inst.id); + const instrument = chipInstruments[copiedIndex]; + if (!instrument || !chip) return; + const existingIds = allInstruments.map((inst) => inst.id); const newId = getNextAvailableInstrumentId(existingIds); if (!newId) return; const copiedRows = instrument.rows.map((r) => new InstrumentRow({ ...r })); @@ -269,24 +349,28 @@ newId, copiedRows, instrument.loop, - instrument.name + ' (Copy)' - ); - copyAyInstrumentFields( - instrument as Instrument & Partial, - copy as Instrument & Partial + instrument.name + ' (Copy)', + chip.type ); + chip.copyInstrumentFields?.(instrument, copy); const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); - projectStore.instruments = [...instruments, copy]; + projectStore.instruments = [...allInstruments, copy]; sortInstrumentsAndSyncSelection(newId); - editorStateStore.setCurrentInstrument(newId); + editorStateStore.setCurrentInstrumentForChip(chip.type, newId); projectStore.recordHistory( { type: 'instrument.copy', label: `Copy instrument ${instrument.id}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); services.audioService.updateInstruments(projectStore.instruments); await tick(); @@ -301,8 +385,8 @@ if (!isValidInstrumentId(normalizedId) || !isInstrumentIdInRange(normalizedId)) { return; } - const oldId = instruments[index].id; - const existingIds = instruments.map((inst) => inst.id).filter((id) => id !== oldId); + const oldId = chipInstruments[index].id; + const existingIds = allInstruments.map((inst) => inst.id).filter((id) => id !== oldId); if (existingIds.includes(normalizedId)) { return; } @@ -312,8 +396,10 @@ for (const song of songs) { migrateInstrumentIdInSong(song, oldId, normalizedId); } - const updated = [...instruments]; - updated[index] = { ...updated[index], id: normalizedId }; + const globalIndex = allInstruments.findIndex((inst) => inst.id === oldId); + if (globalIndex < 0) return; + const updated = [...allInstruments]; + updated[globalIndex] = { ...updated[globalIndex], id: normalizedId }; projectStore.instruments = updated; sortInstrumentsAndSyncSelection(normalizedId); projectStore.recordHistory( @@ -323,12 +409,16 @@ affectedDomains: ['instruments', 'patterns'] }, [ - projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments), + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ), projectStore.createSetDiff(['songs'], beforeSongs, projectStore.songs), projectStore.createSetDiff(['patterns'], beforePatterns, projectStore.patterns) ] ); - services.audioService.updateInstruments(instruments); + services.audioService.updateInstruments(projectStore.instruments); requestPatternRedraw?.(); } @@ -337,7 +427,7 @@ function startEditingInstrumentId(index: number): void { editingInstrumentId = index; - editingInstrumentIdValue = instruments[index]?.id || ''; + editingInstrumentIdValue = chipInstruments[index]?.id || ''; } function finishEditingInstrumentId(): void { @@ -354,10 +444,11 @@ } function saveInstrument(): void { - if (instruments.length === 0) return; - const inst = instruments[selectedInstrumentIndex]; + if (chipInstruments.length === 0) return; + const inst = chipInstruments[selectedInstrumentIndex]; if (!inst) return; downloadJson(`instrument-${inst.id}.json`, { + chipType: inst.chipType, name: inst.name, loop: inst.loop, rows: inst.rows.map((r) => ({ ...r })) @@ -366,7 +457,7 @@ async function loadInstrument(): Promise { flushInstrumentUpdateHistory(); - if (instruments.length === 0) return; + if (!chip || chipInstruments.length === 0) return; try { const text = await pickFileAsText(); const parsed: unknown = JSON.parse(text); @@ -382,17 +473,18 @@ const rows = (o.rows as Record[]).map((r) => new InstrumentRow(r)); const loop = typeof o.loop === 'number' ? o.loop : 0; const name = o.name != null ? String(o.name) : ''; - const currentId = instruments[selectedInstrumentIndex]?.id ?? '01'; + const currentId = chipInstruments[selectedInstrumentIndex]?.id ?? '01'; const replacement = new InstrumentModel( currentId, rows, loop, - name || `Instrument ${currentId}` + name || `Instrument ${currentId}`, + chip.type ); - const idx = instruments.findIndex((inst) => inst.id === currentId); + const idx = allInstruments.findIndex((inst) => inst.id === currentId); if (idx >= 0) { const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); - const updated = [...instruments]; + const updated = [...allInstruments]; updated[idx] = new InstrumentModel( currentId, replacement.rows.map((r) => new InstrumentRow({ ...r })), @@ -426,7 +518,7 @@ async function openPresets(): Promise { flushInstrumentUpdateHistory(); - if (instruments.length === 0) return; + if (!chip || chipInstruments.length === 0) return; const item = await open(PresetsModal, { presetType: 'instrument' }); if ( item == null || @@ -439,17 +531,18 @@ const rows = (o.rows as Record[]).map((r) => new InstrumentRow(r)); const loop = typeof o.loop === 'number' ? o.loop : 0; const name = o.name != null ? String(o.name) : ''; - const currentId = instruments[selectedInstrumentIndex]?.id ?? '01'; + const currentId = chipInstruments[selectedInstrumentIndex]?.id ?? '01'; const replacement = new InstrumentModel( currentId, rows, loop, - name || `Instrument ${currentId}` + name || `Instrument ${currentId}`, + chip.type ); - const idx = instruments.findIndex((inst) => inst.id === currentId); + const idx = allInstruments.findIndex((inst) => inst.id === currentId); if (idx >= 0) { const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); - const updated = [...instruments]; + const updated = [...allInstruments]; updated[idx] = new InstrumentModel( currentId, replacement.rows.map((r) => new InstrumentRow({ ...r })), @@ -463,7 +556,13 @@ label: `Apply preset to instrument ${currentId}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); } services.audioService.updateInstruments(projectStore.instruments); @@ -478,7 +577,10 @@ if (!isInstrumentIdInRange(normalizedId)) { return 'ID must be between 01 and ZZ'; } - const existingIds = instruments.map((inst, i) => (i === index ? '' : inst.id)); + const editingInstrument = chipInstruments[index]; + const existingIds = allInstruments + .map((inst) => inst.id) + .filter((instId) => instId !== editingInstrument?.id); if (existingIds.includes(normalizedId)) { return 'This ID is already used'; } @@ -486,15 +588,15 @@ } $effect(() => { - const currentInstruments = instruments; + const currentInstruments = chipInstruments; if (currentInstruments && selectedInstrumentIndex >= currentInstruments.length) { selectedInstrumentIndex = 0; } }); $effect(() => { - if (!instruments || instruments.length === 0) return; - sortInstrumentsAndSyncSelection(instruments[selectedInstrumentIndex]?.id); + if (!chipInstruments || chipInstruments.length === 0) return; + sortInstrumentsAndSyncSelection(chipInstruments[selectedInstrumentIndex]?.id); }); @@ -506,6 +608,12 @@ class="flex flex-col" actions={cardActions}> {#snippet children()} + {#if chipTypeTabs.length > 1} +
+ +
+ {/if}
@@ -517,7 +625,7 @@ class="flex min-w-max shrink-0 items-stretch border-b border-[var(--color-app-border)]" style="height: {ITEM_ROW_HEIGHT}px"> {#each rowIndices as index} - {@const instrument = instruments[index]} + {@const instrument = chipInstruments[index]} {#if instrument} {@const isSelected = selectedInstrumentIndex === index} 1} + showRemove={chipInstruments.length > 1} onSelect={() => (selectedInstrumentIndex = index)} onDoubleClick={() => startEditingInstrumentId(index)} onCopy={(e) => { @@ -545,7 +653,10 @@ = MAX_INSTRUMENT_ID_NUM} - title={instruments.length >= MAX_INSTRUMENT_ID_NUM + disabled={allInstruments.length >= MAX_INSTRUMENT_ID_NUM} + title={allInstruments.length >= MAX_INSTRUMENT_ID_NUM ? 'Maximum 1295 instruments (01–ZZ)' : 'Add new instrument'} />
@@ -599,15 +710,19 @@ onmousedown={instrumentListResize.beginResize} />
- {#if instruments && instruments[selectedInstrumentIndex]} - {#key instruments[selectedInstrumentIndex].id} + {#if chipInstruments[selectedInstrumentIndex] && InstrumentEditor} + {#key chipInstruments[selectedInstrumentIndex].id} {/key} + {:else if chipInstruments[selectedInstrumentIndex] && chip} +

+ Instrument editor for {chip.name} is not available yet. +

{/if}
{/snippet} diff --git a/src/lib/components/RowEditorTable/BooleanPaintableCell.svelte b/src/lib/components/RowEditorTable/BooleanPaintableCell.svelte new file mode 100644 index 00000000..05026174 --- /dev/null +++ b/src/lib/components/RowEditorTable/BooleanPaintableCell.svelte @@ -0,0 +1,49 @@ + + + + {display && active ? display : ''} + diff --git a/src/lib/components/RowEditorTable/CycleValueCell.svelte b/src/lib/components/RowEditorTable/CycleValueCell.svelte new file mode 100644 index 00000000..01e680e3 --- /dev/null +++ b/src/lib/components/RowEditorTable/CycleValueCell.svelte @@ -0,0 +1,45 @@ + + + + {#if children} + {@render children()} + {:else} +
+ {#if icon} + {@const Icon = icon} + + {/if} + {label} +
+ {/if} + diff --git a/src/lib/components/RowEditorTable/IconColumnHeader.svelte b/src/lib/components/RowEditorTable/IconColumnHeader.svelte new file mode 100644 index 00000000..a6454fb0 --- /dev/null +++ b/src/lib/components/RowEditorTable/IconColumnHeader.svelte @@ -0,0 +1,30 @@ + + + +
+ + {#if isExpanded && label} + {label} + {/if} +
+ diff --git a/src/lib/components/RowEditorTable/LoopMarkerOverlay.svelte b/src/lib/components/RowEditorTable/LoopMarkerOverlay.svelte new file mode 100644 index 00000000..aaacde2b --- /dev/null +++ b/src/lib/components/RowEditorTable/LoopMarkerOverlay.svelte @@ -0,0 +1,22 @@ + + +{#if style} +
+
+
+
+
+
+
+
+
+
+{/if} diff --git a/src/lib/components/RowEditorTable/PaintableValueGridCell.svelte b/src/lib/components/RowEditorTable/PaintableValueGridCell.svelte new file mode 100644 index 00000000..d7dda24f --- /dev/null +++ b/src/lib/components/RowEditorTable/PaintableValueGridCell.svelte @@ -0,0 +1,43 @@ + + + onPaintBegin(index, value)} + onmouseover={() => onPaintOver(index, value)} + onfocus={() => onPaintOver(index, value)}> + {#if isSelected} + {display} + {:else} + {display} + {/if} + diff --git a/src/lib/components/RowEditorTable/RowEditorActionsCell.svelte b/src/lib/components/RowEditorTable/RowEditorActionsCell.svelte new file mode 100644 index 00000000..85141219 --- /dev/null +++ b/src/lib/components/RowEditorTable/RowEditorActionsCell.svelte @@ -0,0 +1,52 @@ + + + +
+ + {#if index < rowCount - 1} + + {/if} +
+ diff --git a/src/lib/components/RowEditorTable/RowEditorAddRowButton.svelte b/src/lib/components/RowEditorTable/RowEditorAddRowButton.svelte new file mode 100644 index 00000000..497142c6 --- /dev/null +++ b/src/lib/components/RowEditorTable/RowEditorAddRowButton.svelte @@ -0,0 +1,15 @@ + + +
+ +
diff --git a/src/lib/components/RowEditorTable/RowEditorContainer.svelte b/src/lib/components/RowEditorTable/RowEditorContainer.svelte new file mode 100644 index 00000000..a588111e --- /dev/null +++ b/src/lib/components/RowEditorTable/RowEditorContainer.svelte @@ -0,0 +1,18 @@ + + +
+ {@render children()} +
diff --git a/src/lib/components/RowEditorTable/RowEditorLoopCell.svelte b/src/lib/components/RowEditorTable/RowEditorLoopCell.svelte new file mode 100644 index 00000000..56b3a420 --- /dev/null +++ b/src/lib/components/RowEditorTable/RowEditorLoopCell.svelte @@ -0,0 +1,19 @@ + + + + diff --git a/src/lib/components/RowEditorTable/RowEditorNameField.svelte b/src/lib/components/RowEditorTable/RowEditorNameField.svelte new file mode 100644 index 00000000..78f662ea --- /dev/null +++ b/src/lib/components/RowEditorTable/RowEditorNameField.svelte @@ -0,0 +1,16 @@ + + +
+ Name: + +
diff --git a/src/lib/components/RowEditorTable/RowEditorTableFooter.svelte b/src/lib/components/RowEditorTable/RowEditorTableFooter.svelte new file mode 100644 index 00000000..a6f1981c --- /dev/null +++ b/src/lib/components/RowEditorTable/RowEditorTableFooter.svelte @@ -0,0 +1,41 @@ + + + + + +
+ +
+ + + + + + + + diff --git a/src/lib/components/RowEditorTable/boolean-paint-drag.svelte.ts b/src/lib/components/RowEditorTable/boolean-paint-drag.svelte.ts new file mode 100644 index 00000000..b8339fb1 --- /dev/null +++ b/src/lib/components/RowEditorTable/boolean-paint-drag.svelte.ts @@ -0,0 +1,27 @@ +export class BooleanPaintDrag { + isDragging = $state(false); + private dragValue = $state(null); + + constructor() { + $effect(() => { + const stop = () => { + this.isDragging = false; + this.dragValue = null; + }; + window.addEventListener('mouseup', stop); + return () => window.removeEventListener('mouseup', stop); + }); + } + + begin(getValue: () => boolean, apply: (value: boolean) => void): void { + this.isDragging = true; + this.dragValue = !getValue(); + apply(this.dragValue); + } + + dragOver(apply: (value: boolean) => void): void { + if (this.isDragging && this.dragValue !== null) { + apply(this.dragValue); + } + } +} diff --git a/src/lib/components/RowEditorTable/index.ts b/src/lib/components/RowEditorTable/index.ts index 39f18408..f3dd8040 100644 --- a/src/lib/components/RowEditorTable/index.ts +++ b/src/lib/components/RowEditorTable/index.ts @@ -1 +1,36 @@ export { default as SelectableRowNumberCell } from './SelectableRowNumberCell.svelte'; +export { default as LoopMarkerOverlay } from './LoopMarkerOverlay.svelte'; +export { default as RowEditorNameField } from './RowEditorNameField.svelte'; +export { default as RowEditorActionsCell } from './RowEditorActionsCell.svelte'; +export { default as RowEditorLoopCell } from './RowEditorLoopCell.svelte'; +export { default as IconColumnHeader } from './IconColumnHeader.svelte'; +export { default as BooleanPaintableCell } from './BooleanPaintableCell.svelte'; +export { default as CycleValueCell } from './CycleValueCell.svelte'; +export { default as RowEditorTableFooter } from './RowEditorTableFooter.svelte'; +export { default as PaintableValueGridCell } from './PaintableValueGridCell.svelte'; +export { default as RowEditorAddRowButton } from './RowEditorAddRowButton.svelte'; +export { ValuePaintDrag } from './value-paint-drag.svelte'; +export { createLoopMarkerMeasure } from './loop-marker.svelte'; +export { BooleanPaintDrag } from './boolean-paint-drag.svelte'; +export { createRowEditorSelection } from './row-editor-selection.svelte'; +export { NamedRowEditorSync } from './named-row-editor-sync.svelte'; +export { + ROW_EDITOR_MAX_ROWS, + expandedRowNumberSizeClass, + expandedActionsCellClass, + expandedLoopCellClass, + expandedRowHeightClass, + expandedIconSizeClass, + expandedHeaderRowClass, + expandedHeaderActionsClass, + expandedHeaderLoopClass +} from './row-editor-table-classes'; +export { + clampRowCount, + clampLoopRow, + resizeRowList, + removeRowAt, + removeRowsFromBottomAt +} from './row-list-operations'; +export type { LoopMarkerStyle } from './loop-marker-style'; +export { default as RowEditorContainer } from './RowEditorContainer.svelte'; diff --git a/src/lib/components/RowEditorTable/loop-marker-style.ts b/src/lib/components/RowEditorTable/loop-marker-style.ts new file mode 100644 index 00000000..1e87f335 --- /dev/null +++ b/src/lib/components/RowEditorTable/loop-marker-style.ts @@ -0,0 +1,5 @@ +export type LoopMarkerStyle = { + left: number; + top: number; + height: number; +}; diff --git a/src/lib/components/RowEditorTable/loop-marker.svelte.ts b/src/lib/components/RowEditorTable/loop-marker.svelte.ts new file mode 100644 index 00000000..7b7935e5 --- /dev/null +++ b/src/lib/components/RowEditorTable/loop-marker.svelte.ts @@ -0,0 +1,55 @@ +import type { LoopMarkerStyle } from './loop-marker-style'; + +export function createLoopMarkerMeasure( + getTable: () => HTMLTableElement | null, + getLoopRow: () => number, + getRowCount: () => number, + getLayoutKey: () => unknown = () => undefined +) { + let loopMarkerStyle = $state(null); + + $effect(() => { + const table = getTable(); + const container = table?.parentElement; + const currentLoopRow = getLoopRow(); + const rowCount = getRowCount(); + void getLayoutKey(); + + if (!table || !container || currentLoopRow < 0 || currentLoopRow >= rowCount) { + loopMarkerStyle = null; + return; + } + + const measureLoopMarker = () => { + const tbody = table.querySelector('tbody'); + const loopCell = tbody?.querySelector( + `tr:nth-child(${currentLoopRow + 1}) > td:nth-of-type(3)` + ) as HTMLTableCellElement | null; + const lastRow = tbody?.querySelector(`tr:nth-child(${rowCount})`) as HTMLTableRowElement | null; + if (!loopCell || !lastRow) { + loopMarkerStyle = null; + return; + } + const containerRect = container.getBoundingClientRect(); + const loopRect = loopCell.getBoundingClientRect(); + const lastRowRect = lastRow.getBoundingClientRect(); + loopMarkerStyle = { + left: loopRect.left - containerRect.left + loopRect.width / 2, + top: loopRect.top - containerRect.top, + height: lastRowRect.bottom - loopRect.top + }; + }; + + measureLoopMarker(); + const observer = new ResizeObserver(measureLoopMarker); + observer.observe(table); + observer.observe(container); + return () => observer.disconnect(); + }); + + return { + get style() { + return loopMarkerStyle; + } + }; +} diff --git a/src/lib/components/RowEditorTable/named-row-editor-sync.svelte.ts b/src/lib/components/RowEditorTable/named-row-editor-sync.svelte.ts new file mode 100644 index 00000000..b98bfc11 --- /dev/null +++ b/src/lib/components/RowEditorTable/named-row-editor-sync.svelte.ts @@ -0,0 +1,116 @@ +import { clampLoopRow, resizeRowList, removeRowAt, removeRowsFromBottomAt } from './row-list-operations'; + +type NamedRowSource = { + id: string | number; + name: string; + rows: unknown[]; + loop: number; +}; + +export class NamedRowEditorSync { + rows = $state([]); + loopRow = $state(0); + name = $state(''); + + private lastSourceId: string | number; + private lastSyncedName = ''; + private lastSyncedRows: unknown[] = []; + private lastSyncedLoop = 0; + + constructor( + private readonly options: { + getSource: () => NamedRowSource; + normalizeRows: (rows: unknown[]) => TRow[]; + onUpdate: (updates: { name?: string; rows?: TRow[]; loop?: number }) => void; + onSourceIdChange?: () => void; + } + ) { + const source = options.getSource(); + this.rows = options.normalizeRows([...source.rows]); + this.loopRow = source.loop; + this.name = source.name; + this.lastSourceId = source.id; + this.lastSyncedName = source.name; + this.lastSyncedRows = [...source.rows]; + this.lastSyncedLoop = source.loop; + + $effect(() => { + const currentSource = this.options.getSource(); + if (currentSource.id !== this.lastSourceId) { + this.lastSourceId = currentSource.id; + this.syncFromSource(); + this.options.onSourceIdChange?.(); + } else { + const rowsChanged = + currentSource.rows.length !== this.lastSyncedRows.length || + currentSource.rows.some((row, i) => row !== this.lastSyncedRows[i]); + const loopChanged = currentSource.loop !== this.lastSyncedLoop; + const nameChanged = currentSource.name !== this.lastSyncedName; + + if (rowsChanged || loopChanged) { + this.rows = this.options.normalizeRows([...currentSource.rows]); + this.loopRow = currentSource.loop; + this.lastSyncedRows = [...currentSource.rows]; + this.lastSyncedLoop = currentSource.loop; + } + if (nameChanged) { + this.name = currentSource.name; + this.lastSyncedName = currentSource.name; + } + } + }); + + $effect(() => { + if (this.name !== this.lastSyncedName) { + this.options.onUpdate({ name: this.name }); + } + }); + } + + syncFromSource(): void { + const source = this.options.getSource(); + this.rows = this.options.normalizeRows([...source.rows]); + this.loopRow = source.loop; + this.name = source.name; + this.lastSyncedRows = [...source.rows]; + this.lastSyncedLoop = source.loop; + this.lastSyncedName = source.name; + } + + pushRows(nextRows: TRow[]): void { + this.rows = nextRows; + this.options.onUpdate({ rows: nextRows }); + } + + setLoop(index: number): void { + this.loopRow = index; + this.options.onUpdate({ loop: this.loopRow }); + } + + applyRowChange(nextRows: TRow[]): void { + this.loopRow = clampLoopRow(this.loopRow, nextRows.length); + this.pushRows(nextRows); + this.options.onUpdate({ loop: this.loopRow }); + } + + addRow(createRow: () => TRow): void { + this.applyRowChange([...this.rows, createRow()]); + } + + setRowCount(targetCount: number, createRow: () => TRow, maxRows: number): void { + const nextRows = resizeRowList(this.rows, targetCount, createRow, maxRows); + if (nextRows !== this.rows) { + this.applyRowChange(nextRows); + } + } + + removeRow(index: number): void { + const nextRows = removeRowAt(this.rows, index); + if (nextRows) this.applyRowChange(nextRows); + } + + removeRowsFromBottom(index: number): void { + const nextRows = removeRowsFromBottomAt(this.rows, index); + if (nextRows) this.applyRowChange(nextRows); + } +} diff --git a/src/lib/components/RowEditorTable/row-editor-selection.svelte.ts b/src/lib/components/RowEditorTable/row-editor-selection.svelte.ts new file mode 100644 index 00000000..bb532642 --- /dev/null +++ b/src/lib/components/RowEditorTable/row-editor-selection.svelte.ts @@ -0,0 +1,50 @@ +import { + computeSelectionFromClick, + filterValidSelection, + isRowSelected as checkRowSelected +} from '../../utils/row-selection'; + +export function createRowEditorSelection(options: { + getSelectedIndices: () => number[]; + setSelectedIndices: (indices: number[]) => void; + getRowCount: () => number; + focusContainer: () => void; +}) { + let selectionAnchor = $state(null); + + function isRowSelected(index: number): boolean { + return checkRowSelected(index, options.getSelectedIndices()); + } + + function handleRowSelect(index: number, event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + const result = computeSelectionFromClick( + index, + event, + options.getSelectedIndices(), + selectionAnchor + ); + options.setSelectedIndices(result.indices); + selectionAnchor = result.anchor; + options.focusContainer(); + } + + function clearSelection(): void { + options.setSelectedIndices([]); + selectionAnchor = null; + } + + $effect(() => { + const validIndices = filterValidSelection(options.getSelectedIndices(), options.getRowCount()); + if (validIndices.length !== options.getSelectedIndices().length) { + options.setSelectedIndices(validIndices); + } + }); + + return { + isRowSelected, + handleRowSelect, + clearSelection + }; +} diff --git a/src/lib/components/RowEditorTable/row-editor-table-classes.ts b/src/lib/components/RowEditorTable/row-editor-table-classes.ts new file mode 100644 index 00000000..c0422ba2 --- /dev/null +++ b/src/lib/components/RowEditorTable/row-editor-table-classes.ts @@ -0,0 +1,35 @@ +export const ROW_EDITOR_MAX_ROWS = 512; + +export function expandedRowNumberSizeClass(isExpanded: boolean): string { + return isExpanded ? 'w-14 min-w-14 px-2 py-1.5' : 'w-8 min-w-8 px-1 py-1 text-[0.65rem]'; +} + +export function expandedActionsCellClass(isExpanded: boolean): string { + return isExpanded ? 'w-12 min-w-12 px-1' : 'w-10 min-w-10 px-0.5'; +} + +export function expandedLoopCellClass(isExpanded: boolean): string { + return isExpanded + ? 'w-6 min-w-6 cursor-pointer px-1.5 text-center text-sm' + : 'w-4 min-w-4 cursor-pointer px-0.5 text-center text-[0.65rem]'; +} + +export function expandedRowHeightClass(isExpanded: boolean): string { + return isExpanded ? 'h-8' : 'h-7'; +} + +export function expandedIconSizeClass(isExpanded: boolean): string { + return isExpanded ? 'h-3.5 w-3.5' : 'h-3 w-3'; +} + +export function expandedHeaderRowClass(isExpanded: boolean): string { + return isExpanded ? 'w-14 min-w-14 px-2 py-1.5' : 'w-8 min-w-8 px-1 py-1'; +} + +export function expandedHeaderActionsClass(isExpanded: boolean): string { + return isExpanded ? 'w-12 min-w-12 px-1' : 'w-10 min-w-10 px-0.5'; +} + +export function expandedHeaderLoopClass(isExpanded: boolean): string { + return isExpanded ? 'w-6 min-w-6 px-1.5' : 'w-4 min-w-4 px-0.5'; +} diff --git a/src/lib/components/RowEditorTable/row-list-operations.ts b/src/lib/components/RowEditorTable/row-list-operations.ts new file mode 100644 index 00000000..c51f4ed4 --- /dev/null +++ b/src/lib/components/RowEditorTable/row-list-operations.ts @@ -0,0 +1,37 @@ +import { ROW_EDITOR_MAX_ROWS } from './row-editor-table-classes'; + +export function clampRowCount(targetCount: number, maxRows = ROW_EDITOR_MAX_ROWS): number { + return Math.max(1, Math.min(maxRows, targetCount)); +} + +export function clampLoopRow(loopRow: number, rowCount: number): number { + if (rowCount <= 0) return 0; + return Math.min(loopRow, rowCount - 1); +} + +export function resizeRowList( + rows: T[], + targetCount: number, + createRow: () => T, + maxRows = ROW_EDITOR_MAX_ROWS +): T[] { + const count = clampRowCount(targetCount, maxRows); + if (count === rows.length) return rows; + if (count > rows.length) { + const toAdd = count - rows.length; + return [...rows, ...Array.from({ length: toAdd }, createRow)]; + } + return rows.slice(0, count); +} + +export function removeRowAt(rows: T[], index: number): T[] | null { + if (rows.length <= 1) return null; + return rows.filter((_, i) => i !== index); +} + +export function removeRowsFromBottomAt(rows: T[], index: number): T[] | null { + if (rows.length <= 1) return null; + const rowsToKeep = index + 1; + if (rowsToKeep >= rows.length) return null; + return rows.slice(0, rowsToKeep); +} diff --git a/src/lib/components/RowEditorTable/value-paint-drag.svelte.ts b/src/lib/components/RowEditorTable/value-paint-drag.svelte.ts new file mode 100644 index 00000000..18428798 --- /dev/null +++ b/src/lib/components/RowEditorTable/value-paint-drag.svelte.ts @@ -0,0 +1,34 @@ +export class ValuePaintDrag { + isDragging = $state(false); + private dragValue = $state(null); + + constructor() { + $effect(() => { + const stop = () => { + this.isDragging = false; + this.dragValue = null; + }; + window.addEventListener('mouseup', stop); + return () => window.removeEventListener('mouseup', stop); + }); + } + + begin(value: T, apply: (value: T) => void): void { + this.isDragging = true; + this.dragValue = value; + apply(value); + } + + dragOver(apply: (value: T) => void): void { + if (this.isDragging && this.dragValue !== null) { + apply(this.dragValue); + } + } + + dragOverWithValue(value: T, apply: (value: T) => void): void { + if (this.isDragging) { + this.dragValue = value; + apply(value); + } + } +} diff --git a/src/lib/components/Song/PatternEditor.svelte b/src/lib/components/Song/PatternEditor.svelte index cafb25da..2f0cba63 100644 --- a/src/lib/components/Song/PatternEditor.svelte +++ b/src/lib/components/Song/PatternEditor.svelte @@ -80,6 +80,7 @@ import { keybindingsStore } from '../../stores/keybindings.svelte'; import { ShortcutString } from '../../utils/shortcut-string'; import { projectStore } from '../../stores/project.svelte'; + import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; import { computeStateHorizon, buildCatchUpSegmentsToHorizon @@ -137,7 +138,7 @@ const patternOrder = $derived(projectStore.patternOrder); const tuningTable = $derived(projectStore.songs[songIndex]?.tuningTable ?? []); const speed = $derived(projectStore.songs[songIndex]?.initialSpeed ?? 3); - const instruments = $derived(projectStore.instruments); + const instruments = $derived(filterInstrumentsForChip(projectStore.instruments, chip.type)); const tables = $derived(projectStore.tables); function updatePatterns(newPatterns: Pattern[]): void { @@ -152,9 +153,9 @@ const services: { audioService: AudioService } = getContext('container'); - const formatter = getFormatter(chip); - const converter = getConverter(chip); - const schema = chip.schema; + const formatter = $derived.by(() => getFormatter(chip)); + const converter = $derived.by(() => getConverter(chip)); + const schema = $derived(chip.schema); const previewService = new PreviewService(); const pressedKeyChannels = new Map(); let previewInitialized = false; @@ -1412,6 +1413,7 @@ converter, formatter, schema, + instruments, tuningTable }; const fieldInfoBeforeEdit = PatternEditingService.getFieldAtCursor(context); @@ -1462,7 +1464,10 @@ 'playPreviewRow' in chipProcessor && !pressedKeyChannels.has(previewKey) ) { - services.audioService.setPreviewActiveForChips(songIndex); + const previewChipIndex = getChipIndex(); + if (previewChipIndex >= 0) { + services.audioService.setPreviewActiveForChips(previewChipIndex); + } const processor = chipProcessor as ChipProcessor & PreviewNoteSupport; const isNoteField = fieldInfoBeforeEdit.fieldType === 'note' || @@ -1825,7 +1830,7 @@ : ''; const instrumentId = normalizeInstrumentId(instrumentValue); if (isValidInstrumentId(instrumentId)) { - editorStateStore.requestSelectInstrument(instrumentId); + editorStateStore.requestSelectInstrument(instrumentId, schema.chipType); event.preventDefault(); canvas.focus(); draw(); @@ -2681,7 +2686,10 @@ previewChannel >= 0 && (fieldInfo.channelIndex >= 0 || fieldInfo.fieldKey === 'envelopeValue'); if (shouldPreview && chipProcessor && 'playPreviewRow' in chipProcessor) { - services.audioService.setPreviewActiveForChips(songIndex); + const previewChipIndex = getChipIndex(); + if (previewChipIndex >= 0) { + services.audioService.setPreviewActiveForChips(previewChipIndex); + } const processor = chipProcessor as ChipProcessor & PreviewNoteSupport; const isNoteField = fieldInfo.fieldType === 'note' || fieldInfo.fieldKey === 'envelopeValue'; @@ -2737,19 +2745,38 @@ let lastChannelSeparatorWidth = -1; let lastSelectionStyle: 'inverted' | 'filled' = 'inverted'; let lastChannelCount = -1; + let lastChipType = ''; let needsSetup = true; + function resetEditorSelectionState(): void { + selectedColumn = 0; + selectionStartRow = null; + selectionStartColumn = null; + selectionEndRow = null; + selectionEndColumn = null; + isSelecting = false; + mouseDownCell = null; + } + $effect(() => { if (!canvas) return; + const chipType = chip.type; const currentPatternLength = currentPattern?.length ?? -1; const currentChannelCount = currentPattern?.channels?.length ?? -1; const fontSizeChanged = fontSize !== lastFontSize; const fontFamilyChanged = fontFamily !== lastFontFamily; const channelSeparatorWidthChanged = channelSeparatorWidth !== lastChannelSeparatorWidth; const selectionStyleChanged = selectionStyle !== lastSelectionStyle; + const chipTypeChanged = chipType !== lastChipType; + + if (chipTypeChanged) { + clearAllCaches(); + resetEditorSelectionState(); + needsSetup = true; + } - if (needsSetup || !ctx) { + if (needsSetup || !ctx || chipTypeChanged) { ctx = canvas.getContext('2d')!; const ready = setupCanvas(); needsSetup = false; @@ -2766,6 +2793,7 @@ lastChannelSeparatorWidth = channelSeparatorWidth; lastSelectionStyle = selectionStyle; lastChannelCount = currentChannelCount; + lastChipType = chipType; requestAnimationFrame(() => { if (ctx && canvas && !document.hidden) { updateSize(); diff --git a/src/lib/components/Song/PlaybackToneDebug.svelte b/src/lib/components/Song/PlaybackToneDebug.svelte index 09dd8829..9cdbe492 100644 --- a/src/lib/components/Song/PlaybackToneDebug.svelte +++ b/src/lib/components/Song/PlaybackToneDebug.svelte @@ -1,13 +1,13 @@ -{#if chipProcessors.length > 0} +{#if chipSections.length > 0}
- {#if columns.length > 0} + {#each chipSections as section (section.chipIndex)}
-
- {#each columns as column (column.label)} + class={section.chipIndex > 0 + ? 'mt-1 border-t border-[var(--color-app-border)]/60 pt-1' + : ''}> + {#if section.showChipName}
- {column.label} + class="mb-0.5 px-0.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-app-text-muted)]"> + {section.chipName}
- {/each} + {/if} - {#each metrics as metric (metric.key)} -
- - {metric.label} + {#if section.columns.length > 0} +
+
+ {#each section.columns as column (column.label)} +
+ {column.label} +
+ {/each} + + {#each section.debugSpec.metrics as metric (metric.key)} + {@const MetricIcon = metricIcons[metric.icon]} +
+ + {metric.label} +
+ {#each section.columns as column (metric.key + column.label)} + {@const hz = readMetricHz( + metric, + section.playbackHz, + column.channelIndex + )} +
+ {metric.formatHz(hz)} +
+ {/each} + {/each}
- {#each columns as column (metric.key + column.label)} - {@const hz = metric.readHz(column)} -
- {metric.formatHz(hz)} -
- {/each} - {/each} -
- {/if} + {/if} - {#each chipRegisterRows as row, rowIndex (row.chipLabel ?? rowIndex)} -
-
- {#if row.chipLabel} -
- {row.chipLabel} -
- {/if} - {#each Array.from({ length: AY_REGISTER_COUNT }, (_, regIndex) => regIndex) as regIndex (regIndex)} -
- {regIndex} -
- {/each} - {#each Array.from({ length: AY_REGISTER_COUNT }, (_, regIndex) => regIndex) as regIndex (regIndex + 'v')} + {#if section.debugSpec.registers} + {@const registerSpec = section.debugSpec.registers} + {@const registers = registerSpec.normalizeRegisters( + section.playbackHz?.registers + )} +
0 + ? 'mt-1 border-t border-[var(--color-app-border)]/40 pt-1' + : ''}>
- {formatRegisterByte(row.registers[regIndex] ?? 0)} + class="grid gap-x-0.5 gap-y-0" + style="grid-template-columns: repeat({registerSpec.count}, minmax(1.625rem, 1fr));"> + {#each Array.from({ length: registerSpec.count }, (_, regIndex) => regIndex) as regIndex (regIndex)} +
+ {regIndex} +
+ {/each} + {#each Array.from({ length: registerSpec.count }, (_, regIndex) => regIndex) as regIndex (regIndex + 'v')} +
+ {formatRegisterByte(registers[regIndex] ?? 0)} +
+ {/each}
- {/each} -
+
+ {/if}
{/each}
diff --git a/src/lib/components/Song/SongView.svelte b/src/lib/components/Song/SongView.svelte index 21cbbbb4..272c4e3e 100644 --- a/src/lib/components/Song/SongView.svelte +++ b/src/lib/components/Song/SongView.svelte @@ -37,6 +37,7 @@ import { ShortcutString } from '../../utils/shortcut-string'; import { isEditableElement } from '../../utils/shortcut-input-exclusion'; import { ACTION_REDO, ACTION_TOGGLE_PLAYBACK, ACTION_UNDO } from '../../config/keybindings'; + import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; let { chipProcessors, @@ -134,8 +135,17 @@ const blurredContentClass = $derived( isRightPanelExpanded ? 'pointer-events-none opacity-50' : '' ); - const firstChipProcessor = $derived(chipProcessors[0]); const activeChipProcessor = $derived(chipProcessors[activeEditorIndex]); + const previewInstrumentId = $derived.by(() => { + const chipType = activeChipProcessor?.chip.type; + if (!chipType) return ''; + const chipInstruments = filterInstrumentsForChip(projectStore.instruments, chipType); + const selectedId = editorStateStore.getCurrentInstrument(chipType); + if (selectedId && chipInstruments.some((instrument) => instrument.id === selectedId)) { + return selectedId; + } + return chipInstruments[0]?.id ?? ''; + }); const services: { audioService: AudioService } = getContext('container'); @@ -211,19 +221,23 @@ const el = rightPanelEl; const handler = previewSpaceHandler; if (!el) return; - return addScopedShortcutListener(el, previewPlaybackActionIds, (event, _action, container) => { - if (event.repeat) return; - if (handler) { - event.preventDefault(); - event.stopPropagation(); - const active = document.activeElement as HTMLElement | null; - if (active && active !== container) { - active.blur?.(); - container.focus(); + return addScopedShortcutListener( + el, + previewPlaybackActionIds, + (event, _action, container) => { + if (event.repeat) return; + if (handler) { + event.preventDefault(); + event.stopPropagation(); + const active = document.activeElement as HTMLElement | null; + if (active && active !== container) { + active.blur?.(); + container.focus(); + } + handler(); } - handler(); } - }); + ); }); const SPEED_EFFECT_TYPE = 'S'.charCodeAt(0); @@ -249,7 +263,11 @@ }, [ projectStore.createSetDiff(['patterns'], beforePatterns, projectStore.patterns), - projectStore.createSetDiff(['patternOrder'], beforePatternOrder, projectStore.patternOrder) + projectStore.createSetDiff( + ['patternOrder'], + beforePatternOrder, + projectStore.patternOrder + ) ] ); if (index === sharedPatternOrderIndex) { @@ -324,19 +342,12 @@ const songPatterns = projectStore.patterns[index]; if (!song || !songPatterns) return; - const currentPattern = songPatterns.find((p) => p.id === patternId); - if (!currentPattern) return; - const withVirtual = chipProcessor as ChipProcessor & Partial; if (withVirtual.sendVirtualChannelConfig) { const hwLabels = chipProcessor.chip?.schema?.channelLabels ?? ['A', 'B', 'C']; - withVirtual.sendVirtualChannelConfig( - song.virtualChannelMap ?? {}, - hwLabels.length - ); + withVirtual.sendVirtualChannelConfig(song.virtualChannelMap ?? {}, hwLabels.length); } - chipProcessor.sendInitPattern(currentPattern, patternOrderIndexForInit); chipProcessor.sendInitTables(projectStore.tables); const withTuningTables = chipProcessor as ChipProcessor & Partial; @@ -345,8 +356,15 @@ withTuningTables.sendInitTuningTable(song.tuningTable); } if ('sendInitInstruments' in chipProcessor && withInstruments.sendInitInstruments) { - withInstruments.sendInitInstruments(projectStore.instruments); + withInstruments.sendInitInstruments( + filterInstrumentsForChip(projectStore.instruments, chipProcessor.chip.type) + ); } + + const currentPattern = songPatterns.find((p) => p.id === patternId); + if (!currentPattern) return; + + chipProcessor.sendInitPattern(currentPattern, patternOrderIndexForInit); }); if (!playPattern) { @@ -358,10 +376,7 @@ projectStore.patternOrder; projectStore.loopPointId; if (services.audioService.getPlayPatternId() !== null) return; - services.audioService.updateOrder( - [...projectStore.patternOrder], - projectStore.loopPointId - ); + services.audioService.updateOrder([...projectStore.patternOrder], projectStore.loopPointId); }); function initAllChipsForPlayback() { @@ -602,27 +617,32 @@
{/snippet}
- { - activeEditorIndex = i; - patternEditor = patternEditors[i]; - }} - canFocusOnHover={() => - !patternEditors.some((e) => e?.getCanvas?.() === document.activeElement)} - {onaction} - initAllChips={initAllChipsForPlayback} - {initAllChipsForPlayPattern} - {getSpeedForChip} - {getSpeedForPlayPattern} - {tuningTableVersion} - chip={chipProcessor.chip} - {chipProcessor} /> + {#key `${i}-${chipProcessor.chip.type}`} + { + activeEditorIndex = i; + patternEditor = patternEditors[i]; + }} + canFocusOnHover={() => + !patternEditors.some( + (e) => + e?.getCanvas?.() === document.activeElement + )} + {onaction} + initAllChips={initAllChipsForPlayback} + {initAllChipsForPlayPattern} + {getSpeedForChip} + {getSpeedForPlayPattern} + {tuningTableVersion} + chip={chipProcessor.chip} + {chipProcessor} /> + {/key}
{/if} @@ -647,12 +667,14 @@ role="region" aria-label="Instruments and tables" tabindex={0} - class="relative z-10 flex h-full shrink-0 flex-col border-l border-[var(--color-app-border)] bg-[var(--color-app-surface-secondary)] outline-none transition-all duration-300 focus:outline-none {isRightPanelExpanded + class="relative z-10 flex h-full shrink-0 flex-col border-l border-[var(--color-app-border)] bg-[var(--color-app-surface-secondary)] transition-all duration-300 outline-none focus:outline-none {isRightPanelExpanded ? 'w-[1200px]' : 'w-[32rem]'}" onmousedown={(e: MouseEvent) => { const target = e.target as HTMLElement; - if (!target.closest('input, textarea, button, select, [contenteditable="true"], a')) { + if ( + !target.closest('input, textarea, button, select, [contenteditable="true"], a') + ) { rightPanelEl?.focus(); } }}> @@ -662,11 +684,11 @@ {#if tabId === 'tables'} {:else if tabId === 'instruments'} - {#if firstChipProcessor} - - {/if} + {:else if tabId === 'details'}
diff --git a/src/lib/components/Tables/TableEditor.svelte b/src/lib/components/Tables/TableEditor.svelte index 1f103358..55505698 100644 --- a/src/lib/components/Tables/TableEditor.svelte +++ b/src/lib/components/Tables/TableEditor.svelte @@ -1,17 +1,21 @@ -{#snippet valueCell( - mode: 'pitch' | 'shift', - index: number, - value: number, - isSelected: boolean, - rowSelected: boolean -)} - beginDrag(mode, index, value)} - onmouseover={() => dragOver(mode, index, value)} - onfocus={() => dragOver(mode, index, value)}> - {#if isSelected} - {formatNum(value)} - {:else} - {formatNum(value)} - {/if} - -{/snippet} -
-
- Name: - -
+
- {#if loopRow >= 0 && loopRow < rows.length && loopColumnRef && tableRef} - {@const tbody = tableRef.querySelector('tbody')} - {@const firstRow = tbody?.querySelector('tr') as HTMLTableRowElement | null} - {@const rowTop = firstRow ? firstRow.offsetTop : 0} -
-
-
-
-
-
-
-
-
-
- {/if} + + @@ -478,7 +288,7 @@ - + {#if showOffsetGrid} @@ -499,66 +309,48 @@ {/if} - {#each rows as offset, index} - {@const selected = isRowSelected(index)} + {#each editorSync.rows as offset, index} + {@const selected = selection.isRowSelected(index)} handleRowSelect(index, e)} /> - - + onmousedown={(e) => selection.handleRowSelect(index, e)} /> + editorSync.removeRow(index)} + onRemoveFromBottom={() => editorSync.removeRowsFromBottom(index)} /> + editorSync.setLoop(index)} /> {#if showOffsetGrid} {#each PITCH_VALUES as p} - {@render valueCell( - 'pitch', - index, - p, - p === pitches[index], - selected - )} + formatRowEditorNumber(v, asHex)} + onPaintBegin={(_, value) => + pitchDrag.begin(value, (v) => setValue('pitch', index, v))} + onPaintOver={(_, value) => + pitchDrag.dragOverWithValue(value, (v) => + setValue('pitch', index, v))} /> {/each} {/if} @@ -569,39 +361,24 @@ {#if showOffsetGrid} {:else} {/if} @@ -626,22 +403,26 @@ - {#each rows as _, index} - {@const selected = isRowSelected(index)} + {#each editorSync.rows as _, index} + {@const selected = selection.isRowSelected(index)} handleRowSelect(index, e)} /> + onmousedown={(e) => selection.handleRowSelect(index, e)} /> {#each SHIFT_VALUES as s} - {@render valueCell( - 'shift', - index, - s, - s === shifts[index], - selected - )} + formatRowEditorNumber(v, asHex)} + onPaintBegin={(_, value) => + shiftDrag.begin(value, (v) => setValue('shift', index, v))} + onPaintOver={(_, value) => + shiftDrag.dragOverWithValue(value, (v) => + setValue('shift', index, v))} /> {/each} {/each} diff --git a/src/lib/config/app-menu.ts b/src/lib/config/app-menu.ts index 196f27dd..4bb7aa20 100644 --- a/src/lib/config/app-menu.ts +++ b/src/lib/config/app-menu.ts @@ -21,7 +21,14 @@ export function buildMenuItems(chipConfig: ChipConfiguration): MenuItem[] { label: 'Song', type: 'expandable', icon: '📁', - items: [{ label: 'AY/YM', type: 'normal', action: 'new-song-ay' }] + items: [ + { label: 'AY/YM', type: 'normal', action: 'new-song-ay' }, + { + label: '2A03 / 2A07 (work in progress)', + type: 'normal', + action: 'new-song-nes' + } + ] } ] }, diff --git a/src/lib/config/settings.ts b/src/lib/config/settings.ts index 57841d99..d19a5e06 100644 --- a/src/lib/config/settings.ts +++ b/src/lib/config/settings.ts @@ -44,7 +44,7 @@ export const settingsItems: SettingsItem[] = [ }, { label: 'Debug Mode', - description: 'Show playback debug panel (frequencies and AY/YM registers) and log each playback row in the console', + description: 'Show playback debug panel and log each playback row in the console', type: 'toggle', defaultValue: false, setting: 'debugMode', diff --git a/src/lib/models/pt3/tuning-tables.ts b/src/lib/models/pt3/tuning-tables.ts index 777747b3..b71e819f 100644 --- a/src/lib/models/pt3/tuning-tables.ts +++ b/src/lib/models/pt3/tuning-tables.ts @@ -68,15 +68,19 @@ export const PT3TuneTables: number[][] = [ const A4_NOTE_INDEX = 45; const TABLE_LENGTH = 96; const MIN_PERIOD = 1; -const MAX_PERIOD = 4095; +const DEFAULT_MAX_PERIOD = 4095; -export function generate12TETTuningTable(chipFrequencyHz: number, a4Hz: number = 440): number[] { +export function generate12TETTuningTable( + chipFrequencyHz: number, + a4Hz: number = 440, + maxPeriod: number = DEFAULT_MAX_PERIOD +): number[] { const result: number[] = []; for (let i = 0; i < TABLE_LENGTH; i++) { const freqHz = a4Hz * Math.pow(2, (i - A4_NOTE_INDEX) / 12); const periodF = chipFrequencyHz / 16 / freqHz; let period = Math.round(periodF); - if (period > MAX_PERIOD) period = MAX_PERIOD; + if (period > maxPeriod) period = maxPeriod; if (period < MIN_PERIOD) period = MIN_PERIOD; result.push(period); } diff --git a/src/lib/models/song.ts b/src/lib/models/song.ts index 11cb9ef1..f6f1eda3 100644 --- a/src/lib/models/song.ts +++ b/src/lib/models/song.ts @@ -45,12 +45,20 @@ class Note { class Instrument { id: string; + chipType: string; rows: InstrumentRow[] = []; loop: number = 0; name: string = ''; - constructor(id: string, rows: InstrumentRow[], loop: number = 0, name: string = '') { + constructor( + id: string, + rows: InstrumentRow[], + loop: number = 0, + name: string = '', + chipType: string = 'ay' + ) { this.id = id; + this.chipType = chipType; this.rows = rows; this.loop = loop; this.name = name || `Instrument ${id}`; diff --git a/src/lib/services/app/menu-action-context.ts b/src/lib/services/app/menu-action-context.ts index e0920a37..db21cb77 100644 --- a/src/lib/services/app/menu-action-context.ts +++ b/src/lib/services/app/menu-action-context.ts @@ -30,4 +30,5 @@ export interface MenuActionContext { handleFileExport: (action: string, project: Project) => Promise; clearAutobackup: () => Promise; resetPatternEditor: () => void; + syncChipProcessors: () => void; } diff --git a/src/lib/services/app/menu-action-handler.ts b/src/lib/services/app/menu-action-handler.ts index cf65e3e8..13bdfd50 100644 --- a/src/lib/services/app/menu-action-handler.ts +++ b/src/lib/services/app/menu-action-handler.ts @@ -22,20 +22,30 @@ import { import { autoEnvStore } from '../../stores/auto-env.svelte'; import { editorStateStore } from '../../stores/editor-state.svelte'; import { loadDemoProject } from '../../config/demo-songs'; -import { getChipByType } from '../../chips/registry'; import { AY_CHIP } from '../../chips/ay'; +import { NES_CHIP } from '../../chips/nes'; +import type { Chip } from '../../chips/types'; import type { MenuActionContext } from './menu-action-context'; -function addChipProcessorsForProject( - container: MenuActionContext['container'], - songs: { chipType?: string }[] -): Promise { - return (async () => { - for (const song of songs) { - const chip = song.chipType ? getChipByType(song.chipType) : null; - await container.audioService.addChipProcessor(chip ?? AY_CHIP); - } - })(); +async function createNewSongFromMenu(ctx: MenuActionContext, chip: Chip): Promise { + ctx.playbackStore.isPlaying = false; + ctx.container.audioService.stop(); + const project = ctx.getCurrentProject(); + const newSong = await ctx.projectService.createNewSong(chip, project.songs); + if (project.songs.length > 0 && project.patternOrder.length > 0) { + const refSong = project.songs[0] as unknown as { + patterns: { id: number; length: number }[]; + }; + const schema = newSong.getSchema() ?? chip.schema; + const uniquePatternIds = [...new Set(project.patternOrder)]; + newSong.patterns = uniquePatternIds.map((id) => { + const refPattern = refSong.patterns.find((p: { id: number }) => p.id === id); + const length = refPattern?.length ?? 64; + return new Pattern(id, length, schema); + }); + } + ctx.addSong(newSong); + ctx.resetPatternEditor(); } function dispatchEditorKey( @@ -204,28 +214,12 @@ export function createMenuActionHandler(ctx: MenuActionContext) { } if (data.action === 'new-song-ay') { - ctx.playbackStore.isPlaying = false; - ctx.container.audioService.stop(); - const project = ctx.getCurrentProject(); - const newSong = await ctx.projectService.createNewSong(AY_CHIP, project.songs); - if (project.songs.length > 0 && project.patternOrder.length > 0) { - const refSong = project.songs[0] as unknown as { - patterns: { id: number; length: number }[]; - }; - const schema = - ctx.container.audioService.chipProcessors[0].chip.schema ?? - newSong.getSchema(); - const uniquePatternIds = [...new Set(project.patternOrder)]; - newSong.patterns = uniquePatternIds.map((id) => { - const refPattern = refSong.patterns.find( - (p: { id: number }) => p.id === id - ); - const length = refPattern?.length ?? 64; - return new Pattern(id, length, schema); - }); - } - ctx.addSong(newSong); - ctx.resetPatternEditor(); + await createNewSongFromMenu(ctx, AY_CHIP); + return; + } + + if (data.action === 'new-song-nes') { + await createNewSongFromMenu(ctx, NES_CHIP); return; } @@ -331,8 +325,8 @@ export function createMenuActionHandler(ctx: MenuActionContext) { if (project) { ctx.playbackStore.isPlaying = false; ctx.container.audioService.stop(); - ctx.container.audioService.clearChipProcessors(); - await addChipProcessorsForProject(ctx.container, project.songs); + await ctx.projectService.restoreChipProcessorsForSongs(project.songs); + ctx.syncChipProcessors(); ctx.applyProject(project); ctx.resetPatternEditor(); } @@ -343,8 +337,8 @@ export function createMenuActionHandler(ctx: MenuActionContext) { if (importedProject) { ctx.playbackStore.isPlaying = false; ctx.container.audioService.stop(); - ctx.container.audioService.clearChipProcessors(); - await addChipProcessorsForProject(ctx.container, importedProject.songs); + await ctx.projectService.restoreChipProcessorsForSongs(importedProject.songs); + ctx.syncChipProcessors(); ctx.applyProject(importedProject); ctx.resetPatternEditor(); } diff --git a/src/lib/services/audio/audio-service.ts b/src/lib/services/audio/audio-service.ts index dc5fbb61..be46ea20 100644 --- a/src/lib/services/audio/audio-service.ts +++ b/src/lib/services/audio/audio-service.ts @@ -5,16 +5,17 @@ import { } from '../../chips/base/processor'; import type { Chip } from '../../chips/types'; import type { Table } from '../../models/project'; -import { ChipSettings } from './chip-settings'; +import { ChipSettingsRegistry } from './chip-settings'; import type { CatchUpSegment } from './play-from-position'; import { channelMuteStore } from '../../stores/channel-mute.svelte'; import { waveformStore } from '../../stores/waveform.svelte'; import { playbackToneDebugStore } from '../../stores/playback-tone-debug.svelte'; +import { filterInstrumentsForChip } from '../instrument/instrument-filter'; import type { Pattern } from '../../models/song'; const BITPHASE_AUDIO_PROCESSOR = 'bitphase-audio-processor'; -const BITPHASE_AUDIO_MODULE = `${BITPHASE_AUDIO_PROCESSOR}.js`; +const BITPHASE_AUDIO_MODULE = 'audio/bitphase-audio-processor.js'; export interface PlayFromRowOptions { catchUpSegments?: CatchUpSegment[]; @@ -27,7 +28,7 @@ export class AudioService { private _audioContext: AudioContext | null = new AudioContext(); private _isPlaying = false; private _previewChipIndices = new Set(); - public chipSettings: ChipSettings = new ChipSettings(); + public chipSettings = new ChipSettingsRegistry(); private _masterGainNode: GainNode | null = null; private _playPatternRestoreOrder: number[] | null = null; private _playPatternRestoreLoopPointId = 0; @@ -105,6 +106,7 @@ export class AudioService { const wasmBuffer = await this._loadWasm(processor.chip.wasmUrl); if (revision !== this._processorRevision || !this._mixerNode) return; processor.initialize(wasmBuffer, this._mixerNode); + this.chipSettings.forChip(processor.chip.type).renotifyAll(); } } } @@ -124,7 +126,7 @@ export class AudioService { let unsubscribeSettings: (() => void) | undefined; if (this.hasSettingsSubscription(processor)) { - processor.subscribeToSettings(this.chipSettings); + processor.subscribeToSettings(this.chipSettings.forChip(chip.type)); unsubscribeSettings = () => processor.unsubscribeFromSettings(); } @@ -154,6 +156,7 @@ export class AudioService { processor.bindChipIndex(chipIndex); processor.initialize(wasmBuffer, this._mixerNode); + this.chipSettings.forChip(chip.type).renotifyAll(); const processorWithWaveform = processor as { setWaveformCallback?: (cb: (channels: Float32Array[]) => void) => void; @@ -171,13 +174,17 @@ export class AudioService { }) => void) => void; }; processorWithWaveform.setWaveformCallback?.((channels: Float32Array[]) => { - const showWaveform = this._isPlaying || this._previewChipIndices.has(chipIndex); - if (showWaveform) waveformStore.setChannels(chipIndex, channels); + const idx = this.chipProcessors.indexOf(processor); + if (idx < 0) return; + const showWaveform = this._isPlaying || this._previewChipIndices.has(idx); + if (showWaveform) waveformStore.setChannels(idx, channels); }); processorWithWaveform.setChannelToneHzCallback?.((payload) => { - const showToneDebug = this._isPlaying || this._previewChipIndices.has(chipIndex); + const idx = this.chipProcessors.indexOf(processor); + if (idx < 0) return; + const showToneDebug = this._isPlaying || this._previewChipIndices.has(idx); if (showToneDebug) { - playbackToneDebugStore.setChipPlaybackHz(chipIndex, { + playbackToneDebugStore.setChipPlaybackHz(idx, { toneHz: payload.frequencies, sidTimerHz: payload.sidTimerHz, syncbuzzerTimerHz: payload.syncbuzzerTimerHz, @@ -188,10 +195,12 @@ export class AudioService { } }); processorWithWaveform.setTimerPwmSweepPhaseCallback?.((payload) => { - const showToneDebug = this._isPlaying || this._previewChipIndices.has(chipIndex); + const idx = this.chipProcessors.indexOf(processor); + if (idx < 0) return; + const showToneDebug = this._isPlaying || this._previewChipIndices.has(idx); if (showToneDebug) { playbackToneDebugStore.updateChipTimerPwmSweepPhase( - chipIndex, + idx, payload.timerPwmSweepPhase, payload.channelInstrumentIndex ); @@ -210,7 +219,10 @@ export class AudioService { } const arr = Array.isArray(indices) ? indices : [indices]; if (!this._isPlaying) { - waveformStore.clear(); + const layout = this.chipProcessors.map( + (p) => p.chip.schema.channelLabels?.length ?? 3 + ); + waveformStore.prepareLayout(layout); playbackToneDebugStore.clear(); } this._previewChipIndices = new Set(arr); @@ -313,8 +325,9 @@ export class AudioService { updateInstruments(instruments: import('../../models/song').Instrument[]) { this.chipProcessors.forEach((chipProcessor) => { if ('sendInitInstruments' in chipProcessor) { + const chipInstruments = filterInstrumentsForChip(instruments, chipProcessor.chip.type); (chipProcessor as { sendInitInstruments: (i: typeof instruments) => void }).sendInitInstruments( - instruments + chipInstruments ); } }); diff --git a/src/lib/services/audio/chip-settings.ts b/src/lib/services/audio/chip-settings.ts index 7c37aac7..47195684 100644 --- a/src/lib/services/audio/chip-settings.ts +++ b/src/lib/services/audio/chip-settings.ts @@ -42,4 +42,23 @@ export class ChipSettings { this.set(key, value); }); } + + renotifyAll(): void { + for (const [key, value] of this.settings) { + this.notify(key, value); + } + } +} + +export class ChipSettingsRegistry { + private readonly stores = new Map(); + + forChip(chipType: string): ChipSettings { + let store = this.stores.get(chipType); + if (!store) { + store = new ChipSettings(); + this.stores.set(chipType, store); + } + return store; + } } diff --git a/src/lib/services/audio/mixer-worklet-bridge.ts b/src/lib/services/audio/mixer-worklet-bridge.ts index 77ba0b9c..3edb3a13 100644 --- a/src/lib/services/audio/mixer-worklet-bridge.ts +++ b/src/lib/services/audio/mixer-worklet-bridge.ts @@ -60,6 +60,7 @@ export type MixerSlotCommand = | { type: 'init_tables'; tables: Table[] } | { type: 'init_instruments'; instruments: Instrument[] } | { type: 'update_ay_frequency'; aymFrequency: number } + | { type: 'update_cpu_frequency'; cpuFrequency: number } | { type: 'update_int_frequency'; intFrequency: number } | { type: 'update_chip_variant'; chipVariant: string } | { type: 'update_st_mixing'; stMixing: boolean } diff --git a/src/lib/services/file/file-import.ts b/src/lib/services/file/file-import.ts index 410136aa..ded8c774 100644 --- a/src/lib/services/file/file-import.ts +++ b/src/lib/services/file/file-import.ts @@ -15,6 +15,7 @@ import { } from '../../models/song'; import { isValidInstrumentSampleByteLength } from '../../utils/audio-sample-decode'; import { normalizeSamplePlaybackBounds } from '../../chips/ay/sample-region'; +import { normalizeNesInstrumentRow } from '../../chips/nes/instrument'; import type { ChipSchema } from '../../chips/base/schema'; import { computeEffectiveChannelLabels } from '../../models/virtual-channels'; @@ -191,9 +192,17 @@ function reconstructInstrument(data: any): Instrument { const numId = data.id ?? 1; id = numId.toString(36).toUpperCase().padStart(2, '0'); } - const instrument = new Instrument(id, [], data.loop ?? 0, data.name ?? ''); + const instrument = new Instrument( + id, + [], + data.loop ?? 0, + data.name ?? '', + typeof data.chipType === 'string' ? data.chipType : 'ay' + ); if (data.rows) { - instrument.rows = data.rows.map((rowData: any) => reconstructInstrumentRow(rowData)); + instrument.rows = data.rows.map((rowData: any) => + reconstructInstrumentRow(rowData, instrument.chipType) + ); } if (data.timerRows) { ( @@ -362,23 +371,30 @@ function reconstructInstrument(data: any): Instrument { return instrument; } -function reconstructInstrumentRow(data: any): InstrumentRow { - return new InstrumentRow({ - tone: data.tone ?? false, - noise: data.noise ?? false, - envelope: data.envelope ?? false, - toneAdd: data.toneAdd ?? 0, - noiseAdd: data.noiseAdd ?? 0, - envelopeAdd: data.envelopeAdd ?? 0, - envelopeAccumulation: data.envelopeAccumulation ?? false, - volume: data.volume ?? 0, - loop: data.loop ?? false, - amplitudeSliding: data.amplitudeSliding ?? false, - amplitudeSlideUp: data.amplitudeSlideUp ?? false, - toneAccumulation: data.toneAccumulation ?? false, - noiseAccumulation: data.noiseAccumulation ?? false, - retriggerEnvelope: data.retriggerEnvelope ?? false - }); +function reconstructInstrumentRow(data: any, chipType?: string): InstrumentRow { + const rowData = data ?? {}; + switch (chipType) { + case 'nes': + return new InstrumentRow(normalizeNesInstrumentRow(rowData)); + case 'ay': + default: + return new InstrumentRow({ + tone: rowData.tone ?? false, + noise: rowData.noise ?? false, + envelope: rowData.envelope ?? false, + toneAdd: rowData.toneAdd ?? 0, + noiseAdd: rowData.noiseAdd ?? 0, + envelopeAdd: rowData.envelopeAdd ?? 0, + envelopeAccumulation: rowData.envelopeAccumulation ?? false, + volume: rowData.volume ?? 0, + loop: rowData.loop ?? false, + amplitudeSliding: rowData.amplitudeSliding ?? false, + amplitudeSlideUp: rowData.amplitudeSlideUp ?? false, + toneAccumulation: rowData.toneAccumulation ?? false, + noiseAccumulation: rowData.noiseAccumulation ?? false, + retriggerEnvelope: rowData.retriggerEnvelope ?? false + }); + } } export class FileImportService { diff --git a/src/lib/services/file/psg-export.ts b/src/lib/services/file/psg-export.ts index b47804fa..d637b6e7 100644 --- a/src/lib/services/file/psg-export.ts +++ b/src/lib/services/file/psg-export.ts @@ -11,6 +11,7 @@ import { TONE_CHANNELS, type SongCaptureFrame } from './ay-export-utils'; +import { filterInstrumentsForChip } from '../instrument/instrument-filter'; const DEFAULT_SPEED = 6; @@ -242,7 +243,7 @@ class PsgExportService { const state = new AyumiState(totalChannelCount); state.setTuningTable(song.tuningTable); - state.setInstruments(project.instruments); + state.setInstruments(filterInstrumentsForChip(project.instruments, song.chipType ?? 'ay')); state.setTables(project.tables); state.setPatternOrder(project.patternOrder || [0]); state.setSpeed(song.initialSpeed || DEFAULT_SPEED); @@ -331,16 +332,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(`${baseUrl}ay/ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + `${baseUrl}tracker/tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); + const { default: AYAudioDriver } = await import(`${baseUrl}ay/ay-audio-driver.js`); const { default: AYChipRegisterState } = await import( - `${baseUrl}ay-chip-register-state.js` + `${baseUrl}ay/ay-chip-register-state.js` ); const { default: VirtualChannelMixer } = await import( - `${baseUrl}virtual-channel-mixer.js` + `${baseUrl}ay/virtual-channel-mixer.js` ); const modules: PsgExportModules = { @@ -456,16 +457,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(`${baseUrl}ay/ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + `${baseUrl}tracker/tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); + const { default: AYAudioDriver } = await import(`${baseUrl}ay/ay-audio-driver.js`); const { default: AYChipRegisterState } = await import( - `${baseUrl}ay-chip-register-state.js` + `${baseUrl}ay/ay-chip-register-state.js` ); const { default: VirtualChannelMixer } = await import( - `${baseUrl}virtual-channel-mixer.js` + `${baseUrl}ay/virtual-channel-mixer.js` ); modules = { AyumiState, diff --git a/src/lib/services/file/tmr-export.ts b/src/lib/services/file/tmr-export.ts index 6d4c190c..c46ffec2 100644 --- a/src/lib/services/file/tmr-export.ts +++ b/src/lib/services/file/tmr-export.ts @@ -10,13 +10,13 @@ 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(`${baseUrl}ay/ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + `${baseUrl}tracker/tracker-pattern-processor.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`); + const { default: AYAudioDriver } = await import(`${baseUrl}ay/ay-audio-driver.js`); + const { default: AYChipRegisterState } = await import(`${baseUrl}ay/ay-chip-register-state.js`); + const { default: VirtualChannelMixer } = await import(`${baseUrl}ay/virtual-channel-mixer.js`); return { AyumiState, TrackerPatternProcessor, diff --git a/src/lib/services/history/history-clone.ts b/src/lib/services/history/history-clone.ts index f079d952..c4f3c8a9 100644 --- a/src/lib/services/history/history-clone.ts +++ b/src/lib/services/history/history-clone.ts @@ -116,7 +116,8 @@ export class HistoryClone { instrument.id, instrument.rows.map((row) => this.instrumentRow(row)), instrument.loop, - instrument.name + instrument.name, + instrument.chipType ?? 'ay' ); const extended = instrument as Instrument & { timerRows?: { diff --git a/src/lib/services/instrument/instrument-filter.ts b/src/lib/services/instrument/instrument-filter.ts new file mode 100644 index 00000000..9c0e9b90 --- /dev/null +++ b/src/lib/services/instrument/instrument-filter.ts @@ -0,0 +1,45 @@ +import type { Instrument, Song } from '../../models/song'; +import { getAllChips } from '../../chips/registry'; + +export function resolveInstrumentChipType(instrument: Instrument): string { + return instrument.chipType ?? 'ay'; +} + +export function filterInstrumentsForChip( + instruments: Instrument[], + chipType: string +): Instrument[] { + return instruments.filter((instrument) => resolveInstrumentChipType(instrument) === chipType); +} + +export function getActiveChipTypes(songs: Song[]): Set { + const types = new Set(); + for (const song of songs) { + if (song.chipType) { + types.add(song.chipType); + } + } + return types; +} + +export function filterInstrumentsForActiveChipTypes( + songs: Song[], + instruments: Instrument[] +): Instrument[] { + const activeChipTypes = getActiveChipTypes(songs); + return instruments.filter((instrument) => + activeChipTypes.has(resolveInstrumentChipType(instrument)) + ); +} + +export function getOrderedProjectChipTypes( + chipProcessors: { chip: { type: string } }[] +): string[] { + const types = new Set(); + for (const processor of chipProcessors) { + types.add(processor.chip.type); + } + return getAllChips() + .map((chip) => chip.type) + .filter((type) => types.has(type)); +} diff --git a/src/lib/services/pattern/editing/editing-context.ts b/src/lib/services/pattern/editing/editing-context.ts index 4ad56951..8c64503a 100644 --- a/src/lib/services/pattern/editing/editing-context.ts +++ b/src/lib/services/pattern/editing/editing-context.ts @@ -1,4 +1,5 @@ import type { Pattern } from '../../../models/song'; +import type { Instrument } from '../../../models/song'; import type { Chip } from '../../../chips/types'; import type { PatternConverter } from '../../../chips/base/adapter'; import type { PatternFormatter } from '../../../chips/base/formatter-interface'; @@ -13,6 +14,7 @@ export interface EditingContext { converter: PatternConverter; formatter: PatternFormatter; schema: Chip['schema']; + instruments?: Instrument[]; tuningTable?: number[]; } diff --git a/src/lib/services/pattern/editing/pattern-note-input.ts b/src/lib/services/pattern/editing/pattern-note-input.ts index 2d91f10a..ac629d06 100644 --- a/src/lib/services/pattern/editing/pattern-note-input.ts +++ b/src/lib/services/pattern/editing/pattern-note-input.ts @@ -11,6 +11,7 @@ import { PatternValueUpdates } from './pattern-value-updates'; import { editorStateStore } from '../../../stores/editor-state.svelte'; import { settingsStore } from '../../../stores/settings.svelte'; import { parseSymbol } from '../../../chips/base/field-formatters'; +import { resolveInstrumentChipType } from '../../instrument/instrument-filter'; export class PatternNoteInput { private static readonly PIANO_KEYBOARD_MAP: Record< @@ -142,7 +143,19 @@ export class PatternNoteInput { return pattern; } - const currentInstrumentId = editorStateStore.currentInstrument; + const chipType = context.schema.chipType; + const currentInstrumentId = editorStateStore.getCurrentInstrument(chipType); + if ( + !currentInstrumentId || + !context.instruments?.some( + (instrument) => + instrument.id === currentInstrumentId && + resolveInstrumentChipType(instrument) === chipType + ) + ) { + return pattern; + } + const instrumentValue = parseSymbol(currentInstrumentId, instrumentFieldDef.length); const instrumentFieldInfo: FieldInfo = { diff --git a/src/lib/services/project/project-service.ts b/src/lib/services/project/project-service.ts index 2e8ba388..41aa3bfa 100644 --- a/src/lib/services/project/project-service.ts +++ b/src/lib/services/project/project-service.ts @@ -5,6 +5,7 @@ import type { AudioService } from '../audio/audio-service'; import { applySchemaDefaults, type ChipSchema } from '../../chips/base/schema'; import { normalizeChipSettingsRecord } from '../../chips/base/chip-settings'; import { getChipByType } from '../../chips/registry'; +import { AY_CHIP } from '../../chips/ay'; export class ProjectService { constructor(private audioService: AudioService) {} @@ -30,11 +31,22 @@ export class ProjectService { newSong.chipType = chip.type; applySchemaDefaults(newSong, chip.schema); this.applyChipDefaults(newSong, chip.schema); + if (existingSongs.length > 0) { + newSong.initialSpeed = existingSongs[0].initialSpeed; + } this.syncFromPeerSongs(newSong, existingSongs, chip.schema); await this.audioService.addChipProcessor(chip); return newSong; } + async restoreChipProcessorsForSongs(songs: { chipType?: string }[]): Promise { + this.audioService.clearChipProcessors(); + for (const song of songs) { + const chip = song.chipType ? getChipByType(song.chipType) : null; + await this.audioService.addChipProcessor(chip ?? AY_CHIP); + } + } + private syncFromPeerSongs(targetSong: Song, existingSongs: Song[], schema: ChipSchema): void { const peer = existingSongs.find((s) => s.chipType === targetSong.chipType); if (!peer) return; diff --git a/src/lib/stores/editor-state.svelte.ts b/src/lib/stores/editor-state.svelte.ts index f8457969..2c3361b0 100644 --- a/src/lib/stores/editor-state.svelte.ts +++ b/src/lib/stores/editor-state.svelte.ts @@ -7,11 +7,20 @@ interface StoredEditorState { step?: number; } +interface SelectInstrumentRequest { + instrumentId: string; + chipType?: string; +} + class EditorStateStore { octave = $state(4); step = $state(0); envelopeAsNote = $state(false); - currentInstrument = $state('01'); + currentInstrumentByChip = $state>({ ay: '01' }); + + get currentInstrument(): string { + return this.getCurrentInstrument('ay') ?? '01'; + } init(): void { this.envelopeAsNote = settingsStore.envelopeAsNote; @@ -57,14 +66,26 @@ class EditorStateStore { } setCurrentInstrument(instrument: string): void { - this.currentInstrument = instrument; + this.setCurrentInstrumentForChip('ay', instrument); } - selectInstrumentRequest = $state(null); + getCurrentInstrument(chipType: string): string | null { + return this.currentInstrumentByChip[chipType] ?? null; + } - requestSelectInstrument(instrumentId: string): void { - this.currentInstrument = instrumentId; - this.selectInstrumentRequest = instrumentId; + setCurrentInstrumentForChip(chipType: string, instrumentId: string): void { + this.currentInstrumentByChip[chipType] = instrumentId; + } + + selectInstrumentRequest = $state(null); + + requestSelectInstrument(instrumentId: string, chipType?: string): void { + if (chipType) { + this.setCurrentInstrumentForChip(chipType, instrumentId); + } else { + this.setCurrentInstrument(instrumentId); + } + this.selectInstrumentRequest = { instrumentId, chipType }; } clearSelectInstrumentRequest(): void { diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index 76176fa0..d8342084 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -1,5 +1,6 @@ import type { Song, Instrument, Pattern } from '../models/song'; import { Project, Table } from '../models/project'; +import { filterInstrumentsForActiveChipTypes } from '../services/instrument/instrument-filter'; import { undoRedoStore } from './undo-redo.svelte'; import type { ProjectDiff, ProjectHistoryEntry, ProjectHistoryMetadata } from '../models/history'; import { HistoryClone } from '../services/history/history-clone'; @@ -108,9 +109,14 @@ class ProjectStore { removeSong(index: number): void { this.songs = this.songs.filter((_, i) => i !== index); this.patterns = this.patterns.filter((_, i) => i !== index); + this.instruments = filterInstrumentsForActiveChipTypes(this.songs, this.instruments); } addSong(song: Song): void { + const projectInitialSpeed = this.settings.initialSpeed; + if (typeof projectInitialSpeed === 'number' && projectInitialSpeed >= 1) { + song.initialSpeed = projectInitialSpeed; + } this.songs = [...this.songs, song]; this.patterns = [...this.patterns, song.patterns]; } diff --git a/src/lib/stores/waveform.svelte.ts b/src/lib/stores/waveform.svelte.ts index 63bb7c83..20e2f8b5 100644 --- a/src/lib/stores/waveform.svelte.ts +++ b/src/lib/stores/waveform.svelte.ts @@ -1,14 +1,21 @@ const WAVEFORM_FRAME_SIZE = 512; const WAVEFORM_DISPLAY_LENGTH = 1536; +const ZERO_WAVEFORM = new Float32Array(WAVEFORM_DISPLAY_LENGTH); class WaveformStore { private channelDataByChip: Float32Array[][] = $state([]); private writeIndexByChip: number[] = $state([]); + private channelCountByChip: number[] = $state([]); get channels(): Float32Array[] { - return this.channelDataByChip.flatMap((chipBuffers, chipIndex) => { + return this.channelCountByChip.flatMap((count, chipIndex) => { + const chipBuffers = this.channelDataByChip[chipIndex] ?? []; const writeIndex = this.writeIndexByChip[chipIndex] ?? 0; - return chipBuffers.map((buf) => { + return Array.from({ length: count }, (_, ch) => { + const buf = chipBuffers[ch]; + if (!buf || buf.length === 0) { + return ZERO_WAVEFORM; + } const out = new Float32Array(buf.length); for (let i = 0; i < buf.length; i++) { out[i] = buf[(writeIndex + i) % buf.length]; @@ -18,11 +25,23 @@ class WaveformStore { }); } + prepareLayout(channelCounts: number[]): void { + const ringSize = WAVEFORM_DISPLAY_LENGTH; + this.channelCountByChip = [...channelCounts]; + this.channelDataByChip = channelCounts.map((count) => + Array.from({ length: count }, () => new Float32Array(ringSize)) + ); + this.writeIndexByChip = channelCounts.map(() => 0); + } + setChannels(chipIndex: number, channels: Float32Array[]): void { while (this.channelDataByChip.length <= chipIndex) { this.channelDataByChip = [...this.channelDataByChip, []]; this.writeIndexByChip = [...this.writeIndexByChip, 0]; + this.channelCountByChip = [...this.channelCountByChip, 0]; } + this.channelCountByChip = this.channelCountByChip.slice(); + this.channelCountByChip[chipIndex] = channels.length; const ringSize = WAVEFORM_DISPLAY_LENGTH; let writeIndex = this.writeIndexByChip[chipIndex] ?? 0; const existing = this.channelDataByChip[chipIndex]; @@ -44,6 +63,7 @@ class WaveformStore { clear(): void { this.channelDataByChip = []; this.writeIndexByChip = []; + this.channelCountByChip = []; } } diff --git a/src/lib/ui-rendering/pattern-editor-renderer.ts b/src/lib/ui-rendering/pattern-editor-renderer.ts index a3e306ee..0de2aa2c 100644 --- a/src/lib/ui-rendering/pattern-editor-renderer.ts +++ b/src/lib/ui-rendering/pattern-editor-renderer.ts @@ -99,7 +99,7 @@ export class PatternEditorRenderer extends BaseCanvasRenderer { } else { for (let i = 0; i < data.channelLabels.length && i < channelPositions.length; i++) { this.drawSingleChannelLabel( - `Channel ${data.channelLabels[i]}`, + this.formatChannelHeaderLabel(data.channelLabels[i]), i, channelPositions, separatorMargin, @@ -130,7 +130,7 @@ export class PatternEditorRenderer extends BaseCanvasRenderer { const idx = indices[0]; if (idx < channelPositions.length) { this.drawSingleChannelLabel( - `Channel ${group.hardwareLabel}`, + this.formatChannelHeaderLabel(group.hardwareLabel), idx, channelPositions, separatorMargin, @@ -187,6 +187,13 @@ export class PatternEditorRenderer extends BaseCanvasRenderer { } } + private formatChannelHeaderLabel(label: string): string { + if (label.includes(' ') || label.length > 2) { + return label; + } + return `Channel ${label}`; + } + private drawSingleChannelLabel( label: string, index: number, diff --git a/src/lib/utils/row-editor-numeric.ts b/src/lib/utils/row-editor-numeric.ts new file mode 100644 index 00000000..215b5dbf --- /dev/null +++ b/src/lib/utils/row-editor-numeric.ts @@ -0,0 +1,83 @@ +export function formatRowEditorNumber(value: number, asHex: boolean): string { + if (asHex) { + const sign = value < 0 ? '-' : ''; + return sign + Math.abs(value).toString(16).toUpperCase(); + } + return String(value); +} + +export type NumericFieldLimits = { + min?: number; + max?: number; + maxDigits?: number; +}; + +export function parseRowEditorNumericText( + text: string, + asHex: boolean, + limits?: NumericFieldLimits +): number | null { + let normalized = text.trim().replace(/\+/g, ''); + const allowedPattern = asHex ? /[^0-9a-fA-F-]/g : /[^0-9-]/g; + normalized = normalized.replace(allowedPattern, ''); + + if (limits?.maxDigits !== undefined && asHex && normalized.replace('-', '').length > limits.maxDigits) { + normalized = normalized.startsWith('-') + ? '-' + normalized.slice(1, limits.maxDigits + 1) + : normalized.slice(0, limits.maxDigits); + } + + if (!asHex && limits?.max !== undefined) { + const num = parseInt(normalized, 10); + if (!Number.isNaN(num) && num > limits.max) { + normalized = String(limits.max); + } + } + + let parsed: number | null = null; + if (asHex) { + let sign = 1; + let temp = normalized; + if (temp.startsWith('-')) { + sign = -1; + temp = temp.substring(1); + } + if (/^[0-9a-fA-F]+$/.test(temp)) { + parsed = sign * parseInt(temp, 16); + } + } else if (/^-?\d+$/.test(normalized)) { + parsed = parseInt(normalized, 10); + } + + if (parsed === null) return null; + if (limits?.min !== undefined) parsed = Math.max(limits.min, parsed); + if (limits?.max !== undefined) parsed = Math.min(limits.max, parsed); + return parsed; +} + +export function focusRowEditorInputInRow( + row: HTMLTableRowElement | null, + currentInput?: HTMLInputElement +): void { + if (!row) return; + let input: HTMLInputElement | null = null; + if (currentInput) { + const currentCell = currentInput.closest('td'); + if (currentCell) { + const cellIndex = Array.from(currentCell.parentElement?.children || []).indexOf(currentCell); + const targetCell = row.children[cellIndex] as HTMLTableCellElement | undefined; + input = targetCell?.querySelector('input[type="text"]') ?? null; + } + } + input ??= row.querySelector('input[type="text"]'); + if (input) { + input.focus(); + input.select(); + } +} + +export function shouldBlockRowEditorNumericKey(key: string, asHex: boolean): boolean { + if (key.length > 1) return false; + const pattern = asHex ? /^[0-9a-fA-F-]$/ : /^[0-9-]$/; + return !pattern.test(key); +} diff --git a/tests/lib/chips/nes/instrument.test.ts b/tests/lib/chips/nes/instrument.test.ts new file mode 100644 index 00000000..4aeda2c6 --- /dev/null +++ b/tests/lib/chips/nes/instrument.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + buildLengthCounterNibble, + buildNoiseEnvelopeVolumeReg, + buildSquareEnvelopeVolumeReg, + buildSquareSweepReg, + buildTriangleLinearReg, + createDefaultNesInstrumentRow, + cyclePulseWidth, + ensureNesInstrumentRows, + isNesVolumeField, + NES_LENGTH_COUNTER_LENGTHS, + NES_SQUARE_SWEEP_DISABLED, + normalizeNesInstrumentRow, + resolveLengthCounterIndex, + usesTriangleLinearCounter +} from '@/lib/chips/nes/instrument'; + +describe('nes instrument', () => { + it('creates a default macro row with constant volume and retrigger off', () => { + expect(createDefaultNesInstrumentRow()).toEqual({ + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: false, + volumeOrRate: 15, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }); + }); + + it('normalizes partial rows and ensures at least one row', () => { + expect( + normalizeNesInstrumentRow({ + retrigger: 1, + pulseWidth: 99, + toneAdd: -2, + soundLength: 999, + envelope: true, + volumeOrRate: 20 + }) + ).toEqual({ + pulseWidth: 2, + retrigger: true, + soundLength: 511, + envelope: true, + volumeOrRate: 15, + toneAdd: -2, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }); + expect(normalizeNesInstrumentRow({ toneAccumulation: true, toneAdd: 5000 })).toEqual({ + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: false, + volumeOrRate: 15, + toneAdd: 4095, + toneAccumulation: true, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }); + expect( + normalizeNesInstrumentRow({ + sweep: true, + sweepRate: 12, + sweepShift: -9, + envelope: true, + volumeOrRate: 7 + }) + ).toEqual({ + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: true, + volumeOrRate: 7, + toneAdd: 0, + toneAccumulation: false, + sweep: true, + sweepRate: 7, + sweepShift: -7 + }); + expect(ensureNesInstrumentRows([])).toHaveLength(1); + }); + + it('exposes length counter table and volume field helper', () => { + expect(NES_LENGTH_COUNTER_LENGTHS).toHaveLength(32); + expect(isNesVolumeField(false)).toBe(true); + expect(isNesVolumeField(true)).toBe(false); + }); + + it('cycles pulse width through duty options', () => { + expect(cyclePulseWidth(0)).toBe(1); + expect(cyclePulseWidth(3)).toBe(0); + }); + + it('builds hardware sweep register bytes', () => { + expect(buildSquareSweepReg(false, 3, 4)).toBe(NES_SQUARE_SWEEP_DISABLED); + expect(buildSquareSweepReg(true, 0, 0)).toBe(NES_SQUARE_SWEEP_DISABLED); + expect(buildSquareSweepReg(true, 3, 4)).toBe(0x80 | 0x34); + expect(buildSquareSweepReg(true, 7, -5)).toBe(0x88 | 0x75); + }); + + it('maps envelope bool and sound length to APU register bytes', () => { + expect(buildSquareEnvelopeVolumeReg(2, false, 15, 0)).toBe(0xbf); + expect(buildSquareEnvelopeVolumeReg(2, true, 7, 40)).toBe(0x87); + expect(buildSquareEnvelopeVolumeReg(2, true, 7, 0)).toBe(0xa7); + expect(buildSquareEnvelopeVolumeReg(2, true, 4, 40)).toBe(0x84); + expect(buildNoiseEnvelopeVolumeReg(false, 10, 40)).toBe(0x1a); + expect(buildLengthCounterNibble(0)).toBe(-1); + expect(buildLengthCounterNibble(20)).toBe(resolveLengthCounterIndex(20)); + expect(buildTriangleLinearReg(0)).toBe(0xff); + expect(buildTriangleLinearReg(64)).toBe(64); + expect(usesTriangleLinearCounter(64)).toBe(true); + expect(usesTriangleLinearCounter(200)).toBe(false); + }); +}); diff --git a/tests/lib/chips/nes/renderer.test.ts b/tests/lib/chips/nes/renderer.test.ts new file mode 100644 index 00000000..071567e5 --- /dev/null +++ b/tests/lib/chips/nes/renderer.test.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { FileSystemResourceLoader } from '../../../../cli/resource-loader-node'; +import { NESChipRenderer } from '@/lib/chips/nes/renderer'; +import { NES_CHIP_SCHEMA, NES_DEFAULT_TUNING_TABLE } from '@/lib/chips/nes/schema'; +import { Project } from '@/lib/models/project'; +import { Instrument, Note, NoteName, Pattern, Song } from '@/lib/models/song'; + +const PUBLIC_DIR = path.join(process.cwd(), 'public'); + +function createNesTestProject(): Project { + const pattern = new Pattern(0, 8, NES_CHIP_SCHEMA); + pattern.channels[0].rows[0].note = new Note(NoteName.C, 1); + pattern.channels[0].rows[0].instrument = 1; + + const song = new Song(); + song.chipType = 'nes'; + song.tuningTable = NES_DEFAULT_TUNING_TABLE; + song.chipFrequency = NES_DEFAULT_TUNING_TABLE[0] ? 1_789_773 : 1_789_773; + song.initialSpeed = 6; + song.interruptFrequency = 50; + song.patterns = [pattern]; + + const instrument = new Instrument('01', [{ pulseWidth: 2, retrigger: false }], 0, 'Pulse', 'nes'); + + return new Project('NES export test', '', [song], 0, [0], [], {}, [instrument]); +} + +describe('NESChipRenderer', () => { + it('renders stereo audio for a minimal NES song', async () => { + const renderer = new NESChipRenderer(new FileSystemResourceLoader(PUBLIC_DIR)); + const project = createNesTestProject(); + const [left, right] = await renderer.render(project, 0); + + expect(left.length).toBeGreaterThan(10_000); + expect(right.length).toBe(left.length); + + let peak = 0; + for (let i = 0; i < left.length; i++) { + peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i])); + } + expect(peak).toBeGreaterThan(0.01); + }); +}); diff --git a/tests/lib/chips/nes/schema-settings.test.ts b/tests/lib/chips/nes/schema-settings.test.ts new file mode 100644 index 00000000..82ac2bfd --- /dev/null +++ b/tests/lib/chips/nes/schema-settings.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + collectSettingSideEffects, + normalizeChipSettingsRecord +} from '@/lib/chips/base/chip-settings'; +import { + NES_CHIP_SCHEMA, + NES_DENDY_CPU_FREQUENCY, + NES_MAX_TUNING_PERIOD, + NES_NTSC_CPU_FREQUENCY, + NES_PAL_CPU_FREQUENCY, + resolveNesApuTimingType, + resolveNesCpuFrequency, + resolveNesTuningTable +} from '@/lib/chips/nes/schema'; + +describe('NES chip settings schema hooks', () => { + it('maps each system to the correct CPU frequency and APU timing type', () => { + expect(resolveNesCpuFrequency('NTSC')).toBe(NES_NTSC_CPU_FREQUENCY); + expect(resolveNesApuTimingType('NTSC')).toBe('NTSC'); + + expect(resolveNesCpuFrequency('PAL')).toBe(NES_PAL_CPU_FREQUENCY); + expect(resolveNesApuTimingType('PAL')).toBe('PAL'); + + expect(resolveNesCpuFrequency('Dendy')).toBe(NES_DENDY_CPU_FREQUENCY); + expect(resolveNesApuTimingType('Dendy')).toBe('NTSC'); + }); + + it('normalizes chip frequency from the selected system', () => { + expect( + normalizeChipSettingsRecord(NES_CHIP_SCHEMA, { + chipVariant: 'PAL' + }) + ).toEqual({ + chipVariant: 'PAL', + chipFrequency: NES_PAL_CPU_FREQUENCY + }); + + expect( + normalizeChipSettingsRecord(NES_CHIP_SCHEMA, { + chipVariant: 'Dendy', + chipFrequency: 1_789_772 + }) + ).toEqual({ + chipVariant: 'Dendy', + chipFrequency: NES_DENDY_CPU_FREQUENCY + }); + }); + + it('sets chip frequency as a side effect when system changes', () => { + expect( + collectSettingSideEffects(NES_CHIP_SCHEMA, 'chipVariant', 'Dendy', { + chipVariant: 'Dendy' + }) + ).toEqual([{ key: 'chipFrequency', value: NES_DENDY_CPU_FREQUENCY }]); + }); + + it('exposes configurable interrupt frequency options like AY', () => { + const setting = NES_CHIP_SCHEMA.settings?.find((s) => s.key === 'interruptFrequency'); + expect(setting).toBeDefined(); + expect(setting?.defaultValue).toBe(50); + expect(setting?.notifyAudioService).toBe(true); + expect(setting?.options?.map((option) => option.value)).toEqual([50, 60]); + }); + + it('builds a 12-TET tuning table from CPU frequency and A4', () => { + const table = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); + expect(table).toHaveLength(96); + expect(table.every((period) => period <= NES_MAX_TUNING_PERIOD)).toBe(true); + expect(table[45]).toBe(Math.round(NES_NTSC_CPU_FREQUENCY / 16 / 440)); + }); + + it('regenerates tuning table when CPU frequency changes', () => { + const ntsc = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); + const pal = resolveNesTuningTable(NES_PAL_CPU_FREQUENCY, 440); + expect(ntsc[45]).not.toBe(pal[45]); + }); + + it('clamps A4 tuning Hz when resolving from song settings', () => { + const table = NES_CHIP_SCHEMA.resolveTuningTable!({ + chipFrequency: NES_NTSC_CPU_FREQUENCY, + a4TuningHz: 1000 + }); + const expected = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 880); + expect(table[45]).toBe(expected[45]); + }); +}); diff --git a/tests/lib/services/file/file-import.test.ts b/tests/lib/services/file/file-import.test.ts index d5800031..8e04d1c5 100644 --- a/tests/lib/services/file/file-import.test.ts +++ b/tests/lib/services/file/file-import.test.ts @@ -57,4 +57,37 @@ describe('FileImportService', () => { expect(fields.timerRows[0]?.fmWaveform).toEqual([0, 12, -4]); expect(fields.timerRows[0]?.envFmWaveform).toEqual([0, -7, 24]); }); + + it('preserves NES pulse width and retrigger when reconstructing instruments', async () => { + const json = JSON.stringify({ + name: 'test', + songs: [], + instruments: [ + { + id: '01', + chipType: 'nes', + name: 'Pulse', + loop: 0, + rows: [ + { + pulseWidth: 1, + retrigger: true, + toneAdd: -2, + toneAccumulation: true + } + ] + } + ], + patterns: [], + tables: [] + }); + + const project = await FileImportService.reconstructFromJsonAsync(json); + const row = project.instruments[0]?.rows[0]; + + expect(row?.pulseWidth).toBe(1); + expect(row?.retrigger).toBe(true); + expect(row?.toneAdd).toBe(-2); + expect(row?.toneAccumulation).toBe(true); + }); }); diff --git a/tests/lib/services/instrument/instrument-filter.test.ts b/tests/lib/services/instrument/instrument-filter.test.ts new file mode 100644 index 00000000..d8b2fe4a --- /dev/null +++ b/tests/lib/services/instrument/instrument-filter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { Instrument } from '@/lib/models/song'; +import { + filterInstrumentsForActiveChipTypes, + filterInstrumentsForChip, + getOrderedProjectChipTypes, + resolveInstrumentChipType +} from '@/lib/services/instrument/instrument-filter'; + +describe('instrument-filter', () => { + it('defaults missing chipType to ay', () => { + const instrument = new Instrument('01', []); + expect(resolveInstrumentChipType(instrument)).toBe('ay'); + }); + + it('filters instruments by chip type', () => { + const instruments = [ + new Instrument('01', [], 0, 'AY', 'ay'), + new Instrument('02', [], 0, 'NES', 'nes') + ]; + + expect(filterInstrumentsForChip(instruments, 'ay').map((inst) => inst.id)).toEqual(['01']); + expect(filterInstrumentsForChip(instruments, 'nes').map((inst) => inst.id)).toEqual(['02']); + }); + + it('returns ordered chip types from active songs only', () => { + const chipProcessors = [{ chip: { type: 'ay' } }, { chip: { type: 'nes' } }]; + + expect(getOrderedProjectChipTypes(chipProcessors)).toEqual(['ay', 'nes']); + expect(getOrderedProjectChipTypes([{ chip: { type: 'nes' } }])).toEqual(['nes']); + }); + + it('removes instruments only when no songs remain for that chip type', () => { + const instruments = [ + new Instrument('01', [], 0, 'AY', 'ay'), + new Instrument('02', [], 0, 'NES', 'nes') + ]; + const songsAfterRemovingLastAy = [{ chipType: 'nes' } as import('@/lib/models/song').Song]; + const songsWithRemainingAy = [ + { chipType: 'ay' } as import('@/lib/models/song').Song, + { chipType: 'nes' } as import('@/lib/models/song').Song + ]; + + expect( + filterInstrumentsForActiveChipTypes(songsAfterRemovingLastAy, instruments).map((inst) => inst.id) + ).toEqual(['02']); + expect( + filterInstrumentsForActiveChipTypes(songsWithRemainingAy, instruments).map((inst) => inst.id) + ).toEqual(['01', '02']); + }); +}); diff --git a/tests/lib/services/pattern/editing/pattern-note-input.test.ts b/tests/lib/services/pattern/editing/pattern-note-input.test.ts index 6be6058f..aacbf88f 100644 --- a/tests/lib/services/pattern/editing/pattern-note-input.test.ts +++ b/tests/lib/services/pattern/editing/pattern-note-input.test.ts @@ -1,19 +1,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PatternNoteInput } from '../../../../../src/lib/services/pattern/editing/pattern-note-input'; -import { Pattern, Note, NoteName } from '../../../../../src/lib/models/song'; +import { Pattern, Note, NoteName, Instrument } from '../../../../../src/lib/models/song'; +import { AY_CHIP_SCHEMA } from '../../../../../src/lib/chips/ay/schema'; import type { EditingContext, FieldInfo } from '../../../../../src/lib/services/pattern/editing/editing-context'; import { PatternValueUpdates } from '../../../../../src/lib/services/pattern/editing/pattern-value-updates'; import { parseNoteFromString, formatNoteFromEnum } from '../../../../../src/lib/utils/note-utils'; +import { editorStateStore } from '../../../../../src/lib/stores/editor-state.svelte'; +import { settingsStore } from '../../../../../src/lib/stores/settings.svelte'; vi.mock('../../../../../src/lib/stores/editor-state.svelte', () => ({ editorStateStore: { octave: 3, step: 1, envelopeAsNote: false, - currentInstrument: '01' + currentInstrument: '01', + getCurrentInstrument: vi.fn(() => '01') + } +})); + +vi.mock('../../../../../src/lib/stores/settings.svelte', () => ({ + settingsStore: { + autoEnterInstrument: false } })); @@ -86,6 +96,8 @@ describe('PatternNoteInput', () => { beforeEach(() => { vi.clearAllMocks(); + settingsStore.autoEnterInstrument = false; + vi.mocked(editorStateStore.getCurrentInstrument).mockReturnValue('01'); mockUpdateFieldValue = vi.fn( (context: EditingContext, fieldInfo: FieldInfo, value: string | number) => { @@ -184,7 +196,8 @@ describe('PatternNoteInput', () => { const createMockContext = ( pattern: Pattern, - selectedRow: number = DEFAULT_ROW_INDEX + selectedRow: number = DEFAULT_ROW_INDEX, + overrides: Partial = {} ): EditingContext => { return { pattern, @@ -193,7 +206,8 @@ describe('PatternNoteInput', () => { cellPositions: [], converter: {} as EditingContext['converter'], formatter: {} as EditingContext['formatter'], - schema: {} as EditingContext['schema'] + schema: {} as EditingContext['schema'], + ...overrides }; }; @@ -340,6 +354,43 @@ describe('PatternNoteInput', () => { expect(result?.shouldMoveNext).toBe(false); }); }); + + describe('auto-enter instrument', () => { + it('writes the selected instrument when it exists for the active chip', () => { + settingsStore.autoEnterInstrument = true; + const pattern = new Pattern(DEFAULT_PATTERN_ID, DEFAULT_PATTERN_LENGTH); + const context = createMockContext(pattern, DEFAULT_ROW_INDEX, { + schema: AY_CHIP_SCHEMA, + instruments: [new Instrument('01', [], 0, 'AY 01', 'ay')] + }); + const fieldInfo = createFieldInfo(DEFAULT_CHANNEL_INDEX); + + const result = PatternNoteInput.handleNoteInput(context, fieldInfo, 'q', 'KeyQ'); + + expect(result).not.toBeNull(); + expect(mockUpdateFieldValue).toHaveBeenCalledTimes(2); + expect(mockUpdateFieldValue.mock.calls[1][1]).toEqual( + expect.objectContaining({ fieldKey: 'instrument' }) + ); + expect(mockUpdateFieldValue.mock.calls[1][2]).toBe(1); + }); + + it('skips stale selected instruments that are missing from the active chip list', () => { + settingsStore.autoEnterInstrument = true; + const pattern = new Pattern(DEFAULT_PATTERN_ID, DEFAULT_PATTERN_LENGTH); + const context = createMockContext(pattern, DEFAULT_ROW_INDEX, { + schema: AY_CHIP_SCHEMA, + instruments: [new Instrument('02', [], 0, 'AY 02', 'ay')] + }); + const fieldInfo = createFieldInfo(DEFAULT_CHANNEL_INDEX); + + const result = PatternNoteInput.handleNoteInput(context, fieldInfo, 'q', 'KeyQ'); + + expect(result).not.toBeNull(); + expect(mockUpdateFieldValue).toHaveBeenCalledTimes(1); + expect(mockUpdateFieldValue).toHaveBeenCalledWith(context, fieldInfo, 'C-4'); + }); + }); }); describe('handleMidiNoteInput', () => { diff --git a/tests/lib/services/project/project-service.test.ts b/tests/lib/services/project/project-service.test.ts index 8c1453fd..bd504856 100644 --- a/tests/lib/services/project/project-service.test.ts +++ b/tests/lib/services/project/project-service.test.ts @@ -139,5 +139,36 @@ describe('ProjectService', () => { expect(mockAudioService.addChipProcessor).toHaveBeenCalledOnce(); expect(mockAudioService.addChipProcessor).toHaveBeenCalledWith(mockChip); }); + + it('should inherit initialSpeed from an existing song', async () => { + const mockChip = createMockChip(); + const existing = new Song(mockChip.schema); + existing.chipType = CHIP_TYPE_AY; + existing.initialSpeed = 12; + + const song = await projectService.createNewSong(mockChip, [existing]); + + expect(song.initialSpeed).toBe(12); + }); + }); + + describe('restoreChipProcessorsForSongs', () => { + it('clears processors and adds one per song using chipType', async () => { + await projectService.restoreChipProcessorsForSongs([ + { chipType: 'ay' }, + { chipType: 'nes' } + ]); + + expect(mockAudioService.clearChipProcessors).toHaveBeenCalledOnce(); + expect(mockAudioService.addChipProcessor).toHaveBeenCalledTimes(2); + }); + + it('falls back to AY when chipType is missing', async () => { + const { AY_CHIP } = await import('../../../../src/lib/chips/ay'); + + await projectService.restoreChipProcessorsForSongs([{}]); + + expect(mockAudioService.addChipProcessor).toHaveBeenCalledWith(AY_CHIP); + }); }); }); diff --git a/tests/lib/stores/waveform.test.ts b/tests/lib/stores/waveform.test.ts new file mode 100644 index 00000000..cfdc17d0 --- /dev/null +++ b/tests/lib/stores/waveform.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { waveformStore } from '@/lib/stores/waveform.svelte'; + +describe('WaveformStore', () => { + it('keeps oscilloscope slot alignment when only a later chip sends preview data', () => { + waveformStore.clear(); + waveformStore.prepareLayout([3, 5]); + waveformStore.setChannels( + 1, + Array.from({ length: 5 }, () => new Float32Array(512)) + ); + + expect(waveformStore.channels).toHaveLength(8); + }); + + it('collapses channel indices without a prepared layout', () => { + waveformStore.clear(); + waveformStore.setChannels( + 1, + Array.from({ length: 5 }, () => new Float32Array(512)) + ); + + expect(waveformStore.channels).toHaveLength(5); + }); + + it('returns zero-filled channels for chips without data after prepareLayout', () => { + waveformStore.clear(); + waveformStore.prepareLayout([3, 2]); + + expect(waveformStore.channels).toHaveLength(5); + expect(waveformStore.channels.every((channel) => channel[0] === 0)).toBe(true); + }); +}); diff --git a/tests/psg/playback-regression.test.ts b/tests/psg/playback-regression.test.ts index a05db5d8..8532ea0e 100644 --- a/tests/psg/playback-regression.test.ts +++ b/tests/psg/playback-regression.test.ts @@ -5,11 +5,11 @@ import fs from 'fs'; import { gunzipSync } from 'zlib'; import { generatePSGBuffer } from '@/lib/services/file/psg-export'; import { FileImportService } from '@/lib/services/file/file-import'; -import AyumiState from '../../public/ayumi-state.js'; -import AYAudioDriver from '../../public/ay-audio-driver.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; -import TrackerPatternProcessor from '../../public/tracker-pattern-processor.js'; -import VirtualChannelMixer from '../../public/virtual-channel-mixer.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; +import AYAudioDriver from '../../public/ay/ay-audio-driver.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; +import TrackerPatternProcessor from '../../public/tracker/tracker-pattern-processor.js'; +import VirtualChannelMixer from '../../public/ay/virtual-channel-mixer.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/tests/public/ay-audio-driver-auto-envelope.test.ts b/tests/public/ay-audio-driver-auto-envelope.test.ts index 0c091a5f..05c6f67b 100644 --- a/tests/public/ay-audio-driver-auto-envelope.test.ts +++ b/tests/public/ay-audio-driver-auto-envelope.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import AYAudioDriver from '../../public/ay-audio-driver.js'; -import AyumiState from '../../public/ayumi-state.js'; +import AYAudioDriver from '../../public/ay/ay-audio-driver.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; describe('AYAudioDriver - Auto Envelope (EA)', () => { let driver: InstanceType; @@ -23,7 +23,14 @@ describe('AYAudioDriver - Auto Envelope (EA)', () => { beforeEach(() => { driver = new AYAudioDriver(); state = new AyumiState(); - state.setTuningTable([3328, 3136, 2960, 2794, 2637, 2489, 2349, 2217, 2093, 1975, 1864, 1760, 1664, 1568, 1480, 1397, 1319, 1245, 1175, 1109, 1047, 988, 932, 880, 832, 784, 740, 699, 659, 622, 587, 554, 523, 494, 466, 440, 416, 392, 370, 349, 330, 311, 294, 277, 262, 247, 233, 220, 208, 196, 185, 175, 165, 156, 147, 139, 131, 124, 117, 110, 104, 98, 93, 87, 82, 78, 74, 69, 66, 62, 59, 55, 52, 49, 46, 44, 41, 39, 37, 35, 33, 31, 29, 28, 26, 25, 23, 22, 21, 20, 18, 17, 16, 15, 14, 13]); + state.setTuningTable([ + 3328, 3136, 2960, 2794, 2637, 2489, 2349, 2217, 2093, 1975, 1864, 1760, 1664, 1568, + 1480, 1397, 1319, 1245, 1175, 1109, 1047, 988, 932, 880, 832, 784, 740, 699, 659, 622, + 587, 554, 523, 494, 466, 440, 416, 392, 370, 349, 330, 311, 294, 277, 262, 247, 233, + 220, 208, 196, 185, 175, 165, 156, 147, 139, 131, 124, 117, 110, 104, 98, 93, 87, 82, + 78, 74, 69, 66, 62, 59, 55, 52, 49, 46, 44, 41, 39, 37, 35, 33, 31, 29, 28, 26, 25, 23, + 22, 21, 20, 18, 17, 16, 15, 14, 13 + ]); }); describe('getAutoEnvelopeDivisor', () => { @@ -389,7 +396,23 @@ describe('AYAudioDriver - Auto Envelope (EA)', () => { state.setInstruments([ { id: '01', - rows: [{ tone: true, volume: 15, noise: false, envelope: true, toneAdd: 0, noiseAdd: 0, envelopeAdd: 0, toneAccumulation: false, noiseAccumulation: false, envelopeAccumulation: false, amplitudeSliding: false, amplitudeSlideUp: false, retriggerEnvelope: false }], + rows: [ + { + tone: true, + volume: 15, + noise: false, + envelope: true, + toneAdd: 0, + noiseAdd: 0, + envelopeAdd: 0, + toneAccumulation: false, + noiseAccumulation: false, + envelopeAccumulation: false, + amplitudeSliding: false, + amplitudeSlideUp: false, + retriggerEnvelope: false + } + ], loop: 0 } ]); diff --git a/tests/public/ay-audio-driver.test.ts b/tests/public/ay-audio-driver.test.ts index ff8d6e9e..087ef870 100644 --- a/tests/public/ay-audio-driver.test.ts +++ b/tests/public/ay-audio-driver.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import AYAudioDriver from '../../public/ay-audio-driver.js'; -import AyumiState from '../../public/ayumi-state.js'; -import EffectAlgorithms from '../../public/effect-algorithms.js'; +import AYAudioDriver from '../../public/ay/ay-audio-driver.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; +import EffectAlgorithms from '../../public/tracker/effect-algorithms.js'; describe('AYAudioDriver', () => { describe('constructor', () => { diff --git a/tests/public/ay-chip-register-state.test.ts b/tests/public/ay-chip-register-state.test.ts index 1426d5cd..7d6d93cc 100644 --- a/tests/public/ay-chip-register-state.test.ts +++ b/tests/public/ay-chip-register-state.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; describe('AYChipRegisterState', () => { describe('constructor', () => { diff --git a/tests/public/ay-instrument-utils-parity.test.ts b/tests/public/ay-instrument-utils-parity.test.ts index 9a53e8b6..bd1bde3f 100644 --- a/tests/public/ay-instrument-utils-parity.test.ts +++ b/tests/public/ay-instrument-utils-parity.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import * as ts from '@/lib/chips/ay/instrument'; -import * as jsModule from '../../public/ay-instrument-utils.js'; +import * as jsModule from '../../public/ay/ay-instrument-utils.js'; const js = jsModule as unknown as Record unknown> & Record; diff --git a/tests/public/ay-sample-playback.test.js b/tests/public/ay-sample-playback.test.js index 5bfcd7f4..30fd272e 100644 --- a/tests/public/ay-sample-playback.test.js +++ b/tests/public/ay-sample-playback.test.js @@ -7,7 +7,7 @@ import { resolveSamplePlaybackBounds, resetChannelSamplePlayback, resolveSamplePitchReferencePeriod -} from '../../public/ay-sample-playback.js'; +} from '../../public/ay/ay-sample-playback.js'; const DEFAULT_CLOCK_HZ = 1_773_400; const REFERENCE_PERIOD = resolveSamplePitchReferencePeriod(DEFAULT_CLOCK_HZ); diff --git a/tests/public/ayumi-constants.test.ts b/tests/public/ayumi-constants.test.ts index 72c3fcd7..58190621 100644 --- a/tests/public/ayumi-constants.test.ts +++ b/tests/public/ayumi-constants.test.ts @@ -8,14 +8,14 @@ import { DEFAULT_CHANNEL_VOLUMES, DEFAULT_AYM_FREQUENCY, getPanSettingsForLayout -} from '../../public/ayumi-constants.js'; +} from '../../public/ay/ayumi-constants.js'; 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/ayumi.wasm'); + 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: () => {} } diff --git a/tests/public/ayumi-engine.test.ts b/tests/public/ayumi-engine.test.ts index ca95ba51..07808cdf 100644 --- a/tests/public/ayumi-engine.test.ts +++ b/tests/public/ayumi-engine.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import AyumiEngine from '../../public/ayumi-engine.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; +import AyumiEngine from '../../public/ay/ayumi-engine.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; import { TIMER_EFFECT_KIND_VOLUME, TIMER_EFFECT_KIND_ENVELOPE_SHAPE, @@ -15,7 +15,7 @@ import { createVolumeTimerEffect, createEnvelopeShapeTimerEffect, createEnvelopePeriodTimerEffect -} from '../../public/ay-timer-effect-constants.js'; +} from '../../public/ay/ay-timer-effect-constants.js'; describe('AyumiEngine', () => { let mockWasm: { diff --git a/tests/public/ayumi-state.test.ts b/tests/public/ayumi-state.test.ts index da56c73e..ed5e96dc 100644 --- a/tests/public/ayumi-state.test.ts +++ b/tests/public/ayumi-state.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import AyumiState from '../../public/ayumi-state.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; describe('AyumiState', () => { describe('constructor', () => { diff --git a/tests/public/effect-algorithms.test.ts b/tests/public/effect-algorithms.test.ts index e19993fd..a915deb4 100644 --- a/tests/public/effect-algorithms.test.ts +++ b/tests/public/effect-algorithms.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import EffectAlgorithms from '../../public/effect-algorithms.js'; +import EffectAlgorithms from '../../public/tracker/effect-algorithms.js'; describe('EffectAlgorithms', () => { describe('initSlide', () => { diff --git a/tests/public/effect-interactions.test.ts b/tests/public/effect-interactions.test.ts index d0df70d7..6744745d 100644 --- a/tests/public/effect-interactions.test.ts +++ b/tests/public/effect-interactions.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import TrackerPatternProcessor from '../../public/tracker-pattern-processor.js'; -import AYAudioDriver from '../../public/ay-audio-driver.js'; -import AyumiState from '../../public/ayumi-state.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; -import EffectAlgorithms from '../../public/effect-algorithms.js'; +import TrackerPatternProcessor from '../../public/tracker/tracker-pattern-processor.js'; +import AYAudioDriver from '../../public/ay/ay-audio-driver.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; +import EffectAlgorithms from '../../public/tracker/effect-algorithms.js'; function createState() { const state = new AyumiState(); diff --git a/tests/public/export-engine-sync.test.ts b/tests/public/export-engine-sync.test.ts index 5e45ecae..b954b178 100644 --- a/tests/public/export-engine-sync.test.ts +++ b/tests/public/export-engine-sync.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import AyumiEngine from '../../public/ayumi-engine.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; +import AyumiEngine from '../../public/ay/ayumi-engine.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; describe('export engine register sync', () => { let mockWasm: { diff --git a/tests/public/nes-apu-engine.test.js b/tests/public/nes-apu-engine.test.js new file mode 100644 index 00000000..717820b7 --- /dev/null +++ b/tests/public/nes-apu-engine.test.js @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createNesApuEngine } from '../../public/nes/nes-apu-engine.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; + +async function loadWasm() { + const wasmPath = path.join(process.cwd(), 'public/nes/nes_apu.wasm'); + const wasmBuffer = readFileSync(wasmPath); + const result = await WebAssembly.instantiate(wasmBuffer, { + env: { emscripten_notify_memory_growth: () => {} } + }); + return result.instance.exports; +} + +function renderSquarePeak(engine, sampleRate = 44100) { + let peak = 0; + for (let i = 0; i < 400; i++) { + const { left } = engine.process(sampleRate); + peak = Math.max(peak, Math.abs(left)); + } + return peak; +} + +describe('NesApuEngine', () => { + it('stays silent when no channels are active', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + engine.applyRegisterState(registerState); + + expect(renderSquarePeak(engine)).toBeLessThan(0.001); + }); + + it('plays square waves after channel enable and register writes', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + registerState.channels[0].enabled = true; + registerState.channels[0].period = 428; + registerState.channels[0].volume = 15; + registerState.channels[0].duty = 2; + registerState.channels[0].retrigger = true; + + engine.applyRegisterState(registerState); + + expect(renderSquarePeak(engine)).toBeGreaterThan(0.01); + }); + + it('writes sweep disable for pulse channels by default', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + registerState.channels[0].enabled = true; + registerState.channels[0].period = 428; + registerState.channels[0].volume = 15; + registerState.channels[0].duty = 2; + registerState.channels[0].retrigger = true; + + engine.applyRegisterState(registerState); + + expect(engine.lastState.channels[0].sweepReg).toBe(0x08); + }); + + it('writes enabled hardware sweep register for pulse channels', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + registerState.channels[0].enabled = true; + registerState.channels[0].period = 428; + registerState.channels[0].volume = 15; + registerState.channels[0].duty = 2; + registerState.channels[0].sweepReg = 0x84; + registerState.channels[0].retrigger = true; + + engine.applyRegisterState(registerState); + + expect(engine.lastState.channels[0].sweepReg).toBe(0x84); + }); + + it('triggers pulse channel when re-enabled without an explicit retrigger flag', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + registerState.channels[0].enabled = true; + registerState.channels[0].period = 428; + registerState.channels[0].volume = 15; + registerState.channels[0].duty = 2; + registerState.channels[0].retrigger = false; + engine.applyRegisterState(registerState); + + registerState.channels[0].enabled = false; + registerState.channels[0].volume = 0; + engine.applyRegisterState(registerState); + + registerState.channels[0].enabled = true; + registerState.channels[0].volume = 15; + registerState.channels[0].retrigger = false; + engine.applyRegisterState(registerState); + + expect(renderSquarePeak(engine)).toBeGreaterThan(0.01); + }); +}); diff --git a/tests/public/nes-audio-driver-envelope.test.js b/tests/public/nes-audio-driver-envelope.test.js new file mode 100644 index 00000000..377bca13 --- /dev/null +++ b/tests/public/nes-audio-driver-envelope.test.js @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; +import { + buildLengthCounterNibble, + buildSquareEnvelopeVolumeReg, + buildTriangleLinearReg, + NES_REGISTER_UNCHANGED +} from '../../public/nes/nes-instrument-utils.js'; + +function createEnvelopeState(rowOverrides = {}) { + return { + channelMuted: [false, false, false, false, false], + channelSoundEnabled: [true, true, true, true, false], + channelInstruments: [0, 0, 0, 0, -1], + instruments: [ + { + rows: [ + { + pulseWidth: 2, + retrigger: false, + soundLength: 40, + envelope: true, + volumeOrRate: 6, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0, + ...rowOverrides + } + ], + loop: 0 + } + ], + instrumentPositions: [0, 0, 0, 0, 0], + channelPatternVolumes: [15, 15, 15, 15, 15], + channelCurrentNotes: [60, 60, 60, 60, 0], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0, 0, 0, 0, 0], + channelVibratoSliding: [0, 0, 0, 0, 0], + channelDetune: [0, 0, 0, 0, 0], + channelKeyOn: [false, false, false, false, false], + channelToneAccumulator: [0, 0, 0, 0, 0], + channelOnOffCounter: [0, 0, 0, 0, 0], + channelOnDuration: [0, 0, 0, 0, 0], + channelOffDuration: [0, 0, 0, 0, 0] + }; +} + +describe('NesAudioDriver envelope and length macro', () => { + it('writes envelope and length counter settings for pulse channels', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createEnvelopeState(); + + driver.processInstruments(state, registerState); + + expect(registerState.channels[0].volumeReg).toBe( + buildSquareEnvelopeVolumeReg(2, true, 6, 40) + ); + expect(registerState.channels[0].lengthNibble).toBe(buildLengthCounterNibble(40)); + }); + + it('uses triangle linear counter for short sound lengths', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createEnvelopeState({ soundLength: 64, envelope: false }); + + driver.processInstruments(state, registerState); + + expect(registerState.channels[2].linearReg).toBe(buildTriangleLinearReg(64)); + expect(registerState.channels[2].lengthNibble).toBe(NES_REGISTER_UNCHANGED); + }); + + it('writes constant volume register when envelope is off', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createEnvelopeState({ envelope: false, volumeOrRate: 10, soundLength: 40 }); + + driver.processInstruments(state, registerState); + + expect(registerState.channels[0].volumeReg).toBe( + buildSquareEnvelopeVolumeReg(2, false, 10, 40) + ); + }); +}); diff --git a/tests/public/nes-audio-driver-noise.test.js b/tests/public/nes-audio-driver-noise.test.js new file mode 100644 index 00000000..59e7adb7 --- /dev/null +++ b/tests/public/nes-audio-driver-noise.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver, { + resolveNesNoisePeriodFromSemitoneOffset +} from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; + +describe('resolveNesNoisePeriodFromSemitoneOffset', () => { + it('maps C-1 to period 15 and each semitone decrements by 1 through 0', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(0)).toBe(15); + expect(resolveNesNoisePeriodFromSemitoneOffset(1)).toBe(14); + expect(resolveNesNoisePeriodFromSemitoneOffset(14)).toBe(1); + expect(resolveNesNoisePeriodFromSemitoneOffset(15)).toBe(0); + }); + + it('loops back to 15 after period 0', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(16)).toBe(15); + expect(resolveNesNoisePeriodFromSemitoneOffset(17)).toBe(14); + expect(resolveNesNoisePeriodFromSemitoneOffset(32)).toBe(15); + }); + + it('wraps negative offsets', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(-1)).toBe(0); + expect(resolveNesNoisePeriodFromSemitoneOffset(-16)).toBe(15); + }); +}); + +describe('NesAudioDriver noise period', () => { + it('derives noise period from channel note index', () => { + const driver = new NesAudioDriver(); + const state = { + channelCurrentNotes: [0, 0, 0, 5, 0], + channelToneSliding: [0, 0, 0, 0, 0], + channelVibratoSliding: [0, 0, 0, 0, 0], + channelDetune: [0, 0, 0, 0, 0] + }; + + expect(driver.resolveNoisePeriod(state, 3)).toBe(10); + }); + + it('writes mapped noise period to register state', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = { + channelMuted: [false, false, false, false, false], + channelSoundEnabled: [false, false, false, true, false], + channelInstruments: [-1, -1, -1, 0, -1], + instruments: [ + { + rows: [ + { + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: false, + volumeOrRate: 15, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + } + ], + loop: 0 + } + ], + instrumentPositions: [0, 0, 0, 0, 0], + channelPatternVolumes: [15, 15, 15, 15, 15], + channelCurrentNotes: [0, 0, 0, 0, 0], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0, 0, 0, 0, 0], + channelVibratoSliding: [0, 0, 0, 0, 0], + channelDetune: [0, 0, 0, 0, 0], + channelKeyOn: [false, false, false, false, false], + channelToneAccumulator: [0, 0, 0, 0, 0], + channelOnOffCounter: [0, 0, 0, 0, 0], + channelOnDuration: [0, 0, 0, 0, 0], + channelOffDuration: [0, 0, 0, 0, 0] + }; + + driver.processInstruments(state, registerState); + + expect(registerState.channels[3].noisePeriod).toBe(15); + }); +}); diff --git a/tests/public/nes-audio-driver-onoff.test.js b/tests/public/nes-audio-driver-onoff.test.js new file mode 100644 index 00000000..c98ba747 --- /dev/null +++ b/tests/public/nes-audio-driver-onoff.test.js @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; +import EffectAlgorithms from '../../public/tracker/effect-algorithms.js'; + +describe('NesAudioDriver on/off effect', () => { + it('silences channel when on/off counter is in off phase', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = { + channelMuted: [false], + channelSoundEnabled: [false], + channelInstruments: [0], + instruments: [{ rows: [{ pulseWidth: 2, retrigger: false }], loop: 0 }], + instrumentPositions: [0], + channelPatternVolumes: [15], + channelCurrentNotes: [60], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0], + channelVibratoSliding: [0], + channelDetune: [0], + channelKeyOn: [false], + channelToneAccumulator: [0], + channelOnOffCounter: [2], + channelOnDuration: [3], + channelOffDuration: [2] + }; + + driver.processInstruments(state, registerState); + + expect(registerState.channels[0].enabled).toBe(false); + expect(registerState.channels[0].volume).toBe(0); + }); + + it('processes on/off counter each instrument tick', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const onOff = EffectAlgorithms.initOnOff(0x32); + const state = { + channelMuted: [false], + channelSoundEnabled: [true], + channelInstruments: [0], + instruments: [{ rows: [{ pulseWidth: 2, retrigger: false }], loop: 0 }], + instrumentPositions: [0], + channelPatternVolumes: [15], + channelCurrentNotes: [60], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0], + channelVibratoSliding: [0], + channelDetune: [0], + channelKeyOn: [false], + channelToneAccumulator: [0], + channelOnOffCounter: [onOff.counter], + channelOnDuration: [onOff.onDuration], + channelOffDuration: [onOff.offDuration] + }; + + driver.processInstruments(state, registerState); + expect(state.channelOnOffCounter[0]).toBe(onOff.counter - 1); + expect(state.channelSoundEnabled[0]).toBe(true); + expect(registerState.channels[0].enabled).toBe(true); + }); +}); diff --git a/tests/public/nes-audio-driver-sweep.test.js b/tests/public/nes-audio-driver-sweep.test.js new file mode 100644 index 00000000..0809aca1 --- /dev/null +++ b/tests/public/nes-audio-driver-sweep.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; +import { buildSquareSweepReg } from '../../public/nes/nes-instrument-utils.js'; + +function createLoopingSweepState() { + return { + channelMuted: [false], + channelSoundEnabled: [true], + channelInstruments: [0], + instruments: [ + { + rows: [ + { + pulseWidth: 2, + retrigger: false, + sweep: true, + sweepRate: 4, + sweepShift: -1 + }, + { + pulseWidth: 2, + retrigger: false, + sweep: true, + sweepRate: 4, + sweepShift: 2 + } + ], + loop: 0 + } + ], + instrumentPositions: [0], + channelPatternVolumes: [15], + channelCurrentNotes: [60], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0], + channelVibratoSliding: [0], + channelDetune: [0], + channelKeyOn: [false], + channelToneAccumulator: [0], + channelOnOffCounter: [0], + channelOnDuration: [0], + channelOffDuration: [0] + }; +} + +describe('NesAudioDriver hardware sweep macro', () => { + it('cycles sweep register when macro rows loop', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createLoopingSweepState(); + const row0Sweep = buildSquareSweepReg(true, 4, -1); + const row1Sweep = buildSquareSweepReg(true, 4, 2); + + driver.processInstruments(state, registerState); + expect(registerState.channels[0].sweepReg).toBe(row0Sweep); + expect(state.instrumentPositions[0]).toBe(1); + + driver.processInstruments(state, registerState); + expect(registerState.channels[0].sweepReg).toBe(row1Sweep); + expect(state.instrumentPositions[0]).toBe(0); + + driver.processInstruments(state, registerState); + expect(registerState.channels[0].sweepReg).toBe(row0Sweep); + expect(state.instrumentPositions[0]).toBe(1); + }); +}); diff --git a/tests/public/nes-waveform-capture.test.js b/tests/public/nes-waveform-capture.test.js new file mode 100644 index 00000000..af58e2ec --- /dev/null +++ b/tests/public/nes-waveform-capture.test.js @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeNesChannelWaveformSample, + NesWaveformCapture +} from '../../public/nes/nes-waveform-capture.js'; + +describe('NesWaveformCapture', () => { + it('normalizes emulator channel output around zero', () => { + expect(normalizeNesChannelWaveformSample(0, 0)).toBe(-0.5); + expect(normalizeNesChannelWaveformSample(0, 15)).toBe(0.5); + expect(normalizeNesChannelWaveformSample(0, 7)).toBeCloseTo(-0.033333, 5); + }); + + it('prefers emulator channel outputs when available', () => { + const capture = new NesWaveformCapture(5); + const apuEngine = { + canReadChannelOutputs: () => true, + getChannelRawOut: (channelIndex) => (channelIndex === 0 ? 15 : 0) + }; + const outputs = capture.readChannelOutputs(apuEngine); + expect(outputs[0]).toBe(0.5); + expect(outputs[1]).toBe(-0.5); + }); +}); diff --git a/tests/public/playback-pipeline.test.ts b/tests/public/playback-pipeline.test.ts index 8a6c218e..b28e9d9e 100644 --- a/tests/public/playback-pipeline.test.ts +++ b/tests/public/playback-pipeline.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; -import AyumiState from '../../public/ayumi-state.js'; -import AYAudioDriver from '../../public/ay-audio-driver.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; -import TrackerPatternProcessor from '../../public/tracker-pattern-processor.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; +import AYAudioDriver from '../../public/ay/ay-audio-driver.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; +import TrackerPatternProcessor from '../../public/tracker/tracker-pattern-processor.js'; function runOneTick( state: AyumiState, diff --git a/tests/public/pt3-volume-table.test.ts b/tests/public/pt3-volume-table.test.ts index a540618c..c7b2cbaf 100644 --- a/tests/public/pt3-volume-table.test.ts +++ b/tests/public/pt3-volume-table.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { PT3VolumeTable } from '../../public/pt3-volume-table.js'; +import { PT3VolumeTable } from '../../public/tracker/pt3-volume-table.js'; describe('PT3VolumeTable', () => { it('is an array of 16 rows', () => { diff --git a/tests/public/tracker-audio-utils.test.js b/tests/public/tracker-audio-utils.test.js new file mode 100644 index 00000000..87113c75 --- /dev/null +++ b/tests/public/tracker-audio-utils.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + advanceInstrumentRowPosition, + calculatePt3Volume, + getEffectiveTuningPeriod +} from '../../public/tracker/tracker-audio-utils.js'; + +describe('tracker-audio-utils', () => { + it('advances instrument rows with loop wrap', () => { + expect(advanceInstrumentRowPosition(0, 4, 2)).toBe(1); + expect(advanceInstrumentRowPosition(3, 4, 2)).toBe(2); + expect(advanceInstrumentRowPosition(3, 4, 0)).toBe(0); + }); + + it('calculates PT3 volume from pattern and instrument columns', () => { + expect(calculatePt3Volume(15, 15)).toBe(15); + expect(calculatePt3Volume(0, 15)).toBe(0); + }); + + it('resolves effective tuning period with slide offsets', () => { + const state = { + channelCurrentNotes: [1, -1], + currentTuningTable: [1000, 900], + channelToneSliding: [10, 0], + channelVibratoSliding: [5, 0], + channelDetune: [-2, 0] + }; + expect(getEffectiveTuningPeriod(state, 0, 2047)).toBe(913); + expect(getEffectiveTuningPeriod(state, 1, 2047)).toBe(0); + }); +}); diff --git a/tests/public/tracker-instrument-channel.test.js b/tests/public/tracker-instrument-channel.test.js new file mode 100644 index 00000000..c96fa181 --- /dev/null +++ b/tests/public/tracker-instrument-channel.test.js @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + assignPatternRowInstrument, + channelHasAssignedInstrument, + getChannelInstrument, + isChannelOnOffHalted, + processChannelOnOffCounters +} from '../../public/tracker/tracker-instrument-channel.js'; + +describe('tracker-instrument-channel', () => { + const state = { + channelInstruments: [-1, 1], + instrumentPositions: [0, 0], + instruments: [{ id: 1 }, { id: 2 }], + instrumentIdToIndex: new Map([ + [1, 0], + [2, 1] + ]), + channelMuted: [false, false] + }; + + it('assigns instrument from pattern row', () => { + const result = assignPatternRowInstrument(state, 0, { instrument: 1 }); + expect(result.assigned).toBe(true); + expect(result.instrumentIndex).toBe(0); + expect(state.channelInstruments[0]).toBe(0); + }); + + it('clears instrument when pattern row instrument is missing', () => { + assignPatternRowInstrument(state, 0, { instrument: 99 }); + expect(state.channelInstruments[0]).toBe(-1); + expect(channelHasAssignedInstrument(state, 0)).toBe(false); + }); + + it('reports assigned instrument for channel', () => { + expect(getChannelInstrument(state, 1)).toEqual({ + instrumentIndex: 1, + instrument: { id: 2 } + }); + expect(channelHasAssignedInstrument(state, 1)).toBe(true); + }); + + it('processChannelOnOffCounters toggles channel sound during on/off effect', () => { + const onOffState = { + channelOnOffCounter: [3], + channelOnDuration: [3], + channelOffDuration: [2], + channelSoundEnabled: [true] + }; + + processChannelOnOffCounters(onOffState, 1); + expect(onOffState.channelOnOffCounter[0]).toBe(2); + expect(onOffState.channelSoundEnabled[0]).toBe(true); + + processChannelOnOffCounters(onOffState, 1); + expect(onOffState.channelOnOffCounter[0]).toBe(1); + expect(onOffState.channelSoundEnabled[0]).toBe(true); + + processChannelOnOffCounters(onOffState, 1); + expect(onOffState.channelOnOffCounter[0]).toBe(2); + expect(onOffState.channelSoundEnabled[0]).toBe(false); + expect(isChannelOnOffHalted(onOffState, 0)).toBe(true); + }); +}); diff --git a/tests/public/tracker-pattern-processor.test.ts b/tests/public/tracker-pattern-processor.test.ts index 6b2b83bd..37c82a56 100644 --- a/tests/public/tracker-pattern-processor.test.ts +++ b/tests/public/tracker-pattern-processor.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import TrackerPatternProcessor from '../../public/tracker-pattern-processor.js'; -import AyumiState from '../../public/ayumi-state.js'; -import AYAudioDriver from '../../public/ay-audio-driver.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; +import TrackerPatternProcessor from '../../public/tracker/tracker-pattern-processor.js'; +import AyumiState from '../../public/ay/ayumi-state.js'; +import AYAudioDriver from '../../public/ay/ay-audio-driver.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; function createMockState() { const state = new AyumiState(); diff --git a/tests/public/tracker-state.test.ts b/tests/public/tracker-state.test.ts index b618ed46..2b82f066 100644 --- a/tests/public/tracker-state.test.ts +++ b/tests/public/tracker-state.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import TrackerState from '../../public/tracker-state.js'; +import TrackerState from '../../public/tracker/tracker-state.js'; describe('TrackerState', () => { describe('constructor', () => { diff --git a/tests/public/virtual-channel-mixer.test.ts b/tests/public/virtual-channel-mixer.test.ts index cacebcbc..2c3f6b6c 100644 --- a/tests/public/virtual-channel-mixer.test.ts +++ b/tests/public/virtual-channel-mixer.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import VirtualChannelMixer from '../../public/virtual-channel-mixer.js'; -import AYChipRegisterState from '../../public/ay-chip-register-state.js'; +import VirtualChannelMixer from '../../public/ay/virtual-channel-mixer.js'; +import AYChipRegisterState from '../../public/ay/ay-chip-register-state.js'; function createState(channelCount: number) { return {
row looploop offsetnote key offset
-
- - {#if index < rows.length - 1} - - {/if} -
-
setLoop(index)}> - handleOffsetKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => onOffsetInput(index, e)} />
-
- -
+ editorSync.addRow(() => 0)} />
-
- -
+ editorSync.addRow(() => 0)} />
+ editorSync.setRowCount(count, () => 0, ROW_EDITOR_MAX_ROWS)} rowHeightPx={32} - maxRows={MAX_ROWS} /> + maxRows={ROW_EDITOR_MAX_ROWS} />