From 5504704d4b0d35ce0d83ecf8d96de92d497a4a78 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Wed, 17 Jun 2026 10:30:54 +0200 Subject: [PATCH 1/5] Nes init --- public/builtin-audio-slots.js | 7 + public/nes-stub-slot.js | 252 ++++++++++++++++++ src/App.svelte | 5 +- src/lib/chips/ay/core.ts | 6 +- src/lib/chips/ay/playback-debug.ts | 47 ++++ src/lib/chips/ay/renderer.ts | 3 +- src/lib/chips/base/playback-debug.ts | 37 +++ src/lib/chips/chip-registration.ts | 2 +- src/lib/chips/nes/adapter.ts | 109 ++++++++ src/lib/chips/nes/audio-slot-kind.ts | 1 + src/lib/chips/nes/core.ts | 25 ++ src/lib/chips/nes/formatter.ts | 3 + src/lib/chips/nes/index.ts | 6 + src/lib/chips/nes/playback-debug.ts | 17 ++ src/lib/chips/nes/processor.ts | 144 ++++++++++ src/lib/chips/nes/renderer.ts | 13 + src/lib/chips/nes/schema.ts | 74 +++++ src/lib/chips/registry.ts | 4 +- src/lib/chips/types.ts | 5 + .../Instruments/InstrumentsView.svelte | 138 +++++----- src/lib/components/Song/PatternEditor.svelte | 3 +- .../components/Song/PlaybackToneDebug.svelte | 229 ++++++++-------- src/lib/components/Song/SongView.svelte | 10 +- src/lib/config/app-menu.ts | 5 +- src/lib/config/settings.ts | 2 +- src/lib/models/song.ts | 10 +- src/lib/services/app/menu-action-handler.ts | 66 ++--- src/lib/services/audio/audio-service.ts | 4 +- src/lib/services/file/file-import.ts | 8 +- src/lib/services/file/psg-export.ts | 3 +- src/lib/services/history/history-clone.ts | 3 +- .../services/instrument/instrument-filter.ts | 12 + src/lib/services/project/project-service.ts | 9 + .../ui-rendering/pattern-editor-renderer.ts | 11 +- .../instrument/instrument-filter.test.ts | 23 ++ .../services/project/project-service.test.ts | 20 ++ 36 files changed, 1080 insertions(+), 236 deletions(-) create mode 100644 public/nes-stub-slot.js create mode 100644 src/lib/chips/ay/playback-debug.ts create mode 100644 src/lib/chips/base/playback-debug.ts create mode 100644 src/lib/chips/nes/adapter.ts create mode 100644 src/lib/chips/nes/audio-slot-kind.ts create mode 100644 src/lib/chips/nes/core.ts create mode 100644 src/lib/chips/nes/formatter.ts create mode 100644 src/lib/chips/nes/index.ts create mode 100644 src/lib/chips/nes/playback-debug.ts create mode 100644 src/lib/chips/nes/processor.ts create mode 100644 src/lib/chips/nes/renderer.ts create mode 100644 src/lib/chips/nes/schema.ts create mode 100644 src/lib/services/instrument/instrument-filter.ts create mode 100644 tests/lib/services/instrument/instrument-filter.test.ts diff --git a/public/builtin-audio-slots.js b/public/builtin-audio-slots.js index 8fd3cba5..9627096e 100644 --- a/public/builtin-audio-slots.js +++ b/public/builtin-audio-slots.js @@ -1,8 +1,15 @@ import { registerAudioSlotKind } from './audio-slot-registry.js'; import { AyumiSlot } from './ayumi-slot.js'; +import { NesStubSlot } from './nes-stub-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 NesStubSlot(port, chipIndex, sharedTimeline); + void slot.handleMessage({ type: 'init', wasmBuffer: initData.wasmBuffer }); + return slot; +}); diff --git a/public/nes-stub-slot.js b/public/nes-stub-slot.js new file mode 100644 index 00000000..87aee200 --- /dev/null +++ b/public/nes-stub-slot.js @@ -0,0 +1,252 @@ +import TrackerState from './tracker-state.js'; +import TrackerPatternProcessor from './tracker-pattern-processor.js'; +import { WorkletSlotBase } from './worklet-slot-base.js'; + +const NES_CHANNEL_COUNT = 5; + +class NesStubAudioDriver { + processPatternRow() {} + + processInstruments() {} + + resetChannelMixerState() {} + + resizeChannels() {} +} + +class NesState extends TrackerState { + constructor(sharedTimeline) { + super(NES_CHANNEL_COUNT, sharedTimeline); + this.instruments = []; + this.instrumentIdToIndex = new Map(); + this.channelSoundEnabled = Array(NES_CHANNEL_COUNT).fill(false); + this.channelMuted = Array(NES_CHANNEL_COUNT).fill(false); + this.channelInstruments = Array(NES_CHANNEL_COUNT).fill(-1); + } + + 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); + } + }); + } + + resizeChannels(newCount) { + super.resizeChannels(newCount); + this._resizeArray('channelSoundEnabled', newCount, false); + this._resizeArray('channelMuted', newCount, false); + this._resizeArray('channelInstruments', newCount, -1); + } + + _resizeArray(name, newCount, defaultVal) { + const arr = this[name]; + while (arr.length < newCount) arr.push(defaultVal); + if (arr.length > newCount) arr.length = newCount; + } +} + +export class NesStubSlot extends WorkletSlotBase { + constructor(port, chipIndex, sharedTimeline) { + super(port, chipIndex); + this.state = new NesState(sharedTimeline); + this.initialized = false; + this.audioDriver = new NesStubAudioDriver(); + this.patternProcessor = new TrackerPatternProcessor(this.state, this.audioDriver, this.port); + this.registerState = { channelCount: NES_CHANNEL_COUNT }; + } + + _slotState() { + return this.state; + } + + _isReadyForPlayback() { + return this.initialized; + } + + _applyPlaybackSpeed(speed) { + if (!(speed > 0)) return; + this.state.publishPlaybackSpeed(speed); + } + + _resizeForPatternChannels(channelCount) { + if (channelCount <= this.state.channelTables.length) return; + this.state.resizeChannels(channelCount); + this.registerState.channelCount = channelCount; + } + + _prepareOutputForPlay() {} + + _preparePatternWorkersForPlay() {} + + _replayCatchUpSegments(catchUpSegments) { + if (!catchUpSegments?.length) 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.patternProcessor.parsePatternRow( + this.state.currentPattern, + row, + this.registerState + ); + } + } + } + + _runCatchUpRows(upToRow) { + if (!this.state.currentPattern || this.state.currentPattern.length === 0 || upToRow <= 0) { + return; + } + for (let row = 0; row < upToRow; row++) { + this.patternProcessor.parsePatternRow(this.state.currentPattern, row, this.registerState); + } + } + + _onTransportStop() {} + + _afterTransportStop() {} + + async handleMessage(payload) { + if (payload == null || typeof payload !== 'object') return; + const { type, ...data } = payload; + if (type === undefined) return; + + if (type === 'init') { + await this.handleInit(data); + return; + } + + this.dispatchPortMessages(type, data); + } + + async handleInit({ wasmBuffer }) { + if (!wasmBuffer) return; + this.initialized = true; + this.state.updateSamplesPerTick(sampleRate); + } + + 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 'set_channel_mute': + this.handleSetChannelMute(data); + break; + default: + break; + } + } + + handleInitTuningTable({ tuningTable }) { + this.state.setTuningTable(tuningTable); + } + + handleInitSpeed({ speed }) { + if (!(speed > 0)) return; + this.state.publishPlaybackSpeed(speed); + } + + handleInitTables({ tables }) { + this.state.setTables(tables); + } + + handleInitInstruments({ instruments }) { + this.state.setInstruments(instruments); + } + + handleSetChannelMute({ channelIndex, muted }) { + if (channelIndex >= 0 && channelIndex < this.state.channelMuted.length) { + this.state.channelMuted[channelIndex] = muted; + } + } + + canRender() { + return this.initialized; + } + + isPreviewActive() { + return false; + } + + 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.patternProcessor.processTables(); + this.patternProcessor.processArpeggio(); + this.patternProcessor.processEffectTables(); + this.patternProcessor.processVibrato(); + this.patternProcessor.processSlides(); + } + + runPreviewStep() {} + + accumulateStereoOutput(_sampleIndex, _mix) {} + + finishAudioBlock(numSamples) { + this.finishAudioBlockFlushTransport(numSamples, this.paused); + } +} diff --git a/src/App.svelte b/src/App.svelte index c81096c7..e8b1ed71 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); diff --git a/src/lib/chips/ay/core.ts b/src/lib/chips/ay/core.ts index b9a618f1..750a6757 100644 --- a/src/lib/chips/ay/core.ts +++ b/src/lib/chips/ay/core.ts @@ -4,6 +4,8 @@ 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 = { @@ -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/renderer.ts b/src/lib/chips/ay/renderer.ts index 7042edef..d0f2af14 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, @@ -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); 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/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..aabb1018 --- /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_apu.wasm', + audioSlotKind: NES_AUDIO_SLOT_KIND, + processorMap: (chip) => new NESProcessor(chip), + schema: NES_CHIP_SCHEMA, + createConverter: () => new NESConverter(), + createFormatter: () => new NESFormatter(), + createRenderer: () => new NESChipRenderer(), + 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..f026bcbc --- /dev/null +++ b/src/lib/chips/nes/index.ts @@ -0,0 +1,6 @@ +export { NES_CHIP, CHIP } from './core'; +export { NESProcessor } from './processor'; +export { NESConverter } from './adapter'; +export { NESFormatter } from './formatter'; +export { NESChipRenderer } from './renderer'; +export { NES_CHIP_SCHEMA } from './schema'; 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..792c594d --- /dev/null +++ b/src/lib/chips/nes/processor.ts @@ -0,0 +1,144 @@ +import type { Chip } from '../types'; +import type { Pattern, Instrument } from '../../models/song'; +import type { Table } from '../../models/project'; +import type { + MixerWorkletSlotProcessor, + TuningTableSupport, + InstrumentSupport +} from '../base/processor'; +import type { CatchUpSegment } from '../../services/audio/play-from-position'; +import { MixerWorkletBridge } from '../../services/audio/mixer-worklet-bridge'; + +export class NESProcessor + implements MixerWorkletSlotProcessor, TuningTableSupport, InstrumentSupport +{ + chip: Chip; + private readonly bridge: MixerWorkletBridge; + + constructor(chip: Chip) { + this.chip = chip; + this.bridge = new MixerWorkletBridge(chip); + } + + bindChipIndex(index: number): void { + this.bridge.bindChipIndex(index); + } + + 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); + } + + 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) => ({ + id: instrument.id, + rows: Array.from(instrument.rows).map((row) => ({ ...row })), + loop: instrument.loop, + name: instrument.name + })); + 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 + }); + } + + 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 }); + } + } + } + + 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..c4ed9024 --- /dev/null +++ b/src/lib/chips/nes/renderer.ts @@ -0,0 +1,13 @@ +import type { Project } from '../../models/project'; +import type { ChipRenderer, RenderOptions } from '../base/renderer'; + +export class NESChipRenderer implements ChipRenderer { + async render( + _project: Project, + _songIndex: number, + _onProgress?: (progress: number, message: string) => void, + _options?: RenderOptions + ): Promise { + throw new Error('2A03 / 2A07 WAV export is not implemented yet'); + } +} diff --git a/src/lib/chips/nes/schema.ts b/src/lib/chips/nes/schema.ts new file mode 100644 index 00000000..175e54d9 --- /dev/null +++ b/src/lib/chips/nes/schema.ts @@ -0,0 +1,74 @@ +import type { ChipSchema } from '../base/schema'; + +export const NES_NTSC_CPU_FREQUENCY = 1_789_772; + +export const NES_DEFAULT_TUNING_TABLE = Array.from({ length: 88 }, (_, index) => + Math.max(1, 2034 - index * 8) +); + +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' } + ], + defaultValue: 'NTSC', + group: 'chip' + } + ] +}; 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..3ec86896 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/Instruments/InstrumentsView.svelte b/src/lib/components/Instruments/InstrumentsView.svelte index 3798362f..a71241bf 100644 --- a/src/lib/components/Instruments/InstrumentsView.svelte +++ b/src/lib/components/Instruments/InstrumentsView.svelte @@ -30,7 +30,7 @@ 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 } 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'; @@ -54,7 +54,8 @@ chip: Chip; } = $props(); - let instruments = $derived(projectStore.instruments); + let allInstruments = $derived(projectStore.instruments); + const chipInstruments = $derived(filterInstrumentsForChip(allInstruments, chip.type)); const songs = $derived(projectStore.songs); const instrumentListResize = createPersistedResizableListHeight({ @@ -66,7 +67,7 @@ const instrumentGridRows = $derived.by(() => computeGridRows( - instruments?.length ?? 0, + chipInstruments?.length ?? 0, instrumentListResize.listHeight, ITEM_ROW_HEIGHT, ITEM_BUTTON_BAR_HEIGHT @@ -79,9 +80,10 @@ let instrumentListScrollRef: HTMLDivElement | null = $state(null); $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); }); @@ -90,9 +92,14 @@ $effect(() => { const targetId = editorStateStore.currentInstrument; - const idx = instruments.findIndex((inst) => inst.id === targetId); + 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.setCurrentInstrument(chipInstruments[0].id); + }); } if (editorStateStore.selectInstrumentRequest) { editorStateStore.clearSelectInstrumentRequest(); @@ -135,13 +142,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); + const filtered = filterInstrumentsForChip(projectStore.instruments, chip.type); + const newIndex = filtered.findIndex((inst) => inst.id === selectedId); if (newIndex >= 0) selectedInstrumentIndex = newIndex; } } @@ -201,10 +209,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,12 +222,12 @@ 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}`); + 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); projectStore.recordHistory( @@ -239,12 +247,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( { @@ -259,9 +267,9 @@ async function copyInstrument(copiedIndex: number): Promise { flushInstrumentUpdateHistory(); - const instrument = instruments[copiedIndex]; + const instrument = chipInstruments[copiedIndex]; if (!instrument) return; - const existingIds = instruments.map((inst) => inst.id); + const existingIds = allInstruments.map((inst) => inst.id); const newId = getNextAvailableInstrumentId(existingIds); if (!newId) return; const copiedRows = instrument.rows.map((r) => new InstrumentRow({ ...r })); @@ -269,15 +277,13 @@ 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); projectStore.recordHistory( @@ -301,8 +307,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 +318,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( @@ -328,7 +336,7 @@ projectStore.createSetDiff(['patterns'], beforePatterns, projectStore.patterns) ] ); - services.audioService.updateInstruments(instruments); + services.audioService.updateInstruments(projectStore.instruments); requestPatternRedraw?.(); } @@ -337,7 +345,7 @@ function startEditingInstrumentId(index: number): void { editingInstrumentId = index; - editingInstrumentIdValue = instruments[index]?.id || ''; + editingInstrumentIdValue = chipInstruments[index]?.id || ''; } function finishEditingInstrumentId(): void { @@ -354,10 +362,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 +375,7 @@ async function loadInstrument(): Promise { flushInstrumentUpdateHistory(); - if (instruments.length === 0) return; + if (chipInstruments.length === 0) return; try { const text = await pickFileAsText(); const parsed: unknown = JSON.parse(text); @@ -382,17 +391,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 +436,7 @@ async function openPresets(): Promise { flushInstrumentUpdateHistory(); - if (instruments.length === 0) return; + if (chipInstruments.length === 0) return; const item = await open(PresetsModal, { presetType: 'instrument' }); if ( item == null || @@ -439,17 +449,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 })), @@ -478,7 +489,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,21 +500,21 @@ } $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); });
{#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) => { @@ -568,27 +582,27 @@ icon={IconCarbonAdd} label="Add" onclick={addInstrument} - disabled={instruments.length >= 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 +613,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]} +

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

{/if}
{/snippet} diff --git a/src/lib/components/Song/PatternEditor.svelte b/src/lib/components/Song/PatternEditor.svelte index cafb25da..f4c38b1b 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 { 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..bb9fff2f 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,7 +135,6 @@ const blurredContentClass = $derived( isRightPanelExpanded ? 'pointer-events-none opacity-50' : '' ); - const firstChipProcessor = $derived(chipProcessors[0]); const activeChipProcessor = $derived(chipProcessors[activeEditorIndex]); const services: { audioService: AudioService } = getContext('container'); @@ -345,7 +345,9 @@ withTuningTables.sendInitTuningTable(song.tuningTable); } if ('sendInitInstruments' in chipProcessor && withInstruments.sendInitInstruments) { - withInstruments.sendInitInstruments(projectStore.instruments); + withInstruments.sendInitInstruments( + filterInstrumentsForChip(projectStore.instruments, chipProcessor.chip.type) + ); } }); @@ -662,10 +664,10 @@ {#if tabId === 'tables'} {:else if tabId === 'instruments'} - {#if firstChipProcessor} + {#if activeChipProcessor} + chip={activeChipProcessor.chip} /> {/if} {:else if tabId === 'details'} { - 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,7 @@ 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.applyProject(project); ctx.resetPatternEditor(); } @@ -343,8 +336,7 @@ 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.applyProject(importedProject); ctx.resetPatternEditor(); } diff --git a/src/lib/services/audio/audio-service.ts b/src/lib/services/audio/audio-service.ts index dc5fbb61..967e2795 100644 --- a/src/lib/services/audio/audio-service.ts +++ b/src/lib/services/audio/audio-service.ts @@ -10,6 +10,7 @@ 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'; @@ -313,8 +314,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/file/file-import.ts b/src/lib/services/file/file-import.ts index 410136aa..639d3906 100644 --- a/src/lib/services/file/file-import.ts +++ b/src/lib/services/file/file-import.ts @@ -191,7 +191,13 @@ 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)); } diff --git a/src/lib/services/file/psg-export.ts b/src/lib/services/file/psg-export.ts index b47804fa..913330c7 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); 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..2a32877e --- /dev/null +++ b/src/lib/services/instrument/instrument-filter.ts @@ -0,0 +1,12 @@ +import type { Instrument } from '../../models/song'; + +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); +} diff --git a/src/lib/services/project/project-service.ts b/src/lib/services/project/project-service.ts index 2e8ba388..bc9c0c0c 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) {} @@ -35,6 +36,14 @@ export class ProjectService { 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/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/tests/lib/services/instrument/instrument-filter.test.ts b/tests/lib/services/instrument/instrument-filter.test.ts new file mode 100644 index 00000000..163b113a --- /dev/null +++ b/tests/lib/services/instrument/instrument-filter.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { Instrument } from '@/lib/models/song'; +import { + filterInstrumentsForChip, + 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']); + }); +}); diff --git a/tests/lib/services/project/project-service.test.ts b/tests/lib/services/project/project-service.test.ts index 8c1453fd..28ed2ea9 100644 --- a/tests/lib/services/project/project-service.test.ts +++ b/tests/lib/services/project/project-service.test.ts @@ -140,4 +140,24 @@ describe('ProjectService', () => { expect(mockAudioService.addChipProcessor).toHaveBeenCalledWith(mockChip); }); }); + + 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); + }); + }); }); From 4ae68e5317c3c4c7ae699bb1701770572c363605 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Wed, 17 Jun 2026 17:06:24 +0200 Subject: [PATCH 2/5] Instrument editor --- src/lib/chips/ay/AYInstrumentEditor.svelte | 811 ++++++------------ src/lib/chips/ay/processor.ts | 1 + src/lib/chips/nes/NESInstrumentEditor.svelte | 172 ++++ src/lib/chips/nes/index.ts | 10 +- src/lib/chips/nes/instrument.ts | 43 + src/lib/chips/nes/processor.ts | 1 + src/lib/chips/nes/schema.ts | 57 +- src/lib/chips/types.ts | 2 +- .../BooleanPaintableCell.svelte | 49 ++ .../RowEditorTable/CycleValueCell.svelte | 45 + .../RowEditorTable/IconColumnHeader.svelte | 30 + .../RowEditorTable/LoopMarkerOverlay.svelte | 22 + .../PaintableValueGridCell.svelte | 43 + .../RowEditorActionsCell.svelte | 52 ++ .../RowEditorAddRowButton.svelte | 15 + .../RowEditorTable/RowEditorContainer.svelte | 18 + .../RowEditorTable/RowEditorLoopCell.svelte | 19 + .../RowEditorTable/RowEditorNameField.svelte | 16 + .../RowEditorTableFooter.svelte | 41 + .../boolean-paint-drag.svelte.ts | 27 + src/lib/components/RowEditorTable/index.ts | 35 + .../RowEditorTable/loop-marker-style.ts | 5 + .../RowEditorTable/loop-marker.svelte.ts | 55 ++ .../named-row-editor-sync.svelte.ts | 116 +++ .../row-editor-selection.svelte.ts | 50 ++ .../row-editor-table-classes.ts | 35 + .../RowEditorTable/row-list-operations.ts | 37 + .../RowEditorTable/value-paint-drag.svelte.ts | 34 + src/lib/components/Tables/TableEditor.svelte | 577 ++++--------- src/lib/utils/row-editor-numeric.ts | 83 ++ tests/lib/chips/nes/instrument.test.ts | 26 + tests/lib/chips/nes/schema-settings.test.ts | 55 ++ 32 files changed, 1614 insertions(+), 968 deletions(-) create mode 100644 src/lib/chips/nes/NESInstrumentEditor.svelte create mode 100644 src/lib/chips/nes/instrument.ts create mode 100644 src/lib/components/RowEditorTable/BooleanPaintableCell.svelte create mode 100644 src/lib/components/RowEditorTable/CycleValueCell.svelte create mode 100644 src/lib/components/RowEditorTable/IconColumnHeader.svelte create mode 100644 src/lib/components/RowEditorTable/LoopMarkerOverlay.svelte create mode 100644 src/lib/components/RowEditorTable/PaintableValueGridCell.svelte create mode 100644 src/lib/components/RowEditorTable/RowEditorActionsCell.svelte create mode 100644 src/lib/components/RowEditorTable/RowEditorAddRowButton.svelte create mode 100644 src/lib/components/RowEditorTable/RowEditorContainer.svelte create mode 100644 src/lib/components/RowEditorTable/RowEditorLoopCell.svelte create mode 100644 src/lib/components/RowEditorTable/RowEditorNameField.svelte create mode 100644 src/lib/components/RowEditorTable/RowEditorTableFooter.svelte create mode 100644 src/lib/components/RowEditorTable/boolean-paint-drag.svelte.ts create mode 100644 src/lib/components/RowEditorTable/loop-marker-style.ts create mode 100644 src/lib/components/RowEditorTable/loop-marker.svelte.ts create mode 100644 src/lib/components/RowEditorTable/named-row-editor-sync.svelte.ts create mode 100644 src/lib/components/RowEditorTable/row-editor-selection.svelte.ts create mode 100644 src/lib/components/RowEditorTable/row-editor-table-classes.ts create mode 100644 src/lib/components/RowEditorTable/row-list-operations.ts create mode 100644 src/lib/components/RowEditorTable/value-paint-drag.svelte.ts create mode 100644 src/lib/utils/row-editor-numeric.ts create mode 100644 tests/lib/chips/nes/instrument.test.ts create mode 100644 tests/lib/chips/nes/schema-settings.test.ts 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/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/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte new file mode 100644 index 00000000..3ee4c24f --- /dev/null +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -0,0 +1,172 @@ + + + + + +
+ + +
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) => updateRow(index, { retrigger: value }) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => updateRow(index, { retrigger: value }))} /> + + updateRow(index, { pulseWidth: cyclePulseWidth(row.pulseWidth) })} /> + + {/each} + + editorSync.addRow(createDefaultNesInstrumentRow)} + onRowCountChange={(count) => + editorSync.setRowCount(count, createDefaultNesInstrumentRow, ROW_EDITOR_MAX_ROWS)} /> +
row{isExpanded ? 'loop' : 'lp'}
+
+ diff --git a/src/lib/chips/nes/index.ts b/src/lib/chips/nes/index.ts index f026bcbc..eb4de0df 100644 --- a/src/lib/chips/nes/index.ts +++ b/src/lib/chips/nes/index.ts @@ -1,6 +1,14 @@ -export { NES_CHIP, CHIP } from './core'; +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..ab798511 --- /dev/null +++ b/src/lib/chips/nes/instrument.ts @@ -0,0 +1,43 @@ +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: '⅛', + 1: '¼', + 2: '½', + 3: '¾' +}; + +export type NesInstrumentRow = { + pulseWidth: NesPulseWidth; + retrigger: boolean; +}; + +export function createDefaultNesInstrumentRow(): NesInstrumentRow { + return { pulseWidth: 2, retrigger: false }; +} + +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: Boolean(row.retrigger) + }; +} + +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/processor.ts b/src/lib/chips/nes/processor.ts index 792c594d..38255407 100644 --- a/src/lib/chips/nes/processor.ts +++ b/src/lib/chips/nes/processor.ts @@ -103,6 +103,7 @@ export class NESProcessor sendInitInstruments(instruments: Instrument[]): void { const sanitized = instruments.map((instrument) => ({ 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/nes/schema.ts b/src/lib/chips/nes/schema.ts index 175e54d9..76187b80 100644 --- a/src/lib/chips/nes/schema.ts +++ b/src/lib/chips/nes/schema.ts @@ -1,6 +1,33 @@ import type { ChipSchema } from '../base/schema'; -export const NES_NTSC_CPU_FREQUENCY = 1_789_772; +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 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 const NES_DEFAULT_TUNING_TABLE = Array.from({ length: 88 }, (_, index) => Math.max(1, 2034 - index * 8) @@ -65,10 +92,32 @@ export const NES_CHIP_SCHEMA: ChipSchema = { type: 'toggle', options: [ { label: 'NTSC', value: 'NTSC' }, - { label: 'PAL', value: 'PAL' } + { label: 'PAL', value: 'PAL' }, + { label: 'Dendy', value: 'Dendy' } ], defaultValue: 'NTSC', - group: 'chip' + 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`; + } } - ] + ], + 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/types.ts b/src/lib/chips/types.ts index 3ec86896..ea714c18 100644 --- a/src/lib/chips/types.ts +++ b/src/lib/chips/types.ts @@ -7,7 +7,7 @@ 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'; +import type { Instrument } from '../models/song'; export interface Chip { type: string; 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/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/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..f57de0a8 --- /dev/null +++ b/tests/lib/chips/nes/instrument.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + createDefaultNesInstrumentRow, + cyclePulseWidth, + ensureNesInstrumentRows, + normalizeNesInstrumentRow +} from '@/lib/chips/nes/instrument'; + +describe('nes instrument', () => { + it('creates a default macro row with retrigger off', () => { + expect(createDefaultNesInstrumentRow()).toEqual({ pulseWidth: 2, retrigger: false }); + }); + + it('normalizes partial rows and ensures at least one row', () => { + expect(normalizeNesInstrumentRow({ retrigger: 1, pulseWidth: 99 })).toEqual({ + pulseWidth: 2, + retrigger: true + }); + expect(ensureNesInstrumentRows([])).toHaveLength(1); + }); + + it('cycles pulse width through duty options', () => { + expect(cyclePulseWidth(0)).toBe(1); + expect(cyclePulseWidth(3)).toBe(0); + }); +}); 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..9ce4f6b5 --- /dev/null +++ b/tests/lib/chips/nes/schema-settings.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + collectSettingSideEffects, + normalizeChipSettingsRecord +} from '@/lib/chips/base/chip-settings'; +import { + NES_CHIP_SCHEMA, + NES_DENDY_CPU_FREQUENCY, + NES_NTSC_CPU_FREQUENCY, + NES_PAL_CPU_FREQUENCY, + resolveNesApuTimingType, + resolveNesCpuFrequency +} 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 }]); + }); +}); From 75d761235ed83da680db3c74001890f6a004abf2 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Wed, 17 Jun 2026 17:27:13 +0200 Subject: [PATCH 3/5] Some nes sounds --- build-wasm.ps1 | 6 +- build-wasm.sh | 11 +- external/nsfplug/wasm-stdlib-shim.c | 12 + public/{ => audio}/audio-slot-registry.js | 0 .../{ => audio}/bitphase-audio-processor.js | 2 +- public/{ => audio}/builtin-audio-slots.js | 6 +- public/{ => ay}/ay-audio-driver.js | 41 +- public/{ => ay}/ay-chip-register-state.js | 0 public/{ => ay}/ay-instrument-utils.js | 0 public/{ => ay}/ay-sample-lut.js | 0 public/{ => ay}/ay-sample-playback.js | 0 public/{ => ay}/ay-timer-effect-constants.js | 0 public/ay/ay8910-worklet-slot.js | 200 ++++++++ public/{ => ay}/ayumi-constants.js | 0 public/{ => ay}/ayumi-engine.js | 0 public/{ => ay}/ayumi-slot.js | 2 +- public/{ => ay}/ayumi-state.js | 2 +- public/{ => ay}/virtual-channel-mixer.js | 0 public/ay8910-worklet-slot.js | 428 ------------------ public/nes-stub-slot.js | 252 ----------- public/nes/nes-apu-engine.js | 251 ++++++++++ public/nes/nes-audio-driver.js | 174 +++++++ public/nes/nes-chip-register-state.js | 39 ++ public/nes/nes-constants.js | 12 + public/nes/nes-instrument-utils.js | 32 ++ public/nes/nes-state.js | 75 +++ public/nes/nes-waveform-capture.js | 41 ++ public/nes/nes-worklet-slot.js | 262 +++++++++++ public/{ => tracker}/effect-algorithms.js | 0 public/{ => tracker}/pt3-volume-table.js | 0 public/{ => tracker}/song-timeline.js | 0 .../timeline-pattern-coordinator.js | 0 public/tracker/tracker-audio-utils.js | 30 ++ .../tracker-pattern-processor.js | 0 public/{ => tracker}/tracker-state.js | 0 public/tracker/tracker-worklet-slot.js | 274 +++++++++++ public/{ => tracker}/worklet-slot-base.js | 0 src/lib/chips/ay/core.ts | 2 +- src/lib/chips/ay/renderer.ts | 2 +- src/lib/chips/nes/core.ts | 2 +- src/lib/chips/nes/processor.ts | 98 +++- src/lib/services/audio/audio-service.ts | 2 +- .../services/audio/mixer-worklet-bridge.ts | 1 + tests/psg/playback-regression.test.ts | 10 +- .../ay-audio-driver-auto-envelope.test.ts | 4 +- tests/public/ay-audio-driver.test.ts | 6 +- tests/public/ay-chip-register-state.test.ts | 2 +- .../public/ay-instrument-utils-parity.test.ts | 2 +- tests/public/ay-sample-playback.test.js | 2 +- tests/public/ayumi-constants.test.ts | 4 +- tests/public/ayumi-engine.test.ts | 6 +- tests/public/ayumi-state.test.ts | 2 +- tests/public/effect-algorithms.test.ts | 2 +- tests/public/effect-interactions.test.ts | 10 +- tests/public/export-engine-sync.test.ts | 4 +- tests/public/nes-apu-engine.test.js | 83 ++++ tests/public/playback-pipeline.test.ts | 8 +- tests/public/pt3-volume-table.test.ts | 2 +- tests/public/tracker-audio-utils.test.js | 31 ++ .../public/tracker-pattern-processor.test.ts | 8 +- tests/public/tracker-state.test.ts | 2 +- tests/public/virtual-channel-mixer.test.ts | 4 +- 62 files changed, 1693 insertions(+), 758 deletions(-) create mode 100644 external/nsfplug/wasm-stdlib-shim.c rename public/{ => audio}/audio-slot-registry.js (100%) rename public/{ => audio}/bitphase-audio-processor.js (98%) rename public/{ => audio}/builtin-audio-slots.js (72%) rename public/{ => ay}/ay-audio-driver.js (98%) rename public/{ => ay}/ay-chip-register-state.js (100%) rename public/{ => ay}/ay-instrument-utils.js (100%) rename public/{ => ay}/ay-sample-lut.js (100%) rename public/{ => ay}/ay-sample-playback.js (100%) rename public/{ => ay}/ay-timer-effect-constants.js (100%) create mode 100644 public/ay/ay8910-worklet-slot.js rename public/{ => ay}/ayumi-constants.js (100%) rename public/{ => ay}/ayumi-engine.js (100%) rename public/{ => ay}/ayumi-slot.js (99%) rename public/{ => ay}/ayumi-state.js (98%) rename public/{ => ay}/virtual-channel-mixer.js (100%) delete mode 100644 public/ay8910-worklet-slot.js delete mode 100644 public/nes-stub-slot.js create mode 100644 public/nes/nes-apu-engine.js create mode 100644 public/nes/nes-audio-driver.js create mode 100644 public/nes/nes-chip-register-state.js create mode 100644 public/nes/nes-constants.js create mode 100644 public/nes/nes-instrument-utils.js create mode 100644 public/nes/nes-state.js create mode 100644 public/nes/nes-waveform-capture.js create mode 100644 public/nes/nes-worklet-slot.js rename public/{ => tracker}/effect-algorithms.js (100%) rename public/{ => tracker}/pt3-volume-table.js (100%) rename public/{ => tracker}/song-timeline.js (100%) rename public/{ => tracker}/timeline-pattern-coordinator.js (100%) create mode 100644 public/tracker/tracker-audio-utils.js rename public/{ => tracker}/tracker-pattern-processor.js (100%) rename public/{ => tracker}/tracker-state.js (100%) create mode 100644 public/tracker/tracker-worklet-slot.js rename public/{ => tracker}/worklet-slot-base.js (100%) create mode 100644 tests/public/nes-apu-engine.test.js create mode 100644 tests/public/tracker-audio-utils.test.js diff --git a/build-wasm.ps1 b/build-wasm.ps1 index 28de3c37..a63ab49b 100644 --- a/build-wasm.ps1 +++ b/build-wasm.ps1 @@ -8,13 +8,13 @@ $emccArgs = "-O3", "-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", +$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", +$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_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\`"]`"" -$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..45684966 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"]' + external/nsfplug/nes_apu.c external/nsfplug/nes_dmc.c external/nsfplug/wasm-stdlib-shim.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_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", "_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/wasm-stdlib-shim.c b/external/nsfplug/wasm-stdlib-shim.c new file mode 100644 index 00000000..9854745b --- /dev/null +++ b/external/nsfplug/wasm-stdlib-shim.c @@ -0,0 +1,12 @@ +#include + +static uint32_t rand_state = 1; + +int rand(void) { + rand_state = rand_state * 1103515245u + 12345u; + return (int)((rand_state >> 16) & 32767u); +} + +void srand(unsigned int seed) { + rand_state = seed != 0 ? seed : 1; +} 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 98% rename from public/bitphase-audio-processor.js rename to public/audio/bitphase-audio-processor.js index 11f5f7cc..2ae8c7f7 100644 --- a/public/bitphase-audio-processor.js +++ b/public/audio/bitphase-audio-processor.js @@ -1,4 +1,4 @@ -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'; diff --git a/public/builtin-audio-slots.js b/public/audio/builtin-audio-slots.js similarity index 72% rename from public/builtin-audio-slots.js rename to public/audio/builtin-audio-slots.js index 9627096e..066d1652 100644 --- a/public/builtin-audio-slots.js +++ b/public/audio/builtin-audio-slots.js @@ -1,6 +1,6 @@ import { registerAudioSlotKind } from './audio-slot-registry.js'; -import { AyumiSlot } from './ayumi-slot.js'; -import { NesStubSlot } from './nes-stub-slot.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); @@ -9,7 +9,7 @@ registerAudioSlotKind('ayumi', (port, chipIndex, sharedTimeline, initData) => { }); registerAudioSlotKind('nes', (port, chipIndex, sharedTimeline, initData) => { - const slot = new NesStubSlot(port, chipIndex, sharedTimeline); + 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 98% rename from public/ay-audio-driver.js rename to public/ay/ay-audio-driver.js index 512233dc..340f2fbc 100644 --- a/public/ay-audio-driver.js +++ b/public/ay/ay-audio-driver.js @@ -1,7 +1,33 @@ 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 { + 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 +49,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 = []; 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..8085d1f3 --- /dev/null +++ b/public/ay/ay8910-worklet-slot.js @@ -0,0 +1,200 @@ +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'; + +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() { + this.registerState.reset(); + if (this.audioDriver) { + this.audioDriver.resetChannelMixerState(); + } + if (this.ayumiEngine) { + this.ayumiEngine.reset(); + } + 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) { + 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(); + } + } + } + } + + _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(); + } + } + + _resetEnginesForPreview() { + if (this.audioDriver) { + this.audioDriver.resetChannelMixerState(); + } + if (this.ayumiEngine) { + this.ayumiEngine.reset(); + } + } + + _silencePreviewChannel(channelIndex) { + this.applyChannelSilent(this.registerState, channelIndex); + } + + canRender() { + return 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 99% rename from public/ayumi-slot.js rename to public/ay/ayumi-slot.js index fe9b99b8..8625fb9f 100644 --- a/public/ayumi-slot.js +++ b/public/ay/ayumi-slot.js @@ -16,7 +16,7 @@ import { } from './ay-timer-effect-constants.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 { diff --git a/public/ayumi-state.js b/public/ay/ayumi-state.js similarity index 98% rename from public/ayumi-state.js rename to public/ay/ayumi-state.js index 100d3408..9a65fe3e 100644 --- a/public/ayumi-state.js +++ b/public/ay/ayumi-state.js @@ -1,5 +1,5 @@ import { DEFAULT_AYM_FREQUENCY } from './ayumi-constants.js'; -import TrackerState from './tracker-state.js'; +import TrackerState from '../tracker/tracker-state.js'; const AY_CHANNEL_ARRAY_SPECS = [ ['channelInstruments', -1], 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/nes-stub-slot.js b/public/nes-stub-slot.js deleted file mode 100644 index 87aee200..00000000 --- a/public/nes-stub-slot.js +++ /dev/null @@ -1,252 +0,0 @@ -import TrackerState from './tracker-state.js'; -import TrackerPatternProcessor from './tracker-pattern-processor.js'; -import { WorkletSlotBase } from './worklet-slot-base.js'; - -const NES_CHANNEL_COUNT = 5; - -class NesStubAudioDriver { - processPatternRow() {} - - processInstruments() {} - - resetChannelMixerState() {} - - resizeChannels() {} -} - -class NesState extends TrackerState { - constructor(sharedTimeline) { - super(NES_CHANNEL_COUNT, sharedTimeline); - this.instruments = []; - this.instrumentIdToIndex = new Map(); - this.channelSoundEnabled = Array(NES_CHANNEL_COUNT).fill(false); - this.channelMuted = Array(NES_CHANNEL_COUNT).fill(false); - this.channelInstruments = Array(NES_CHANNEL_COUNT).fill(-1); - } - - 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); - } - }); - } - - resizeChannels(newCount) { - super.resizeChannels(newCount); - this._resizeArray('channelSoundEnabled', newCount, false); - this._resizeArray('channelMuted', newCount, false); - this._resizeArray('channelInstruments', newCount, -1); - } - - _resizeArray(name, newCount, defaultVal) { - const arr = this[name]; - while (arr.length < newCount) arr.push(defaultVal); - if (arr.length > newCount) arr.length = newCount; - } -} - -export class NesStubSlot extends WorkletSlotBase { - constructor(port, chipIndex, sharedTimeline) { - super(port, chipIndex); - this.state = new NesState(sharedTimeline); - this.initialized = false; - this.audioDriver = new NesStubAudioDriver(); - this.patternProcessor = new TrackerPatternProcessor(this.state, this.audioDriver, this.port); - this.registerState = { channelCount: NES_CHANNEL_COUNT }; - } - - _slotState() { - return this.state; - } - - _isReadyForPlayback() { - return this.initialized; - } - - _applyPlaybackSpeed(speed) { - if (!(speed > 0)) return; - this.state.publishPlaybackSpeed(speed); - } - - _resizeForPatternChannels(channelCount) { - if (channelCount <= this.state.channelTables.length) return; - this.state.resizeChannels(channelCount); - this.registerState.channelCount = channelCount; - } - - _prepareOutputForPlay() {} - - _preparePatternWorkersForPlay() {} - - _replayCatchUpSegments(catchUpSegments) { - if (!catchUpSegments?.length) 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.patternProcessor.parsePatternRow( - this.state.currentPattern, - row, - this.registerState - ); - } - } - } - - _runCatchUpRows(upToRow) { - if (!this.state.currentPattern || this.state.currentPattern.length === 0 || upToRow <= 0) { - return; - } - for (let row = 0; row < upToRow; row++) { - this.patternProcessor.parsePatternRow(this.state.currentPattern, row, this.registerState); - } - } - - _onTransportStop() {} - - _afterTransportStop() {} - - async handleMessage(payload) { - if (payload == null || typeof payload !== 'object') return; - const { type, ...data } = payload; - if (type === undefined) return; - - if (type === 'init') { - await this.handleInit(data); - return; - } - - this.dispatchPortMessages(type, data); - } - - async handleInit({ wasmBuffer }) { - if (!wasmBuffer) return; - this.initialized = true; - this.state.updateSamplesPerTick(sampleRate); - } - - 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 'set_channel_mute': - this.handleSetChannelMute(data); - break; - default: - break; - } - } - - handleInitTuningTable({ tuningTable }) { - this.state.setTuningTable(tuningTable); - } - - handleInitSpeed({ speed }) { - if (!(speed > 0)) return; - this.state.publishPlaybackSpeed(speed); - } - - handleInitTables({ tables }) { - this.state.setTables(tables); - } - - handleInitInstruments({ instruments }) { - this.state.setInstruments(instruments); - } - - handleSetChannelMute({ channelIndex, muted }) { - if (channelIndex >= 0 && channelIndex < this.state.channelMuted.length) { - this.state.channelMuted[channelIndex] = muted; - } - } - - canRender() { - return this.initialized; - } - - isPreviewActive() { - return false; - } - - 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.patternProcessor.processTables(); - this.patternProcessor.processArpeggio(); - this.patternProcessor.processEffectTables(); - this.patternProcessor.processVibrato(); - this.patternProcessor.processSlides(); - } - - runPreviewStep() {} - - accumulateStereoOutput(_sampleIndex, _mix) {} - - finishAudioBlock(numSamples) { - this.finishAudioBlockFlushTransport(numSamples, this.paused); - } -} diff --git a/public/nes/nes-apu-engine.js b/public/nes/nes-apu-engine.js new file mode 100644 index 00000000..786ea751 --- /dev/null +++ b/public/nes/nes-apu-engine.js @@ -0,0 +1,251 @@ +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'; + +const SQUARE_BASE = [0x4000, 0x4004]; +const TRIANGLE_BASE = 0x4008; +const NOISE_BASE = 0x400c; +const SQUARE_SWEEP_DISABLED = 0x08; + +function buildSquareVolumeReg(volume, duty) { + return (1 << 4) | (volume & 15) | ((duty & 3) << 6); +} + +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(8); + this.forceFullApply = false; + this._lastApu4015 = -1; + this._lastDmc4015 = -1; + } + + 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; + } + + _writeSquare(channelIndex, channel, forceApply, triggerChannel) { + const last = this.lastState.channels[channelIndex]; + const base = SQUARE_BASE[channelIndex]; + const volumeReg = buildSquareVolumeReg(channel.volume, channel.duty); + const periodLow = channel.period & 0xff; + const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((channel.period >> 8) & 7); + const lastPeriodHigh = + (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); + + if (forceApply || volumeReg !== buildSquareVolumeReg(last.volume, last.duty)) { + this.wasmModule.nes_apu_Write(this.apuPtr, base, volumeReg); + last.volume = channel.volume; + last.duty = channel.duty; + } + + if (forceApply || last.sweepReg !== SQUARE_SWEEP_DISABLED) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 1, SQUARE_SWEEP_DISABLED); + last.sweepReg = SQUARE_SWEEP_DISABLED; + } + + if (forceApply || periodLow !== (last.period & 0xff)) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 2, periodLow); + } + + if (forceApply || triggerChannel || channel.retrigger || periodHigh !== lastPeriodHigh) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 3, periodHigh); + } + + last.period = channel.period; + last.retrigger = channel.retrigger; + } + + _writeTriangle(channel, forceApply, triggerChannel) { + const last = this.lastState.channels[2]; + const linearReg = (1 << 7) | NES_TRIANGLE_LINEAR_RELOAD; + const periodLow = channel.period & 0xff; + const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((channel.period >> 8) & 7); + const lastPeriodHigh = + (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); + + if (forceApply || linearReg !== ((1 << 7) | NES_TRIANGLE_LINEAR_RELOAD)) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE, 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) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE + 3, periodHigh); + } + last.period = channel.period; + last.retrigger = channel.retrigger; + } + + _writeNoise(channel, forceApply, triggerChannel) { + const last = this.lastState.channels[3]; + const volumeReg = buildSquareVolumeReg(channel.volume, 0); + const periodReg = (channel.noiseMode ? 0x80 : 0) | (channel.noisePeriod & 15); + + if (forceApply || volumeReg !== buildSquareVolumeReg(last.volume, 0)) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE, 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) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE + 3, NES_SQUARE_LENGTH_NIBBLE << 3); + last.retrigger = channel.retrigger; + } + } + + _channelJustEnabled(mask, bit) { + return (this._lastApu4015 & bit) === 0 && (mask & bit) !== 0; + } + + _dmcChannelJustEnabled(mask, bit) { + return (this._lastDmc4015 & bit) === 0 && (mask & bit) !== 0; + } + + applyRegisterState(registerState) { + const forceApply = this.forceFullApply; + this.forceFullApply = false; + + let apu4015 = 0; + let dmc4015 = 0; + const squareChannels = []; + let triangle = null; + let noise = null; + + for (let i = 0; i < 2; i++) { + const channel = registerState.channels[i]; + const last = this.lastState.channels[i]; + const isEnabled = channel.enabled && channel.period > 0 && channel.volume > 0; + if (isEnabled) { + apu4015 |= 1 << i; + squareChannels.push({ index: i, channel }); + } + last.enabled = isEnabled; + } + + const triangleChannel = registerState.channels[2]; + const triangleLast = this.lastState.channels[2]; + const triangleEnabled = triangleChannel.enabled && triangleChannel.period > 0; + if (triangleEnabled) { + dmc4015 |= 4; + triangle = triangleChannel; + } + triangleLast.enabled = triangleEnabled; + + const noiseChannel = registerState.channels[3]; + const noiseLast = this.lastState.channels[3]; + const noiseEnabled = noiseChannel.enabled && noiseChannel.volume > 0; + if (noiseEnabled) { + dmc4015 |= 8; + noise = noiseChannel; + } + noiseLast.enabled = noiseEnabled; + + 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 (const { index, channel } of squareChannels) { + const bit = 1 << index; + const triggerChannel = forceApply || this._channelJustEnabled(apu4015, bit); + this._writeSquare(index, channel, forceApply, triggerChannel); + } + if (triangle) { + const triggerChannel = forceApply || this._dmcChannelJustEnabled(dmc4015, 4); + this._writeTriangle(triangle, forceApply, triggerChannel); + } + if (noise) { + const triggerChannel = forceApply || this._dmcChannelJustEnabled(dmc4015, 8); + this._writeNoise(noise, forceApply, triggerChannel); + } + } + + process(sampleRate) { + this.clockAccumulator += this.cpuFrequency / sampleRate; + const clocks = Math.floor(this.clockAccumulator); + if (clocks <= 0) { + return { left: 0, right: 0 }; + } + 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 + 4); + const samples = new Int32Array(memory, this.outputPtr, 2); + const left = (samples[0] + samples[1]) * NES_APU_OUTPUT_SCALE; + const right = left; + return { left, right }; + } + + 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, 0); + wasmModule.nes_dmc_SetMask(dmcPtr, 0); + 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..90cbc44d --- /dev/null +++ b/public/nes/nes-audio-driver.js @@ -0,0 +1,174 @@ +import { + advanceInstrumentRowPosition, + calculatePt3Volume, + getEffectiveTuningPeriod +} from '../tracker/tracker-audio-utils.js'; +import { + createDefaultNesInstrumentRow, + ensureNesInstrumentRows, + normalizeNesInstrumentRow +} from './nes-instrument-utils.js'; +import { NES_CHANNEL_COUNT } from './nes-constants.js'; + +const NES_NOISE_TABLE = [ + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, + 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, + 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15 +]; + +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; + } + + _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; + } else if (row.note.name !== 0) { + state.channelSoundEnabled[channelIndex] = true; + state.instrumentPositions[channelIndex] = 0; + state.channelKeyOn[channelIndex] = true; + } + } + + _processInstrument(state, channelIndex, row) { + if (state.channelMuted[channelIndex]) return; + if (!state.channelInstruments || !state.instruments) return; + + if (row.instrument > 0) { + const instrumentIndex = state.instrumentIdToIndex.get(row.instrument); + if (instrumentIndex !== undefined && state.instruments[instrumentIndex]) { + state.channelInstruments[channelIndex] = instrumentIndex; + state.instrumentPositions[channelIndex] = 0; + } else { + state.channelInstruments[channelIndex] = -1; + } + } + } + + calculateVolume(patternVolume, instrumentVolume) { + return calculatePt3Volume(patternVolume, instrumentVolume); + } + + getEffectivePeriod(state, channelIndex) { + return getEffectiveTuningPeriod(state, channelIndex, 2047); + } + + resolveNoisePeriod(state, channelIndex) { + const noteIndex = state.channelCurrentNotes[channelIndex]; + let ntPos = noteIndex - 60; + const toneSliding = state.channelToneSliding?.[channelIndex] || 0; + const vibratoSliding = state.channelVibratoSliding?.[channelIndex] || 0; + const detune = state.channelDetune?.[channelIndex] || 0; + ntPos += toneSliding + vibratoSliding + detune; + if (ntPos < 0) ntPos = 0; + if (ntPos >= NES_NOISE_TABLE.length) ntPos = NES_NOISE_TABLE.length - 1; + return NES_NOISE_TABLE[ntPos]; + } + + resolveInstrumentRow(state, channelIndex) { + const instrumentIndex = state.channelInstruments[channelIndex]; + const instrument = + instrumentIndex >= 0 ? state.instruments[instrumentIndex] : null; + const rows = instrument ? ensureNesInstrumentRows(instrument.rows) : [createDefaultNesInstrumentRow()]; + 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 = + state.channelOnOffCounter[channelIndex] > 0 && !state.channelSoundEnabled[channelIndex]; + + if (isMuted || !isSoundEnabled) { + this._silenceChannel(registerState, channelIndex); + continue; + } + + const { row, rowsLength, loop } = this.resolveInstrumentRow(state, channelIndex); + const patternVolume = state.channelPatternVolumes[channelIndex] ?? 15; + const volume = this.calculateVolume(patternVolume, 15); + const period = this.getEffectivePeriod(state, channelIndex); + const keyOn = state.channelKeyOn[channelIndex]; + + if (channelIndex <= 1) { + channel.enabled = period > 0 && volume > 0; + channel.period = period; + channel.volume = volume; + channel.duty = row.pulseWidth; + channel.retrigger = row.retrigger || keyOn; + state.channelKeyOn[channelIndex] = false; + } else if (channelIndex === 2) { + channel.enabled = period > 0; + channel.period = period; + channel.volume = 15; + channel.duty = 0; + channel.retrigger = row.retrigger || keyOn; + state.channelKeyOn[channelIndex] = false; + } else if (channelIndex === 3) { + channel.enabled = volume > 0; + channel.volume = volume; + channel.noisePeriod = this.resolveNoisePeriod(state, channelIndex); + channel.noiseMode = false; + 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 + ); + } + } + } +} + +export default NesAudioDriver; diff --git a/public/nes/nes-chip-register-state.js b/public/nes/nes-chip-register-state.js new file mode 100644 index 00000000..ecaaba88 --- /dev/null +++ b/public/nes/nes-chip-register-state.js @@ -0,0 +1,39 @@ +import { NES_CHANNEL_COUNT } from './nes-constants.js'; + +function createDefaultChannel() { + return { + enabled: false, + period: 0, + volume: 0, + duty: 2, + retrigger: false, + sweepReg: -1, + noisePeriod: 0, + noiseMode: false + }; +} + +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..e9130c39 --- /dev/null +++ b/public/nes/nes-constants.js @@ -0,0 +1,12 @@ +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_SCALE = 1 / 8192; diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js new file mode 100644 index 00000000..37e39d95 --- /dev/null +++ b/public/nes/nes-instrument-utils.js @@ -0,0 +1,32 @@ +export const NES_PULSE_WIDTHS = [0, 1, 2, 3]; + +export function createDefaultNesInstrumentRow() { + return { pulseWidth: 2, retrigger: false }; +} + +export function normalizeNesInstrumentRow(row) { + const defaults = createDefaultNesInstrumentRow(); + const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) ? row.pulseWidth : defaults.pulseWidth; + return { + pulseWidth, + retrigger: Boolean(row?.retrigger) + }; +} + +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..f090a6c9 --- /dev/null +++ b/public/nes/nes-state.js @@ -0,0 +1,75 @@ +import TrackerState from '../tracker/tracker-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] +]; + +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(); + + for (const [name, defaultVal] of NES_CHANNEL_ARRAY_SPECS) { + this[name] = Array(NES_CHANNEL_COUNT).fill(defaultVal); + } + } + + 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 = 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); + } + }); + } + + resizeChannels(newCount) { + super.resizeChannels(newCount); + for (const [name, defaultVal] of NES_CHANNEL_ARRAY_SPECS) { + const arr = this[name]; + while (arr.length < newCount) arr.push(defaultVal); + if (arr.length > newCount) arr.length = newCount; + } + } +} + +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..e8d793af --- /dev/null +++ b/public/nes/nes-waveform-capture.js @@ -0,0 +1,41 @@ +const SQUARE_DUTY = [0.125, 0.25, 0.5, 0.75]; + +export class NesWaveformCapture { + constructor(channelCount) { + this.phases = new Float64Array(channelCount); + } + + 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..cec062b4 --- /dev/null +++ b/public/nes/nes-worklet-slot.js @@ -0,0 +1,262 @@ +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 { 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; + } + + _onTransportStop() { + this.registerState.reset(); + this.resetChannelWaveformCapture(); + if (this.audioDriver) { + this.audioDriver.resetChannelMixerState(); + } + if (this.apuEngine) { + this.apuEngine.reset(); + } + this._applyRegisterStateToEngine(); + } + + _applyRegisterStateToEngine() { + if (!this.apuEngine) return; + 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); + } + } + this._applyRegisterStateToEngine(); + } + + 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; + 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); + } + + 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() { + this.apuEngine?.reset(); + } + + _silencePreviewChannel(channelIndex) { + this.audioDriver?._silenceChannel(this.registerState, channelIndex); + } + + canRender() { + return 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; + for (let ch = 0; ch < this.channelWaveformBuf.length; ch++) { + const sample = this.waveformCapture.sample( + ch, + this.registerState.channels[ch], + 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 100% rename from public/timeline-pattern-coordinator.js rename to public/tracker/timeline-pattern-coordinator.js 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-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..ac9175c7 --- /dev/null +++ b/public/tracker/tracker-worklet-slot.js @@ -0,0 +1,274 @@ +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); + } + } + + _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; + 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 100% rename from public/worklet-slot-base.js rename to public/tracker/worklet-slot-base.js diff --git a/src/lib/chips/ay/core.ts b/src/lib/chips/ay/core.ts index 750a6757..a64a3435 100644 --- a/src/lib/chips/ay/core.ts +++ b/src/lib/chips/ay/core.ts @@ -11,7 +11,7 @@ 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, diff --git a/src/lib/chips/ay/renderer.ts b/src/lib/chips/ay/renderer.ts index d0f2af14..62325323 100644 --- a/src/lib/chips/ay/renderer.ts +++ b/src/lib/chips/ay/renderer.ts @@ -57,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, { diff --git a/src/lib/chips/nes/core.ts b/src/lib/chips/nes/core.ts index aabb1018..8a1f7989 100644 --- a/src/lib/chips/nes/core.ts +++ b/src/lib/chips/nes/core.ts @@ -10,7 +10,7 @@ import type { Chip } from '../types'; export const NES_CHIP: Chip = { type: 'nes', name: '2A03 / 2A07', - wasmUrl: 'nes_apu.wasm', + wasmUrl: 'nes/nes_apu.wasm', audioSlotKind: NES_AUDIO_SLOT_KIND, processorMap: (chip) => new NESProcessor(chip), schema: NES_CHIP_SCHEMA, diff --git a/src/lib/chips/nes/processor.ts b/src/lib/chips/nes/processor.ts index 38255407..10313fe9 100644 --- a/src/lib/chips/nes/processor.ts +++ b/src/lib/chips/nes/processor.ts @@ -3,17 +3,37 @@ import type { Pattern, Instrument } from '../../models/song'; import type { Table } from '../../models/project'; import type { MixerWorkletSlotProcessor, + SettingsSubscriber, TuningTableSupport, - InstrumentSupport + 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, TuningTableSupport, InstrumentSupport + implements + MixerWorkletSlotProcessor, + SettingsSubscriber, + TuningTableSupport, + InstrumentSupport, + PreviewNoteSupport { chip: Chip; private readonly bridge: MixerWorkletBridge; + private settingsUnsubscribers: (() => void)[] = []; constructor(chip: Chip) { this.chip = chip; @@ -24,6 +44,31 @@ export class NESProcessor 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); + } + }) + ); + } + + 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'); @@ -38,6 +83,23 @@ export class NESProcessor 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 @@ -101,13 +163,9 @@ export class NESProcessor } sendInitInstruments(instruments: Instrument[]): void { - const sanitized = instruments.map((instrument) => ({ - id: instrument.id, - chipType: instrument.chipType, - rows: Array.from(instrument.rows).map((row) => ({ ...row })), - loop: instrument.loop, - name: instrument.name - })); + const sanitized = instruments.map((instrument) => + sanitizeInstrumentForWorklet(instrument) + ); this.bridge.sendCommand({ type: 'init_instruments', instruments: sanitized }); } @@ -130,6 +188,28 @@ export class NESProcessor }); } + 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 }); + } + updateParameter(parameter: string, value: unknown): void { if (parameter.startsWith('channelMute_')) { const channelIndex = parseInt(parameter.replace('channelMute_', ''), 10); diff --git a/src/lib/services/audio/audio-service.ts b/src/lib/services/audio/audio-service.ts index 967e2795..3972c86c 100644 --- a/src/lib/services/audio/audio-service.ts +++ b/src/lib/services/audio/audio-service.ts @@ -15,7 +15,7 @@ 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[]; 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/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..a43d5d30 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; 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..d65ac824 --- /dev/null +++ b/tests/public/nes-apu-engine.test.js @@ -0,0 +1,83 @@ +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('plays square waves after channel enable and register writes', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + wasmModule.nes_apu_Write(engine.apuPtr, 0x4015, 0); + + 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', 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('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; + engine.applyRegisterState(registerState); + + wasmModule.nes_apu_Write(engine.apuPtr, 0x4015, 0); + + registerState.channels[0].enabled = true; + registerState.channels[0].retrigger = false; + engine.applyRegisterState(registerState); + + expect(renderSquarePeak(engine)).toBeGreaterThan(0.01); + }); +}); 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-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 { From d451cc050a234b0573fc624a0ab0e7790a940ebd Mon Sep 17 00:00:00 2001 From: Natt Akuma Date: Thu, 18 Jun 2026 03:20:53 +0700 Subject: [PATCH 4/5] Remove rand() usage from nes_dmc --- build-wasm.sh | 2 +- external/nsfplug/nes_dmc.c | 20 ++++++++++---------- external/nsfplug/wasm-stdlib-shim.c | 12 ------------ 3 files changed, 11 insertions(+), 23 deletions(-) delete mode 100644 external/nsfplug/wasm-stdlib-shim.c diff --git a/build-wasm.sh b/build-wasm.sh index 45684966..1556ffd4 100755 --- a/build-wasm.sh +++ b/build-wasm.sh @@ -28,7 +28,7 @@ emcc ${EMCC_ARGS} \ -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 external/nsfplug/wasm-stdlib-shim.c \ + 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_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", "_malloc", "_free"]' diff --git a/external/nsfplug/nes_dmc.c b/external/nsfplug/nes_dmc.c index 8054e2b6..93583f07 100644 --- a/external/nsfplug/nes_dmc.c +++ b/external/nsfplug/nes_dmc.c @@ -484,16 +484,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/wasm-stdlib-shim.c b/external/nsfplug/wasm-stdlib-shim.c deleted file mode 100644 index 9854745b..00000000 --- a/external/nsfplug/wasm-stdlib-shim.c +++ /dev/null @@ -1,12 +0,0 @@ -#include - -static uint32_t rand_state = 1; - -int rand(void) { - rand_state = rand_state * 1103515245u + 12345u; - return (int)((rand_state >> 16) & 32767u); -} - -void srand(unsigned int seed) { - rand_state = seed != 0 ? seed : 1; -} From 2971b3454f0d0ccfb6af07af02aa19be33374134 Mon Sep 17 00:00:00 2001 From: Natt Akuma Date: Thu, 18 Jun 2026 21:26:40 +0700 Subject: [PATCH 5/5] Fix NES audio driver --- public/nes/nes-apu-engine.js | 37 +++++++++++++++++++--------------- public/nes/nes-audio-driver.js | 19 +++++++++-------- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/public/nes/nes-apu-engine.js b/public/nes/nes-apu-engine.js index 786ea751..6af09f79 100644 --- a/public/nes/nes-apu-engine.js +++ b/public/nes/nes-apu-engine.js @@ -14,7 +14,7 @@ const NOISE_BASE = 0x400c; const SQUARE_SWEEP_DISABLED = 0x08; function buildSquareVolumeReg(volume, duty) { - return (1 << 4) | (volume & 15) | ((duty & 3) << 6); + return (3 << 4) | (volume & 15) | ((duty & 3) << 6); } class NesApuEngine { @@ -26,10 +26,11 @@ class NesApuEngine { this.cpuFrequency = NES_NTSC_CPU_FREQUENCY; this.isPal = false; this.clockAccumulator = 0; - this.outputPtr = wasmModule.malloc(8); + this.outputPtr = wasmModule.malloc(16); this.forceFullApply = false; this._lastApu4015 = -1; this._lastDmc4015 = -1; + this._lastOutput = { left: 0, right: 0 }; } setCpuFrequency(frequency) { @@ -61,10 +62,10 @@ class NesApuEngine { const last = this.lastState.channels[channelIndex]; const base = SQUARE_BASE[channelIndex]; const volumeReg = buildSquareVolumeReg(channel.volume, channel.duty); - const periodLow = channel.period & 0xff; - const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((channel.period >> 8) & 7); - const lastPeriodHigh = - (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); + const period = channel.period > 0 ? channel.period - 1 : 0; + const periodLow = period & 0xff; + const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((period >> 8) & 7); + const lastPeriodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); if (forceApply || volumeReg !== buildSquareVolumeReg(last.volume, last.duty)) { this.wasmModule.nes_apu_Write(this.apuPtr, base, volumeReg); @@ -85,7 +86,7 @@ class NesApuEngine { this.wasmModule.nes_apu_Write(this.apuPtr, base + 3, periodHigh); } - last.period = channel.period; + last.period = period; last.retrigger = channel.retrigger; } @@ -94,8 +95,7 @@ class NesApuEngine { const linearReg = (1 << 7) | NES_TRIANGLE_LINEAR_RELOAD; const periodLow = channel.period & 0xff; const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((channel.period >> 8) & 7); - const lastPeriodHigh = - (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); + const lastPeriodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); if (forceApply || linearReg !== ((1 << 7) | NES_TRIANGLE_LINEAR_RELOAD)) { this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE, linearReg); @@ -125,7 +125,11 @@ class NesApuEngine { last.noiseMode = channel.noiseMode; } if (forceApply || triggerChannel || channel.retrigger) { - this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE + 3, NES_SQUARE_LENGTH_NIBBLE << 3); + this.wasmModule.nes_dmc_Write( + this.dmcPtr, + NOISE_BASE + 3, + NES_SQUARE_LENGTH_NIBBLE << 3 + ); last.retrigger = channel.retrigger; } } @@ -205,7 +209,7 @@ class NesApuEngine { this.clockAccumulator += this.cpuFrequency / sampleRate; const clocks = Math.floor(this.clockAccumulator); if (clocks <= 0) { - return { left: 0, right: 0 }; + return this._lastOutput; } this.clockAccumulator -= clocks; @@ -215,11 +219,12 @@ class NesApuEngine { const memory = this.wasmModule.memory.buffer; this.wasmModule.nes_apu_Render(this.apuPtr, this.outputPtr); - this.wasmModule.nes_dmc_Render(this.dmcPtr, this.outputPtr + 4); - const samples = new Int32Array(memory, this.outputPtr, 2); - const left = (samples[0] + samples[1]) * NES_APU_OUTPUT_SCALE; - const right = left; - return { left, right }; + 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; } dispose() { diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 90cbc44d..627c20f2 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -13,15 +13,16 @@ import { NES_CHANNEL_COUNT } from './nes-constants.js'; const NES_NOISE_TABLE = [ 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, - 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, - 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, + 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, - 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15 + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, + 15, 15 ]; class NesAudioDriver { @@ -85,7 +86,7 @@ class NesAudioDriver { } getEffectivePeriod(state, channelIndex) { - return getEffectiveTuningPeriod(state, channelIndex, 2047); + return getEffectiveTuningPeriod(state, channelIndex, 2048); } resolveNoisePeriod(state, channelIndex) { @@ -102,9 +103,10 @@ class NesAudioDriver { resolveInstrumentRow(state, channelIndex) { const instrumentIndex = state.channelInstruments[channelIndex]; - const instrument = - instrumentIndex >= 0 ? state.instruments[instrumentIndex] : null; - const rows = instrument ? ensureNesInstrumentRows(instrument.rows) : [createDefaultNesInstrumentRow()]; + const instrument = instrumentIndex >= 0 ? state.instruments[instrumentIndex] : null; + const rows = instrument + ? ensureNesInstrumentRows(instrument.rows) + : [createDefaultNesInstrumentRow()]; const loop = instrument?.loop ?? 0; const rowIndex = state.instrumentPositions[channelIndex] % rows.length; return { @@ -122,7 +124,8 @@ class NesAudioDriver { const isMuted = state.channelMuted[channelIndex]; const isSoundEnabled = state.channelSoundEnabled[channelIndex]; const onOffHalted = - state.channelOnOffCounter[channelIndex] > 0 && !state.channelSoundEnabled[channelIndex]; + state.channelOnOffCounter[channelIndex] > 0 && + !state.channelSoundEnabled[channelIndex]; if (isMuted || !isSoundEnabled) { this._silenceChannel(registerState, channelIndex);
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} />