From 5504704d4b0d35ce0d83ecf8d96de92d497a4a78 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Wed, 17 Jun 2026 10:30:54 +0200 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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); From b4c31482a0784814d057143e9b7472eac90341d6 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 16:41:53 +0200 Subject: [PATCH 06/28] A4 table for NES --- src/lib/chips/nes/processor.ts | 8 ++++++ src/lib/chips/nes/schema.ts | 27 ++++++++++++++++++--- src/lib/models/pt3/tuning-tables.ts | 10 +++++--- tests/lib/chips/nes/schema-settings.test.ts | 26 +++++++++++++++++++- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/lib/chips/nes/processor.ts b/src/lib/chips/nes/processor.ts index 10313fe9..cc1bc44c 100644 --- a/src/lib/chips/nes/processor.ts +++ b/src/lib/chips/nes/processor.ts @@ -60,6 +60,14 @@ export class NESProcessor } }) ); + + this.settingsUnsubscribers.push( + chipSettings.subscribe('tuningTable', (value) => { + if (Array.isArray(value) && value.length > 0) { + this.sendInitTuningTable(value as number[]); + } + }) + ); } unsubscribeFromSettings(): void { diff --git a/src/lib/chips/nes/schema.ts b/src/lib/chips/nes/schema.ts index 76187b80..d93cecab 100644 --- a/src/lib/chips/nes/schema.ts +++ b/src/lib/chips/nes/schema.ts @@ -1,8 +1,10 @@ import type { ChipSchema } from '../base/schema'; +import { generate12TETTuningTable } from '../../models/pt3/tuning-tables'; export const NES_NTSC_CPU_FREQUENCY = 1_789_773; export const NES_PAL_CPU_FREQUENCY = 1_662_607; export const NES_DENDY_CPU_FREQUENCY = 1_773_448; +export const NES_MAX_TUNING_PERIOD = 2047; export type NesSystem = 'NTSC' | 'PAL' | 'Dendy'; export type NesApuTimingType = 'NTSC' | 'PAL'; @@ -29,9 +31,11 @@ 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) -); +export function resolveNesTuningTable(cpuFrequencyHz: number, a4TuningHz: number): number[] { + return generate12TETTuningTable(cpuFrequencyHz, a4TuningHz, NES_MAX_TUNING_PERIOD); +} + +export const NES_DEFAULT_TUNING_TABLE = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); export const NES_CHIP_SCHEMA: ChipSchema = { chipType: 'nes', @@ -104,8 +108,25 @@ export const NES_CHIP_SCHEMA: ChipSchema = { const apuTimingType = resolveNesApuTimingType(value); return `${mhz} MHz · ${apuTimingType} type`; } + }, + { + key: 'a4TuningHz', + label: 'A4 (Hz)', + type: 'number', + min: 220, + max: 880, + defaultValue: 440, + group: 'chip', + notifyAudioService: true, + startNewRow: true } ], + resolveTuningTable(song) { + const cpuFreq = Number(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + const a4 = Math.min(880, Math.max(220, Number(song.a4TuningHz ?? 440))); + return resolveNesTuningTable(cpuFreq, a4); + }, + tuningTableSettingKeys: ['a4TuningHz', 'chipFrequency'], applySettingSideEffects(key, value) { if (key === 'chipVariant') { return [{ key: 'chipFrequency', value: resolveNesCpuFrequency(value) }]; diff --git a/src/lib/models/pt3/tuning-tables.ts b/src/lib/models/pt3/tuning-tables.ts index 777747b3..b71e819f 100644 --- a/src/lib/models/pt3/tuning-tables.ts +++ b/src/lib/models/pt3/tuning-tables.ts @@ -68,15 +68,19 @@ export const PT3TuneTables: number[][] = [ const A4_NOTE_INDEX = 45; const TABLE_LENGTH = 96; const MIN_PERIOD = 1; -const MAX_PERIOD = 4095; +const DEFAULT_MAX_PERIOD = 4095; -export function generate12TETTuningTable(chipFrequencyHz: number, a4Hz: number = 440): number[] { +export function generate12TETTuningTable( + chipFrequencyHz: number, + a4Hz: number = 440, + maxPeriod: number = DEFAULT_MAX_PERIOD +): number[] { const result: number[] = []; for (let i = 0; i < TABLE_LENGTH; i++) { const freqHz = a4Hz * Math.pow(2, (i - A4_NOTE_INDEX) / 12); const periodF = chipFrequencyHz / 16 / freqHz; let period = Math.round(periodF); - if (period > MAX_PERIOD) period = MAX_PERIOD; + if (period > maxPeriod) period = maxPeriod; if (period < MIN_PERIOD) period = MIN_PERIOD; result.push(period); } diff --git a/tests/lib/chips/nes/schema-settings.test.ts b/tests/lib/chips/nes/schema-settings.test.ts index 9ce4f6b5..b11dc3e8 100644 --- a/tests/lib/chips/nes/schema-settings.test.ts +++ b/tests/lib/chips/nes/schema-settings.test.ts @@ -6,10 +6,12 @@ import { import { NES_CHIP_SCHEMA, NES_DENDY_CPU_FREQUENCY, + NES_MAX_TUNING_PERIOD, NES_NTSC_CPU_FREQUENCY, NES_PAL_CPU_FREQUENCY, resolveNesApuTimingType, - resolveNesCpuFrequency + resolveNesCpuFrequency, + resolveNesTuningTable } from '@/lib/chips/nes/schema'; describe('NES chip settings schema hooks', () => { @@ -52,4 +54,26 @@ describe('NES chip settings schema hooks', () => { }) ).toEqual([{ key: 'chipFrequency', value: NES_DENDY_CPU_FREQUENCY }]); }); + + it('builds a 12-TET tuning table from CPU frequency and A4', () => { + const table = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); + expect(table).toHaveLength(96); + expect(table.every((period) => period <= NES_MAX_TUNING_PERIOD)).toBe(true); + expect(table[45]).toBe(Math.round(NES_NTSC_CPU_FREQUENCY / 16 / 440)); + }); + + it('regenerates tuning table when CPU frequency changes', () => { + const ntsc = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); + const pal = resolveNesTuningTable(NES_PAL_CPU_FREQUENCY, 440); + expect(ntsc[45]).not.toBe(pal[45]); + }); + + it('clamps A4 tuning Hz when resolving from song settings', () => { + const table = NES_CHIP_SCHEMA.resolveTuningTable!({ + chipFrequency: NES_NTSC_CPU_FREQUENCY, + a4TuningHz: 1000 + }); + const expected = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 880); + expect(table[45]).toBe(expected[45]); + }); }); From b88709002801094c847de0c315b3fc5b1ca373f2 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 16:51:11 +0200 Subject: [PATCH 07/28] Fix loading different chip type .btp --- src/App.svelte | 3 ++- src/lib/components/Song/PatternEditor.svelte | 28 +++++++++++++++++--- src/lib/components/Song/SongView.svelte | 2 ++ src/lib/services/app/menu-action-context.ts | 1 + src/lib/services/app/menu-action-handler.ts | 2 ++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index e8b1ed71..036bdc0a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -241,7 +241,8 @@ activeSongIndex = 0; songView?.resetEditorState?.(); patternEditor?.resetToBeginning?.(); - } + }, + syncChipProcessors }; const baseHandleMenuAction = createMenuActionHandler(menuActionContext); diff --git a/src/lib/components/Song/PatternEditor.svelte b/src/lib/components/Song/PatternEditor.svelte index f4c38b1b..19c4a780 100644 --- a/src/lib/components/Song/PatternEditor.svelte +++ b/src/lib/components/Song/PatternEditor.svelte @@ -153,9 +153,9 @@ const services: { audioService: AudioService } = getContext('container'); - const formatter = getFormatter(chip); - const converter = getConverter(chip); - const schema = chip.schema; + const formatter = $derived.by(() => getFormatter(chip)); + const converter = $derived.by(() => getConverter(chip)); + const schema = $derived(chip.schema); const previewService = new PreviewService(); const pressedKeyChannels = new Map(); let previewInitialized = false; @@ -2738,19 +2738,38 @@ let lastChannelSeparatorWidth = -1; let lastSelectionStyle: 'inverted' | 'filled' = 'inverted'; let lastChannelCount = -1; + let lastChipType = ''; let needsSetup = true; + function resetEditorSelectionState(): void { + selectedColumn = 0; + selectionStartRow = null; + selectionStartColumn = null; + selectionEndRow = null; + selectionEndColumn = null; + isSelecting = false; + mouseDownCell = null; + } + $effect(() => { if (!canvas) return; + const chipType = chip.type; const currentPatternLength = currentPattern?.length ?? -1; const currentChannelCount = currentPattern?.channels?.length ?? -1; const fontSizeChanged = fontSize !== lastFontSize; const fontFamilyChanged = fontFamily !== lastFontFamily; const channelSeparatorWidthChanged = channelSeparatorWidth !== lastChannelSeparatorWidth; const selectionStyleChanged = selectionStyle !== lastSelectionStyle; + const chipTypeChanged = chipType !== lastChipType; + + if (chipTypeChanged) { + clearAllCaches(); + resetEditorSelectionState(); + needsSetup = true; + } - if (needsSetup || !ctx) { + if (needsSetup || !ctx || chipTypeChanged) { ctx = canvas.getContext('2d')!; const ready = setupCanvas(); needsSetup = false; @@ -2767,6 +2786,7 @@ lastChannelSeparatorWidth = channelSeparatorWidth; lastSelectionStyle = selectionStyle; lastChannelCount = currentChannelCount; + lastChipType = chipType; requestAnimationFrame(() => { if (ctx && canvas && !document.hidden) { updateSize(); diff --git a/src/lib/components/Song/SongView.svelte b/src/lib/components/Song/SongView.svelte index bb9fff2f..6c43b709 100644 --- a/src/lib/components/Song/SongView.svelte +++ b/src/lib/components/Song/SongView.svelte @@ -604,6 +604,7 @@ {/snippet}
+ {#key `${i}-${chipProcessor.chip.type}`} + {/key}
{/if} diff --git a/src/lib/services/app/menu-action-context.ts b/src/lib/services/app/menu-action-context.ts index e0920a37..db21cb77 100644 --- a/src/lib/services/app/menu-action-context.ts +++ b/src/lib/services/app/menu-action-context.ts @@ -30,4 +30,5 @@ export interface MenuActionContext { handleFileExport: (action: string, project: Project) => Promise; clearAutobackup: () => Promise; resetPatternEditor: () => void; + syncChipProcessors: () => void; } diff --git a/src/lib/services/app/menu-action-handler.ts b/src/lib/services/app/menu-action-handler.ts index 4671db83..13bdfd50 100644 --- a/src/lib/services/app/menu-action-handler.ts +++ b/src/lib/services/app/menu-action-handler.ts @@ -326,6 +326,7 @@ export function createMenuActionHandler(ctx: MenuActionContext) { ctx.playbackStore.isPlaying = false; ctx.container.audioService.stop(); await ctx.projectService.restoreChipProcessorsForSongs(project.songs); + ctx.syncChipProcessors(); ctx.applyProject(project); ctx.resetPatternEditor(); } @@ -337,6 +338,7 @@ export function createMenuActionHandler(ctx: MenuActionContext) { ctx.playbackStore.isPlaying = false; ctx.container.audioService.stop(); await ctx.projectService.restoreChipProcessorsForSongs(importedProject.songs); + ctx.syncChipProcessors(); ctx.applyProject(importedProject); ctx.resetPatternEditor(); } From 4b60c92db610c6a7cce28a4ffb1a792a14b0b066 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 17:55:53 +0200 Subject: [PATCH 08/28] Reset NES channels properly --- public/ay/ay-audio-driver.js | 49 ++++++----- public/ay/ay8910-worklet-slot.js | 35 ++++---- public/ay/ayumi-slot.js | 15 ++-- public/ay/ayumi-state.js | 34 +++----- public/nes/nes-apu-engine.js | 81 +++++++------------ public/nes/nes-audio-driver.js | 36 ++++----- public/nes/nes-state.js | 34 ++++---- public/nes/nes-worklet-slot.js | 27 ++++--- public/tracker/tracker-chip-state.js | 42 ++++++++++ public/tracker/tracker-engine-transport.js | 19 +++++ public/tracker/tracker-instrument-channel.js | 50 ++++++++++++ tests/public/nes-apu-engine.test.js | 6 +- .../public/tracker-instrument-channel.test.js | 40 +++++++++ 13 files changed, 283 insertions(+), 185 deletions(-) create mode 100644 public/tracker/tracker-chip-state.js create mode 100644 public/tracker/tracker-engine-transport.js create mode 100644 public/tracker/tracker-instrument-channel.js create mode 100644 tests/public/tracker-instrument-channel.test.js diff --git a/public/ay/ay-audio-driver.js b/public/ay/ay-audio-driver.js index 340f2fbc..65a60739 100644 --- a/public/ay/ay-audio-driver.js +++ b/public/ay/ay-audio-driver.js @@ -2,6 +2,12 @@ import AYChipRegisterState from './ay-chip-register-state.js'; import EffectAlgorithms from '../tracker/effect-algorithms.js'; import { PT3VolumeTable } from '../tracker/pt3-volume-table.js'; import { advanceInstrumentRowPosition } from '../tracker/tracker-audio-utils.js'; +import { + assignPatternRowInstrument, + channelHasAssignedInstrument, + getChannelInstrument, + isChannelOnOffHalted +} from '../tracker/tracker-instrument-channel.js'; import { normalizeAyInstrumentFields, getAySidBaseVolume, @@ -307,30 +313,23 @@ class AYAudioDriver { } _processInstrument(state, channelIndex, row) { - if (!state.channelInstruments || !state.instruments) return; - if (state.channelMuted[channelIndex]) return; + const assignment = assignPatternRowInstrument(state, channelIndex, row); + if (!assignment.changed) return; + if (!assignment.assigned) return; - if (row.instrument > 0) { - const instrumentIndex = state.instrumentIdToIndex.get(row.instrument); - if (instrumentIndex !== undefined && state.instruments[instrumentIndex]) { - const instrument = state.instruments[instrumentIndex]; - state.channelInstruments[channelIndex] = instrumentIndex; - const preserveSamplePlayback = this.shouldPreserveSamplePlayback(state, channelIndex, row); - if (!(preserveSamplePlayback && instrumentHasSample(instrument))) { - state.instrumentPositions[channelIndex] = 0; - if (state.channelTimerPositions) { - state.channelTimerPositions[channelIndex] = 0; - } - } - if (instrumentHasSample(instrument) && !preserveSamplePlayback) { - resetChannelSamplePlayback(state, channelIndex, instrument); - } - const preserveTimerPwmSweep = this.shouldPreserveTimerPwmSweep(state, channelIndex, row); - this.resetInstrumentAccumulators(state, channelIndex, { preserveTimerPwmSweep }); - } else { - state.channelInstruments[channelIndex] = -1; + const instrument = assignment.instrument; + const preserveSamplePlayback = this.shouldPreserveSamplePlayback(state, channelIndex, row); + if (!(preserveSamplePlayback && instrumentHasSample(instrument))) { + state.instrumentPositions[channelIndex] = 0; + if (state.channelTimerPositions) { + state.channelTimerPositions[channelIndex] = 0; } } + if (instrumentHasSample(instrument) && !preserveSamplePlayback) { + resetChannelSamplePlayback(state, channelIndex, instrument); + } + const preserveTimerPwmSweep = this.shouldPreserveTimerPwmSweep(state, channelIndex, row); + this.resetInstrumentAccumulators(state, channelIndex, { preserveTimerPwmSweep }); } rowHasPortamentoCommand(row) { @@ -888,8 +887,7 @@ class AYAudioDriver { for (let channelIndex = 0; channelIndex < state.channelInstruments.length; channelIndex++) { const isMuted = state.channelMuted[channelIndex]; const isSoundEnabled = state.channelSoundEnabled[channelIndex]; - const onOffHalted = - state.channelOnOffCounter[channelIndex] > 0 && !state.channelSoundEnabled[channelIndex]; + const onOffHalted = isChannelOnOffHalted(state, channelIndex); if (isMuted) { registerState.channels[channelIndex].volume = 0; @@ -903,8 +901,7 @@ class AYAudioDriver { continue; } - const instrumentIndex = state.channelInstruments[channelIndex]; - const instrument = state.instruments[instrumentIndex]; + const { instrumentIndex, instrument } = getChannelInstrument(state, channelIndex); if (instrumentHasSample(instrument)) { this.processSampleInstrument( @@ -918,7 +915,7 @@ class AYAudioDriver { continue; } - if (instrumentIndex < 0 || !instrument) { + if (!channelHasAssignedInstrument(state, channelIndex)) { registerState.channels[channelIndex].volume = 0; registerState.channels[channelIndex].mixer.tone = false; registerState.channels[channelIndex].mixer.noise = false; diff --git a/public/ay/ay8910-worklet-slot.js b/public/ay/ay8910-worklet-slot.js index 8085d1f3..2143f5d1 100644 --- a/public/ay/ay8910-worklet-slot.js +++ b/public/ay/ay8910-worklet-slot.js @@ -6,6 +6,7 @@ import AYChipRegisterState from './ay-chip-register-state.js'; import VirtualChannelMixer from './virtual-channel-mixer.js'; import { disableAllChannelTimerEffects, ensureChannelTimerEffects } from './ay-timer-effect-constants.js'; import { TrackerWorkletSlot } from '../tracker/tracker-worklet-slot.js'; +import { resetChipPlaybackOutput } from '../tracker/tracker-engine-transport.js'; export class Ay8910WorkletSlot extends TrackerWorkletSlot { constructor(port, chipIndex, sharedTimeline) { @@ -43,14 +44,12 @@ export class Ay8910WorkletSlot extends TrackerWorkletSlot { } _onTransportStop() { - this.registerState.reset(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - } - this._applyRegisterStateToEngine(); + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); } _dispatchChipPortMessage(type, data) { @@ -83,10 +82,12 @@ export class Ay8910WorkletSlot extends TrackerWorkletSlot { this.audioDriver.resizeChannels(totalChannels); } if (this.ayumiEngine) { - this.registerState.reset(); - this.audioDriver?.resetChannelMixerState(); - this.ayumiEngine.reset(); - this._applyRegisterStateToEngine(); + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); } } @@ -182,12 +183,10 @@ export class Ay8910WorkletSlot extends TrackerWorkletSlot { } _resetEnginesForPreview() { - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - } + resetChipPlaybackOutput({ + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine + }); } _silencePreviewChannel(channelIndex) { diff --git a/public/ay/ayumi-slot.js b/public/ay/ayumi-slot.js index 8625fb9f..6b76bbb8 100644 --- a/public/ay/ayumi-slot.js +++ b/public/ay/ayumi-slot.js @@ -14,6 +14,7 @@ import { TIMER_EFFECT_SLOT_SYNCBUZZER, disableAllChannelTimerEffects } from './ay-timer-effect-constants.js'; +import { resetChipPlaybackOutput } from '../tracker/tracker-engine-transport.js'; import AYAudioDriver from './ay-audio-driver.js'; import AyumiEngine from './ayumi-engine.js'; import TrackerPatternProcessor from '../tracker/tracker-pattern-processor.js'; @@ -175,14 +176,12 @@ export class AyumiSlot extends Ay8910WorkletSlot { _prepareOutputForPlay() { this.fadeInSamples = Math.floor(sampleRate * this.fadeInDuration); - this.registerState.reset(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.ayumiEngine) { - this.ayumiEngine.reset(); - this._applyRegisterStateToEngine(); - } + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.ayumiEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); } resetChannelWaveformCapture() { diff --git a/public/ay/ayumi-state.js b/public/ay/ayumi-state.js index 9a65fe3e..4b59ee3a 100644 --- a/public/ay/ayumi-state.js +++ b/public/ay/ayumi-state.js @@ -1,5 +1,11 @@ import { DEFAULT_AYM_FREQUENCY } from './ayumi-constants.js'; import TrackerState from '../tracker/tracker-state.js'; +import { + buildInstrumentIdToIndex, + initChipChannelArrays, + resetChipChannelArrays, + resizeChipChannelArrays +} from '../tracker/tracker-chip-state.js'; const AY_CHANNEL_ARRAY_SPECS = [ ['channelInstruments', -1], @@ -33,9 +39,7 @@ class AyumiState extends TrackerState { this.instruments = []; this.instrumentIdToIndex = new Map(); - for (const [name, defaultVal] of AY_CHANNEL_ARRAY_SPECS) { - this[name] = Array(channelCount).fill(defaultVal); - } + initChipChannelArrays(this, channelCount, AY_CHANNEL_ARRAY_SPECS); this.envelopeSlideDelay = 0; this.envelopeSlideDelayCounter = 0; @@ -104,36 +108,18 @@ class AyumiState extends TrackerState { setInstruments(instruments) { this.instruments = instruments; - this.instrumentIdToIndex = new Map(); - instruments.forEach((instrument, index) => { - if (instrument && instrument.id !== undefined) { - let numericId; - if (typeof instrument.id === 'string') { - numericId = parseInt(instrument.id, 36); - } else { - numericId = instrument.id; - } - this.instrumentIdToIndex.set(numericId, index); - } - }); + this.instrumentIdToIndex = buildInstrumentIdToIndex(instruments); } resizeChannels(newCount) { super.resizeChannels(newCount); - for (const [name, defaultVal] of AY_CHANNEL_ARRAY_SPECS) { - const arr = this[name]; - while (arr.length < newCount) arr.push(defaultVal); - if (arr.length > newCount) arr.length = newCount; - } + resizeChipChannelArrays(this, newCount, AY_CHANNEL_ARRAY_SPECS); } reset(opts = {}) { super.reset(opts); - for (const [name, defaultVal] of AY_CHANNEL_ARRAY_SPECS) { - if (name === 'channelMuted') continue; - this[name].fill(defaultVal); - } + resetChipChannelArrays(this, AY_CHANNEL_ARRAY_SPECS); this.envelopeSlideDelay = 0; this.envelopeSlideDelayCounter = 0; diff --git a/public/nes/nes-apu-engine.js b/public/nes/nes-apu-engine.js index 6af09f79..cb824e4a 100644 --- a/public/nes/nes-apu-engine.js +++ b/public/nes/nes-apu-engine.js @@ -12,6 +12,8 @@ const SQUARE_BASE = [0x4000, 0x4004]; const TRIANGLE_BASE = 0x4008; const NOISE_BASE = 0x400c; const SQUARE_SWEEP_DISABLED = 0x08; +const NES_APU_4015_PULSE_MASK = 0x03; +const NES_DMC_4015_TND_MASK = 0x0c; function buildSquareVolumeReg(volume, duty) { return (3 << 4) | (volume & 15) | ((duty & 3) << 6); @@ -134,75 +136,46 @@ class NesApuEngine { } } - _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; + if (forceApply || NES_APU_4015_PULSE_MASK !== this._lastApu4015) { + this.wasmModule.nes_apu_Write(this.apuPtr, 0x4015, NES_APU_4015_PULSE_MASK); + this._lastApu4015 = NES_APU_4015_PULSE_MASK; + } + if (forceApply || NES_DMC_4015_TND_MASK !== this._lastDmc4015) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, 0x4015, NES_DMC_4015_TND_MASK); + this._lastDmc4015 = NES_DMC_4015_TND_MASK; + } 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 isActive = channel.enabled && channel.period > 0 && channel.volume > 0; + const triggerChannel = + forceApply || channel.retrigger || (isActive && !last.enabled); + this._writeSquare(i, channel, forceApply, triggerChannel); + last.enabled = isActive; } 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 triangleActive = triangleChannel.enabled && triangleChannel.period > 0; + const triangleTrigger = + forceApply || + triangleChannel.retrigger || + (triangleActive && !triangleLast.enabled); + this._writeTriangle(triangleChannel, forceApply, triangleTrigger); + triangleLast.enabled = triangleActive; 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); - } + const noiseActive = noiseChannel.enabled && noiseChannel.volume > 0; + const noiseTrigger = + forceApply || noiseChannel.retrigger || (noiseActive && !noiseLast.enabled); + this._writeNoise(noiseChannel, forceApply, noiseTrigger); + noiseLast.enabled = noiseActive; } process(sampleRate) { diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 627c20f2..af356b90 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -4,7 +4,11 @@ import { getEffectiveTuningPeriod } from '../tracker/tracker-audio-utils.js'; import { - createDefaultNesInstrumentRow, + assignPatternRowInstrument, + channelHasAssignedInstrument, + isChannelOnOffHalted +} from '../tracker/tracker-instrument-channel.js'; +import { ensureNesInstrumentRows, normalizeNesInstrumentRow } from './nes-instrument-utils.js'; @@ -67,18 +71,7 @@ class NesAudioDriver { } _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; - } - } + assignPatternRowInstrument(state, channelIndex, row); } calculateVolume(patternVolume, instrumentVolume) { @@ -103,11 +96,9 @@ 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 loop = instrument?.loop ?? 0; + const instrument = state.instruments[instrumentIndex]; + const rows = ensureNesInstrumentRows(instrument.rows); + const loop = instrument.loop ?? 0; const rowIndex = state.instrumentPositions[channelIndex] % rows.length; return { row: normalizeNesInstrumentRow(rows[rowIndex]), @@ -123,15 +114,18 @@ class NesAudioDriver { const isMuted = state.channelMuted[channelIndex]; const isSoundEnabled = state.channelSoundEnabled[channelIndex]; - const onOffHalted = - state.channelOnOffCounter[channelIndex] > 0 && - !state.channelSoundEnabled[channelIndex]; + const onOffHalted = isChannelOnOffHalted(state, channelIndex); if (isMuted || !isSoundEnabled) { this._silenceChannel(registerState, channelIndex); continue; } + if (!channelHasAssignedInstrument(state, channelIndex)) { + this._silenceChannel(registerState, channelIndex); + continue; + } + const { row, rowsLength, loop } = this.resolveInstrumentRow(state, channelIndex); const patternVolume = state.channelPatternVolumes[channelIndex] ?? 15; const volume = this.calculateVolume(patternVolume, 15); diff --git a/public/nes/nes-state.js b/public/nes/nes-state.js index f090a6c9..53d8b258 100644 --- a/public/nes/nes-state.js +++ b/public/nes/nes-state.js @@ -1,4 +1,10 @@ import TrackerState from '../tracker/tracker-state.js'; +import { + buildInstrumentIdToIndex, + initChipChannelArrays, + resetChipChannelArrays, + resizeChipChannelArrays +} from '../tracker/tracker-chip-state.js'; import { NES_CHANNEL_COUNT, NES_NTSC_CPU_FREQUENCY } from './nes-constants.js'; const NES_CHANNEL_ARRAY_SPECS = [ @@ -24,9 +30,7 @@ class NesState extends TrackerState { this.instruments = []; this.instrumentIdToIndex = new Map(); - for (const [name, defaultVal] of NES_CHANNEL_ARRAY_SPECS) { - this[name] = Array(NES_CHANNEL_COUNT).fill(defaultVal); - } + initChipChannelArrays(this, NES_CHANNEL_COUNT, NES_CHANNEL_ARRAY_SPECS); } setWasmModule(wasmModule, apuPtr, dmcPtr, wasmBuffer) { @@ -48,27 +52,17 @@ class NesState extends TrackerState { setInstruments(instruments) { this.instruments = instruments; - this.instrumentIdToIndex = new Map(); - instruments.forEach((instrument, index) => { - if (instrument && instrument.id !== undefined) { - let numericId; - if (typeof instrument.id === 'string') { - numericId = parseInt(instrument.id, 36); - } else { - numericId = instrument.id; - } - this.instrumentIdToIndex.set(numericId, index); - } - }); + this.instrumentIdToIndex = buildInstrumentIdToIndex(instruments); } resizeChannels(newCount) { super.resizeChannels(newCount); - for (const [name, defaultVal] of NES_CHANNEL_ARRAY_SPECS) { - const arr = this[name]; - while (arr.length < newCount) arr.push(defaultVal); - if (arr.length > newCount) arr.length = newCount; - } + resizeChipChannelArrays(this, newCount, NES_CHANNEL_ARRAY_SPECS); + } + + reset(opts = {}) { + super.reset(opts); + resetChipChannelArrays(this, NES_CHANNEL_ARRAY_SPECS); } } diff --git a/public/nes/nes-worklet-slot.js b/public/nes/nes-worklet-slot.js index cec062b4..a3da6190 100644 --- a/public/nes/nes-worklet-slot.js +++ b/public/nes/nes-worklet-slot.js @@ -5,6 +5,7 @@ import NesChipRegisterState from './nes-chip-register-state.js'; import { NesWaveformCapture } from './nes-waveform-capture.js'; import TrackerPatternProcessor from '../tracker/tracker-pattern-processor.js'; import { TrackerWorkletSlot } from '../tracker/tracker-worklet-slot.js'; +import { resetChipPlaybackOutput } from '../tracker/tracker-engine-transport.js'; import { NES_CHANNEL_COUNT } from './nes-constants.js'; export class NesWorkletSlot extends TrackerWorkletSlot { @@ -40,16 +41,22 @@ export class NesWorkletSlot extends TrackerWorkletSlot { return this._playbackWorkersReady() && this.apuEngine; } + _prepareOutputForPlay() { + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.apuEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); + } + _onTransportStop() { - this.registerState.reset(); - this.resetChannelWaveformCapture(); - if (this.audioDriver) { - this.audioDriver.resetChannelMixerState(); - } - if (this.apuEngine) { - this.apuEngine.reset(); - } - this._applyRegisterStateToEngine(); + resetChipPlaybackOutput({ + registerState: this.registerState, + audioDriver: this.audioDriver, + chipEngine: this.apuEngine, + applyRegisterState: () => this._applyRegisterStateToEngine() + }); } _applyRegisterStateToEngine() { @@ -177,7 +184,7 @@ export class NesWorkletSlot extends TrackerWorkletSlot { } _resetEnginesForPreview() { - this.apuEngine?.reset(); + resetChipPlaybackOutput({ chipEngine: this.apuEngine }); } _silencePreviewChannel(channelIndex) { diff --git a/public/tracker/tracker-chip-state.js b/public/tracker/tracker-chip-state.js new file mode 100644 index 00000000..a43b5a37 --- /dev/null +++ b/public/tracker/tracker-chip-state.js @@ -0,0 +1,42 @@ +export const TRACKER_CHANNEL_ARRAY_SKIP_ON_RESET = ['channelMuted']; + +export function buildInstrumentIdToIndex(instruments) { + const instrumentIdToIndex = new Map(); + instruments.forEach((instrument, index) => { + if (instrument && instrument.id !== undefined) { + let numericId; + if (typeof instrument.id === 'string') { + numericId = parseInt(instrument.id, 36); + } else { + numericId = instrument.id; + } + instrumentIdToIndex.set(numericId, index); + } + }); + return instrumentIdToIndex; +} + +export function initChipChannelArrays(state, channelCount, specs) { + for (const [name, defaultVal] of specs) { + state[name] = Array(channelCount).fill(defaultVal); + } +} + +export function resizeChipChannelArrays(state, newCount, specs) { + for (const [name, defaultVal] of specs) { + const arr = state[name]; + while (arr.length < newCount) arr.push(defaultVal); + if (arr.length > newCount) arr.length = newCount; + } +} + +export function resetChipChannelArrays( + state, + specs, + skipNames = TRACKER_CHANNEL_ARRAY_SKIP_ON_RESET +) { + for (const [name, defaultVal] of specs) { + if (skipNames.includes(name)) continue; + state[name].fill(defaultVal); + } +} diff --git a/public/tracker/tracker-engine-transport.js b/public/tracker/tracker-engine-transport.js new file mode 100644 index 00000000..a80a6b90 --- /dev/null +++ b/public/tracker/tracker-engine-transport.js @@ -0,0 +1,19 @@ +export function resetChipPlaybackOutput({ + registerState, + audioDriver, + chipEngine, + applyRegisterState +}) { + if (registerState) { + registerState.reset(); + } + if (audioDriver?.resetChannelMixerState) { + audioDriver.resetChannelMixerState(); + } + if (chipEngine) { + chipEngine.reset(); + } + if (applyRegisterState) { + applyRegisterState(); + } +} diff --git a/public/tracker/tracker-instrument-channel.js b/public/tracker/tracker-instrument-channel.js new file mode 100644 index 00000000..f1127728 --- /dev/null +++ b/public/tracker/tracker-instrument-channel.js @@ -0,0 +1,50 @@ +export function getChannelInstrument(state, channelIndex) { + const instrumentIndex = state.channelInstruments?.[channelIndex] ?? -1; + if (instrumentIndex < 0) { + return { instrumentIndex: -1, instrument: null }; + } + const instrument = state.instruments?.[instrumentIndex] ?? null; + return { instrumentIndex, instrument }; +} + +export function channelHasAssignedInstrument(state, channelIndex) { + const { instrumentIndex, instrument } = getChannelInstrument(state, channelIndex); + return instrumentIndex >= 0 && instrument != null; +} + +export function isChannelOnOffHalted(state, channelIndex) { + return ( + state.channelOnOffCounter?.[channelIndex] > 0 && !state.channelSoundEnabled[channelIndex] + ); +} + +export function assignPatternRowInstrument(state, channelIndex, row) { + if (!state.channelInstruments || !state.instruments || state.channelMuted?.[channelIndex]) { + return { changed: false, assigned: false, instrument: null, instrumentIndex: -1 }; + } + + if (!row.instrument || row.instrument <= 0) { + const { instrumentIndex, instrument } = getChannelInstrument(state, channelIndex); + return { + changed: false, + assigned: instrumentIndex >= 0 && instrument != null, + instrument, + instrumentIndex + }; + } + + const instrumentIndex = state.instrumentIdToIndex?.get(row.instrument); + if (instrumentIndex === undefined || !state.instruments[instrumentIndex]) { + state.channelInstruments[channelIndex] = -1; + return { changed: true, assigned: false, instrument: null, instrumentIndex: -1 }; + } + + state.channelInstruments[channelIndex] = instrumentIndex; + state.instrumentPositions[channelIndex] = 0; + return { + changed: true, + assigned: true, + instrument: state.instruments[instrumentIndex], + instrumentIndex + }; +} diff --git a/tests/public/nes-apu-engine.test.js b/tests/public/nes-apu-engine.test.js index d65ac824..d002a46e 100644 --- a/tests/public/nes-apu-engine.test.js +++ b/tests/public/nes-apu-engine.test.js @@ -28,8 +28,6 @@ describe('NesApuEngine', () => { 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; @@ -70,11 +68,11 @@ describe('NesApuEngine', () => { engine.applyRegisterState(registerState); registerState.channels[0].enabled = false; + registerState.channels[0].volume = 0; engine.applyRegisterState(registerState); - wasmModule.nes_apu_Write(engine.apuPtr, 0x4015, 0); - registerState.channels[0].enabled = true; + registerState.channels[0].volume = 15; registerState.channels[0].retrigger = false; engine.applyRegisterState(registerState); diff --git a/tests/public/tracker-instrument-channel.test.js b/tests/public/tracker-instrument-channel.test.js new file mode 100644 index 00000000..f777f45d --- /dev/null +++ b/tests/public/tracker-instrument-channel.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + assignPatternRowInstrument, + channelHasAssignedInstrument, + getChannelInstrument +} from '../../public/tracker/tracker-instrument-channel.js'; + +describe('tracker-instrument-channel', () => { + const state = { + channelInstruments: [-1, 1], + instrumentPositions: [0, 0], + instruments: [{ id: 1 }, { id: 2 }], + instrumentIdToIndex: new Map([ + [1, 0], + [2, 1] + ]), + channelMuted: [false, false] + }; + + it('assigns instrument from pattern row', () => { + const result = assignPatternRowInstrument(state, 0, { instrument: 1 }); + expect(result.assigned).toBe(true); + expect(result.instrumentIndex).toBe(0); + expect(state.channelInstruments[0]).toBe(0); + }); + + it('clears instrument when pattern row instrument is missing', () => { + assignPatternRowInstrument(state, 0, { instrument: 99 }); + expect(state.channelInstruments[0]).toBe(-1); + expect(channelHasAssignedInstrument(state, 0)).toBe(false); + }); + + it('reports assigned instrument for channel', () => { + expect(getChannelInstrument(state, 1)).toEqual({ + instrumentIndex: 1, + instrument: { id: 2 } + }); + expect(channelHasAssignedInstrument(state, 1)).toBe(true); + }); +}); From d3b167521fc7510dede4fe4c89c967dcef508465 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 22:10:28 +0200 Subject: [PATCH 09/28] Fix multichip issues --- public/audio/bitphase-audio-processor.js | 29 ++++++++++++------- public/ay/ay8910-worklet-slot.js | 3 +- public/ay/ayumi-slot.js | 17 ++++++++++- public/nes/nes-worklet-slot.js | 3 +- .../tracker/timeline-pattern-coordinator.js | 1 - public/tracker/tracker-worklet-slot.js | 23 +++++++++++++++ public/tracker/worklet-slot-base.js | 23 ++++++++++++--- src/App.svelte | 2 +- .../chips/ay/AYInstrumentSamplePanel.svelte | 2 +- src/lib/chips/ay/AYPreviewRow.svelte | 4 +-- src/lib/chips/ay/AYTimerWaveformEditor.svelte | 2 +- src/lib/components/Details/DetailsView.svelte | 17 +++++++---- src/lib/components/Song/SongView.svelte | 9 +++--- src/lib/services/audio/audio-service.ts | 8 +++-- src/lib/services/audio/chip-settings.ts | 19 ++++++++++++ src/lib/services/project/project-service.ts | 3 ++ src/lib/stores/project.svelte.ts | 4 +++ .../services/project/project-service.test.ts | 11 +++++++ 18 files changed, 142 insertions(+), 38 deletions(-) diff --git a/public/audio/bitphase-audio-processor.js b/public/audio/bitphase-audio-processor.js index 2ae8c7f7..efa952de 100644 --- a/public/audio/bitphase-audio-processor.js +++ b/public/audio/bitphase-audio-processor.js @@ -2,6 +2,10 @@ import SongTimeline from '../tracker/song-timeline.js'; import { createAudioSlot } from './audio-slot-registry.js'; import './builtin-audio-slots.js'; +function sortPlaySlotsForQuantum(playSlots) { + return [...playSlots].sort((a, b) => (b.chipIndex ?? 0) - (a.chipIndex ?? 0)); +} + class BitphaseAudioProcessor extends AudioWorkletProcessor { constructor() { super(); @@ -78,14 +82,19 @@ class BitphaseAudioProcessor extends AudioWorkletProcessor { return true; } + const active = slots.filter((s) => s && s.canRender()); + const anyPreview = active.some((s) => s.isPreviewActive()); + const playSlots = anyPreview ? [] : active.filter((s) => s.shouldRunPlaybackAccumulation()); + const outputSlots = anyPreview + ? [] + : active.filter((s) => s.shouldAccumulateStereoOutput()); + const quantumSlots = sortPlaySlotsForQuantum(playSlots); + const leaderLen = this.leaderPatternLength(); + for (let i = 0; i < numSamples; i++) { tl.tickAccumulator += tl.tickStep; const mix = { l: 0, r: 0 }; - const active = slots.filter((s) => s && s.canRender()); - const anyPreview = active.some((s) => s.isPreviewActive()); - const playSlots = active.filter((s) => s.shouldRunPlaybackAccumulation()); - if (anyPreview) { for (const s of active) { if (s.isPreviewActive()) { @@ -94,21 +103,19 @@ class BitphaseAudioProcessor extends AudioWorkletProcessor { } } } else if (playSlots.length > 0 && tl.tickAccumulator >= 1.0) { - for (const s of playSlots) { + for (const s of quantumSlots) { s.runSharedPlaybackQuantum(); } - const leaderLen = this.leaderPatternLength(); const needsOrderWrap = tl.advancePosition(leaderLen); for (let j = 0; j < slots.length; j++) { const s = slots[j]; if (s) s.onPatternOrderAdvanced(needsOrderWrap); } tl.tickAccumulator -= 1.0; - for (const s of playSlots) { - s.accumulateStereoOutput(i, mix); - } - } else { - for (const s of playSlots) { + } + + if (!anyPreview) { + for (const s of outputSlots) { s.accumulateStereoOutput(i, mix); } } diff --git a/public/ay/ay8910-worklet-slot.js b/public/ay/ay8910-worklet-slot.js index 2143f5d1..9df0997d 100644 --- a/public/ay/ay8910-worklet-slot.js +++ b/public/ay/ay8910-worklet-slot.js @@ -166,7 +166,6 @@ export class Ay8910WorkletSlot extends TrackerWorkletSlot { this.state.channelEnvelopeEnabled[ch] = false; } } - this._applyRegisterStateToEngine(); } ensurePlaybackWorkers() { @@ -194,6 +193,6 @@ export class Ay8910WorkletSlot extends TrackerWorkletSlot { } canRender() { - return this.initialized && this.state.wasmModule && this.state.ayumiPtr; + return Boolean(this.initialized && this.state.wasmModule && this.state.ayumiPtr); } } diff --git a/public/ay/ayumi-slot.js b/public/ay/ayumi-slot.js index 6b76bbb8..2b1654d5 100644 --- a/public/ay/ayumi-slot.js +++ b/public/ay/ayumi-slot.js @@ -307,7 +307,22 @@ export class AyumiSlot extends Ay8910WorkletSlot { } accumulateStereoOutput(sampleIndex, mix) { - if (this.audioDriver && this.ayumiEngine) { + if (!this.ayumiEngine) { + return; + } + const channelMuted = this.state.channelMuted; + const channelSoundEnabled = this.state.channelSoundEnabled; + let hasAudibleChannel = false; + for (let ch = 0; ch < channelMuted.length; ch++) { + if (!channelMuted[ch] && channelSoundEnabled[ch]) { + hasAudibleChannel = true; + break; + } + } + if (!hasAudibleChannel) { + return; + } + if (this.audioDriver) { const resolveAyumiChannelIndex = this.virtualChannelMixer?.hasVirtualChannels?.() ? (channelIndex) => this.virtualChannelMixer.getHardwareChannelIndex(channelIndex) diff --git a/public/nes/nes-worklet-slot.js b/public/nes/nes-worklet-slot.js index a3da6190..59abb759 100644 --- a/public/nes/nes-worklet-slot.js +++ b/public/nes/nes-worklet-slot.js @@ -97,7 +97,6 @@ export class NesWorkletSlot extends TrackerWorkletSlot { this.audioDriver._silenceChannel(this.registerState, ch); } } - this._applyRegisterStateToEngine(); } async handleMessage(payload) { @@ -192,7 +191,7 @@ export class NesWorkletSlot extends TrackerWorkletSlot { } canRender() { - return this.initialized && this.state.wasmModule && this.state.apuPtr; + return Boolean(this.initialized && this.state.wasmModule && this.state.apuPtr); } _collectPlaybackHz() { diff --git a/public/tracker/timeline-pattern-coordinator.js b/public/tracker/timeline-pattern-coordinator.js index be4b56d5..2f46806a 100644 --- a/public/tracker/timeline-pattern-coordinator.js +++ b/public/tracker/timeline-pattern-coordinator.js @@ -52,7 +52,6 @@ export class TimelinePatternCoordinator { pattern: data.pattern, orderIndex: data.patternOrderIndex }; - this.nextPatternRequested = false; return; } diff --git a/public/tracker/tracker-worklet-slot.js b/public/tracker/tracker-worklet-slot.js index ac9175c7..1ca3845c 100644 --- a/public/tracker/tracker-worklet-slot.js +++ b/public/tracker/tracker-worklet-slot.js @@ -54,6 +54,28 @@ export class TrackerWorkletSlot extends WorkletSlotBase { } } + _afterPlaybackPositionSet(rowIndex) { + this._primePatternRowForPlayback(rowIndex); + } + + _primePatternRowForPlayback(rowIndex) { + if (!this._chipEngineReady()) { + return; + } + const pattern = this.state.currentPattern; + if (!pattern?.length || rowIndex < 0 || rowIndex >= pattern.length) { + return; + } + if (pattern.channels?.length) { + this._resizeForPatternChannels(pattern.channels.length); + } + this.patternProcessor.parsePatternRow(pattern, rowIndex, this.registerState); + this.patternProcessor.processSpeedTable(); + this.enforceMuteState(); + this._processTrackerTick(); + this._applyRegisterStateToEngine(); + } + _afterTransportStop() { this.handleStopPreview(); } @@ -148,6 +170,7 @@ export class TrackerWorkletSlot extends WorkletSlotBase { handleInitSpeed({ speed }) { if (!(speed > 0)) return; + if (this.chipIndex !== 0) return; this.state.publishPlaybackSpeed(speed); } diff --git a/public/tracker/worklet-slot-base.js b/public/tracker/worklet-slot-base.js index 6b2ce4a6..11ad1d8e 100644 --- a/public/tracker/worklet-slot-base.js +++ b/public/tracker/worklet-slot-base.js @@ -36,6 +36,11 @@ export class WorkletSlotBase { _applyPlaybackSpeed(_speed) {} + _publishLeaderPlaybackSpeed(speed) { + if (this.chipIndex !== 0 || !(speed > 0)) return; + this._applyPlaybackSpeed(speed); + } + _onTransportStop() {} _afterTransportStop() {} @@ -56,6 +61,7 @@ export class WorkletSlotBase { this._runCatchUpRows(this._slotState().timeline.currentRow); } if (!paused) { + this._afterPlaybackPositionSet(this._slotState().timeline.currentRow); this.timelinePattern.postPositionUpdate(); } } @@ -97,7 +103,7 @@ export class WorkletSlotBase { } if (speed !== undefined && speed !== null && speed > 0) { - this._applyPlaybackSpeed(speed); + this._publishLeaderPlaybackSpeed(speed); } if (row !== undefined && (patternChanged || state.currentPattern)) { @@ -124,10 +130,11 @@ export class WorkletSlotBase { state.timeline.currentPatternOrderIndex = startPatternOrderIndex; } if (initialSpeed !== undefined && initialSpeed > 0) { - this._applyPlaybackSpeed(initialSpeed); + this._publishLeaderPlaybackSpeed(initialSpeed); } this.timelinePattern.postPositionUpdate(); + this._afterPlaybackPositionSet(state.timeline.currentRow); } handlePlayFromRow({ row, patternOrderIndex, speed }) { @@ -151,11 +158,12 @@ export class WorkletSlotBase { } state.timeline.currentRow = row; if (speed !== undefined && speed !== null && speed > 0) { - this._applyPlaybackSpeed(speed); + this._publishLeaderPlaybackSpeed(speed); } this.timelinePattern.postPositionUpdate(); this._runCatchUpRows(state.timeline.currentRow); + this._afterPlaybackPositionSet(state.timeline.currentRow); } handlePlayFromPosition({ @@ -173,7 +181,7 @@ export class WorkletSlotBase { this.startPlaybackCommon(); if (speed !== undefined && speed !== null && speed > 0) { - this._applyPlaybackSpeed(speed); + this._publishLeaderPlaybackSpeed(speed); } this._replayCatchUpSegments(catchUpSegments); @@ -187,6 +195,7 @@ export class WorkletSlotBase { state.timeline.currentRow = startRow; this.timelinePattern.postPositionUpdate(); + this._afterPlaybackPositionSet(startRow); } startPlaybackCommon() { @@ -220,6 +229,12 @@ export class WorkletSlotBase { return p && p.length > 0 ? p.length : 0; } + _afterPlaybackPositionSet(_rowIndex) {} + + shouldAccumulateStereoOutput() { + return !this.paused; + } + shouldRunPlaybackAccumulation() { const state = this._slotState(); return ( diff --git a/src/App.svelte b/src/App.svelte index 036bdc0a..4196314c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -201,7 +201,7 @@ .forEach((s) => { const value = firstSong[s.key] ?? s.defaultValue; if (value !== undefined) { - container.audioService.chipSettings.set(s.key, value); + container.audioService.chipSettings.forChip(chipType).set(s.key, value); } }); }); diff --git a/src/lib/chips/ay/AYInstrumentSamplePanel.svelte b/src/lib/chips/ay/AYInstrumentSamplePanel.svelte index d6347a8f..d0030eb5 100644 --- a/src/lib/chips/ay/AYInstrumentSamplePanel.svelte +++ b/src/lib/chips/ay/AYInstrumentSamplePanel.svelte @@ -81,7 +81,7 @@ }); $effect(() => { - const chipSettings = containerContext.audioService.chipSettings; + const chipSettings = containerContext.audioService.chipSettings.forChip('ay'); return chipSettings.subscribe('chipVariant', (value) => { chipVariant = resolveAyChipVariant(value); }); diff --git a/src/lib/chips/ay/AYPreviewRow.svelte b/src/lib/chips/ay/AYPreviewRow.svelte index 832302ad..8f8e0b22 100644 --- a/src/lib/chips/ay/AYPreviewRow.svelte +++ b/src/lib/chips/ay/AYPreviewRow.svelte @@ -124,7 +124,7 @@ $effect(() => { return () => { if (savedStereoLayout !== undefined) { - containerContext.audioService.chipSettings.set('stereoLayout', savedStereoLayout); + containerContext.audioService.chipSettings.forChip(chip.type).set('stereoLayout', savedStereoLayout); savedStereoLayout = undefined; } }; @@ -134,7 +134,7 @@ const processors = previewProcessors as unknown as PreviewNoteSupport[]; if (processors.length === 0) return; const hasNotes = effectiveNoteStrings.length > 0; - const chipSettings = containerContext.audioService.chipSettings; + const chipSettings = containerContext.audioService.chipSettings.forChip(chip.type); if (!hasNotes) { if (hadActiveNotes) { hadActiveNotes = false; diff --git a/src/lib/chips/ay/AYTimerWaveformEditor.svelte b/src/lib/chips/ay/AYTimerWaveformEditor.svelte index 36a47414..f062d6ed 100644 --- a/src/lib/chips/ay/AYTimerWaveformEditor.svelte +++ b/src/lib/chips/ay/AYTimerWaveformEditor.svelte @@ -56,7 +56,7 @@ let hoverStepIndex = $state(null); const chipVariant = $derived( - resolveAyChipVariant(containerContext.audioService.chipSettings.get('chipVariant')) + resolveAyChipVariant(containerContext.audioService.chipSettings.forChip('ay').get('chipVariant')) ); const waveform = $derived(controller.rowTimerWaveform(rowIndex)); diff --git a/src/lib/components/Details/DetailsView.svelte b/src/lib/components/Details/DetailsView.svelte index 90214c40..ade03b0f 100644 --- a/src/lib/components/Details/DetailsView.svelte +++ b/src/lib/components/Details/DetailsView.svelte @@ -129,7 +129,14 @@ ] ); if (setting.notifyAudioService) { - services.audioService.chipSettings.set(key, value); + for (const group of chipsByType) { + const notifies = group.chip.schema.settings?.some( + (s) => s.key === key && s.notifyAudioService + ); + if (notifies) { + services.audioService.chipSettings.forChip(group.type).set(key, normalized); + } + } } } @@ -144,7 +151,7 @@ for (const s of songsOfType) { s.tuningTable = table; } - services.audioService.chipSettings.set('tuningTable', [...table]); + services.audioService.chipSettings.forChip(chipType).set('tuningTable', [...table]); } function getChipSettingValue(chipType: string, key: string): unknown { @@ -188,7 +195,7 @@ const songsOfType = songs.filter((s) => s.chipType === chipType); for (const [updateKey, updateValue] of Object.entries(updates)) { const currentValue = getChipSettingValue(chipType, updateKey); - const audioValue = services.audioService.chipSettings.get(updateKey); + const audioValue = services.audioService.chipSettings.forChip(chipType).get(updateKey); const updateSetting = chipSettings.find((s) => s.key === updateKey); const needsSongUpdate = songsOfType.some( (song) => (song as unknown as Record)[updateKey] !== updateValue @@ -211,7 +218,7 @@ for (const processor of processors) { processor.updateParameter(updateKey, updateValue); } - services.audioService.chipSettings.set(updateKey, updateValue); + services.audioService.chipSettings.forChip(chipType).set(updateKey, updateValue); } } } @@ -274,7 +281,7 @@ for (const processor of processors) { processor.updateParameter(key, normalized); } - services.audioService.chipSettings.set(key, normalized); + services.audioService.chipSettings.forChip(chipType).set(key, normalized); if (chip) { const context = buildChipContext(chipType, chipSettings); diff --git a/src/lib/components/Song/SongView.svelte b/src/lib/components/Song/SongView.svelte index 6c43b709..0c733a4a 100644 --- a/src/lib/components/Song/SongView.svelte +++ b/src/lib/components/Song/SongView.svelte @@ -324,9 +324,6 @@ const songPatterns = projectStore.patterns[index]; if (!song || !songPatterns) return; - const currentPattern = songPatterns.find((p) => p.id === patternId); - if (!currentPattern) return; - const withVirtual = chipProcessor as ChipProcessor & Partial; if (withVirtual.sendVirtualChannelConfig) { const hwLabels = chipProcessor.chip?.schema?.channelLabels ?? ['A', 'B', 'C']; @@ -336,7 +333,6 @@ ); } - chipProcessor.sendInitPattern(currentPattern, patternOrderIndexForInit); chipProcessor.sendInitTables(projectStore.tables); const withTuningTables = chipProcessor as ChipProcessor & Partial; @@ -349,6 +345,11 @@ filterInstrumentsForChip(projectStore.instruments, chipProcessor.chip.type) ); } + + const currentPattern = songPatterns.find((p) => p.id === patternId); + if (!currentPattern) return; + + chipProcessor.sendInitPattern(currentPattern, patternOrderIndexForInit); }); if (!playPattern) { diff --git a/src/lib/services/audio/audio-service.ts b/src/lib/services/audio/audio-service.ts index 3972c86c..7231ac23 100644 --- a/src/lib/services/audio/audio-service.ts +++ b/src/lib/services/audio/audio-service.ts @@ -5,7 +5,7 @@ import { } from '../../chips/base/processor'; import type { Chip } from '../../chips/types'; import type { Table } from '../../models/project'; -import { ChipSettings } from './chip-settings'; +import { ChipSettingsRegistry } from './chip-settings'; import type { CatchUpSegment } from './play-from-position'; import { channelMuteStore } from '../../stores/channel-mute.svelte'; import { waveformStore } from '../../stores/waveform.svelte'; @@ -28,7 +28,7 @@ export class AudioService { private _audioContext: AudioContext | null = new AudioContext(); private _isPlaying = false; private _previewChipIndices = new Set(); - public chipSettings: ChipSettings = new ChipSettings(); + public chipSettings = new ChipSettingsRegistry(); private _masterGainNode: GainNode | null = null; private _playPatternRestoreOrder: number[] | null = null; private _playPatternRestoreLoopPointId = 0; @@ -106,6 +106,7 @@ export class AudioService { const wasmBuffer = await this._loadWasm(processor.chip.wasmUrl); if (revision !== this._processorRevision || !this._mixerNode) return; processor.initialize(wasmBuffer, this._mixerNode); + this.chipSettings.forChip(processor.chip.type).renotifyAll(); } } } @@ -125,7 +126,7 @@ export class AudioService { let unsubscribeSettings: (() => void) | undefined; if (this.hasSettingsSubscription(processor)) { - processor.subscribeToSettings(this.chipSettings); + processor.subscribeToSettings(this.chipSettings.forChip(chip.type)); unsubscribeSettings = () => processor.unsubscribeFromSettings(); } @@ -155,6 +156,7 @@ export class AudioService { processor.bindChipIndex(chipIndex); processor.initialize(wasmBuffer, this._mixerNode); + this.chipSettings.forChip(chip.type).renotifyAll(); const processorWithWaveform = processor as { setWaveformCallback?: (cb: (channels: Float32Array[]) => void) => void; diff --git a/src/lib/services/audio/chip-settings.ts b/src/lib/services/audio/chip-settings.ts index 7c37aac7..47195684 100644 --- a/src/lib/services/audio/chip-settings.ts +++ b/src/lib/services/audio/chip-settings.ts @@ -42,4 +42,23 @@ export class ChipSettings { this.set(key, value); }); } + + renotifyAll(): void { + for (const [key, value] of this.settings) { + this.notify(key, value); + } + } +} + +export class ChipSettingsRegistry { + private readonly stores = new Map(); + + forChip(chipType: string): ChipSettings { + let store = this.stores.get(chipType); + if (!store) { + store = new ChipSettings(); + this.stores.set(chipType, store); + } + return store; + } } diff --git a/src/lib/services/project/project-service.ts b/src/lib/services/project/project-service.ts index bc9c0c0c..41aa3bfa 100644 --- a/src/lib/services/project/project-service.ts +++ b/src/lib/services/project/project-service.ts @@ -31,6 +31,9 @@ export class ProjectService { newSong.chipType = chip.type; applySchemaDefaults(newSong, chip.schema); this.applyChipDefaults(newSong, chip.schema); + if (existingSongs.length > 0) { + newSong.initialSpeed = existingSongs[0].initialSpeed; + } this.syncFromPeerSongs(newSong, existingSongs, chip.schema); await this.audioService.addChipProcessor(chip); return newSong; diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index 76176fa0..bbb3ea67 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -111,6 +111,10 @@ class ProjectStore { } addSong(song: Song): void { + const projectInitialSpeed = this.settings.initialSpeed; + if (typeof projectInitialSpeed === 'number' && projectInitialSpeed >= 1) { + song.initialSpeed = projectInitialSpeed; + } this.songs = [...this.songs, song]; this.patterns = [...this.patterns, song.patterns]; } diff --git a/tests/lib/services/project/project-service.test.ts b/tests/lib/services/project/project-service.test.ts index 28ed2ea9..bd504856 100644 --- a/tests/lib/services/project/project-service.test.ts +++ b/tests/lib/services/project/project-service.test.ts @@ -139,6 +139,17 @@ describe('ProjectService', () => { expect(mockAudioService.addChipProcessor).toHaveBeenCalledOnce(); expect(mockAudioService.addChipProcessor).toHaveBeenCalledWith(mockChip); }); + + it('should inherit initialSpeed from an existing song', async () => { + const mockChip = createMockChip(); + const existing = new Song(mockChip.schema); + existing.chipType = CHIP_TYPE_AY; + existing.initialSpeed = 12; + + const song = await projectService.createNewSong(mockChip, [existing]); + + expect(song.initialSpeed).toBe(12); + }); }); describe('restoreChipProcessorsForSongs', () => { From 6c84effe6e914e5a6446ea8c195d60046b783acd Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 22:14:13 +0200 Subject: [PATCH 10/28] Add tone offset and accumulation features to NES audio driver and instrument editor --- public/nes/nes-audio-driver.js | 34 +++++- public/nes/nes-instrument-utils.js | 15 ++- public/nes/nes-state.js | 3 +- src/lib/chips/nes/NESInstrumentEditor.svelte | 117 ++++++++++++++++++- src/lib/chips/nes/instrument.ts | 17 ++- tests/lib/chips/nes/instrument.test.ts | 19 ++- 6 files changed, 192 insertions(+), 13 deletions(-) diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index af356b90..5b4e9317 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -56,6 +56,27 @@ class NesAudioDriver { channel.volume = 0; } + _resetToneAccumulator(state, channelIndex) { + if (state.channelToneAccumulator) { + state.channelToneAccumulator[channelIndex] = 0; + } + } + + _applyToneOffset(state, channelIndex, instrumentRow, basePeriod) { + if (basePeriod <= 0) return 0; + let sampleTone = state.channelToneAccumulator[channelIndex] ?? 0; + if (instrumentRow.toneAdd !== 0) { + sampleTone += instrumentRow.toneAdd; + } + if (instrumentRow.toneAccumulation) { + state.channelToneAccumulator[channelIndex] = sampleTone; + } + const period = basePeriod + sampleTone; + if (period < 0) return 0; + if (period > 2047) return 2047; + return period; + } + _processNote(state, channelIndex, row) { if (state.channelMuted[channelIndex]) return; @@ -63,15 +84,20 @@ class NesAudioDriver { state.channelSoundEnabled[channelIndex] = false; state.instrumentPositions[channelIndex] = 0; state.channelKeyOn[channelIndex] = false; + this._resetToneAccumulator(state, channelIndex); } else if (row.note.name !== 0) { state.channelSoundEnabled[channelIndex] = true; state.instrumentPositions[channelIndex] = 0; state.channelKeyOn[channelIndex] = true; + this._resetToneAccumulator(state, channelIndex); } } _processInstrument(state, channelIndex, row) { - assignPatternRowInstrument(state, channelIndex, row); + const assignment = assignPatternRowInstrument(state, channelIndex, row); + if (assignment.changed) { + this._resetToneAccumulator(state, channelIndex); + } } calculateVolume(patternVolume, instrumentVolume) { @@ -129,7 +155,11 @@ class NesAudioDriver { 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 basePeriod = this.getEffectivePeriod(state, channelIndex); + const period = + channelIndex <= 2 + ? this._applyToneOffset(state, channelIndex, row, basePeriod) + : basePeriod; const keyOn = state.channelKeyOn[channelIndex]; if (channelIndex <= 1) { diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js index 37e39d95..d7dcc4a1 100644 --- a/public/nes/nes-instrument-utils.js +++ b/public/nes/nes-instrument-utils.js @@ -1,7 +1,16 @@ export const NES_PULSE_WIDTHS = [0, 1, 2, 3]; +const TONE_ADD_MIN = -4096; +const TONE_ADD_MAX = 4095; + +function normalizeToneAdd(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(TONE_ADD_MIN, Math.min(TONE_ADD_MAX, Math.round(parsed))); +} + export function createDefaultNesInstrumentRow() { - return { pulseWidth: 2, retrigger: false }; + return { pulseWidth: 2, retrigger: false, toneAdd: 0, toneAccumulation: false }; } export function normalizeNesInstrumentRow(row) { @@ -9,7 +18,9 @@ export function normalizeNesInstrumentRow(row) { const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) ? row.pulseWidth : defaults.pulseWidth; return { pulseWidth, - retrigger: Boolean(row?.retrigger) + retrigger: Boolean(row?.retrigger), + toneAdd: normalizeToneAdd(row?.toneAdd), + toneAccumulation: Boolean(row?.toneAccumulation) }; } diff --git a/public/nes/nes-state.js b/public/nes/nes-state.js index 53d8b258..0c0b0fef 100644 --- a/public/nes/nes-state.js +++ b/public/nes/nes-state.js @@ -14,7 +14,8 @@ const NES_CHANNEL_ARRAY_SPECS = [ ['channelPatternVolumes', 15], ['channelMuted', false], ['channelSoundEnabled', false], - ['channelKeyOn', false] + ['channelKeyOn', false], + ['channelToneAccumulator', 0] ]; class NesState extends TrackerState { diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index 3ee4c24f..f64181c2 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -25,6 +25,13 @@ SelectableRowNumberCell } from '../../components/RowEditorTable'; import { ROW_SELECTION_STYLES } from '../../utils/row-selection'; + import { + formatRowEditorNumber, + focusRowEditorInputInRow, + parseRowEditorNumericText, + shouldBlockRowEditorNumericKey + } from '../../utils/row-editor-numeric'; + import { compactTableInputClass } from '../../utils/compact-table-input'; import { createDefaultNesInstrumentRow, cyclePulseWidth, @@ -35,6 +42,7 @@ let { instrument, + asHex = false, isExpanded = false, onInstrumentChange, selectedRowIndices = $bindable([]) @@ -46,7 +54,7 @@ selectedRowIndices?: number[]; } = $props(); - const TABLE_COLUMNS = 5; + const TABLE_COLUMNS = 7; let tableRef: HTMLTableElement | null = $state(null); let editorContainerRef: HTMLDivElement | null = $state(null); @@ -91,6 +99,69 @@ nextRows[index] = next; editorSync.applyRowChange(nextRows); } + + function updateBooleanRow(index: number, field: 'retrigger' | 'toneAccumulation', value: boolean) { + if (Boolean(editorSync.rows[index][field]) === value) return; + updateRow(index, { [field]: value }); + } + + function updateNumericField(index: number, field: 'toneAdd', event: Event) { + const inputEl = event.target as HTMLInputElement; + const parsed = parseRowEditorNumericText(inputEl.value, asHex); + if (parsed !== null) { + const normalized = formatRowEditorNumber(parsed, asHex); + if (inputEl.value !== normalized) { + inputEl.value = normalized; + } + updateRow(index, { [field]: parsed }); + } + } + + function handleNumericKeyDown(index: number, event: KeyboardEvent) { + const key = event.key; + const inputEl = event.target as HTMLInputElement; + + if (event.ctrlKey || event.metaKey || event.altKey) return; + + if (key === 'ArrowDown') { + event.preventDefault(); + const nextIndex = index + 1; + if (nextIndex < editorSync.rows.length) { + const currentRow = inputEl.closest('tr'); + focusRowEditorInputInRow( + currentRow?.nextElementSibling as HTMLTableRowElement | null, + inputEl + ); + } else if (nextIndex === editorSync.rows.length) { + editorSync.addRow(createDefaultNesInstrumentRow); + setTimeout(() => { + const currentRow = inputEl.closest('tr'); + focusRowEditorInputInRow( + currentRow?.nextElementSibling as HTMLTableRowElement | null, + inputEl + ); + }, 0); + } + return; + } + + if (key === 'ArrowUp') { + event.preventDefault(); + const prevIndex = index - 1; + if (prevIndex >= 0) { + const currentRow = inputEl.closest('tr'); + focusRowEditorInputInRow( + currentRow?.previousElementSibling as HTMLTableRowElement | null, + inputEl + ); + } + return; + } + + if (shouldBlockRowEditorNumericKey(key, asHex)) { + event.preventDefault(); + } + } @@ -114,6 +185,22 @@ label="duty" {isExpanded} class="w-10 min-w-10 px-1" /> +
+ @@ -144,10 +231,10 @@ onPaintBegin={() => booleanDrag.begin( () => row.retrigger, - (value) => updateRow(index, { retrigger: value }) + (value) => updateBooleanRow(index, 'retrigger', value) )} onPaintOver={() => - booleanDrag.dragOver((value) => updateRow(index, { retrigger: value }))} /> + booleanDrag.dragOver((value) => updateBooleanRow(index, 'retrigger', value))} /> updateRow(index, { pulseWidth: cyclePulseWidth(row.pulseWidth) })} /> + + + booleanDrag.begin( + () => row.toneAccumulation, + (value) => updateBooleanRow(index, 'toneAccumulation', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'toneAccumulation', value))} /> {/each} diff --git a/src/lib/chips/nes/instrument.ts b/src/lib/chips/nes/instrument.ts index ab798511..0c70a866 100644 --- a/src/lib/chips/nes/instrument.ts +++ b/src/lib/chips/nes/instrument.ts @@ -9,13 +9,24 @@ export const NES_PULSE_WIDTH_LABELS: Record = { 3: '¾' }; +const TONE_ADD_MIN = -4096; +const TONE_ADD_MAX = 4095; + export type NesInstrumentRow = { pulseWidth: NesPulseWidth; retrigger: boolean; + toneAdd: number; + toneAccumulation: boolean; }; export function createDefaultNesInstrumentRow(): NesInstrumentRow { - return { pulseWidth: 2, retrigger: false }; + return { pulseWidth: 2, retrigger: false, toneAdd: 0, toneAccumulation: false }; +} + +function normalizeToneAdd(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(TONE_ADD_MIN, Math.min(TONE_ADD_MAX, Math.round(parsed))); } export function normalizeNesInstrumentRow(row: Record): NesInstrumentRow { @@ -25,7 +36,9 @@ export function normalizeNesInstrumentRow(row: Record): NesInst : defaults.pulseWidth; return { pulseWidth, - retrigger: Boolean(row.retrigger) + retrigger: Boolean(row.retrigger), + toneAdd: normalizeToneAdd(row.toneAdd), + toneAccumulation: Boolean(row.toneAccumulation) }; } diff --git a/tests/lib/chips/nes/instrument.test.ts b/tests/lib/chips/nes/instrument.test.ts index f57de0a8..90bac6c6 100644 --- a/tests/lib/chips/nes/instrument.test.ts +++ b/tests/lib/chips/nes/instrument.test.ts @@ -8,13 +8,26 @@ import { describe('nes instrument', () => { it('creates a default macro row with retrigger off', () => { - expect(createDefaultNesInstrumentRow()).toEqual({ pulseWidth: 2, retrigger: false }); + expect(createDefaultNesInstrumentRow()).toEqual({ + pulseWidth: 2, + retrigger: false, + toneAdd: 0, + toneAccumulation: false + }); }); it('normalizes partial rows and ensures at least one row', () => { - expect(normalizeNesInstrumentRow({ retrigger: 1, pulseWidth: 99 })).toEqual({ + expect(normalizeNesInstrumentRow({ retrigger: 1, pulseWidth: 99, toneAdd: -2 })).toEqual({ + pulseWidth: 2, + retrigger: true, + toneAdd: -2, + toneAccumulation: false + }); + expect(normalizeNesInstrumentRow({ toneAccumulation: true, toneAdd: 5000 })).toEqual({ pulseWidth: 2, - retrigger: true + retrigger: false, + toneAdd: 4095, + toneAccumulation: true }); expect(ensureNesInstrumentRows([])).toHaveLength(1); }); From a8756503d4f2be474fd73923d8d426ed1a827955 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 22:29:56 +0200 Subject: [PATCH 11/28] NES renderer --- src/lib/chips/ay/renderer.ts | 16 +- src/lib/chips/nes/core.ts | 2 +- src/lib/chips/nes/renderer.ts | 667 ++++++++++++++++++++++++++- tests/lib/chips/nes/renderer.test.ts | 44 ++ 4 files changed, 714 insertions(+), 15 deletions(-) create mode 100644 tests/lib/chips/nes/renderer.test.ts diff --git a/src/lib/chips/ay/renderer.ts b/src/lib/chips/ay/renderer.ts index 62325323..4b37ea44 100644 --- a/src/lib/chips/ay/renderer.ts +++ b/src/lib/chips/ay/renderer.ts @@ -115,24 +115,24 @@ export class AYChipRenderer implements ChipRenderer { onProgress?.(20, 'Loading processor modules...'); const { default: AyumiState } = await this.loader.loadModule<{ default: new () => unknown; - }>('ayumi-state.js'); + }>('ay/ayumi-state.js'); onProgress?.(30, 'Loading pattern processor...'); const { default: TrackerPatternProcessor } = await this.loader.loadModule<{ default: new (a: unknown, b: unknown, c: unknown) => unknown; - }>('tracker-pattern-processor.js'); + }>('tracker/tracker-pattern-processor.js'); onProgress?.(40, 'Loading audio driver...'); const { default: AYAudioDriver } = await this.loader.loadModule<{ default: new () => unknown; - }>('ay-audio-driver.js'); + }>('ay/ay-audio-driver.js'); const { default: AyumiEngine } = await this.loader.loadModule<{ default: new (a: unknown, b: unknown) => unknown; - }>('ayumi-engine.js'); + }>('ay/ayumi-engine.js'); const { default: AYChipRegisterState } = await this.loader.loadModule<{ default: new () => unknown; - }>('ay-chip-register-state.js'); + }>('ay/ay-chip-register-state.js'); const { default: VirtualChannelMixer } = await this.loader.loadModule<{ default: new () => unknown; - }>('virtual-channel-mixer.js'); + }>('ay/virtual-channel-mixer.js'); return { AyumiState, @@ -564,7 +564,7 @@ export class AYChipRenderer implements ChipRenderer { const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); const { getPanSettingsForLayout } = await this.loader.loadModule<{ getPanSettingsForLayout: GetPanSettingsForLayout; - }>('ayumi-constants.js'); + }>('ay/ayumi-constants.js'); const { AyumiState, TrackerPatternProcessor, @@ -717,7 +717,7 @@ export class AYChipRenderer implements ChipRenderer { const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); const { getPanSettingsForLayout } = await this.loader.loadModule<{ getPanSettingsForLayout: GetPanSettingsForLayout; - }>('ayumi-constants.js'); + }>('ay/ayumi-constants.js'); const ayumiPtr = this.initializeAyumi(wasm, song, getPanSettingsForLayout); const { AyumiState, diff --git a/src/lib/chips/nes/core.ts b/src/lib/chips/nes/core.ts index 8a1f7989..1c7fb8ed 100644 --- a/src/lib/chips/nes/core.ts +++ b/src/lib/chips/nes/core.ts @@ -16,7 +16,7 @@ export const NES_CHIP: Chip = { schema: NES_CHIP_SCHEMA, createConverter: () => new NESConverter(), createFormatter: () => new NESFormatter(), - createRenderer: () => new NESChipRenderer(), + createRenderer: (loader, binding) => new NESChipRenderer(loader, binding), instrumentEditor: undefined, previewRow: undefined, playbackDebug: NES_PLAYBACK_DEBUG diff --git a/src/lib/chips/nes/renderer.ts b/src/lib/chips/nes/renderer.ts index c4ed9024..695f5364 100644 --- a/src/lib/chips/nes/renderer.ts +++ b/src/lib/chips/nes/renderer.ts @@ -1,13 +1,668 @@ import type { Project } from '../../models/project'; -import type { ChipRenderer, RenderOptions } from '../base/renderer'; +import type { Pattern } from '../../models/song'; +import { + assertSharedTimelineSlotsForChip, + type ChipRenderer, + type ChipRendererBinding, + type RenderOptions, + type SharedTimelineExportResult, + type SharedTimelineExportSlot +} from '../base/renderer'; +import { NES_AUDIO_SLOT_KIND } from './audio-slot-kind'; +import type { ResourceLoader } from '../base/resource-loader'; +import { BrowserResourceLoader } from '../base/resource-loader'; +import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; +import { NES_NTSC_CPU_FREQUENCY } from './schema'; + +const SAMPLE_RATE = 44100; +const DEFAULT_SPEED = 6; +const DEFAULT_INTERRUPT_FREQUENCY = 50; + +type NesSlotLane = { + songIndex: number; + song: { patterns: Pattern[]; chipType?: string; chipVariant?: string; chipFrequency?: number; interruptFrequency?: number; initialSpeed?: number; tuningTable: number[] }; + state: { + currentPattern: Pattern | null; + timeline: { + tickAccumulator: number; + tickStep: number; + currentTick: number; + currentSpeed: number; + currentRow: number; + currentPatternOrderIndex: number; + patternOrder: number[]; + loopPointId: number; + }; + advancePosition: (leaderPatternLength?: number) => boolean; + }; + patternProcessor: { + parsePatternRow: (pattern: Pattern, rowIndex: number, registerState: unknown) => void; + processSpeedTable: () => void; + processTables: () => void; + processArpeggio: () => void; + processEffectTables: () => void; + processVibrato: () => void; + processSlides: () => void; + }; + audioDriver: { + processInstruments: (state: unknown, registerState: unknown) => void; + }; + apuEngine: { + applyRegisterState: (registerState: unknown) => void; + process: (sampleRate: number) => { left: number; right: number }; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + dispose: () => void; + }; + registerState: unknown; + patterns: Pattern[]; + apuPtr: number; + dmcPtr: number; +}; export class NESChipRenderer implements ChipRenderer { + private loader: ResourceLoader; + private readonly binding: ChipRendererBinding; + + constructor(loader?: ResourceLoader, binding?: ChipRendererBinding) { + this.loader = loader ?? new BrowserResourceLoader(); + this.binding = binding ?? { + chipType: 'nes', + audioSlotKind: NES_AUDIO_SLOT_KIND + }; + } + + private async loadWasmModule( + onProgress?: (progress: number, message: string) => void + ): Promise<{ wasm: Record; wasmBuffer: ArrayBuffer }> { + onProgress?.(0, 'Loading WASM module...'); + const wasmBuffer = await this.loader.loadWasm('nes/nes_apu.wasm'); + onProgress?.(10, 'Instantiating WASM...'); + const result = await WebAssembly.instantiate(wasmBuffer, { + env: { emscripten_notify_memory_growth: () => {} } + }); + return { wasm: result.instance.exports as Record, wasmBuffer }; + } + + private async loadProcessorModules( + onProgress?: (progress: number, message: string) => void + ): Promise<{ + NesState: new (sharedTimeline?: unknown) => NesSlotLane['state']; + TrackerPatternProcessor: new ( + state: unknown, + driver: unknown, + port: { postMessage?: (...args: unknown[]) => void } + ) => NesSlotLane['patternProcessor']; + NesAudioDriver: new () => NesSlotLane['audioDriver']; + createNesApuEngine: ( + wasm: Record + ) => { engine: NesSlotLane['apuEngine']; apuPtr: number; dmcPtr: number }; + NesChipRegisterState: new (channelCount?: number) => unknown; + }> { + onProgress?.(20, 'Loading processor modules...'); + const { default: NesState } = await this.loader.loadModule<{ default: new (...args: unknown[]) => unknown }>( + 'nes/nes-state.js' + ); + onProgress?.(30, 'Loading pattern processor...'); + const { default: TrackerPatternProcessor } = await this.loader.loadModule<{ + default: new (...args: unknown[]) => unknown; + }>('tracker/tracker-pattern-processor.js'); + onProgress?.(40, 'Loading audio driver...'); + const { default: NesAudioDriver } = await this.loader.loadModule<{ + default: new () => unknown; + }>('nes/nes-audio-driver.js'); + const { createNesApuEngine } = await this.loader.loadModule<{ + createNesApuEngine: (wasm: Record) => { + engine: NesSlotLane['apuEngine']; + apuPtr: number; + dmcPtr: number; + }; + }>('nes/nes-apu-engine.js'); + const { default: NesChipRegisterState } = await this.loader.loadModule<{ + default: new (channelCount?: number) => unknown; + }>('nes/nes-chip-register-state.js'); + + return { + NesState: NesState as new (sharedTimeline?: unknown) => NesSlotLane['state'], + TrackerPatternProcessor: TrackerPatternProcessor as new ( + state: unknown, + driver: unknown, + port: { postMessage?: (...args: unknown[]) => void } + ) => NesSlotLane['patternProcessor'], + NesAudioDriver: NesAudioDriver as new () => NesSlotLane['audioDriver'], + createNesApuEngine, + NesChipRegisterState + }; + } + + private setupExportState( + state: NesSlotLane['state'] & { + setWasmModule: (wasm: unknown, apuPtr: number, dmcPtr: number, wasmBuffer: ArrayBuffer) => void; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + setTuningTable: (table: number[]) => void; + setInstruments: (instruments: unknown[]) => void; + setTables: (tables: unknown[]) => void; + setIntFrequency: (frequency: number, sampleRate: number) => void; + setPatternOrder: (order: number[], loopPointId: number) => void; + setSpeed: (speed: number) => void; + updateSamplesPerTick: (sampleRate: number) => void; + }, + song: NesSlotLane['song'], + project: Project, + wasm: Record, + apuPtr: number, + dmcPtr: number, + wasmBuffer: ArrayBuffer, + ownsSharedPlaybackTimeline: boolean + ): void { + state.setWasmModule(wasm, apuPtr, dmcPtr, wasmBuffer); + state.setCpuFrequency(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + state.setChipVariant(song.chipVariant ?? 'NTSC'); + state.setTuningTable(song.tuningTable); + state.setInstruments(filterInstrumentsForChip(project.instruments, song.chipType ?? 'nes')); + state.setTables(project.tables); + if (ownsSharedPlaybackTimeline) { + state.setIntFrequency(song.interruptFrequency ?? DEFAULT_INTERRUPT_FREQUENCY, SAMPLE_RATE); + state.setPatternOrder(project.patternOrder || [0], project.loopPointId || 0); + state.setSpeed(song.initialSpeed || DEFAULT_SPEED); + state.updateSamplesPerTick(SAMPLE_RATE); + } + } + + private getPatterns(song: NesSlotLane['song'], patternOrder: number[]): Pattern[] { + const patterns: Pattern[] = []; + for (const patternId of patternOrder) { + const pattern = song.patterns.find((p) => p.id === patternId); + if (pattern) { + patterns.push(pattern); + } + } + return patterns; + } + + private calculateTotalRows(song: NesSlotLane['song'], patternOrder: number[]): number { + let totalRows = 0; + for (const patternId of patternOrder) { + const pattern = song.patterns.find((p) => p.id === patternId); + if (pattern) { + totalRows += pattern.length; + } + } + return totalRows; + } + + private calculateCurrentRow(state: NesSlotLane['state'], song: NesSlotLane['song']): number { + let currentRow = 0; + for (let i = 0; i < state.timeline.currentPatternOrderIndex; i++) { + const patternId = state.timeline.patternOrder[i]; + const pattern = song.patterns.find((p) => p.id === patternId); + if (pattern) { + currentRow += pattern.length; + } + } + if (state.currentPattern) { + currentRow += state.timeline.currentRow; + } + return currentRow; + } + + private freeWasmPointers(wasm: Record, apuPtr: number, dmcPtr: number): void { + const free = wasm.free as ((ptr: number) => void) | undefined; + if (!free) return; + try { + free(apuPtr); + free(dmcPtr); + } catch { + /* ignore */ + } + } + + private async renderAudioLoop( + lane: NesSlotLane, + song: NesSlotLane['song'], + totalRows: number, + patterns: Pattern[], + loopCount: number, + onProgress?: (progress: number, message: string) => void + ): Promise { + const leftSamples: number[] = []; + const rightSamples: number[] = []; + let totalSamples = 0; + const maxSamples = SAMPLE_RATE * 300 * Math.max(1, loopCount); + let completedLoops = 0; + let lastProgressUpdate = 0; + const progressUpdateInterval = SAMPLE_RATE * 0.1; + let lastProgressTime = Date.now(); + const minProgressUpdateMs = 100; + + onProgress?.(50, 'Starting render...'); + + while (totalSamples < maxSamples) { + const now = Date.now(); + if ( + (totalSamples - lastProgressUpdate >= progressUpdateInterval || + now - lastProgressTime >= minProgressUpdateMs) && + totalSamples > 0 + ) { + const renderProgress = (totalSamples / maxSamples) * 50; + const progress = 50 + renderProgress; + const currentRow = this.calculateCurrentRow(lane.state, song); + onProgress?.(progress, `Rendering... ${currentRow}/${totalRows} rows`); + lastProgressUpdate = totalSamples; + lastProgressTime = now; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const tl = lane.state.timeline; + tl.tickAccumulator += tl.tickStep; + + if (tl.tickAccumulator >= 1.0) { + if (tl.currentTick === 0 && lane.state.currentPattern) { + lane.patternProcessor.parsePatternRow( + lane.state.currentPattern, + tl.currentRow, + lane.registerState + ); + lane.patternProcessor.processSpeedTable(); + } + + lane.patternProcessor.processTables(); + lane.patternProcessor.processArpeggio(); + lane.patternProcessor.processEffectTables(); + lane.audioDriver.processInstruments(lane.state, lane.registerState); + lane.patternProcessor.processVibrato(); + lane.patternProcessor.processSlides(); + lane.apuEngine.applyRegisterState(lane.registerState); + + const isLastPattern = + tl.currentPatternOrderIndex >= tl.patternOrder.length - 1; + const isLastRow = + lane.state.currentPattern != null && + tl.currentRow >= lane.state.currentPattern.length - 1; + const isLastTick = tl.currentTick >= tl.currentSpeed - 1; + + if (isLastPattern && isLastRow && isLastTick) { + completedLoops++; + if (completedLoops >= loopCount) { + break; + } + } + + const needsPatternChange = lane.state.advancePosition(); + if (needsPatternChange) { + if (tl.currentPatternOrderIndex >= tl.patternOrder.length) { + break; + } + if (tl.currentPatternOrderIndex < patterns.length) { + lane.state.currentPattern = patterns[tl.currentPatternOrderIndex]!; + } else { + break; + } + } + + tl.tickAccumulator -= 1.0; + } + + const { left, right } = lane.apuEngine.process(SAMPLE_RATE); + leftSamples.push(left); + rightSamples.push(right); + totalSamples++; + } + + return [new Float32Array(leftSamples), new Float32Array(rightSamples)]; + } + + private async renderAudioLoopSharedTimeline( + contexts: NesSlotLane[], + leaderSong: NesSlotLane['song'], + totalRows: number, + loopCount: number, + onProgress?: (progress: number, message: string) => void + ): Promise { + const leftByChip: number[][] = contexts.map(() => []); + const rightByChip: number[][] = contexts.map(() => []); + let totalSamples = 0; + const maxSamples = SAMPLE_RATE * 300 * Math.max(1, loopCount); + let completedLoops = 0; + let lastProgressUpdate = 0; + const progressUpdateInterval = SAMPLE_RATE * 0.1; + let lastProgressTime = Date.now(); + const minProgressUpdateMs = 100; + const leader = contexts[0]!; + + while (totalSamples < maxSamples) { + const now = Date.now(); + if ( + (totalSamples - lastProgressUpdate >= progressUpdateInterval || + now - lastProgressTime >= minProgressUpdateMs) && + totalSamples > 0 + ) { + const renderProgress = (totalSamples / maxSamples) * 50; + const progress = 50 + renderProgress; + const currentRow = this.calculateCurrentRow(leader.state, leaderSong); + onProgress?.(progress, `Rendering... ${currentRow}/${totalRows} rows`); + lastProgressUpdate = totalSamples; + lastProgressTime = now; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const tl = leader.state.timeline; + tl.tickAccumulator += tl.tickStep; + + if (tl.tickAccumulator >= 1.0) { + for (const ctx of contexts) { + if (tl.currentTick === 0 && ctx.state.currentPattern) { + ctx.patternProcessor.parsePatternRow( + ctx.state.currentPattern, + tl.currentRow, + ctx.registerState + ); + ctx.patternProcessor.processSpeedTable(); + } + ctx.patternProcessor.processTables(); + ctx.patternProcessor.processArpeggio(); + ctx.patternProcessor.processEffectTables(); + ctx.audioDriver.processInstruments(ctx.state, ctx.registerState); + ctx.patternProcessor.processVibrato(); + ctx.patternProcessor.processSlides(); + ctx.apuEngine.applyRegisterState(ctx.registerState); + } + + const isLastPattern = tl.currentPatternOrderIndex >= tl.patternOrder.length - 1; + const isLastRow = + leader.state.currentPattern != null && + tl.currentRow >= leader.state.currentPattern.length - 1; + const isLastTick = tl.currentTick >= tl.currentSpeed - 1; + + if (isLastPattern && isLastRow && isLastTick) { + completedLoops++; + if (completedLoops >= loopCount) { + break; + } + } + + const needsPatternChange = leader.state.advancePosition(); + if (needsPatternChange) { + if (tl.currentPatternOrderIndex >= tl.patternOrder.length) { + break; + } + for (const ctx of contexts) { + const pattern = ctx.patterns[tl.currentPatternOrderIndex]; + if (pattern) { + ctx.state.currentPattern = pattern; + } + } + } + + tl.tickAccumulator -= 1.0; + } + + for (let ci = 0; ci < contexts.length; ci++) { + const { left, right } = contexts[ci]!.apuEngine.process(SAMPLE_RATE); + leftByChip[ci].push(left); + rightByChip[ci].push(right); + } + totalSamples++; + } + + return contexts.map((_, ci) => [ + new Float32Array(leftByChip[ci]), + new Float32Array(rightByChip[ci]) + ]); + } + + async renderSharedTimelineSlots( + project: Project, + slots: readonly SharedTimelineExportSlot[], + onProgress?: (progress: number, message: string) => void, + options?: RenderOptions + ): Promise { + assertSharedTimelineSlotsForChip(slots, this.binding); + const songIndices = slots.map((s) => s.songIndex); + const loopCount = Math.max(1, options?.loopCount ?? 1); + const patternOrder = project.patternOrder || [0]; + const requestedStartOrderIndex = options?.startPatternOrderIndex ?? 0; + const startOrderIndex = + requestedStartOrderIndex >= 0 && requestedStartOrderIndex < patternOrder.length + ? requestedStartOrderIndex + : 0; + + const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); + const { NesState, TrackerPatternProcessor, NesAudioDriver, createNesApuEngine, NesChipRegisterState } = + await this.loadProcessorModules(onProgress); + + const contexts: NesSlotLane[] = []; + const ptrPairs: Array<{ apuPtr: number; dmcPtr: number }> = []; + + try { + for (const songIndex of songIndices) { + const song = project.songs[songIndex]; + if (!song?.patterns?.length) { + throw new Error('Song is empty'); + } + + const { engine, apuPtr, dmcPtr } = createNesApuEngine(wasm); + ptrPairs.push({ apuPtr, dmcPtr }); + + const state = ( + contexts.length === 0 + ? new NesState() + : new NesState(contexts[0]!.state.timeline) + ) as NesSlotLane['state'] & { + setWasmModule: (...args: unknown[]) => void; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + setTuningTable: (table: number[]) => void; + setInstruments: (instruments: unknown[]) => void; + setTables: (tables: unknown[]) => void; + setIntFrequency: (frequency: number, sampleRate: number) => void; + setPatternOrder: (order: number[], loopPointId: number) => void; + setSpeed: (speed: number) => void; + updateSamplesPerTick: (sampleRate: number) => void; + }; + + this.setupExportState( + state, + song as NesSlotLane['song'], + project, + wasm, + apuPtr, + dmcPtr, + wasmBuffer, + contexts.length === 0 + ); + + const audioDriver = new NesAudioDriver(); + engine.setCpuFrequency(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + engine.setChipVariant(song.chipVariant ?? 'NTSC'); + const registerState = new NesChipRegisterState(); + const patternProcessor = new TrackerPatternProcessor(state, audioDriver, { + postMessage: () => {} + }); + + const patterns = this.getPatterns(song as NesSlotLane['song'], patternOrder); + if (patterns.length === 0) { + throw new Error('No patterns found'); + } + + state.currentPattern = patterns[startOrderIndex]!; + state.timeline.currentPatternOrderIndex = startOrderIndex; + if (contexts.length === 0) { + state.timeline.tickAccumulator = 1.0; + } + + engine.applyRegisterState(registerState); + + contexts.push({ + songIndex, + song: song as NesSlotLane['song'], + state, + patternProcessor, + audioDriver, + apuEngine: engine, + registerState, + patterns, + apuPtr, + dmcPtr + }); + } + + const leaderSong = contexts[0]!.song; + const firstPassRows = this.calculateTotalRows(leaderSong, patternOrder); + const validLoopPointId = + project.loopPointId >= 0 && project.loopPointId < patternOrder.length + ? project.loopPointId + : 0; + const loopOrderSegment = patternOrder.slice(validLoopPointId); + const loopSegmentRows = this.calculateTotalRows(leaderSong, loopOrderSegment); + const totalRows = + loopCount <= 1 ? firstPassRows : firstPassRows + loopSegmentRows * (loopCount - 1); + + const buffers = await this.renderAudioLoopSharedTimeline( + contexts, + leaderSong, + totalRows, + loopCount, + onProgress + ); + + for (const ctx of contexts) { + ctx.apuEngine.dispose(); + } + for (const { apuPtr, dmcPtr } of ptrPairs) { + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + } + + return contexts.map((ctx, i) => ({ + songIndex: ctx.songIndex, + channels: buffers[i]! + })); + } catch (error) { + for (const ctx of contexts) { + try { + ctx.apuEngine.dispose(); + } catch { + /* ignore */ + } + } + for (const { apuPtr, dmcPtr } of ptrPairs) { + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + } + throw error; + } + } + async render( - _project: Project, - _songIndex: number, - _onProgress?: (progress: number, message: string) => void, - _options?: RenderOptions + project: Project, + songIndex: number, + onProgress?: (progress: number, message: string) => void, + options?: RenderOptions ): Promise { - throw new Error('2A03 / 2A07 WAV export is not implemented yet'); + const song = project.songs[songIndex]; + if (!song || song.patterns.length === 0) { + throw new Error('Song is empty'); + } + + const loopCount = Math.max(1, options?.loopCount ?? 1); + const patternOrder = project.patternOrder || [0]; + const requestedStartOrderIndex = options?.startPatternOrderIndex ?? 0; + const startOrderIndex = + requestedStartOrderIndex >= 0 && requestedStartOrderIndex < patternOrder.length + ? requestedStartOrderIndex + : 0; + + const { wasm, wasmBuffer } = await this.loadWasmModule(onProgress); + const { NesState, TrackerPatternProcessor, NesAudioDriver, createNesApuEngine, NesChipRegisterState } = + await this.loadProcessorModules(onProgress); + + const { engine, apuPtr, dmcPtr } = createNesApuEngine(wasm); + + try { + const state = new NesState() as NesSlotLane['state'] & { + setWasmModule: (...args: unknown[]) => void; + setCpuFrequency: (frequency: number) => void; + setChipVariant: (variant: string) => void; + setTuningTable: (table: number[]) => void; + setInstruments: (instruments: unknown[]) => void; + setTables: (tables: unknown[]) => void; + setIntFrequency: (frequency: number, sampleRate: number) => void; + setPatternOrder: (order: number[], loopPointId: number) => void; + setSpeed: (speed: number) => void; + updateSamplesPerTick: (sampleRate: number) => void; + }; + + this.setupExportState( + state, + song as NesSlotLane['song'], + project, + wasm, + apuPtr, + dmcPtr, + wasmBuffer, + true + ); + + const audioDriver = new NesAudioDriver(); + engine.setCpuFrequency(song.chipFrequency ?? NES_NTSC_CPU_FREQUENCY); + engine.setChipVariant(song.chipVariant ?? 'NTSC'); + const registerState = new NesChipRegisterState(); + const patternProcessor = new TrackerPatternProcessor(state, audioDriver, { + postMessage: () => {} + }); + + engine.applyRegisterState(registerState); + + const patterns = this.getPatterns(song as NesSlotLane['song'], patternOrder); + if (patterns.length === 0) { + throw new Error('No patterns found'); + } + + state.currentPattern = patterns[startOrderIndex]!; + state.timeline.currentPatternOrderIndex = startOrderIndex; + state.timeline.tickAccumulator = 1.0; + + onProgress?.(50, 'Initializing renderer...'); + const firstPassRows = this.calculateTotalRows(song as NesSlotLane['song'], patternOrder); + const validLoopPointId = + project.loopPointId >= 0 && project.loopPointId < patternOrder.length + ? project.loopPointId + : 0; + const loopOrderSegment = patternOrder.slice(validLoopPointId); + const loopSegmentRows = this.calculateTotalRows(song as NesSlotLane['song'], loopOrderSegment); + const totalRows = + loopCount <= 1 ? firstPassRows : firstPassRows + loopSegmentRows * (loopCount - 1); + + const lane: NesSlotLane = { + songIndex, + song: song as NesSlotLane['song'], + state, + patternProcessor, + audioDriver, + apuEngine: engine, + registerState, + patterns, + apuPtr, + dmcPtr + }; + + const channels = await this.renderAudioLoop( + lane, + song as NesSlotLane['song'], + totalRows, + patterns, + loopCount, + onProgress + ); + + engine.dispose(); + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + onProgress?.(100, 'Rendering complete'); + return channels; + } catch (error) { + engine.dispose(); + this.freeWasmPointers(wasm, apuPtr, dmcPtr); + throw error; + } } } diff --git a/tests/lib/chips/nes/renderer.test.ts b/tests/lib/chips/nes/renderer.test.ts new file mode 100644 index 00000000..071567e5 --- /dev/null +++ b/tests/lib/chips/nes/renderer.test.ts @@ -0,0 +1,44 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { FileSystemResourceLoader } from '../../../../cli/resource-loader-node'; +import { NESChipRenderer } from '@/lib/chips/nes/renderer'; +import { NES_CHIP_SCHEMA, NES_DEFAULT_TUNING_TABLE } from '@/lib/chips/nes/schema'; +import { Project } from '@/lib/models/project'; +import { Instrument, Note, NoteName, Pattern, Song } from '@/lib/models/song'; + +const PUBLIC_DIR = path.join(process.cwd(), 'public'); + +function createNesTestProject(): Project { + const pattern = new Pattern(0, 8, NES_CHIP_SCHEMA); + pattern.channels[0].rows[0].note = new Note(NoteName.C, 1); + pattern.channels[0].rows[0].instrument = 1; + + const song = new Song(); + song.chipType = 'nes'; + song.tuningTable = NES_DEFAULT_TUNING_TABLE; + song.chipFrequency = NES_DEFAULT_TUNING_TABLE[0] ? 1_789_773 : 1_789_773; + song.initialSpeed = 6; + song.interruptFrequency = 50; + song.patterns = [pattern]; + + const instrument = new Instrument('01', [{ pulseWidth: 2, retrigger: false }], 0, 'Pulse', 'nes'); + + return new Project('NES export test', '', [song], 0, [0], [], {}, [instrument]); +} + +describe('NESChipRenderer', () => { + it('renders stereo audio for a minimal NES song', async () => { + const renderer = new NESChipRenderer(new FileSystemResourceLoader(PUBLIC_DIR)); + const project = createNesTestProject(); + const [left, right] = await renderer.render(project, 0); + + expect(left.length).toBeGreaterThan(10_000); + expect(right.length).toBe(left.length); + + let peak = 0; + for (let i = 0; i < left.length; i++) { + peak = Math.max(peak, Math.abs(left[i]), Math.abs(right[i])); + } + expect(peak).toBeGreaterThan(0.01); + }); +}); From 042021e7625dca4aa1ddb8742fe8b2e6dc742c16 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 22:30:37 +0200 Subject: [PATCH 12/28] Refactor module imports in PSG and TMR export services to use updated directory structure --- src/lib/services/file/psg-export.ts | 20 ++++++++++---------- src/lib/services/file/tmr-export.ts | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/lib/services/file/psg-export.ts b/src/lib/services/file/psg-export.ts index 913330c7..d637b6e7 100644 --- a/src/lib/services/file/psg-export.ts +++ b/src/lib/services/file/psg-export.ts @@ -332,16 +332,16 @@ class PsgExportService { onProgress?.(10, 'Loading processor modules...'); const baseUrl = import.meta.env.BASE_URL; - const { default: AyumiState } = await import(`${baseUrl}ayumi-state.js`); + const { default: AyumiState } = await import(`${baseUrl}ay/ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + `${baseUrl}tracker/tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); + const { default: AYAudioDriver } = await import(`${baseUrl}ay/ay-audio-driver.js`); const { default: AYChipRegisterState } = await import( - `${baseUrl}ay-chip-register-state.js` + `${baseUrl}ay/ay-chip-register-state.js` ); const { default: VirtualChannelMixer } = await import( - `${baseUrl}virtual-channel-mixer.js` + `${baseUrl}ay/virtual-channel-mixer.js` ); const modules: PsgExportModules = { @@ -457,16 +457,16 @@ export async function captureSongRegisterFrames( modules = options.modules; } else { const baseUrl = import.meta.env.BASE_URL; - const { default: AyumiState } = await import(`${baseUrl}ayumi-state.js`); + const { default: AyumiState } = await import(`${baseUrl}ay/ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + `${baseUrl}tracker/tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); + const { default: AYAudioDriver } = await import(`${baseUrl}ay/ay-audio-driver.js`); const { default: AYChipRegisterState } = await import( - `${baseUrl}ay-chip-register-state.js` + `${baseUrl}ay/ay-chip-register-state.js` ); const { default: VirtualChannelMixer } = await import( - `${baseUrl}virtual-channel-mixer.js` + `${baseUrl}ay/virtual-channel-mixer.js` ); modules = { AyumiState, diff --git a/src/lib/services/file/tmr-export.ts b/src/lib/services/file/tmr-export.ts index 6d4c190c..c46ffec2 100644 --- a/src/lib/services/file/tmr-export.ts +++ b/src/lib/services/file/tmr-export.ts @@ -10,13 +10,13 @@ import { encodeTMR, type EncodedTmrFiles } from './tmr-encoder'; async function loadPsgExportModules(): Promise { const baseUrl = import.meta.env.BASE_URL; - const { default: AyumiState } = await import(`${baseUrl}ayumi-state.js`); + const { default: AyumiState } = await import(`${baseUrl}ay/ayumi-state.js`); const { default: TrackerPatternProcessor } = await import( - `${baseUrl}tracker-pattern-processor.js` + `${baseUrl}tracker/tracker-pattern-processor.js` ); - const { default: AYAudioDriver } = await import(`${baseUrl}ay-audio-driver.js`); - const { default: AYChipRegisterState } = await import(`${baseUrl}ay-chip-register-state.js`); - const { default: VirtualChannelMixer } = await import(`${baseUrl}virtual-channel-mixer.js`); + const { default: AYAudioDriver } = await import(`${baseUrl}ay/ay-audio-driver.js`); + const { default: AYChipRegisterState } = await import(`${baseUrl}ay/ay-chip-register-state.js`); + const { default: VirtualChannelMixer } = await import(`${baseUrl}ay/virtual-channel-mixer.js`); return { AyumiState, TrackerPatternProcessor, From acfb0fb068cb4101453d45100b49ecbe5a7b6771 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Thu, 18 Jun 2026 22:34:22 +0200 Subject: [PATCH 13/28] Enhance instrument reconstruction to support NES chip type, preserving pulse width and retrigger properties. Update tests to verify correct behavior for NES instruments. --- src/lib/services/file/file-import.ts | 46 +++++++++++++-------- tests/lib/services/file/file-import.test.ts | 33 +++++++++++++++ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/lib/services/file/file-import.ts b/src/lib/services/file/file-import.ts index 639d3906..ded8c774 100644 --- a/src/lib/services/file/file-import.ts +++ b/src/lib/services/file/file-import.ts @@ -15,6 +15,7 @@ import { } from '../../models/song'; import { isValidInstrumentSampleByteLength } from '../../utils/audio-sample-decode'; import { normalizeSamplePlaybackBounds } from '../../chips/ay/sample-region'; +import { normalizeNesInstrumentRow } from '../../chips/nes/instrument'; import type { ChipSchema } from '../../chips/base/schema'; import { computeEffectiveChannelLabels } from '../../models/virtual-channels'; @@ -199,7 +200,9 @@ function reconstructInstrument(data: any): Instrument { typeof data.chipType === 'string' ? data.chipType : 'ay' ); if (data.rows) { - instrument.rows = data.rows.map((rowData: any) => reconstructInstrumentRow(rowData)); + instrument.rows = data.rows.map((rowData: any) => + reconstructInstrumentRow(rowData, instrument.chipType) + ); } if (data.timerRows) { ( @@ -368,23 +371,30 @@ function reconstructInstrument(data: any): Instrument { return instrument; } -function reconstructInstrumentRow(data: any): InstrumentRow { - return new InstrumentRow({ - tone: data.tone ?? false, - noise: data.noise ?? false, - envelope: data.envelope ?? false, - toneAdd: data.toneAdd ?? 0, - noiseAdd: data.noiseAdd ?? 0, - envelopeAdd: data.envelopeAdd ?? 0, - envelopeAccumulation: data.envelopeAccumulation ?? false, - volume: data.volume ?? 0, - loop: data.loop ?? false, - amplitudeSliding: data.amplitudeSliding ?? false, - amplitudeSlideUp: data.amplitudeSlideUp ?? false, - toneAccumulation: data.toneAccumulation ?? false, - noiseAccumulation: data.noiseAccumulation ?? false, - retriggerEnvelope: data.retriggerEnvelope ?? false - }); +function reconstructInstrumentRow(data: any, chipType?: string): InstrumentRow { + const rowData = data ?? {}; + switch (chipType) { + case 'nes': + return new InstrumentRow(normalizeNesInstrumentRow(rowData)); + case 'ay': + default: + return new InstrumentRow({ + tone: rowData.tone ?? false, + noise: rowData.noise ?? false, + envelope: rowData.envelope ?? false, + toneAdd: rowData.toneAdd ?? 0, + noiseAdd: rowData.noiseAdd ?? 0, + envelopeAdd: rowData.envelopeAdd ?? 0, + envelopeAccumulation: rowData.envelopeAccumulation ?? false, + volume: rowData.volume ?? 0, + loop: rowData.loop ?? false, + amplitudeSliding: rowData.amplitudeSliding ?? false, + amplitudeSlideUp: rowData.amplitudeSlideUp ?? false, + toneAccumulation: rowData.toneAccumulation ?? false, + noiseAccumulation: rowData.noiseAccumulation ?? false, + retriggerEnvelope: rowData.retriggerEnvelope ?? false + }); + } } export class FileImportService { diff --git a/tests/lib/services/file/file-import.test.ts b/tests/lib/services/file/file-import.test.ts index d5800031..8e04d1c5 100644 --- a/tests/lib/services/file/file-import.test.ts +++ b/tests/lib/services/file/file-import.test.ts @@ -57,4 +57,37 @@ describe('FileImportService', () => { expect(fields.timerRows[0]?.fmWaveform).toEqual([0, 12, -4]); expect(fields.timerRows[0]?.envFmWaveform).toEqual([0, -7, 24]); }); + + it('preserves NES pulse width and retrigger when reconstructing instruments', async () => { + const json = JSON.stringify({ + name: 'test', + songs: [], + instruments: [ + { + id: '01', + chipType: 'nes', + name: 'Pulse', + loop: 0, + rows: [ + { + pulseWidth: 1, + retrigger: true, + toneAdd: -2, + toneAccumulation: true + } + ] + } + ], + patterns: [], + tables: [] + }); + + const project = await FileImportService.reconstructFromJsonAsync(json); + const row = project.instruments[0]?.rows[0]; + + expect(row?.pulseWidth).toBe(1); + expect(row?.retrigger).toBe(true); + expect(row?.toneAdd).toBe(-2); + expect(row?.toneAccumulation).toBe(true); + }); }); From c10b111bef67b04418ca9e821ad1cbe812f5e3ad Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Fri, 19 Jun 2026 15:29:21 +0200 Subject: [PATCH 14/28] Fix 6 command for NES --- public/ay/ay-audio-driver.js | 16 +---- public/nes/nes-audio-driver.js | 5 +- public/tracker/tracker-instrument-channel.js | 17 +++++ tests/public/nes-audio-driver-onoff.test.js | 63 +++++++++++++++++++ .../public/tracker-instrument-channel.test.js | 26 +++++++- 5 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 tests/public/nes-audio-driver-onoff.test.js diff --git a/public/ay/ay-audio-driver.js b/public/ay/ay-audio-driver.js index 65a60739..b7fd0141 100644 --- a/public/ay/ay-audio-driver.js +++ b/public/ay/ay-audio-driver.js @@ -6,7 +6,8 @@ import { assignPatternRowInstrument, channelHasAssignedInstrument, getChannelInstrument, - isChannelOnOffHalted + isChannelOnOffHalted, + processChannelOnOffCounters } from '../tracker/tracker-instrument-channel.js'; import { normalizeAyInstrumentFields, @@ -1210,18 +1211,7 @@ class AYAudioDriver { ); } - for (let channelIndex = 0; channelIndex < state.channelInstruments.length; channelIndex++) { - if (state.channelOnOffCounter[channelIndex] > 0) { - const result = EffectAlgorithms.processOnOffCounter( - state.channelOnOffCounter[channelIndex], - state.channelOnDuration[channelIndex], - state.channelOffDuration[channelIndex], - state.channelSoundEnabled[channelIndex] - ); - state.channelOnOffCounter[channelIndex] = result.counter; - state.channelSoundEnabled[channelIndex] = result.enabled; - } - } + processChannelOnOffCounters(state, state.channelInstruments.length); this.processEnvelopeOnOff(state); diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 5b4e9317..4d3a119e 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -6,7 +6,8 @@ import { import { assignPatternRowInstrument, channelHasAssignedInstrument, - isChannelOnOffHalted + isChannelOnOffHalted, + processChannelOnOffCounters } from '../tracker/tracker-instrument-channel.js'; import { ensureNesInstrumentRows, @@ -195,6 +196,8 @@ class NesAudioDriver { ); } } + + processChannelOnOffCounters(state, NES_CHANNEL_COUNT); } } diff --git a/public/tracker/tracker-instrument-channel.js b/public/tracker/tracker-instrument-channel.js index f1127728..03c48be6 100644 --- a/public/tracker/tracker-instrument-channel.js +++ b/public/tracker/tracker-instrument-channel.js @@ -1,3 +1,5 @@ +import EffectAlgorithms from './effect-algorithms.js'; + export function getChannelInstrument(state, channelIndex) { const instrumentIndex = state.channelInstruments?.[channelIndex] ?? -1; if (instrumentIndex < 0) { @@ -18,6 +20,21 @@ export function isChannelOnOffHalted(state, channelIndex) { ); } +export function processChannelOnOffCounters(state, channelCount) { + for (let channelIndex = 0; channelIndex < channelCount; channelIndex++) { + if (state.channelOnOffCounter?.[channelIndex] > 0) { + const result = EffectAlgorithms.processOnOffCounter( + state.channelOnOffCounter[channelIndex], + state.channelOnDuration[channelIndex], + state.channelOffDuration[channelIndex], + state.channelSoundEnabled[channelIndex] + ); + state.channelOnOffCounter[channelIndex] = result.counter; + state.channelSoundEnabled[channelIndex] = result.enabled; + } + } +} + export function assignPatternRowInstrument(state, channelIndex, row) { if (!state.channelInstruments || !state.instruments || state.channelMuted?.[channelIndex]) { return { changed: false, assigned: false, instrument: null, instrumentIndex: -1 }; diff --git a/tests/public/nes-audio-driver-onoff.test.js b/tests/public/nes-audio-driver-onoff.test.js new file mode 100644 index 00000000..c98ba747 --- /dev/null +++ b/tests/public/nes-audio-driver-onoff.test.js @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; +import EffectAlgorithms from '../../public/tracker/effect-algorithms.js'; + +describe('NesAudioDriver on/off effect', () => { + it('silences channel when on/off counter is in off phase', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = { + channelMuted: [false], + channelSoundEnabled: [false], + channelInstruments: [0], + instruments: [{ rows: [{ pulseWidth: 2, retrigger: false }], loop: 0 }], + instrumentPositions: [0], + channelPatternVolumes: [15], + channelCurrentNotes: [60], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0], + channelVibratoSliding: [0], + channelDetune: [0], + channelKeyOn: [false], + channelToneAccumulator: [0], + channelOnOffCounter: [2], + channelOnDuration: [3], + channelOffDuration: [2] + }; + + driver.processInstruments(state, registerState); + + expect(registerState.channels[0].enabled).toBe(false); + expect(registerState.channels[0].volume).toBe(0); + }); + + it('processes on/off counter each instrument tick', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const onOff = EffectAlgorithms.initOnOff(0x32); + const state = { + channelMuted: [false], + channelSoundEnabled: [true], + channelInstruments: [0], + instruments: [{ rows: [{ pulseWidth: 2, retrigger: false }], loop: 0 }], + instrumentPositions: [0], + channelPatternVolumes: [15], + channelCurrentNotes: [60], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0], + channelVibratoSliding: [0], + channelDetune: [0], + channelKeyOn: [false], + channelToneAccumulator: [0], + channelOnOffCounter: [onOff.counter], + channelOnDuration: [onOff.onDuration], + channelOffDuration: [onOff.offDuration] + }; + + driver.processInstruments(state, registerState); + expect(state.channelOnOffCounter[0]).toBe(onOff.counter - 1); + expect(state.channelSoundEnabled[0]).toBe(true); + expect(registerState.channels[0].enabled).toBe(true); + }); +}); diff --git a/tests/public/tracker-instrument-channel.test.js b/tests/public/tracker-instrument-channel.test.js index f777f45d..c96fa181 100644 --- a/tests/public/tracker-instrument-channel.test.js +++ b/tests/public/tracker-instrument-channel.test.js @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { assignPatternRowInstrument, channelHasAssignedInstrument, - getChannelInstrument + getChannelInstrument, + isChannelOnOffHalted, + processChannelOnOffCounters } from '../../public/tracker/tracker-instrument-channel.js'; describe('tracker-instrument-channel', () => { @@ -37,4 +39,26 @@ describe('tracker-instrument-channel', () => { }); expect(channelHasAssignedInstrument(state, 1)).toBe(true); }); + + it('processChannelOnOffCounters toggles channel sound during on/off effect', () => { + const onOffState = { + channelOnOffCounter: [3], + channelOnDuration: [3], + channelOffDuration: [2], + channelSoundEnabled: [true] + }; + + processChannelOnOffCounters(onOffState, 1); + expect(onOffState.channelOnOffCounter[0]).toBe(2); + expect(onOffState.channelSoundEnabled[0]).toBe(true); + + processChannelOnOffCounters(onOffState, 1); + expect(onOffState.channelOnOffCounter[0]).toBe(1); + expect(onOffState.channelSoundEnabled[0]).toBe(true); + + processChannelOnOffCounters(onOffState, 1); + expect(onOffState.channelOnOffCounter[0]).toBe(2); + expect(onOffState.channelSoundEnabled[0]).toBe(false); + expect(isChannelOnOffHalted(onOffState, 0)).toBe(true); + }); }); From 3424255ff8385b7c2fe12b30fd339fcdffdaaee1 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sat, 20 Jun 2026 12:30:39 +0200 Subject: [PATCH 15/28] Add NES hw sweep --- public/nes/nes-apu-engine.js | 25 ++++++-- public/nes/nes-audio-driver.js | 6 ++ public/nes/nes-chip-register-state.js | 3 +- public/nes/nes-instrument-utils.js | 41 +++++++++++- public/nes/nes-worklet-slot.js | 5 +- public/tracker/worklet-slot-base.js | 9 +-- src/lib/chips/nes/NESInstrumentEditor.svelte | 65 +++++++++++++++++-- src/lib/chips/nes/instrument.ts | 44 ++++++++++++- src/lib/config/app-menu.ts | 6 +- tests/lib/chips/nes/instrument.test.ts | 39 +++++++++++- tests/public/nes-apu-engine.test.js | 19 +++++- tests/public/nes-audio-driver-sweep.test.js | 67 ++++++++++++++++++++ 12 files changed, 302 insertions(+), 27 deletions(-) create mode 100644 tests/public/nes-audio-driver-sweep.test.js diff --git a/public/nes/nes-apu-engine.js b/public/nes/nes-apu-engine.js index cb824e4a..40d123ce 100644 --- a/public/nes/nes-apu-engine.js +++ b/public/nes/nes-apu-engine.js @@ -11,7 +11,7 @@ import { const SQUARE_BASE = [0x4000, 0x4004]; const TRIANGLE_BASE = 0x4008; const NOISE_BASE = 0x400c; -const SQUARE_SWEEP_DISABLED = 0x08; +import { NES_SQUARE_SWEEP_DISABLED } from './nes-instrument-utils.js'; const NES_APU_4015_PULSE_MASK = 0x03; const NES_DMC_4015_TND_MASK = 0x0c; @@ -75,16 +75,29 @@ class NesApuEngine { 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; + const sweepReg = + channel.sweepReg === undefined || channel.sweepReg < 0 + ? NES_SQUARE_SWEEP_DISABLED + : channel.sweepReg; + const sweepChanged = last.sweepReg !== sweepReg; + const sweepRetrigger = + triggerChannel && sweepReg !== NES_SQUARE_SWEEP_DISABLED; + if (forceApply || sweepChanged || sweepRetrigger) { + this.wasmModule.nes_apu_Write(this.apuPtr, base + 1, sweepReg); + last.sweepReg = sweepReg; } - if (forceApply || periodLow !== (last.period & 0xff)) { + if (forceApply || periodLow !== (last.period & 0xff) || sweepChanged) { this.wasmModule.nes_apu_Write(this.apuPtr, base + 2, periodLow); } - if (forceApply || triggerChannel || channel.retrigger || periodHigh !== lastPeriodHigh) { + if ( + forceApply || + triggerChannel || + channel.retrigger || + periodHigh !== lastPeriodHigh || + sweepChanged + ) { this.wasmModule.nes_apu_Write(this.apuPtr, base + 3, periodHigh); } diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 4d3a119e..8df64dde 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -10,7 +10,9 @@ import { processChannelOnOffCounters } from '../tracker/tracker-instrument-channel.js'; import { + buildSquareSweepReg, ensureNesInstrumentRows, + NES_SQUARE_SWEEP_DISABLED, normalizeNesInstrumentRow } from './nes-instrument-utils.js'; import { NES_CHANNEL_COUNT } from './nes-constants.js'; @@ -55,6 +57,9 @@ class NesAudioDriver { channel.enabled = false; channel.period = 0; channel.volume = 0; + if (channelIndex <= 1) { + channel.sweepReg = NES_SQUARE_SWEEP_DISABLED; + } } _resetToneAccumulator(state, channelIndex) { @@ -168,6 +173,7 @@ class NesAudioDriver { channel.period = period; channel.volume = volume; channel.duty = row.pulseWidth; + channel.sweepReg = buildSquareSweepReg(row.sweep, row.sweepRate, row.sweepShift); channel.retrigger = row.retrigger || keyOn; state.channelKeyOn[channelIndex] = false; } else if (channelIndex === 2) { diff --git a/public/nes/nes-chip-register-state.js b/public/nes/nes-chip-register-state.js index ecaaba88..787f72b5 100644 --- a/public/nes/nes-chip-register-state.js +++ b/public/nes/nes-chip-register-state.js @@ -1,4 +1,5 @@ import { NES_CHANNEL_COUNT } from './nes-constants.js'; +import { NES_SQUARE_SWEEP_DISABLED } from './nes-instrument-utils.js'; function createDefaultChannel() { return { @@ -7,7 +8,7 @@ function createDefaultChannel() { volume: 0, duty: 2, retrigger: false, - sweepReg: -1, + sweepReg: NES_SQUARE_SWEEP_DISABLED, noisePeriod: 0, noiseMode: false }; diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js index d7dcc4a1..db0e30b7 100644 --- a/public/nes/nes-instrument-utils.js +++ b/public/nes/nes-instrument-utils.js @@ -2,6 +2,11 @@ export const NES_PULSE_WIDTHS = [0, 1, 2, 3]; const TONE_ADD_MIN = -4096; const TONE_ADD_MAX = 4095; +const SWEEP_RATE_MIN = 0; +const SWEEP_RATE_MAX = 7; +const SWEEP_SHIFT_MIN = -7; +const SWEEP_SHIFT_MAX = 7; +export const NES_SQUARE_SWEEP_DISABLED = 0x08; function normalizeToneAdd(value) { const parsed = Number(value); @@ -10,7 +15,36 @@ function normalizeToneAdd(value) { } export function createDefaultNesInstrumentRow() { - return { pulseWidth: 2, retrigger: false, toneAdd: 0, toneAccumulation: false }; + return { + pulseWidth: 2, + retrigger: false, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }; +} + +function normalizeSweepRate(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_RATE_MIN, Math.min(SWEEP_RATE_MAX, Math.round(parsed))); +} + +function normalizeSweepShift(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_SHIFT_MIN, Math.min(SWEEP_SHIFT_MAX, Math.round(parsed))); +} + +export function buildSquareSweepReg(enabled, rate, shift) { + if (!enabled || shift === 0) { + return NES_SQUARE_SWEEP_DISABLED; + } + const amount = Math.abs(shift); + const packed = ((rate & 7) << 4) | (amount & 7); + return shift < 0 ? 0x88 | packed : 0x80 | packed; } export function normalizeNesInstrumentRow(row) { @@ -20,7 +54,10 @@ export function normalizeNesInstrumentRow(row) { pulseWidth, retrigger: Boolean(row?.retrigger), toneAdd: normalizeToneAdd(row?.toneAdd), - toneAccumulation: Boolean(row?.toneAccumulation) + toneAccumulation: Boolean(row?.toneAccumulation), + sweep: Boolean(row?.sweep), + sweepRate: normalizeSweepRate(row?.sweepRate), + sweepShift: normalizeSweepShift(row?.sweepShift) }; } diff --git a/public/nes/nes-worklet-slot.js b/public/nes/nes-worklet-slot.js index 59abb759..7cda3414 100644 --- a/public/nes/nes-worklet-slot.js +++ b/public/nes/nes-worklet-slot.js @@ -17,7 +17,10 @@ export class NesWorkletSlot extends TrackerWorkletSlot { 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.channelWaveformBuf = Array.from( + { length: NES_CHANNEL_COUNT }, + () => new Float32Array(512) + ); this.channelWaveformWriteIndex = 0; this.waveformPostCounter = 0; this.waveformPostInterval = 6; diff --git a/public/tracker/worklet-slot-base.js b/public/tracker/worklet-slot-base.js index 11ad1d8e..b62b3b5a 100644 --- a/public/tracker/worklet-slot-base.js +++ b/public/tracker/worklet-slot-base.js @@ -82,7 +82,8 @@ export class WorkletSlotBase { const state = this._slotState(); if (patternOrderIndex !== undefined) { - const patternOrderChanged = state.timeline.currentPatternOrderIndex !== patternOrderIndex; + const patternOrderChanged = + state.timeline.currentPatternOrderIndex !== patternOrderIndex; state.timeline.currentPatternOrderIndex = patternOrderIndex; if (pattern) { @@ -237,11 +238,7 @@ export class WorkletSlotBase { shouldRunPlaybackAccumulation() { const state = this._slotState(); - return ( - !this.paused && - state.currentPattern && - state.currentPattern.length > 0 - ); + return !this.paused && state.currentPattern && state.currentPattern.length > 0; } finishAudioBlockFlushTransport(numSamples, paused) { diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index f64181c2..7d219773 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -2,6 +2,7 @@ import type { Instrument } from '../../models/song'; import IconCarbonRepeat from '~icons/carbon/repeat'; import IconCarbonChartWinLoss from '~icons/carbon/chart-win-loss'; + import IconCarbonArrowsVertical from '~icons/carbon/arrows-vertical'; import { BooleanPaintableCell, BooleanPaintDrag, @@ -54,7 +55,7 @@ selectedRowIndices?: number[]; } = $props(); - const TABLE_COLUMNS = 7; + const TABLE_COLUMNS = 10; let tableRef: HTMLTableElement | null = $state(null); let editorContainerRef: HTMLDivElement | null = $state(null); @@ -100,14 +101,23 @@ editorSync.applyRowChange(nextRows); } - function updateBooleanRow(index: number, field: 'retrigger' | 'toneAccumulation', value: boolean) { + function updateBooleanRow( + index: number, + field: 'retrigger' | 'toneAccumulation' | 'sweep', + value: boolean + ) { if (Boolean(editorSync.rows[index][field]) === value) return; updateRow(index, { [field]: value }); } - function updateNumericField(index: number, field: 'toneAdd', event: Event) { + function updateNumericField( + index: number, + field: 'toneAdd' | 'sweepRate' | 'sweepShift', + event: Event, + limits?: { min?: number; max?: number } + ) { const inputEl = event.target as HTMLInputElement; - const parsed = parseRowEditorNumericText(inputEl.value, asHex); + const parsed = parseRowEditorNumericText(inputEl.value, asHex, limits); if (parsed !== null) { const normalized = formatRowEditorNumber(parsed, asHex); if (inputEl.value !== normalized) { @@ -201,6 +211,22 @@ + + + @@ -267,6 +293,37 @@ onPaintOver={() => booleanDrag.dragOver((value) => updateBooleanRow(index, 'toneAccumulation', value))} /> + + booleanDrag.begin( + () => row.sweep, + (value) => updateBooleanRow(index, 'sweep', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => updateBooleanRow(index, 'sweep', value))} /> + + {/each} diff --git a/src/lib/chips/nes/instrument.ts b/src/lib/chips/nes/instrument.ts index 0c70a866..c87b3de0 100644 --- a/src/lib/chips/nes/instrument.ts +++ b/src/lib/chips/nes/instrument.ts @@ -11,16 +11,32 @@ export const NES_PULSE_WIDTH_LABELS: Record = { const TONE_ADD_MIN = -4096; const TONE_ADD_MAX = 4095; +const SWEEP_RATE_MIN = 0; +const SWEEP_RATE_MAX = 7; +const SWEEP_SHIFT_MIN = -7; +const SWEEP_SHIFT_MAX = 7; +export const NES_SQUARE_SWEEP_DISABLED = 0x08; export type NesInstrumentRow = { pulseWidth: NesPulseWidth; retrigger: boolean; toneAdd: number; toneAccumulation: boolean; + sweep: boolean; + sweepRate: number; + sweepShift: number; }; export function createDefaultNesInstrumentRow(): NesInstrumentRow { - return { pulseWidth: 2, retrigger: false, toneAdd: 0, toneAccumulation: false }; + return { + pulseWidth: 2, + retrigger: false, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }; } function normalizeToneAdd(value: unknown): number { @@ -29,6 +45,27 @@ function normalizeToneAdd(value: unknown): number { return Math.max(TONE_ADD_MIN, Math.min(TONE_ADD_MAX, Math.round(parsed))); } +function normalizeSweepRate(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_RATE_MIN, Math.min(SWEEP_RATE_MAX, Math.round(parsed))); +} + +function normalizeSweepShift(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SWEEP_SHIFT_MIN, Math.min(SWEEP_SHIFT_MAX, Math.round(parsed))); +} + +export function buildSquareSweepReg(enabled: boolean, rate: number, shift: number): number { + if (!enabled || shift === 0) { + return NES_SQUARE_SWEEP_DISABLED; + } + const amount = Math.abs(shift); + const packed = ((rate & 7) << 4) | (amount & 7); + return shift < 0 ? 0x88 | packed : 0x80 | packed; +} + export function normalizeNesInstrumentRow(row: Record): NesInstrumentRow { const defaults = createDefaultNesInstrumentRow(); const pulseWidth = NES_PULSE_WIDTHS.includes(row.pulseWidth as NesPulseWidth) @@ -38,7 +75,10 @@ export function normalizeNesInstrumentRow(row: Record): NesInst pulseWidth, retrigger: Boolean(row.retrigger), toneAdd: normalizeToneAdd(row.toneAdd), - toneAccumulation: Boolean(row.toneAccumulation) + toneAccumulation: Boolean(row.toneAccumulation), + sweep: Boolean(row.sweep), + sweepRate: normalizeSweepRate(row.sweepRate), + sweepShift: normalizeSweepShift(row.sweepShift) }; } diff --git a/src/lib/config/app-menu.ts b/src/lib/config/app-menu.ts index f488c2b1..4bb7aa20 100644 --- a/src/lib/config/app-menu.ts +++ b/src/lib/config/app-menu.ts @@ -23,7 +23,11 @@ export function buildMenuItems(chipConfig: ChipConfiguration): MenuItem[] { icon: '📁', items: [ { label: 'AY/YM', type: 'normal', action: 'new-song-ay' }, - { label: '2A03 / 2A07', type: 'normal', action: 'new-song-nes' } + { + label: '2A03 / 2A07 (work in progress)', + type: 'normal', + action: 'new-song-nes' + } ] } ] diff --git a/tests/lib/chips/nes/instrument.test.ts b/tests/lib/chips/nes/instrument.test.ts index 90bac6c6..9d550b80 100644 --- a/tests/lib/chips/nes/instrument.test.ts +++ b/tests/lib/chips/nes/instrument.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + buildSquareSweepReg, createDefaultNesInstrumentRow, cyclePulseWidth, ensureNesInstrumentRows, + NES_SQUARE_SWEEP_DISABLED, normalizeNesInstrumentRow } from '@/lib/chips/nes/instrument'; @@ -12,7 +14,10 @@ describe('nes instrument', () => { pulseWidth: 2, retrigger: false, toneAdd: 0, - toneAccumulation: false + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 }); }); @@ -21,13 +26,34 @@ describe('nes instrument', () => { pulseWidth: 2, retrigger: true, toneAdd: -2, - toneAccumulation: false + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 }); expect(normalizeNesInstrumentRow({ toneAccumulation: true, toneAdd: 5000 })).toEqual({ pulseWidth: 2, retrigger: false, toneAdd: 4095, - toneAccumulation: true + toneAccumulation: true, + sweep: false, + sweepRate: 0, + sweepShift: 0 + }); + expect( + normalizeNesInstrumentRow({ + sweep: true, + sweepRate: 12, + sweepShift: -9 + }) + ).toEqual({ + pulseWidth: 2, + retrigger: false, + toneAdd: 0, + toneAccumulation: false, + sweep: true, + sweepRate: 7, + sweepShift: -7 }); expect(ensureNesInstrumentRows([])).toHaveLength(1); }); @@ -36,4 +62,11 @@ describe('nes instrument', () => { expect(cyclePulseWidth(0)).toBe(1); expect(cyclePulseWidth(3)).toBe(0); }); + + it('builds hardware sweep register bytes', () => { + expect(buildSquareSweepReg(false, 3, 4)).toBe(NES_SQUARE_SWEEP_DISABLED); + expect(buildSquareSweepReg(true, 0, 0)).toBe(NES_SQUARE_SWEEP_DISABLED); + expect(buildSquareSweepReg(true, 3, 4)).toBe(0x80 | 0x34); + expect(buildSquareSweepReg(true, 7, -5)).toBe(0x88 | 0x75); + }); }); diff --git a/tests/public/nes-apu-engine.test.js b/tests/public/nes-apu-engine.test.js index d002a46e..794a6af4 100644 --- a/tests/public/nes-apu-engine.test.js +++ b/tests/public/nes-apu-engine.test.js @@ -39,7 +39,7 @@ describe('NesApuEngine', () => { expect(renderSquarePeak(engine)).toBeGreaterThan(0.01); }); - it('writes sweep disable for pulse channels', async () => { + it('writes sweep disable for pulse channels by default', async () => { const wasmModule = await loadWasm(); const { engine } = createNesApuEngine(wasmModule); const registerState = new NesChipRegisterState(); @@ -55,6 +55,23 @@ describe('NesApuEngine', () => { expect(engine.lastState.channels[0].sweepReg).toBe(0x08); }); + it('writes enabled hardware sweep register for pulse channels', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + registerState.channels[0].enabled = true; + registerState.channels[0].period = 428; + registerState.channels[0].volume = 15; + registerState.channels[0].duty = 2; + registerState.channels[0].sweepReg = 0x84; + registerState.channels[0].retrigger = true; + + engine.applyRegisterState(registerState); + + expect(engine.lastState.channels[0].sweepReg).toBe(0x84); + }); + it('triggers pulse channel when re-enabled without an explicit retrigger flag', async () => { const wasmModule = await loadWasm(); const { engine } = createNesApuEngine(wasmModule); diff --git a/tests/public/nes-audio-driver-sweep.test.js b/tests/public/nes-audio-driver-sweep.test.js new file mode 100644 index 00000000..0809aca1 --- /dev/null +++ b/tests/public/nes-audio-driver-sweep.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; +import { buildSquareSweepReg } from '../../public/nes/nes-instrument-utils.js'; + +function createLoopingSweepState() { + return { + channelMuted: [false], + channelSoundEnabled: [true], + channelInstruments: [0], + instruments: [ + { + rows: [ + { + pulseWidth: 2, + retrigger: false, + sweep: true, + sweepRate: 4, + sweepShift: -1 + }, + { + pulseWidth: 2, + retrigger: false, + sweep: true, + sweepRate: 4, + sweepShift: 2 + } + ], + loop: 0 + } + ], + instrumentPositions: [0], + channelPatternVolumes: [15], + channelCurrentNotes: [60], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0], + channelVibratoSliding: [0], + channelDetune: [0], + channelKeyOn: [false], + channelToneAccumulator: [0], + channelOnOffCounter: [0], + channelOnDuration: [0], + channelOffDuration: [0] + }; +} + +describe('NesAudioDriver hardware sweep macro', () => { + it('cycles sweep register when macro rows loop', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createLoopingSweepState(); + const row0Sweep = buildSquareSweepReg(true, 4, -1); + const row1Sweep = buildSquareSweepReg(true, 4, 2); + + driver.processInstruments(state, registerState); + expect(registerState.channels[0].sweepReg).toBe(row0Sweep); + expect(state.instrumentPositions[0]).toBe(1); + + driver.processInstruments(state, registerState); + expect(registerState.channels[0].sweepReg).toBe(row1Sweep); + expect(state.instrumentPositions[0]).toBe(0); + + driver.processInstruments(state, registerState); + expect(registerState.channels[0].sweepReg).toBe(row0Sweep); + expect(state.instrumentPositions[0]).toBe(1); + }); +}); From 0557e347db4cc792491d237db9e9f391560cb3b0 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sat, 20 Jun 2026 13:08:55 +0200 Subject: [PATCH 16/28] Add 60hz option for NES --- public/nes/nes-worklet-slot.js | 7 +++++ src/lib/chips/nes/processor.ts | 27 +++++++++++++++++++ src/lib/chips/nes/schema.ts | 12 +++++++++ tests/lib/chips/nes/schema-settings.test.ts | 8 ++++++ .../ay-audio-driver-auto-envelope.test.ts | 27 +++++++++++++++++-- 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/public/nes/nes-worklet-slot.js b/public/nes/nes-worklet-slot.js index 7cda3414..571fc1dd 100644 --- a/public/nes/nes-worklet-slot.js +++ b/public/nes/nes-worklet-slot.js @@ -117,6 +117,9 @@ export class NesWorkletSlot extends TrackerWorkletSlot { case 'update_chip_variant': this.handleUpdateChipVariant(data); break; + case 'update_int_frequency': + this.handleUpdateIntFrequency(data); + break; default: this.dispatchPortMessages(type, data); } @@ -167,6 +170,10 @@ export class NesWorkletSlot extends TrackerWorkletSlot { this.apuEngine?.setChipVariant(chipVariant); } + handleUpdateIntFrequency({ intFrequency }) { + this.state.setIntFrequency(intFrequency, sampleRate); + } + handleSetChannelMute({ channelIndex, muted }) { if (channelIndex >= 0 && channelIndex < this.state.channelMuted.length) { this.state.channelMuted[channelIndex] = muted; diff --git a/src/lib/chips/nes/processor.ts b/src/lib/chips/nes/processor.ts index cc1bc44c..df110b5f 100644 --- a/src/lib/chips/nes/processor.ts +++ b/src/lib/chips/nes/processor.ts @@ -61,6 +61,14 @@ export class NESProcessor }) ); + this.settingsUnsubscribers.push( + chipSettings.subscribe('interruptFrequency', (value) => { + if (typeof value === 'number') { + this.sendUpdateIntFrequency(value); + } + }) + ); + this.settingsUnsubscribers.push( chipSettings.subscribe('tuningTable', (value) => { if (Array.isArray(value) && value.length > 0) { @@ -218,12 +226,31 @@ export class NESProcessor this.bridge.sendCommand({ type: 'update_chip_variant', chipVariant }); } + sendUpdateIntFrequency(intFrequency: number): void { + this.bridge.sendCommand({ type: 'update_int_frequency', intFrequency }); + } + updateParameter(parameter: string, value: unknown): void { if (parameter.startsWith('channelMute_')) { const channelIndex = parseInt(parameter.replace('channelMute_', ''), 10); if (!isNaN(channelIndex) && typeof value === 'boolean') { this.bridge.sendCommand({ type: 'set_channel_mute', channelIndex, muted: value }); } + return; + } + + switch (parameter) { + case 'chipFrequency': + this.sendUpdateCpuFrequency(value as number); + break; + case 'interruptFrequency': + this.sendUpdateIntFrequency(value as number); + break; + case 'chipVariant': + this.sendUpdateChipVariant(value as string); + break; + default: + break; } } diff --git a/src/lib/chips/nes/schema.ts b/src/lib/chips/nes/schema.ts index d93cecab..d5cdc041 100644 --- a/src/lib/chips/nes/schema.ts +++ b/src/lib/chips/nes/schema.ts @@ -109,6 +109,18 @@ export const NES_CHIP_SCHEMA: ChipSchema = { return `${mhz} MHz · ${apuTimingType} type`; } }, + { + key: 'interruptFrequency', + label: 'Interrupt Frequency', + type: 'select', + options: [ + { label: 'PAL (50 Hz)', value: 50 }, + { label: 'NTSC (60 Hz)', value: 60 } + ], + defaultValue: 50, + group: 'chip', + notifyAudioService: true + }, { key: 'a4TuningHz', label: 'A4 (Hz)', diff --git a/tests/lib/chips/nes/schema-settings.test.ts b/tests/lib/chips/nes/schema-settings.test.ts index b11dc3e8..82ac2bfd 100644 --- a/tests/lib/chips/nes/schema-settings.test.ts +++ b/tests/lib/chips/nes/schema-settings.test.ts @@ -55,6 +55,14 @@ describe('NES chip settings schema hooks', () => { ).toEqual([{ key: 'chipFrequency', value: NES_DENDY_CPU_FREQUENCY }]); }); + it('exposes configurable interrupt frequency options like AY', () => { + const setting = NES_CHIP_SCHEMA.settings?.find((s) => s.key === 'interruptFrequency'); + expect(setting).toBeDefined(); + expect(setting?.defaultValue).toBe(50); + expect(setting?.notifyAudioService).toBe(true); + expect(setting?.options?.map((option) => option.value)).toEqual([50, 60]); + }); + it('builds a 12-TET tuning table from CPU frequency and A4', () => { const table = resolveNesTuningTable(NES_NTSC_CPU_FREQUENCY, 440); expect(table).toHaveLength(96); diff --git a/tests/public/ay-audio-driver-auto-envelope.test.ts b/tests/public/ay-audio-driver-auto-envelope.test.ts index a43d5d30..05c6f67b 100644 --- a/tests/public/ay-audio-driver-auto-envelope.test.ts +++ b/tests/public/ay-audio-driver-auto-envelope.test.ts @@ -23,7 +23,14 @@ describe('AYAudioDriver - Auto Envelope (EA)', () => { beforeEach(() => { driver = new AYAudioDriver(); state = new AyumiState(); - state.setTuningTable([3328, 3136, 2960, 2794, 2637, 2489, 2349, 2217, 2093, 1975, 1864, 1760, 1664, 1568, 1480, 1397, 1319, 1245, 1175, 1109, 1047, 988, 932, 880, 832, 784, 740, 699, 659, 622, 587, 554, 523, 494, 466, 440, 416, 392, 370, 349, 330, 311, 294, 277, 262, 247, 233, 220, 208, 196, 185, 175, 165, 156, 147, 139, 131, 124, 117, 110, 104, 98, 93, 87, 82, 78, 74, 69, 66, 62, 59, 55, 52, 49, 46, 44, 41, 39, 37, 35, 33, 31, 29, 28, 26, 25, 23, 22, 21, 20, 18, 17, 16, 15, 14, 13]); + state.setTuningTable([ + 3328, 3136, 2960, 2794, 2637, 2489, 2349, 2217, 2093, 1975, 1864, 1760, 1664, 1568, + 1480, 1397, 1319, 1245, 1175, 1109, 1047, 988, 932, 880, 832, 784, 740, 699, 659, 622, + 587, 554, 523, 494, 466, 440, 416, 392, 370, 349, 330, 311, 294, 277, 262, 247, 233, + 220, 208, 196, 185, 175, 165, 156, 147, 139, 131, 124, 117, 110, 104, 98, 93, 87, 82, + 78, 74, 69, 66, 62, 59, 55, 52, 49, 46, 44, 41, 39, 37, 35, 33, 31, 29, 28, 26, 25, 23, + 22, 21, 20, 18, 17, 16, 15, 14, 13 + ]); }); describe('getAutoEnvelopeDivisor', () => { @@ -389,7 +396,23 @@ describe('AYAudioDriver - Auto Envelope (EA)', () => { state.setInstruments([ { id: '01', - rows: [{ tone: true, volume: 15, noise: false, envelope: true, toneAdd: 0, noiseAdd: 0, envelopeAdd: 0, toneAccumulation: false, noiseAccumulation: false, envelopeAccumulation: false, amplitudeSliding: false, amplitudeSlideUp: false, retriggerEnvelope: false }], + rows: [ + { + tone: true, + volume: 15, + noise: false, + envelope: true, + toneAdd: 0, + noiseAdd: 0, + envelopeAdd: 0, + toneAccumulation: false, + noiseAccumulation: false, + envelopeAccumulation: false, + amplitudeSliding: false, + amplitudeSlideUp: false, + retriggerEnvelope: false + } + ], loop: 0 } ]); From fbca3ada710788d4f3e44976ba09ed0dfc68b758 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sat, 20 Jun 2026 14:26:37 +0200 Subject: [PATCH 17/28] NES env --- build-wasm.ps1 | 2 +- build-wasm.sh | 2 +- external/nsfplug/nes_apu.c | 7 + external/nsfplug/nes_apu.h | 2 + external/nsfplug/nes_dmc.c | 7 + external/nsfplug/nes_dmc.h | 2 + public/nes/nes-apu-engine.js | 277 +++++++++++++++--- public/nes/nes-audio-driver.js | 84 +++++- public/nes/nes-chip-register-state.js | 7 +- public/nes/nes-instrument-utils.js | 148 ++++++++++ public/nes/nes-waveform-capture.js | 27 ++ public/nes/nes-worklet-slot.js | 20 +- src/lib/chips/nes/NESInstrumentEditor.svelte | 77 ++++- src/lib/chips/nes/NesEnvelopeModeCell.svelte | 78 +++++ src/lib/chips/nes/instrument.ts | 182 ++++++++++++ tests/lib/chips/nes/instrument.test.ts | 79 ++++- tests/public/nes-apu-engine.test.js | 10 + .../public/nes-audio-driver-envelope.test.js | 88 ++++++ tests/public/nes-waveform-capture.test.js | 24 ++ 19 files changed, 1058 insertions(+), 65 deletions(-) create mode 100644 src/lib/chips/nes/NesEnvelopeModeCell.svelte create mode 100644 tests/public/nes-audio-driver-envelope.test.js create mode 100644 tests/public/nes-waveform-capture.test.js diff --git a/build-wasm.ps1 b/build-wasm.ps1 index a63ab49b..0ad60714 100644 --- a/build-wasm.ps1 +++ b/build-wasm.ps1 @@ -12,7 +12,7 @@ $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/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\`"]`"" + "-s", "EXPORTED_FUNCTIONS=`"[\`"_nes_apu_Init\`", \`"_nes_apu_Reset\`", \`"_nes_apu_Tick\`", \`"_nes_apu_Render\`", \`"_nes_apu_Write\`", \`"_nes_apu_SetMask\`", \`"_nes_apu_SetStereoMix\`", \`"_nes_apu_GetOut\`", \`"_nes_dmc_Init\`", \`"_nes_dmc_Reset\`", \`"_nes_dmc_Tick\`", \`"_nes_dmc_Render\`", \`"_nes_dmc_Write\`", \`"_nes_dmc_SetMask\`", \`"_nes_dmc_SetStereoMix\`", \`"_nes_dmc_SetPal\`", \`"_nes_dmc_SetAPU\`", \`"_nes_dmc_SetMemory_Read\`", \`"_nes_dmc_TickFrameSequence\`", \`"_nes_dmc_GetOut\`", \`"_malloc\`", \`"_free\`"]`"" $nesMmc5Args = "external/nsfplug/nes_mmc5.c", "-o", "public/nes/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/build-wasm.sh b/build-wasm.sh index 1556ffd4..7b0ea090 100755 --- a/build-wasm.sh +++ b/build-wasm.sh @@ -30,7 +30,7 @@ emcc ${EMCC_ARGS} \ emcc ${EMCC_ARGS} \ 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"]' + -s EXPORTED_FUNCTIONS='["_nes_apu_Init", "_nes_apu_Reset", "_nes_apu_Tick", "_nes_apu_Render", "_nes_apu_Write", "_nes_apu_SetMask", "_nes_apu_SetStereoMix", "_nes_apu_GetOut", "_nes_dmc_Init", "_nes_dmc_Reset", "_nes_dmc_Tick", "_nes_dmc_Render", "_nes_dmc_Write", "_nes_dmc_SetMask", "_nes_dmc_SetStereoMix", "_nes_dmc_SetPal", "_nes_dmc_SetAPU", "_nes_dmc_SetMemory_Read", "_nes_dmc_TickFrameSequence", "_nes_dmc_GetOut", "_malloc", "_free"]' emcc ${EMCC_ARGS} \ external/nsfplug/nes_mmc5.c \ diff --git a/external/nsfplug/nes_apu.c b/external/nsfplug/nes_apu.c index b5fd9a59..5542c2f5 100644 --- a/external/nsfplug/nes_apu.c +++ b/external/nsfplug/nes_apu.c @@ -183,6 +183,13 @@ uint32_t nes_apu_Render (nes_apu_t *s, int32_t b[2]) return 2; } +int32_t nes_apu_GetOut (nes_apu_t *s, int channel) +{ + if (channel < 0 || channel > 1) + return 0; + return s->out[channel]; +} + void nes_apu_Init (nes_apu_t *s) { s->option[NES_APU_OPT_UNMUTE_ON_RESET] = true; diff --git a/external/nsfplug/nes_apu.h b/external/nsfplug/nes_apu.h index 46a093ff..9c2fa48f 100644 --- a/external/nsfplug/nes_apu.h +++ b/external/nsfplug/nes_apu.h @@ -67,4 +67,6 @@ void nes_apu_SetOption (nes_apu_t *s, int id, int b); void nes_apu_SetMask(nes_apu_t *s, int m); void nes_apu_SetStereoMix (nes_apu_t *s, int trk, int32_t mixl, int32_t mixr); +int32_t nes_apu_GetOut (nes_apu_t *s, int channel); + #endif diff --git a/external/nsfplug/nes_dmc.c b/external/nsfplug/nes_dmc.c index 93583f07..6a83e26b 100644 --- a/external/nsfplug/nes_dmc.c +++ b/external/nsfplug/nes_dmc.c @@ -375,6 +375,13 @@ uint32_t nes_dmc_Render (nes_dmc_t* s, int32_t b[2]) return 2; } +int32_t nes_dmc_GetOut (nes_dmc_t* s, int channel) +{ + if (channel < 0 || channel > 2) + return 0; + return (int32_t)s->out[channel]; +} + void nes_dmc_SetPal (nes_dmc_t* s, bool is_pal) { s->pal = (is_pal ? 1 : 0); diff --git a/external/nsfplug/nes_dmc.h b/external/nsfplug/nes_dmc.h index d2d94c1d..f3cc6a8c 100644 --- a/external/nsfplug/nes_dmc.h +++ b/external/nsfplug/nes_dmc.h @@ -101,6 +101,8 @@ bool nes_dmc_Read (nes_dmc_t* s, uint32_t adr, uint32_t* val); void nes_dmc_SetOption (nes_dmc_t* s, int id, int b); void nes_dmc_SetStereoMix (nes_dmc_t* s, int trk, int32_t mixl, int32_t mixr); +int32_t nes_dmc_GetOut (nes_dmc_t* s, int channel); + // void nes_dmc_SetCPU(nes_dmc_t* s, NES_CPU* cpu_); #endif diff --git a/public/nes/nes-apu-engine.js b/public/nes/nes-apu-engine.js index 40d123ce..ebcf78c2 100644 --- a/public/nes/nes-apu-engine.js +++ b/public/nes/nes-apu-engine.js @@ -7,18 +7,62 @@ import { NES_TRIANGLE_LINEAR_RELOAD, NES_APU_OUTPUT_SCALE } from './nes-constants.js'; +import { + buildNoiseSilentVolumeReg, + buildSquareSilentVolumeReg, + buildTriangleSilentLinearReg, + NES_REGISTER_UNCHANGED, + NES_SQUARE_SWEEP_DISABLED +} from './nes-instrument-utils.js'; const SQUARE_BASE = [0x4000, 0x4004]; const TRIANGLE_BASE = 0x4008; const NOISE_BASE = 0x400c; -import { NES_SQUARE_SWEEP_DISABLED } from './nes-instrument-utils.js'; -const NES_APU_4015_PULSE_MASK = 0x03; -const NES_DMC_4015_TND_MASK = 0x0c; function buildSquareVolumeReg(volume, duty) { return (3 << 4) | (volume & 15) | ((duty & 3) << 6); } +function isSquareChannelActive(channel) { + return channel.enabled && channel.period > 0; +} + +function isTriangleChannelActive(channel) { + return channel.enabled && channel.period > 0; +} + +function isNoiseChannelActive(channel) { + return channel.enabled; +} + +function buildApu4015Mask(registerState) { + let mask = 0; + if (isSquareChannelActive(registerState.channels[0])) mask |= 1; + if (isSquareChannelActive(registerState.channels[1])) mask |= 2; + return mask; +} + +function buildDmc4015Mask(registerState) { + let mask = 0; + if (isTriangleChannelActive(registerState.channels[2])) mask |= 4; + if (isNoiseChannelActive(registerState.channels[3])) mask |= 8; + return mask; +} + +function buildApuOutputMask(registerState) { + let mask = 0; + if (!isSquareChannelActive(registerState.channels[0])) mask |= 1; + if (!isSquareChannelActive(registerState.channels[1])) mask |= 2; + return mask; +} + +function buildDmcOutputMask(registerState) { + let mask = 4; + if (!isTriangleChannelActive(registerState.channels[2])) mask |= 1; + if (!isNoiseChannelActive(registerState.channels[3])) mask |= 2; + return mask; +} + class NesApuEngine { constructor(wasmModule, apuPtr, dmcPtr) { this.wasmModule = wasmModule; @@ -32,6 +76,8 @@ class NesApuEngine { this.forceFullApply = false; this._lastApu4015 = -1; this._lastDmc4015 = -1; + this._lastApuOutputMask = -1; + this._lastDmcOutputMask = -1; this._lastOutput = { left: 0, right: 0 }; } @@ -58,19 +104,97 @@ class NesApuEngine { this.lastState.reset(); this.forceFullApply = true; this.clockAccumulator = 0; + this._lastApu4015 = -1; + this._lastDmc4015 = -1; + this._lastApuOutputMask = -1; + this._lastDmcOutputMask = -1; + this._lastOutput = { left: 0, right: 0 }; + } + + _applyOutputMasks(registerState, forceApply) { + const apuOutputMask = buildApuOutputMask(registerState); + const dmcOutputMask = buildDmcOutputMask(registerState); + if (forceApply || apuOutputMask !== this._lastApuOutputMask) { + this.wasmModule.nes_apu_SetMask(this.apuPtr, apuOutputMask); + this._lastApuOutputMask = apuOutputMask; + } + if (forceApply || dmcOutputMask !== this._lastDmcOutputMask) { + this.wasmModule.nes_dmc_SetMask(this.dmcPtr, dmcOutputMask); + this._lastDmcOutputMask = dmcOutputMask; + } + } + + _writeSquareSilent(channelIndex, channel) { + const last = this.lastState.channels[channelIndex]; + const base = SQUARE_BASE[channelIndex]; + const volumeReg = buildSquareSilentVolumeReg(channel.duty); + this.wasmModule.nes_apu_Write(this.apuPtr, base, volumeReg); + this.wasmModule.nes_apu_Write(this.apuPtr, base + 1, NES_SQUARE_SWEEP_DISABLED); + last.volumeReg = volumeReg; + last.volume = 0; + last.duty = channel.duty; + last.sweepReg = NES_SQUARE_SWEEP_DISABLED; + last.period = 0; + last.lengthNibble = NES_REGISTER_UNCHANGED; + last.retrigger = false; + } + + _writeTriangleSilent(channel) { + const last = this.lastState.channels[2]; + const linearReg = buildTriangleSilentLinearReg(); + this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE, linearReg); + last.linearReg = linearReg; + last.period = 0; + last.lengthNibble = NES_REGISTER_UNCHANGED; + last.retrigger = false; + } + + _writeNoiseSilent(channel) { + const last = this.lastState.channels[3]; + const volumeReg = buildNoiseSilentVolumeReg(); + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE, volumeReg); + last.volumeReg = volumeReg; + last.volume = 0; + last.lengthNibble = NES_REGISTER_UNCHANGED; + last.retrigger = false; } _writeSquare(channelIndex, channel, forceApply, triggerChannel) { const last = this.lastState.channels[channelIndex]; + if (!isSquareChannelActive(channel)) { + if (forceApply || last.enabled) { + this._writeSquareSilent(channelIndex, channel); + } + last.enabled = false; + return; + } const base = SQUARE_BASE[channelIndex]; - const volumeReg = buildSquareVolumeReg(channel.volume, channel.duty); + const volumeReg = + channel.volumeReg !== NES_REGISTER_UNCHANGED + ? channel.volumeReg + : buildSquareVolumeReg(channel.volume, channel.duty); + const lengthNibble = + channel.lengthNibble !== NES_REGISTER_UNCHANGED + ? channel.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; const period = channel.period > 0 ? channel.period - 1 : 0; const periodLow = period & 0xff; - const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((period >> 8) & 7); - const lastPeriodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); + const periodHigh = (lengthNibble << 3) | ((period >> 8) & 7); + const lastLengthNibble = + last.lengthNibble !== NES_REGISTER_UNCHANGED + ? last.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const lastPeriodHigh = (lastLengthNibble << 3) | ((last.period >> 8) & 7); - if (forceApply || volumeReg !== buildSquareVolumeReg(last.volume, last.duty)) { + if ( + isSquareChannelActive(channel) && + (channel.volumeReg !== NES_REGISTER_UNCHANGED || + forceApply || + !last.enabled || + volumeReg !== last.volumeReg) + ) { this.wasmModule.nes_apu_Write(this.apuPtr, base, volumeReg); + last.volumeReg = volumeReg; last.volume = channel.volume; last.duty = channel.duty; } @@ -96,42 +220,96 @@ class NesApuEngine { triggerChannel || channel.retrigger || periodHigh !== lastPeriodHigh || - sweepChanged + sweepChanged || + (channel.lengthNibble !== NES_REGISTER_UNCHANGED && + lengthNibble !== lastLengthNibble) ) { this.wasmModule.nes_apu_Write(this.apuPtr, base + 3, periodHigh); } last.period = period; + last.lengthNibble = channel.lengthNibble; last.retrigger = channel.retrigger; } _writeTriangle(channel, forceApply, triggerChannel) { const last = this.lastState.channels[2]; - const linearReg = (1 << 7) | NES_TRIANGLE_LINEAR_RELOAD; + if (!isTriangleChannelActive(channel)) { + if (forceApply || last.enabled) { + this._writeTriangleSilent(channel); + } + last.enabled = false; + return; + } + const linearReg = + channel.linearReg !== NES_REGISTER_UNCHANGED + ? channel.linearReg + : (1 << 7) | NES_TRIANGLE_LINEAR_RELOAD; + const lengthNibble = + channel.lengthNibble !== NES_REGISTER_UNCHANGED + ? channel.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; const periodLow = channel.period & 0xff; - const periodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((channel.period >> 8) & 7); - const lastPeriodHigh = (NES_SQUARE_LENGTH_NIBBLE << 3) | ((last.period >> 8) & 7); + const periodHigh = (lengthNibble << 3) | ((channel.period >> 8) & 7); + const lastLengthNibble = + last.lengthNibble !== NES_REGISTER_UNCHANGED + ? last.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; + const lastPeriodHigh = (lastLengthNibble << 3) | ((last.period >> 8) & 7); - if (forceApply || linearReg !== ((1 << 7) | NES_TRIANGLE_LINEAR_RELOAD)) { + if ( + channel.linearReg !== NES_REGISTER_UNCHANGED && + (forceApply || linearReg !== last.linearReg) + ) { this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE, linearReg); + last.linearReg = linearReg; } if (forceApply || periodLow !== (last.period & 0xff)) { this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE + 2, periodLow); } - if (forceApply || triggerChannel || channel.retrigger || periodHigh !== lastPeriodHigh) { + if ( + forceApply || + triggerChannel || + channel.retrigger || + periodHigh !== lastPeriodHigh || + (channel.lengthNibble !== NES_REGISTER_UNCHANGED && + lengthNibble !== lastLengthNibble) + ) { this.wasmModule.nes_dmc_Write(this.dmcPtr, TRIANGLE_BASE + 3, periodHigh); } last.period = channel.period; + last.lengthNibble = channel.lengthNibble; last.retrigger = channel.retrigger; } _writeNoise(channel, forceApply, triggerChannel) { const last = this.lastState.channels[3]; - const volumeReg = buildSquareVolumeReg(channel.volume, 0); + if (!isNoiseChannelActive(channel)) { + if (forceApply || last.enabled) { + this._writeNoiseSilent(channel); + } + last.enabled = false; + return; + } + const volumeReg = + channel.volumeReg !== NES_REGISTER_UNCHANGED + ? channel.volumeReg + : buildSquareVolumeReg(channel.volume, 0); + const lengthNibble = + channel.lengthNibble !== NES_REGISTER_UNCHANGED + ? channel.lengthNibble + : NES_SQUARE_LENGTH_NIBBLE; const periodReg = (channel.noiseMode ? 0x80 : 0) | (channel.noisePeriod & 15); - if (forceApply || volumeReg !== buildSquareVolumeReg(last.volume, 0)) { + if ( + isNoiseChannelActive(channel) && + (channel.volumeReg !== NES_REGISTER_UNCHANGED || + forceApply || + !last.enabled || + volumeReg !== last.volumeReg) + ) { this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE, volumeReg); + last.volumeReg = volumeReg; last.volume = channel.volume; } if (forceApply || periodReg !== ((last.noiseMode ? 0x80 : 0) | (last.noisePeriod & 15))) { @@ -139,12 +317,15 @@ class NesApuEngine { 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 - ); + if ( + forceApply || + triggerChannel || + channel.retrigger || + (channel.lengthNibble !== NES_REGISTER_UNCHANGED && + lengthNibble !== last.lengthNibble) + ) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, NOISE_BASE + 3, lengthNibble << 3); + last.lengthNibble = channel.lengthNibble; last.retrigger = channel.retrigger; } } @@ -153,42 +334,42 @@ class NesApuEngine { const forceApply = this.forceFullApply; this.forceFullApply = false; - if (forceApply || NES_APU_4015_PULSE_MASK !== this._lastApu4015) { - this.wasmModule.nes_apu_Write(this.apuPtr, 0x4015, NES_APU_4015_PULSE_MASK); - this._lastApu4015 = NES_APU_4015_PULSE_MASK; + const apu4015 = buildApu4015Mask(registerState); + const dmc4015 = buildDmc4015Mask(registerState); + if (forceApply || apu4015 !== this._lastApu4015) { + this.wasmModule.nes_apu_Write(this.apuPtr, 0x4015, apu4015); + this._lastApu4015 = apu4015; } - if (forceApply || NES_DMC_4015_TND_MASK !== this._lastDmc4015) { - this.wasmModule.nes_dmc_Write(this.dmcPtr, 0x4015, NES_DMC_4015_TND_MASK); - this._lastDmc4015 = NES_DMC_4015_TND_MASK; + if (forceApply || dmc4015 !== this._lastDmc4015) { + this.wasmModule.nes_dmc_Write(this.dmcPtr, 0x4015, dmc4015); + this._lastDmc4015 = dmc4015; } for (let i = 0; i < 2; i++) { const channel = registerState.channels[i]; const last = this.lastState.channels[i]; - const isActive = channel.enabled && channel.period > 0 && channel.volume > 0; - const triggerChannel = - forceApply || channel.retrigger || (isActive && !last.enabled); + const isActive = isSquareChannelActive(channel); + const triggerChannel = isActive && (channel.retrigger || !last.enabled); this._writeSquare(i, channel, forceApply, triggerChannel); last.enabled = isActive; } const triangleChannel = registerState.channels[2]; const triangleLast = this.lastState.channels[2]; - const triangleActive = triangleChannel.enabled && triangleChannel.period > 0; + const triangleActive = isTriangleChannelActive(triangleChannel); const triangleTrigger = - forceApply || - triangleChannel.retrigger || - (triangleActive && !triangleLast.enabled); + triangleActive && (triangleChannel.retrigger || !triangleLast.enabled); this._writeTriangle(triangleChannel, forceApply, triangleTrigger); triangleLast.enabled = triangleActive; const noiseChannel = registerState.channels[3]; const noiseLast = this.lastState.channels[3]; - const noiseActive = noiseChannel.enabled && noiseChannel.volume > 0; - const noiseTrigger = - forceApply || noiseChannel.retrigger || (noiseActive && !noiseLast.enabled); + const noiseActive = isNoiseChannelActive(noiseChannel); + const noiseTrigger = noiseActive && (noiseChannel.retrigger || !noiseLast.enabled); this._writeNoise(noiseChannel, forceApply, noiseTrigger); noiseLast.enabled = noiseActive; + + this._applyOutputMasks(registerState, forceApply); } process(sampleRate) { @@ -213,6 +394,24 @@ class NesApuEngine { return this._lastOutput; } + canReadChannelOutputs() { + return ( + typeof this.wasmModule.nes_apu_GetOut === 'function' && + typeof this.wasmModule.nes_dmc_GetOut === 'function' + ); + } + + getChannelRawOut(channelIndex) { + if (!this.canReadChannelOutputs()) return 0; + if (channelIndex <= 1) { + return this.wasmModule.nes_apu_GetOut(this.apuPtr, channelIndex); + } + if (channelIndex >= 2 && channelIndex <= 4) { + return this.wasmModule.nes_dmc_GetOut(this.dmcPtr, channelIndex - 2); + } + return 0; + } + dispose() { if (this.outputPtr) { this.wasmModule.free(this.outputPtr); @@ -229,8 +428,8 @@ export function createNesApuEngine(wasmModule) { 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_SetMask(apuPtr, 3); + wasmModule.nes_dmc_SetMask(dmcPtr, 7); wasmModule.nes_apu_SetStereoMix(apuPtr, 0, 128, 128); wasmModule.nes_apu_SetStereoMix(apuPtr, 1, 128, 128); wasmModule.nes_dmc_SetStereoMix(dmcPtr, 0, 128, 128); diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 8df64dde..6f23843c 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -10,10 +10,21 @@ import { processChannelOnOffCounters } from '../tracker/tracker-instrument-channel.js'; import { + buildLengthCounterNibble, + buildNoiseEnvelopeVolumeReg, + buildNoiseSilentVolumeReg, + buildSquareEnvelopeVolumeReg, + buildSquareSilentVolumeReg, buildSquareSweepReg, + buildTriangleLinearReg, + buildTriangleSilentLinearReg, ensureNesInstrumentRows, + isChannelAudible, + NES_REGISTER_UNCHANGED, NES_SQUARE_SWEEP_DISABLED, - normalizeNesInstrumentRow + normalizeNesInstrumentRow, + resolveEnvelopeVolumeOrRate, + usesTriangleLinearCounter } from './nes-instrument-utils.js'; import { NES_CHANNEL_COUNT } from './nes-constants.js'; @@ -57,11 +68,66 @@ class NesAudioDriver { channel.enabled = false; channel.period = 0; channel.volume = 0; + channel.retrigger = false; + channel.lengthNibble = NES_REGISTER_UNCHANGED; if (channelIndex <= 1) { + channel.volumeReg = buildSquareSilentVolumeReg(channel.duty); + channel.linearReg = NES_REGISTER_UNCHANGED; channel.sweepReg = NES_SQUARE_SWEEP_DISABLED; + } else if (channelIndex === 2) { + channel.volumeReg = NES_REGISTER_UNCHANGED; + channel.linearReg = buildTriangleSilentLinearReg(); + } else if (channelIndex === 3) { + channel.volumeReg = buildNoiseSilentVolumeReg(); + channel.linearReg = NES_REGISTER_UNCHANGED; + } else { + channel.volumeReg = NES_REGISTER_UNCHANGED; + channel.linearReg = NES_REGISTER_UNCHANGED; } } + _applyEnvelopeAndLength(channel, channelIndex, row, patternVolume) { + const combinedVolume = this.calculateVolume(patternVolume, row.volumeOrRate); + const volumeNibble = resolveEnvelopeVolumeOrRate( + row.envelopeMode, + patternVolume, + row.volumeOrRate, + combinedVolume + ); + channel.volume = combinedVolume; + + if (channelIndex <= 1) { + channel.volumeReg = buildSquareEnvelopeVolumeReg( + row.pulseWidth, + row.envelopeMode, + volumeNibble, + row.soundLength + ); + channel.duty = row.pulseWidth; + channel.lengthNibble = buildLengthCounterNibble(row.envelopeMode, row.soundLength); + channel.linearReg = NES_REGISTER_UNCHANGED; + } else if (channelIndex === 2) { + channel.volumeReg = NES_REGISTER_UNCHANGED; + channel.linearReg = buildTriangleLinearReg(row.envelopeMode, row.soundLength); + channel.lengthNibble = usesTriangleLinearCounter(row.envelopeMode, row.soundLength) + ? NES_REGISTER_UNCHANGED + : buildLengthCounterNibble(row.envelopeMode, row.soundLength); + channel.duty = 0; + } else if (channelIndex === 3) { + channel.volumeReg = buildNoiseEnvelopeVolumeReg( + row.envelopeMode, + volumeNibble, + row.soundLength + ); + channel.lengthNibble = buildLengthCounterNibble(row.envelopeMode, row.soundLength); + channel.linearReg = NES_REGISTER_UNCHANGED; + } + } + + _isChannelAudible(row, patternVolume, combinedVolume) { + return isChannelAudible(row.envelopeMode, patternVolume, row.volumeOrRate, combinedVolume); + } + _resetToneAccumulator(state, channelIndex) { if (state.channelToneAccumulator) { state.channelToneAccumulator[channelIndex] = 0; @@ -160,7 +226,7 @@ class NesAudioDriver { const { row, rowsLength, loop } = this.resolveInstrumentRow(state, channelIndex); const patternVolume = state.channelPatternVolumes[channelIndex] ?? 15; - const volume = this.calculateVolume(patternVolume, 15); + const combinedVolume = this.calculateVolume(patternVolume, row.volumeOrRate); const basePeriod = this.getEffectivePeriod(state, channelIndex); const period = channelIndex <= 2 @@ -168,24 +234,22 @@ class NesAudioDriver { : basePeriod; const keyOn = state.channelKeyOn[channelIndex]; + this._applyEnvelopeAndLength(channel, channelIndex, row, patternVolume); + const audible = this._isChannelAudible(row, patternVolume, combinedVolume); + if (channelIndex <= 1) { - channel.enabled = period > 0 && volume > 0; + channel.enabled = period > 0 && audible; channel.period = period; - channel.volume = volume; - channel.duty = row.pulseWidth; channel.sweepReg = buildSquareSweepReg(row.sweep, row.sweepRate, row.sweepShift); channel.retrigger = row.retrigger || keyOn; state.channelKeyOn[channelIndex] = false; } else if (channelIndex === 2) { - channel.enabled = period > 0; + channel.enabled = period > 0 && patternVolume > 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.enabled = audible; channel.noisePeriod = this.resolveNoisePeriod(state, channelIndex); channel.noiseMode = false; channel.retrigger = row.retrigger || keyOn; diff --git a/public/nes/nes-chip-register-state.js b/public/nes/nes-chip-register-state.js index 787f72b5..074d3736 100644 --- a/public/nes/nes-chip-register-state.js +++ b/public/nes/nes-chip-register-state.js @@ -1,5 +1,5 @@ import { NES_CHANNEL_COUNT } from './nes-constants.js'; -import { NES_SQUARE_SWEEP_DISABLED } from './nes-instrument-utils.js'; +import { NES_REGISTER_UNCHANGED, NES_SQUARE_SWEEP_DISABLED } from './nes-instrument-utils.js'; function createDefaultChannel() { return { @@ -10,7 +10,10 @@ function createDefaultChannel() { retrigger: false, sweepReg: NES_SQUARE_SWEEP_DISABLED, noisePeriod: 0, - noiseMode: false + noiseMode: false, + volumeReg: NES_REGISTER_UNCHANGED, + lengthNibble: NES_REGISTER_UNCHANGED, + linearReg: NES_REGISTER_UNCHANGED }; } diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js index db0e30b7..e27d46a4 100644 --- a/public/nes/nes-instrument-utils.js +++ b/public/nes/nes-instrument-utils.js @@ -8,6 +8,48 @@ const SWEEP_SHIFT_MIN = -7; const SWEEP_SHIFT_MAX = 7; export const NES_SQUARE_SWEEP_DISABLED = 0x08; +export const NES_ENVELOPE_MODES = ['infinite', 'decay', 'loop', 'hold', 'unchanged']; + +export const NES_LENGTH_COUNTER_LENGTHS = [ + 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, + 192, 24, 72, 26, 16, 28, 32, 30 +]; + +export const NES_REGISTER_UNCHANGED = -1; + +export function buildSquareSilentVolumeReg(duty = 2) { + return ((duty & 3) << 6) | (1 << 4); +} + +export function buildNoiseSilentVolumeReg() { + return 1 << 4; +} + +export function buildTriangleSilentLinearReg() { + return (1 << 7) | 0x7f; +} + +const SOUND_LENGTH_MIN = 0; +const SOUND_LENGTH_MAX = 511; +const VOLUME_OR_RATE_MIN = 0; +const VOLUME_OR_RATE_MAX = 15; + +function normalizeEnvelopeMode(value) { + return NES_ENVELOPE_MODES.includes(value) ? value : 'infinite'; +} + +function normalizeSoundLength(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SOUND_LENGTH_MIN, Math.min(SOUND_LENGTH_MAX, Math.round(parsed))); +} + +function normalizeVolumeOrRate(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(VOLUME_OR_RATE_MIN, Math.min(VOLUME_OR_RATE_MAX, Math.round(parsed))); +} + function normalizeToneAdd(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return 0; @@ -18,6 +60,9 @@ export function createDefaultNesInstrumentRow() { return { pulseWidth: 2, retrigger: false, + soundLength: 0, + envelopeMode: 'infinite', + volumeOrRate: 15, toneAdd: 0, toneAccumulation: false, sweep: false, @@ -47,12 +92,115 @@ export function buildSquareSweepReg(enabled, rate, shift) { return shift < 0 ? 0x88 | packed : 0x80 | packed; } +function isSoundLengthEnabled(envelopeMode) { + return envelopeMode !== 'infinite' && envelopeMode !== 'unchanged'; +} + +export function resolveLengthCounterIndex(soundLength) { + if (soundLength <= 0) return 0; + const target = soundLength; + let bestIndex = 0; + let bestDelta = Number.POSITIVE_INFINITY; + for (let i = 0; i < NES_LENGTH_COUNTER_LENGTHS.length; i++) { + const frames = NES_LENGTH_COUNTER_LENGTHS[i] * 2; + const delta = Math.abs(frames - target); + if (delta < bestDelta) { + bestDelta = delta; + bestIndex = i; + } + } + return bestIndex; +} + +function resolveEnvelopeLoopBit(soundLength) { + return soundLength === 0 ? 1 << 5 : 0; +} + +export function buildSquareEnvelopeVolumeReg(duty, envelopeMode, volumeOrRate, soundLength) { + if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; + const volume = volumeOrRate & 15; + const dutyBits = (duty & 3) << 6; + const loopBit = resolveEnvelopeLoopBit(soundLength); + switch (envelopeMode) { + case 'infinite': + return dutyBits | loopBit | (1 << 4) | volume; + case 'decay': + case 'loop': + return dutyBits | loopBit | volume; + case 'hold': + return dutyBits | loopBit | (1 << 4) | volume; + default: + return dutyBits | loopBit | (1 << 4) | volume; + } +} + +export function buildNoiseEnvelopeVolumeReg(envelopeMode, volumeOrRate, soundLength) { + if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; + const volume = volumeOrRate & 15; + const loopBit = resolveEnvelopeLoopBit(soundLength); + switch (envelopeMode) { + case 'infinite': + return loopBit | (1 << 4) | volume; + case 'decay': + case 'loop': + return loopBit | volume; + case 'hold': + return loopBit | (1 << 4) | volume; + default: + return loopBit | (1 << 4) | volume; + } +} + +export function buildLengthCounterNibble(envelopeMode, soundLength) { + if (envelopeMode === 'unchanged' || envelopeMode === 'infinite' || soundLength === 0) { + return NES_REGISTER_UNCHANGED; + } + return resolveLengthCounterIndex(soundLength) & 31; +} + +export function buildTriangleLinearReg(envelopeMode, soundLength) { + if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; + if (soundLength === 0) { + return (1 << 7) | 0x7f; + } + if (soundLength > 0 && soundLength < 128) { + return soundLength & 0x7f; + } + return 0x7f; +} + +export function usesTriangleLinearCounter(envelopeMode, soundLength) { + return isSoundLengthEnabled(envelopeMode) && soundLength > 0 && soundLength < 128; +} + +export function resolveEnvelopeVolumeOrRate(envelopeMode, patternVolume, instrumentVolumeOrRate, combinedVolume) { + if (envelopeMode === 'unchanged') return 0; + if (envelopeMode === 'infinite' || envelopeMode === 'hold') { + return combinedVolume; + } + return instrumentVolumeOrRate; +} + +export function isChannelAudible(envelopeMode, patternVolume, volumeOrRate, combinedVolume) { + if (envelopeMode === 'unchanged') return patternVolume > 0; + if (envelopeMode === 'infinite' || envelopeMode === 'hold') { + return combinedVolume > 0; + } + return patternVolume > 0 && volumeOrRate > 0; +} + export function normalizeNesInstrumentRow(row) { const defaults = createDefaultNesInstrumentRow(); const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) ? row.pulseWidth : defaults.pulseWidth; + const envelopeMode = normalizeEnvelopeMode(row?.envelopeMode); + const soundLength = + envelopeMode === 'infinite' ? 0 : normalizeSoundLength(row?.soundLength); return { pulseWidth, retrigger: Boolean(row?.retrigger), + soundLength, + envelopeMode, + volumeOrRate: normalizeVolumeOrRate(row?.volumeOrRate), toneAdd: normalizeToneAdd(row?.toneAdd), toneAccumulation: Boolean(row?.toneAccumulation), sweep: Boolean(row?.sweep), diff --git a/public/nes/nes-waveform-capture.js b/public/nes/nes-waveform-capture.js index e8d793af..a23abfa6 100644 --- a/public/nes/nes-waveform-capture.js +++ b/public/nes/nes-waveform-capture.js @@ -1,10 +1,37 @@ +const NES_WAVEFORM_SCALE = 0.5; +const NES_SQUARE_NOISE_MAX = 15; +const NES_DMC_MAX = 127; + const SQUARE_DUTY = [0.125, 0.25, 0.5, 0.75]; +export function normalizeNesChannelWaveformSample(channelIndex, rawOut) { + if (rawOut < 0) return 0; + const max = + channelIndex === 4 ? NES_DMC_MAX : channelIndex <= 3 ? NES_SQUARE_NOISE_MAX : 0; + if (!(max > 0)) return 0; + return (rawOut / max - 0.5) * NES_WAVEFORM_SCALE; +} + export class NesWaveformCapture { constructor(channelCount) { + this.channelCount = channelCount; this.phases = new Float64Array(channelCount); } + readChannelOutputs(apuEngine) { + const outputs = new Float32Array(this.channelCount); + if (!apuEngine?.canReadChannelOutputs?.()) { + return null; + } + for (let ch = 0; ch < this.channelCount; ch++) { + outputs[ch] = normalizeNesChannelWaveformSample( + ch, + apuEngine.getChannelRawOut(ch) + ); + } + return outputs; + } + reset() { this.phases.fill(0); } diff --git a/public/nes/nes-worklet-slot.js b/public/nes/nes-worklet-slot.js index 571fc1dd..467b5edb 100644 --- a/public/nes/nes-worklet-slot.js +++ b/public/nes/nes-worklet-slot.js @@ -64,6 +64,7 @@ export class NesWorkletSlot extends TrackerWorkletSlot { _applyRegisterStateToEngine() { if (!this.apuEngine) return; + this.enforceMuteState(); this.apuEngine.applyRegisterState(this.registerState); } @@ -228,13 +229,20 @@ export class NesWorkletSlot extends TrackerWorkletSlot { const cpuFrequency = this.state.cpuFrequency; const wi = this.channelWaveformWriteIndex; + const emulatorOutputs = this.waveformCapture.readChannelOutputs(this.apuEngine); for (let ch = 0; ch < this.channelWaveformBuf.length; ch++) { - const sample = this.waveformCapture.sample( - ch, - this.registerState.channels[ch], - cpuFrequency, - sampleRate - ); + const channel = this.registerState.channels[ch]; + const sample = + emulatorOutputs != null + ? channel?.enabled + ? emulatorOutputs[ch] + : 0 + : this.waveformCapture.sample( + ch, + channel, + cpuFrequency, + sampleRate + ); this.channelWaveformBuf[ch][(wi + sampleIndex) % 512] = sample; } } diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index 7d219773..5a95800c 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -3,6 +3,7 @@ import IconCarbonRepeat from '~icons/carbon/repeat'; import IconCarbonChartWinLoss from '~icons/carbon/chart-win-loss'; import IconCarbonArrowsVertical from '~icons/carbon/arrows-vertical'; + import IconCarbonVolumeUp from '~icons/carbon/volume-up'; import { BooleanPaintableCell, BooleanPaintDrag, @@ -33,10 +34,16 @@ shouldBlockRowEditorNumericKey } from '../../utils/row-editor-numeric'; import { compactTableInputClass } from '../../utils/compact-table-input'; + import NesEnvelopeModeCell from './NesEnvelopeModeCell.svelte'; import { createDefaultNesInstrumentRow, + cycleNesEnvelopeMode, cyclePulseWidth, ensureNesInstrumentRows, + isNesEnvelopeInfinite, + isNesSoundLengthEnabled, + isNesVolumeField, + isNesVolumeOrRateEnabled, NES_PULSE_WIDTH_LABELS, type NesInstrumentRow } from './instrument'; @@ -55,7 +62,7 @@ selectedRowIndices?: number[]; } = $props(); - const TABLE_COLUMNS = 10; + const TABLE_COLUMNS = 13; let tableRef: HTMLTableElement | null = $state(null); let editorContainerRef: HTMLDivElement | null = $state(null); @@ -112,7 +119,7 @@ function updateNumericField( index: number, - field: 'toneAdd' | 'sweepRate' | 'sweepShift', + field: 'toneAdd' | 'sweepRate' | 'sweepShift' | 'soundLength' | 'volumeOrRate', event: Event, limits?: { min?: number; max?: number } ) { @@ -227,6 +234,24 @@ title="Hardware sweep shift (−7–7)"> shift + + + @@ -324,6 +349,54 @@ oninput={(e) => updateNumericField(index, 'sweepShift', e, { min: -7, max: 7 })} /> + + { + const envelopeMode = cycleNesEnvelopeMode(row.envelopeMode); + updateRow(index, { + envelopeMode, + ...(isNesEnvelopeInfinite(envelopeMode) ? { soundLength: 0 } : {}) + }); + }} /> + {/each} diff --git a/src/lib/chips/nes/NesEnvelopeModeCell.svelte b/src/lib/chips/nes/NesEnvelopeModeCell.svelte new file mode 100644 index 00000000..079b5475 --- /dev/null +++ b/src/lib/chips/nes/NesEnvelopeModeCell.svelte @@ -0,0 +1,78 @@ + + + diff --git a/src/lib/chips/nes/instrument.ts b/src/lib/chips/nes/instrument.ts index c87b3de0..8ad4eda0 100644 --- a/src/lib/chips/nes/instrument.ts +++ b/src/lib/chips/nes/instrument.ts @@ -17,9 +17,34 @@ const SWEEP_SHIFT_MIN = -7; const SWEEP_SHIFT_MAX = 7; export const NES_SQUARE_SWEEP_DISABLED = 0x08; +export const NES_ENVELOPE_MODES = ['infinite', 'decay', 'loop', 'hold', 'unchanged'] as const; + +export type NesEnvelopeMode = (typeof NES_ENVELOPE_MODES)[number]; + +export const NES_ENVELOPE_MODE_LABELS: Record = { + infinite: 'Infinite', + decay: 'Decay', + loop: 'Loop', + hold: 'Hold', + unchanged: 'Unchanged' +}; + +export const NES_LENGTH_COUNTER_LENGTHS = [ + 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, + 192, 24, 72, 26, 16, 28, 32, 30 +] as const; + +const SOUND_LENGTH_MIN = 0; +const SOUND_LENGTH_MAX = 511; +const VOLUME_OR_RATE_MIN = 0; +const VOLUME_OR_RATE_MAX = 15; + export type NesInstrumentRow = { pulseWidth: NesPulseWidth; retrigger: boolean; + soundLength: number; + envelopeMode: NesEnvelopeMode; + volumeOrRate: number; toneAdd: number; toneAccumulation: boolean; sweep: boolean; @@ -27,10 +52,39 @@ export type NesInstrumentRow = { sweepShift: number; }; +export function isNesEnvelopeInfinite(mode: NesEnvelopeMode): boolean { + return mode === 'infinite'; +} + +export function isNesEnvelopeUnchanged(mode: NesEnvelopeMode): boolean { + return mode === 'unchanged'; +} + +export function isNesSoundLengthEnabled(mode: NesEnvelopeMode): boolean { + return !isNesEnvelopeInfinite(mode) && !isNesEnvelopeUnchanged(mode); +} + +export function isNesVolumeOrRateEnabled(mode: NesEnvelopeMode): boolean { + return !isNesEnvelopeUnchanged(mode); +} + +export function isNesVolumeField(mode: NesEnvelopeMode): boolean { + return mode === 'infinite'; +} + +export function cycleNesEnvelopeMode(current: NesEnvelopeMode): NesEnvelopeMode { + const index = NES_ENVELOPE_MODES.indexOf(current); + const nextIndex = index < 0 ? 0 : (index + 1) % NES_ENVELOPE_MODES.length; + return NES_ENVELOPE_MODES[nextIndex]; +} + export function createDefaultNesInstrumentRow(): NesInstrumentRow { return { pulseWidth: 2, retrigger: false, + soundLength: 0, + envelopeMode: 'infinite', + volumeOrRate: 15, toneAdd: 0, toneAccumulation: false, sweep: false, @@ -57,6 +111,24 @@ function normalizeSweepShift(value: unknown): number { return Math.max(SWEEP_SHIFT_MIN, Math.min(SWEEP_SHIFT_MAX, Math.round(parsed))); } +function normalizeEnvelopeMode(value: unknown): NesEnvelopeMode { + return NES_ENVELOPE_MODES.includes(value as NesEnvelopeMode) + ? (value as NesEnvelopeMode) + : 'infinite'; +} + +function normalizeSoundLength(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(SOUND_LENGTH_MIN, Math.min(SOUND_LENGTH_MAX, Math.round(parsed))); +} + +function normalizeVolumeOrRate(value: unknown): number { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(VOLUME_OR_RATE_MIN, Math.min(VOLUME_OR_RATE_MAX, Math.round(parsed))); +} + export function buildSquareSweepReg(enabled: boolean, rate: number, shift: number): number { if (!enabled || shift === 0) { return NES_SQUARE_SWEEP_DISABLED; @@ -66,14 +138,124 @@ export function buildSquareSweepReg(enabled: boolean, rate: number, shift: numbe return shift < 0 ? 0x88 | packed : 0x80 | packed; } +export const NES_REGISTER_UNCHANGED = -1; + +export function resolveLengthCounterIndex(soundLength: number): number { + if (soundLength <= 0) return 0; + const target = soundLength; + let bestIndex = 0; + let bestDelta = Number.POSITIVE_INFINITY; + for (let i = 0; i < NES_LENGTH_COUNTER_LENGTHS.length; i++) { + const frames = NES_LENGTH_COUNTER_LENGTHS[i] * 2; + const delta = Math.abs(frames - target); + if (delta < bestDelta) { + bestDelta = delta; + bestIndex = i; + } + } + return bestIndex; +} + +function resolveEnvelopeLoopBit(soundLength: number): number { + return soundLength === 0 ? 1 << 5 : 0; +} + +export function buildSquareEnvelopeVolumeReg( + duty: NesPulseWidth, + envelopeMode: NesEnvelopeMode, + volumeOrRate: number, + soundLength: number +): number { + if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; + const volume = volumeOrRate & 15; + const dutyBits = (duty & 3) << 6; + const loopBit = resolveEnvelopeLoopBit(soundLength); + switch (envelopeMode) { + case 'infinite': + return dutyBits | loopBit | (1 << 4) | volume; + case 'decay': + case 'loop': + return dutyBits | loopBit | volume; + case 'hold': + return dutyBits | loopBit | (1 << 4) | volume; + default: + return dutyBits | loopBit | (1 << 4) | volume; + } +} + +export function buildNoiseEnvelopeVolumeReg( + envelopeMode: NesEnvelopeMode, + volumeOrRate: number, + soundLength: number +): number { + if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; + const volume = volumeOrRate & 15; + const loopBit = resolveEnvelopeLoopBit(soundLength); + switch (envelopeMode) { + case 'infinite': + return loopBit | (1 << 4) | volume; + case 'decay': + case 'loop': + return loopBit | volume; + case 'hold': + return loopBit | (1 << 4) | volume; + default: + return loopBit | (1 << 4) | volume; + } +} + +export function buildLengthCounterNibble( + envelopeMode: NesEnvelopeMode, + soundLength: number +): number { + if ( + envelopeMode === 'unchanged' || + envelopeMode === 'infinite' || + soundLength === 0 + ) { + return NES_REGISTER_UNCHANGED; + } + return resolveLengthCounterIndex(soundLength) & 31; +} + +export function buildTriangleLinearReg( + envelopeMode: NesEnvelopeMode, + soundLength: number +): number { + if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; + if (soundLength === 0) { + return (1 << 7) | 0x7f; + } + if (soundLength > 0 && soundLength < 128) { + return soundLength & 0x7f; + } + return 0x7f; +} + +export function usesTriangleLinearCounter( + envelopeMode: NesEnvelopeMode, + soundLength: number +): boolean { + return ( + isNesSoundLengthEnabled(envelopeMode) && soundLength > 0 && soundLength < 128 + ); +} + export function normalizeNesInstrumentRow(row: Record): NesInstrumentRow { const defaults = createDefaultNesInstrumentRow(); const pulseWidth = NES_PULSE_WIDTHS.includes(row.pulseWidth as NesPulseWidth) ? (row.pulseWidth as NesPulseWidth) : defaults.pulseWidth; + const envelopeMode = normalizeEnvelopeMode(row.envelopeMode); + const soundLength = isNesEnvelopeInfinite(envelopeMode) + ? 0 + : normalizeSoundLength(row.soundLength); return { pulseWidth, retrigger: Boolean(row.retrigger), + soundLength, + envelopeMode, + volumeOrRate: normalizeVolumeOrRate(row.volumeOrRate), toneAdd: normalizeToneAdd(row.toneAdd), toneAccumulation: Boolean(row.toneAccumulation), sweep: Boolean(row.sweep), diff --git a/tests/lib/chips/nes/instrument.test.ts b/tests/lib/chips/nes/instrument.test.ts index 9d550b80..b4fcdcca 100644 --- a/tests/lib/chips/nes/instrument.test.ts +++ b/tests/lib/chips/nes/instrument.test.ts @@ -1,18 +1,32 @@ import { describe, expect, it } from 'vitest'; import { + buildLengthCounterNibble, + buildNoiseEnvelopeVolumeReg, + buildSquareEnvelopeVolumeReg, buildSquareSweepReg, + buildTriangleLinearReg, createDefaultNesInstrumentRow, + cycleNesEnvelopeMode, cyclePulseWidth, ensureNesInstrumentRows, + isNesSoundLengthEnabled, + isNesVolumeField, + NES_LENGTH_COUNTER_LENGTHS, + NES_REGISTER_UNCHANGED, NES_SQUARE_SWEEP_DISABLED, - normalizeNesInstrumentRow + normalizeNesInstrumentRow, + resolveLengthCounterIndex, + usesTriangleLinearCounter } from '@/lib/chips/nes/instrument'; describe('nes instrument', () => { - it('creates a default macro row with retrigger off', () => { + it('creates a default macro row with infinite envelope and retrigger off', () => { expect(createDefaultNesInstrumentRow()).toEqual({ pulseWidth: 2, retrigger: false, + soundLength: 0, + envelopeMode: 'infinite', + volumeOrRate: 15, toneAdd: 0, toneAccumulation: false, sweep: false, @@ -22,9 +36,21 @@ describe('nes instrument', () => { }); it('normalizes partial rows and ensures at least one row', () => { - expect(normalizeNesInstrumentRow({ retrigger: 1, pulseWidth: 99, toneAdd: -2 })).toEqual({ + expect( + normalizeNesInstrumentRow({ + retrigger: 1, + pulseWidth: 99, + toneAdd: -2, + soundLength: 999, + envelopeMode: 'bogus', + volumeOrRate: 20 + }) + ).toEqual({ pulseWidth: 2, retrigger: true, + soundLength: 0, + envelopeMode: 'infinite', + volumeOrRate: 15, toneAdd: -2, toneAccumulation: false, sweep: false, @@ -34,6 +60,9 @@ describe('nes instrument', () => { expect(normalizeNesInstrumentRow({ toneAccumulation: true, toneAdd: 5000 })).toEqual({ pulseWidth: 2, retrigger: false, + soundLength: 0, + envelopeMode: 'infinite', + volumeOrRate: 0, toneAdd: 4095, toneAccumulation: true, sweep: false, @@ -44,11 +73,16 @@ describe('nes instrument', () => { normalizeNesInstrumentRow({ sweep: true, sweepRate: 12, - sweepShift: -9 + sweepShift: -9, + envelopeMode: 'loop', + volumeOrRate: 7 }) ).toEqual({ pulseWidth: 2, retrigger: false, + soundLength: 0, + envelopeMode: 'loop', + volumeOrRate: 7, toneAdd: 0, toneAccumulation: false, sweep: true, @@ -58,6 +92,25 @@ describe('nes instrument', () => { expect(ensureNesInstrumentRows([])).toHaveLength(1); }); + it('forces sound length to zero in infinite envelope mode', () => { + expect( + normalizeNesInstrumentRow({ + envelopeMode: 'infinite', + soundLength: 200 + }).soundLength + ).toBe(0); + }); + + it('cycles envelope modes and exposes length counter table', () => { + expect(cycleNesEnvelopeMode('infinite')).toBe('decay'); + expect(cycleNesEnvelopeMode('unchanged')).toBe('infinite'); + expect(NES_LENGTH_COUNTER_LENGTHS).toHaveLength(32); + expect(isNesSoundLengthEnabled('decay')).toBe(true); + expect(isNesSoundLengthEnabled('infinite')).toBe(false); + expect(isNesVolumeField('infinite')).toBe(true); + expect(isNesVolumeField('decay')).toBe(false); + }); + it('cycles pulse width through duty options', () => { expect(cyclePulseWidth(0)).toBe(1); expect(cyclePulseWidth(3)).toBe(0); @@ -69,4 +122,22 @@ describe('nes instrument', () => { expect(buildSquareSweepReg(true, 3, 4)).toBe(0x80 | 0x34); expect(buildSquareSweepReg(true, 7, -5)).toBe(0x88 | 0x75); }); + + it('maps envelope modes and sound length to APU register bytes', () => { + expect(buildSquareEnvelopeVolumeReg(2, 'infinite', 15, 0)).toBe(0xbf); + expect(buildSquareEnvelopeVolumeReg(2, 'decay', 7, 40)).toBe(0x87); + expect(buildSquareEnvelopeVolumeReg(2, 'decay', 7, 0)).toBe(0xa7); + expect(buildSquareEnvelopeVolumeReg(2, 'loop', 4, 0)).toBe(0xa4); + expect(buildSquareEnvelopeVolumeReg(2, 'loop', 4, 40)).toBe(0x84); + expect(buildSquareEnvelopeVolumeReg(2, 'unchanged', 15, 0)).toBe(NES_REGISTER_UNCHANGED); + expect(buildNoiseEnvelopeVolumeReg('hold', 10, 40)).toBe(0x1a); + expect(buildLengthCounterNibble('infinite', 200)).toBe(NES_REGISTER_UNCHANGED); + expect(buildLengthCounterNibble('decay', 0)).toBe(NES_REGISTER_UNCHANGED); + expect(buildLengthCounterNibble('decay', 20)).toBe(resolveLengthCounterIndex(20)); + expect(buildTriangleLinearReg('infinite', 0)).toBe(0xff); + expect(buildTriangleLinearReg('decay', 0)).toBe(0xff); + expect(buildTriangleLinearReg('decay', 64)).toBe(64); + expect(usesTriangleLinearCounter('decay', 64)).toBe(true); + expect(usesTriangleLinearCounter('decay', 200)).toBe(false); + }); }); diff --git a/tests/public/nes-apu-engine.test.js b/tests/public/nes-apu-engine.test.js index 794a6af4..717820b7 100644 --- a/tests/public/nes-apu-engine.test.js +++ b/tests/public/nes-apu-engine.test.js @@ -23,6 +23,16 @@ function renderSquarePeak(engine, sampleRate = 44100) { } describe('NesApuEngine', () => { + it('stays silent when no channels are active', async () => { + const wasmModule = await loadWasm(); + const { engine } = createNesApuEngine(wasmModule); + const registerState = new NesChipRegisterState(); + + engine.applyRegisterState(registerState); + + expect(renderSquarePeak(engine)).toBeLessThan(0.001); + }); + it('plays square waves after channel enable and register writes', async () => { const wasmModule = await loadWasm(); const { engine } = createNesApuEngine(wasmModule); diff --git a/tests/public/nes-audio-driver-envelope.test.js b/tests/public/nes-audio-driver-envelope.test.js new file mode 100644 index 00000000..4787754f --- /dev/null +++ b/tests/public/nes-audio-driver-envelope.test.js @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; +import { + buildLengthCounterNibble, + buildSquareEnvelopeVolumeReg, + buildTriangleLinearReg, + NES_REGISTER_UNCHANGED +} from '../../public/nes/nes-instrument-utils.js'; + +function createEnvelopeState(rowOverrides = {}) { + return { + channelMuted: [false, false, false, false, false], + channelSoundEnabled: [true, true, true, true, false], + channelInstruments: [0, 0, 0, 0, -1], + instruments: [ + { + rows: [ + { + pulseWidth: 2, + retrigger: false, + soundLength: 40, + envelopeMode: 'decay', + volumeOrRate: 6, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0, + ...rowOverrides + } + ], + loop: 0 + } + ], + instrumentPositions: [0, 0, 0, 0, 0], + channelPatternVolumes: [15, 15, 15, 15, 15], + channelCurrentNotes: [60, 60, 60, 60, 0], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0, 0, 0, 0, 0], + channelVibratoSliding: [0, 0, 0, 0, 0], + channelDetune: [0, 0, 0, 0, 0], + channelKeyOn: [false, false, false, false, false], + channelToneAccumulator: [0, 0, 0, 0, 0], + channelOnOffCounter: [0, 0, 0, 0, 0], + channelOnDuration: [0, 0, 0, 0, 0], + channelOffDuration: [0, 0, 0, 0, 0] + }; +} + +describe('NesAudioDriver envelope and length macro', () => { + it('writes decay envelope and length counter settings for pulse channels', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createEnvelopeState(); + + driver.processInstruments(state, registerState); + + expect(registerState.channels[0].volumeReg).toBe( + buildSquareEnvelopeVolumeReg(2, 'decay', 6, 40) + ); + expect(registerState.channels[0].lengthNibble).toBe( + buildLengthCounterNibble('decay', 40) + ); + }); + + it('uses triangle linear counter for short sound lengths', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createEnvelopeState({ soundLength: 64, envelopeMode: 'hold' }); + + driver.processInstruments(state, registerState); + + expect(registerState.channels[2].linearReg).toBe(buildTriangleLinearReg('hold', 64)); + expect(registerState.channels[2].lengthNibble).toBe(NES_REGISTER_UNCHANGED); + }); + + it('skips envelope register updates in unchanged mode', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = createEnvelopeState({ envelopeMode: 'unchanged' }); + + driver.processInstruments(state, registerState); + + expect(registerState.channels[0].volumeReg).toBe(NES_REGISTER_UNCHANGED); + expect(registerState.channels[0].lengthNibble).toBe(NES_REGISTER_UNCHANGED); + }); +}); diff --git a/tests/public/nes-waveform-capture.test.js b/tests/public/nes-waveform-capture.test.js new file mode 100644 index 00000000..c1fd637b --- /dev/null +++ b/tests/public/nes-waveform-capture.test.js @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeNesChannelWaveformSample, + NesWaveformCapture +} from '../../public/nes/nes-waveform-capture.js'; + +describe('NesWaveformCapture', () => { + it('normalizes emulator channel output around zero', () => { + expect(normalizeNesChannelWaveformSample(0, 0)).toBe(-0.25); + expect(normalizeNesChannelWaveformSample(0, 15)).toBe(0.25); + expect(normalizeNesChannelWaveformSample(0, 7)).toBeCloseTo(-0.016666, 5); + }); + + it('prefers emulator channel outputs when available', () => { + const capture = new NesWaveformCapture(5); + const apuEngine = { + canReadChannelOutputs: () => true, + getChannelRawOut: (channelIndex) => (channelIndex === 0 ? 15 : 0) + }; + const outputs = capture.readChannelOutputs(apuEngine); + expect(outputs[0]).toBe(0.25); + expect(outputs[1]).toBe(-0.25); + }); +}); From c8e675b24282af4bbe7dd09aab83d00cba1d7683 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sat, 20 Jun 2026 18:42:18 +0200 Subject: [PATCH 18/28] Remove envelope types --- public/nes/nes-audio-driver.js | 18 +-- public/nes/nes-instrument-utils.js | 111 ++++++------- src/lib/chips/nes/NESInstrumentEditor.svelte | 66 +++----- src/lib/chips/nes/NesEnvelopeModeCell.svelte | 78 --------- src/lib/chips/nes/instrument.ts | 148 ++++++------------ tests/lib/chips/nes/instrument.test.ts | 68 +++----- .../public/nes-audio-driver-envelope.test.js | 23 ++- 7 files changed, 162 insertions(+), 350 deletions(-) delete mode 100644 src/lib/chips/nes/NesEnvelopeModeCell.svelte diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 6f23843c..27b304e1 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -89,7 +89,7 @@ class NesAudioDriver { _applyEnvelopeAndLength(channel, channelIndex, row, patternVolume) { const combinedVolume = this.calculateVolume(patternVolume, row.volumeOrRate); const volumeNibble = resolveEnvelopeVolumeOrRate( - row.envelopeMode, + row.envelope, patternVolume, row.volumeOrRate, combinedVolume @@ -99,33 +99,33 @@ class NesAudioDriver { if (channelIndex <= 1) { channel.volumeReg = buildSquareEnvelopeVolumeReg( row.pulseWidth, - row.envelopeMode, + row.envelope, volumeNibble, row.soundLength ); channel.duty = row.pulseWidth; - channel.lengthNibble = buildLengthCounterNibble(row.envelopeMode, row.soundLength); + channel.lengthNibble = buildLengthCounterNibble(row.soundLength); channel.linearReg = NES_REGISTER_UNCHANGED; } else if (channelIndex === 2) { channel.volumeReg = NES_REGISTER_UNCHANGED; - channel.linearReg = buildTriangleLinearReg(row.envelopeMode, row.soundLength); - channel.lengthNibble = usesTriangleLinearCounter(row.envelopeMode, row.soundLength) + channel.linearReg = buildTriangleLinearReg(row.soundLength); + channel.lengthNibble = usesTriangleLinearCounter(row.soundLength) ? NES_REGISTER_UNCHANGED - : buildLengthCounterNibble(row.envelopeMode, row.soundLength); + : buildLengthCounterNibble(row.soundLength); channel.duty = 0; } else if (channelIndex === 3) { channel.volumeReg = buildNoiseEnvelopeVolumeReg( - row.envelopeMode, + row.envelope, volumeNibble, row.soundLength ); - channel.lengthNibble = buildLengthCounterNibble(row.envelopeMode, row.soundLength); + channel.lengthNibble = buildLengthCounterNibble(row.soundLength); channel.linearReg = NES_REGISTER_UNCHANGED; } } _isChannelAudible(row, patternVolume, combinedVolume) { - return isChannelAudible(row.envelopeMode, patternVolume, row.volumeOrRate, combinedVolume); + return isChannelAudible(row.envelope, patternVolume, row.volumeOrRate, combinedVolume); } _resetToneAccumulator(state, channelIndex) { diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js index e27d46a4..8fc81c68 100644 --- a/public/nes/nes-instrument-utils.js +++ b/public/nes/nes-instrument-utils.js @@ -8,8 +8,6 @@ const SWEEP_SHIFT_MIN = -7; const SWEEP_SHIFT_MAX = 7; export const NES_SQUARE_SWEEP_DISABLED = 0x08; -export const NES_ENVELOPE_MODES = ['infinite', 'decay', 'loop', 'hold', 'unchanged']; - export const NES_LENGTH_COUNTER_LENGTHS = [ 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, 192, 24, 72, 26, 16, 28, 32, 30 @@ -34,8 +32,8 @@ const SOUND_LENGTH_MAX = 511; const VOLUME_OR_RATE_MIN = 0; const VOLUME_OR_RATE_MAX = 15; -function normalizeEnvelopeMode(value) { - return NES_ENVELOPE_MODES.includes(value) ? value : 'infinite'; +function normalizeEnvelope(value) { + return Boolean(value); } function normalizeSoundLength(value) { @@ -61,7 +59,7 @@ export function createDefaultNesInstrumentRow() { pulseWidth: 2, retrigger: false, soundLength: 0, - envelopeMode: 'infinite', + envelope: false, volumeOrRate: 15, toneAdd: 0, toneAccumulation: false, @@ -92,10 +90,6 @@ export function buildSquareSweepReg(enabled, rate, shift) { return shift < 0 ? 0x88 | packed : 0x80 | packed; } -function isSoundLengthEnabled(envelopeMode) { - return envelopeMode !== 'infinite' && envelopeMode !== 'unchanged'; -} - export function resolveLengthCounterIndex(soundLength) { if (soundLength <= 0) return 0; const target = soundLength; @@ -116,50 +110,34 @@ function resolveEnvelopeLoopBit(soundLength) { return soundLength === 0 ? 1 << 5 : 0; } -export function buildSquareEnvelopeVolumeReg(duty, envelopeMode, volumeOrRate, soundLength) { - if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; +function resolveConstantVolumeBit(envelope) { + return envelope ? 0 : 1 << 4; +} + +export function buildSquareEnvelopeVolumeReg(duty, envelope, volumeOrRate, soundLength) { const volume = volumeOrRate & 15; const dutyBits = (duty & 3) << 6; - const loopBit = resolveEnvelopeLoopBit(soundLength); - switch (envelopeMode) { - case 'infinite': - return dutyBits | loopBit | (1 << 4) | volume; - case 'decay': - case 'loop': - return dutyBits | loopBit | volume; - case 'hold': - return dutyBits | loopBit | (1 << 4) | volume; - default: - return dutyBits | loopBit | (1 << 4) | volume; - } + return ( + dutyBits | + resolveEnvelopeLoopBit(soundLength) | + resolveConstantVolumeBit(envelope) | + volume + ); } -export function buildNoiseEnvelopeVolumeReg(envelopeMode, volumeOrRate, soundLength) { - if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; +export function buildNoiseEnvelopeVolumeReg(envelope, volumeOrRate, soundLength) { const volume = volumeOrRate & 15; - const loopBit = resolveEnvelopeLoopBit(soundLength); - switch (envelopeMode) { - case 'infinite': - return loopBit | (1 << 4) | volume; - case 'decay': - case 'loop': - return loopBit | volume; - case 'hold': - return loopBit | (1 << 4) | volume; - default: - return loopBit | (1 << 4) | volume; - } + return resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume; } -export function buildLengthCounterNibble(envelopeMode, soundLength) { - if (envelopeMode === 'unchanged' || envelopeMode === 'infinite' || soundLength === 0) { +export function buildLengthCounterNibble(soundLength) { + if (soundLength === 0) { return NES_REGISTER_UNCHANGED; } return resolveLengthCounterIndex(soundLength) & 31; } -export function buildTriangleLinearReg(envelopeMode, soundLength) { - if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; +export function buildTriangleLinearReg(soundLength) { if (soundLength === 0) { return (1 << 7) | 0x7f; } @@ -169,21 +147,24 @@ export function buildTriangleLinearReg(envelopeMode, soundLength) { return 0x7f; } -export function usesTriangleLinearCounter(envelopeMode, soundLength) { - return isSoundLengthEnabled(envelopeMode) && soundLength > 0 && soundLength < 128; +export function usesTriangleLinearCounter(soundLength) { + return soundLength > 0 && soundLength < 128; } -export function resolveEnvelopeVolumeOrRate(envelopeMode, patternVolume, instrumentVolumeOrRate, combinedVolume) { - if (envelopeMode === 'unchanged') return 0; - if (envelopeMode === 'infinite' || envelopeMode === 'hold') { +export function resolveEnvelopeVolumeOrRate( + envelope, + patternVolume, + instrumentVolumeOrRate, + combinedVolume +) { + if (!envelope) { return combinedVolume; } return instrumentVolumeOrRate; } -export function isChannelAudible(envelopeMode, patternVolume, volumeOrRate, combinedVolume) { - if (envelopeMode === 'unchanged') return patternVolume > 0; - if (envelopeMode === 'infinite' || envelopeMode === 'hold') { +export function isChannelAudible(envelope, patternVolume, volumeOrRate, combinedVolume) { + if (!envelope) { return combinedVolume > 0; } return patternVolume > 0 && volumeOrRate > 0; @@ -192,20 +173,28 @@ export function isChannelAudible(envelopeMode, patternVolume, volumeOrRate, comb export function normalizeNesInstrumentRow(row) { const defaults = createDefaultNesInstrumentRow(); const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) ? row.pulseWidth : defaults.pulseWidth; - const envelopeMode = normalizeEnvelopeMode(row?.envelopeMode); - const soundLength = - envelopeMode === 'infinite' ? 0 : normalizeSoundLength(row?.soundLength); return { pulseWidth, - retrigger: Boolean(row?.retrigger), - soundLength, - envelopeMode, - volumeOrRate: normalizeVolumeOrRate(row?.volumeOrRate), - toneAdd: normalizeToneAdd(row?.toneAdd), - toneAccumulation: Boolean(row?.toneAccumulation), - sweep: Boolean(row?.sweep), - sweepRate: normalizeSweepRate(row?.sweepRate), - sweepShift: normalizeSweepShift(row?.sweepShift) + retrigger: row?.retrigger !== undefined ? Boolean(row.retrigger) : defaults.retrigger, + soundLength: + row?.soundLength !== undefined + ? normalizeSoundLength(row.soundLength) + : defaults.soundLength, + envelope: row?.envelope !== undefined ? normalizeEnvelope(row.envelope) : defaults.envelope, + volumeOrRate: + row?.volumeOrRate !== undefined + ? normalizeVolumeOrRate(row.volumeOrRate) + : defaults.volumeOrRate, + toneAdd: row?.toneAdd !== undefined ? normalizeToneAdd(row.toneAdd) : defaults.toneAdd, + toneAccumulation: + row?.toneAccumulation !== undefined + ? Boolean(row.toneAccumulation) + : defaults.toneAccumulation, + sweep: row?.sweep !== undefined ? Boolean(row.sweep) : defaults.sweep, + sweepRate: + row?.sweepRate !== undefined ? normalizeSweepRate(row.sweepRate) : defaults.sweepRate, + sweepShift: + row?.sweepShift !== undefined ? normalizeSweepShift(row.sweepShift) : defaults.sweepShift }; } diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index 5a95800c..d7a90b5b 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -34,16 +34,11 @@ shouldBlockRowEditorNumericKey } from '../../utils/row-editor-numeric'; import { compactTableInputClass } from '../../utils/compact-table-input'; - import NesEnvelopeModeCell from './NesEnvelopeModeCell.svelte'; import { createDefaultNesInstrumentRow, - cycleNesEnvelopeMode, cyclePulseWidth, ensureNesInstrumentRows, - isNesEnvelopeInfinite, - isNesSoundLengthEnabled, isNesVolumeField, - isNesVolumeOrRateEnabled, NES_PULSE_WIDTH_LABELS, type NesInstrumentRow } from './instrument'; @@ -110,7 +105,7 @@ function updateBooleanRow( index: number, - field: 'retrigger' | 'toneAccumulation' | 'sweep', + field: 'retrigger' | 'toneAccumulation' | 'sweep' | 'envelope', value: boolean ) { if (Boolean(editorSync.rows[index][field]) === value) return; @@ -241,7 +236,7 @@ {/each} diff --git a/src/lib/chips/nes/NesEnvelopeModeCell.svelte b/src/lib/chips/nes/NesEnvelopeModeCell.svelte deleted file mode 100644 index 079b5475..00000000 --- a/src/lib/chips/nes/NesEnvelopeModeCell.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/src/lib/chips/nes/instrument.ts b/src/lib/chips/nes/instrument.ts index 8ad4eda0..ba713a03 100644 --- a/src/lib/chips/nes/instrument.ts +++ b/src/lib/chips/nes/instrument.ts @@ -17,18 +17,6 @@ const SWEEP_SHIFT_MIN = -7; const SWEEP_SHIFT_MAX = 7; export const NES_SQUARE_SWEEP_DISABLED = 0x08; -export const NES_ENVELOPE_MODES = ['infinite', 'decay', 'loop', 'hold', 'unchanged'] as const; - -export type NesEnvelopeMode = (typeof NES_ENVELOPE_MODES)[number]; - -export const NES_ENVELOPE_MODE_LABELS: Record = { - infinite: 'Infinite', - decay: 'Decay', - loop: 'Loop', - hold: 'Hold', - unchanged: 'Unchanged' -}; - export const NES_LENGTH_COUNTER_LENGTHS = [ 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, 192, 24, 72, 26, 16, 28, 32, 30 @@ -43,7 +31,7 @@ export type NesInstrumentRow = { pulseWidth: NesPulseWidth; retrigger: boolean; soundLength: number; - envelopeMode: NesEnvelopeMode; + envelope: boolean; volumeOrRate: number; toneAdd: number; toneAccumulation: boolean; @@ -52,30 +40,8 @@ export type NesInstrumentRow = { sweepShift: number; }; -export function isNesEnvelopeInfinite(mode: NesEnvelopeMode): boolean { - return mode === 'infinite'; -} - -export function isNesEnvelopeUnchanged(mode: NesEnvelopeMode): boolean { - return mode === 'unchanged'; -} - -export function isNesSoundLengthEnabled(mode: NesEnvelopeMode): boolean { - return !isNesEnvelopeInfinite(mode) && !isNesEnvelopeUnchanged(mode); -} - -export function isNesVolumeOrRateEnabled(mode: NesEnvelopeMode): boolean { - return !isNesEnvelopeUnchanged(mode); -} - -export function isNesVolumeField(mode: NesEnvelopeMode): boolean { - return mode === 'infinite'; -} - -export function cycleNesEnvelopeMode(current: NesEnvelopeMode): NesEnvelopeMode { - const index = NES_ENVELOPE_MODES.indexOf(current); - const nextIndex = index < 0 ? 0 : (index + 1) % NES_ENVELOPE_MODES.length; - return NES_ENVELOPE_MODES[nextIndex]; +export function isNesVolumeField(envelope: boolean): boolean { + return !envelope; } export function createDefaultNesInstrumentRow(): NesInstrumentRow { @@ -83,7 +49,7 @@ export function createDefaultNesInstrumentRow(): NesInstrumentRow { pulseWidth: 2, retrigger: false, soundLength: 0, - envelopeMode: 'infinite', + envelope: false, volumeOrRate: 15, toneAdd: 0, toneAccumulation: false, @@ -111,10 +77,8 @@ function normalizeSweepShift(value: unknown): number { return Math.max(SWEEP_SHIFT_MIN, Math.min(SWEEP_SHIFT_MAX, Math.round(parsed))); } -function normalizeEnvelopeMode(value: unknown): NesEnvelopeMode { - return NES_ENVELOPE_MODES.includes(value as NesEnvelopeMode) - ? (value as NesEnvelopeMode) - : 'infinite'; +function normalizeEnvelope(value: unknown): boolean { + return Boolean(value); } function normalizeSoundLength(value: unknown): number { @@ -160,69 +124,43 @@ function resolveEnvelopeLoopBit(soundLength: number): number { return soundLength === 0 ? 1 << 5 : 0; } +function resolveConstantVolumeBit(envelope: boolean): number { + return envelope ? 0 : 1 << 4; +} + export function buildSquareEnvelopeVolumeReg( duty: NesPulseWidth, - envelopeMode: NesEnvelopeMode, + envelope: boolean, volumeOrRate: number, soundLength: number ): number { - if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; const volume = volumeOrRate & 15; const dutyBits = (duty & 3) << 6; - const loopBit = resolveEnvelopeLoopBit(soundLength); - switch (envelopeMode) { - case 'infinite': - return dutyBits | loopBit | (1 << 4) | volume; - case 'decay': - case 'loop': - return dutyBits | loopBit | volume; - case 'hold': - return dutyBits | loopBit | (1 << 4) | volume; - default: - return dutyBits | loopBit | (1 << 4) | volume; - } + return ( + dutyBits | + resolveEnvelopeLoopBit(soundLength) | + resolveConstantVolumeBit(envelope) | + volume + ); } export function buildNoiseEnvelopeVolumeReg( - envelopeMode: NesEnvelopeMode, + envelope: boolean, volumeOrRate: number, soundLength: number ): number { - if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; const volume = volumeOrRate & 15; - const loopBit = resolveEnvelopeLoopBit(soundLength); - switch (envelopeMode) { - case 'infinite': - return loopBit | (1 << 4) | volume; - case 'decay': - case 'loop': - return loopBit | volume; - case 'hold': - return loopBit | (1 << 4) | volume; - default: - return loopBit | (1 << 4) | volume; - } + return resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume; } -export function buildLengthCounterNibble( - envelopeMode: NesEnvelopeMode, - soundLength: number -): number { - if ( - envelopeMode === 'unchanged' || - envelopeMode === 'infinite' || - soundLength === 0 - ) { +export function buildLengthCounterNibble(soundLength: number): number { + if (soundLength === 0) { return NES_REGISTER_UNCHANGED; } return resolveLengthCounterIndex(soundLength) & 31; } -export function buildTriangleLinearReg( - envelopeMode: NesEnvelopeMode, - soundLength: number -): number { - if (envelopeMode === 'unchanged') return NES_REGISTER_UNCHANGED; +export function buildTriangleLinearReg(soundLength: number): number { if (soundLength === 0) { return (1 << 7) | 0x7f; } @@ -232,13 +170,8 @@ export function buildTriangleLinearReg( return 0x7f; } -export function usesTriangleLinearCounter( - envelopeMode: NesEnvelopeMode, - soundLength: number -): boolean { - return ( - isNesSoundLengthEnabled(envelopeMode) && soundLength > 0 && soundLength < 128 - ); +export function usesTriangleLinearCounter(soundLength: number): boolean { + return soundLength > 0 && soundLength < 128; } export function normalizeNesInstrumentRow(row: Record): NesInstrumentRow { @@ -246,21 +179,28 @@ export function normalizeNesInstrumentRow(row: Record): NesInst const pulseWidth = NES_PULSE_WIDTHS.includes(row.pulseWidth as NesPulseWidth) ? (row.pulseWidth as NesPulseWidth) : defaults.pulseWidth; - const envelopeMode = normalizeEnvelopeMode(row.envelopeMode); - const soundLength = isNesEnvelopeInfinite(envelopeMode) - ? 0 - : normalizeSoundLength(row.soundLength); return { pulseWidth, - retrigger: Boolean(row.retrigger), - soundLength, - envelopeMode, - volumeOrRate: normalizeVolumeOrRate(row.volumeOrRate), - toneAdd: normalizeToneAdd(row.toneAdd), - toneAccumulation: Boolean(row.toneAccumulation), - sweep: Boolean(row.sweep), - sweepRate: normalizeSweepRate(row.sweepRate), - sweepShift: normalizeSweepShift(row.sweepShift) + retrigger: row.retrigger !== undefined ? Boolean(row.retrigger) : defaults.retrigger, + soundLength: + row.soundLength !== undefined + ? normalizeSoundLength(row.soundLength) + : defaults.soundLength, + envelope: row.envelope !== undefined ? normalizeEnvelope(row.envelope) : defaults.envelope, + volumeOrRate: + row.volumeOrRate !== undefined + ? normalizeVolumeOrRate(row.volumeOrRate) + : defaults.volumeOrRate, + toneAdd: row.toneAdd !== undefined ? normalizeToneAdd(row.toneAdd) : defaults.toneAdd, + toneAccumulation: + row.toneAccumulation !== undefined + ? Boolean(row.toneAccumulation) + : defaults.toneAccumulation, + sweep: row.sweep !== undefined ? Boolean(row.sweep) : defaults.sweep, + sweepRate: + row.sweepRate !== undefined ? normalizeSweepRate(row.sweepRate) : defaults.sweepRate, + sweepShift: + row.sweepShift !== undefined ? normalizeSweepShift(row.sweepShift) : defaults.sweepShift }; } diff --git a/tests/lib/chips/nes/instrument.test.ts b/tests/lib/chips/nes/instrument.test.ts index b4fcdcca..4aeda2c6 100644 --- a/tests/lib/chips/nes/instrument.test.ts +++ b/tests/lib/chips/nes/instrument.test.ts @@ -6,13 +6,10 @@ import { buildSquareSweepReg, buildTriangleLinearReg, createDefaultNesInstrumentRow, - cycleNesEnvelopeMode, cyclePulseWidth, ensureNesInstrumentRows, - isNesSoundLengthEnabled, isNesVolumeField, NES_LENGTH_COUNTER_LENGTHS, - NES_REGISTER_UNCHANGED, NES_SQUARE_SWEEP_DISABLED, normalizeNesInstrumentRow, resolveLengthCounterIndex, @@ -20,12 +17,12 @@ import { } from '@/lib/chips/nes/instrument'; describe('nes instrument', () => { - it('creates a default macro row with infinite envelope and retrigger off', () => { + it('creates a default macro row with constant volume and retrigger off', () => { expect(createDefaultNesInstrumentRow()).toEqual({ pulseWidth: 2, retrigger: false, soundLength: 0, - envelopeMode: 'infinite', + envelope: false, volumeOrRate: 15, toneAdd: 0, toneAccumulation: false, @@ -42,14 +39,14 @@ describe('nes instrument', () => { pulseWidth: 99, toneAdd: -2, soundLength: 999, - envelopeMode: 'bogus', + envelope: true, volumeOrRate: 20 }) ).toEqual({ pulseWidth: 2, retrigger: true, - soundLength: 0, - envelopeMode: 'infinite', + soundLength: 511, + envelope: true, volumeOrRate: 15, toneAdd: -2, toneAccumulation: false, @@ -61,8 +58,8 @@ describe('nes instrument', () => { pulseWidth: 2, retrigger: false, soundLength: 0, - envelopeMode: 'infinite', - volumeOrRate: 0, + envelope: false, + volumeOrRate: 15, toneAdd: 4095, toneAccumulation: true, sweep: false, @@ -74,14 +71,14 @@ describe('nes instrument', () => { sweep: true, sweepRate: 12, sweepShift: -9, - envelopeMode: 'loop', + envelope: true, volumeOrRate: 7 }) ).toEqual({ pulseWidth: 2, retrigger: false, soundLength: 0, - envelopeMode: 'loop', + envelope: true, volumeOrRate: 7, toneAdd: 0, toneAccumulation: false, @@ -92,23 +89,10 @@ describe('nes instrument', () => { expect(ensureNesInstrumentRows([])).toHaveLength(1); }); - it('forces sound length to zero in infinite envelope mode', () => { - expect( - normalizeNesInstrumentRow({ - envelopeMode: 'infinite', - soundLength: 200 - }).soundLength - ).toBe(0); - }); - - it('cycles envelope modes and exposes length counter table', () => { - expect(cycleNesEnvelopeMode('infinite')).toBe('decay'); - expect(cycleNesEnvelopeMode('unchanged')).toBe('infinite'); + it('exposes length counter table and volume field helper', () => { expect(NES_LENGTH_COUNTER_LENGTHS).toHaveLength(32); - expect(isNesSoundLengthEnabled('decay')).toBe(true); - expect(isNesSoundLengthEnabled('infinite')).toBe(false); - expect(isNesVolumeField('infinite')).toBe(true); - expect(isNesVolumeField('decay')).toBe(false); + expect(isNesVolumeField(false)).toBe(true); + expect(isNesVolumeField(true)).toBe(false); }); it('cycles pulse width through duty options', () => { @@ -123,21 +107,17 @@ describe('nes instrument', () => { expect(buildSquareSweepReg(true, 7, -5)).toBe(0x88 | 0x75); }); - it('maps envelope modes and sound length to APU register bytes', () => { - expect(buildSquareEnvelopeVolumeReg(2, 'infinite', 15, 0)).toBe(0xbf); - expect(buildSquareEnvelopeVolumeReg(2, 'decay', 7, 40)).toBe(0x87); - expect(buildSquareEnvelopeVolumeReg(2, 'decay', 7, 0)).toBe(0xa7); - expect(buildSquareEnvelopeVolumeReg(2, 'loop', 4, 0)).toBe(0xa4); - expect(buildSquareEnvelopeVolumeReg(2, 'loop', 4, 40)).toBe(0x84); - expect(buildSquareEnvelopeVolumeReg(2, 'unchanged', 15, 0)).toBe(NES_REGISTER_UNCHANGED); - expect(buildNoiseEnvelopeVolumeReg('hold', 10, 40)).toBe(0x1a); - expect(buildLengthCounterNibble('infinite', 200)).toBe(NES_REGISTER_UNCHANGED); - expect(buildLengthCounterNibble('decay', 0)).toBe(NES_REGISTER_UNCHANGED); - expect(buildLengthCounterNibble('decay', 20)).toBe(resolveLengthCounterIndex(20)); - expect(buildTriangleLinearReg('infinite', 0)).toBe(0xff); - expect(buildTriangleLinearReg('decay', 0)).toBe(0xff); - expect(buildTriangleLinearReg('decay', 64)).toBe(64); - expect(usesTriangleLinearCounter('decay', 64)).toBe(true); - expect(usesTriangleLinearCounter('decay', 200)).toBe(false); + it('maps envelope bool and sound length to APU register bytes', () => { + expect(buildSquareEnvelopeVolumeReg(2, false, 15, 0)).toBe(0xbf); + expect(buildSquareEnvelopeVolumeReg(2, true, 7, 40)).toBe(0x87); + expect(buildSquareEnvelopeVolumeReg(2, true, 7, 0)).toBe(0xa7); + expect(buildSquareEnvelopeVolumeReg(2, true, 4, 40)).toBe(0x84); + expect(buildNoiseEnvelopeVolumeReg(false, 10, 40)).toBe(0x1a); + expect(buildLengthCounterNibble(0)).toBe(-1); + expect(buildLengthCounterNibble(20)).toBe(resolveLengthCounterIndex(20)); + expect(buildTriangleLinearReg(0)).toBe(0xff); + expect(buildTriangleLinearReg(64)).toBe(64); + expect(usesTriangleLinearCounter(64)).toBe(true); + expect(usesTriangleLinearCounter(200)).toBe(false); }); }); diff --git a/tests/public/nes-audio-driver-envelope.test.js b/tests/public/nes-audio-driver-envelope.test.js index 4787754f..377bca13 100644 --- a/tests/public/nes-audio-driver-envelope.test.js +++ b/tests/public/nes-audio-driver-envelope.test.js @@ -20,7 +20,7 @@ function createEnvelopeState(rowOverrides = {}) { pulseWidth: 2, retrigger: false, soundLength: 40, - envelopeMode: 'decay', + envelope: true, volumeOrRate: 6, toneAdd: 0, toneAccumulation: false, @@ -49,7 +49,7 @@ function createEnvelopeState(rowOverrides = {}) { } describe('NesAudioDriver envelope and length macro', () => { - it('writes decay envelope and length counter settings for pulse channels', () => { + it('writes envelope and length counter settings for pulse channels', () => { const driver = new NesAudioDriver(); const registerState = new NesChipRegisterState(); const state = createEnvelopeState(); @@ -57,32 +57,31 @@ describe('NesAudioDriver envelope and length macro', () => { driver.processInstruments(state, registerState); expect(registerState.channels[0].volumeReg).toBe( - buildSquareEnvelopeVolumeReg(2, 'decay', 6, 40) - ); - expect(registerState.channels[0].lengthNibble).toBe( - buildLengthCounterNibble('decay', 40) + buildSquareEnvelopeVolumeReg(2, true, 6, 40) ); + expect(registerState.channels[0].lengthNibble).toBe(buildLengthCounterNibble(40)); }); it('uses triangle linear counter for short sound lengths', () => { const driver = new NesAudioDriver(); const registerState = new NesChipRegisterState(); - const state = createEnvelopeState({ soundLength: 64, envelopeMode: 'hold' }); + const state = createEnvelopeState({ soundLength: 64, envelope: false }); driver.processInstruments(state, registerState); - expect(registerState.channels[2].linearReg).toBe(buildTriangleLinearReg('hold', 64)); + expect(registerState.channels[2].linearReg).toBe(buildTriangleLinearReg(64)); expect(registerState.channels[2].lengthNibble).toBe(NES_REGISTER_UNCHANGED); }); - it('skips envelope register updates in unchanged mode', () => { + it('writes constant volume register when envelope is off', () => { const driver = new NesAudioDriver(); const registerState = new NesChipRegisterState(); - const state = createEnvelopeState({ envelopeMode: 'unchanged' }); + const state = createEnvelopeState({ envelope: false, volumeOrRate: 10, soundLength: 40 }); driver.processInstruments(state, registerState); - expect(registerState.channels[0].volumeReg).toBe(NES_REGISTER_UNCHANGED); - expect(registerState.channels[0].lengthNibble).toBe(NES_REGISTER_UNCHANGED); + expect(registerState.channels[0].volumeReg).toBe( + buildSquareEnvelopeVolumeReg(2, false, 10, 40) + ); }); }); From 0cfb5333dfc5e098c1112e8ae893068f9bf8a148 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sat, 20 Jun 2026 18:57:34 +0200 Subject: [PATCH 19/28] Change NES duty cycle labels --- public/nes/nes-constants.js | 3 ++- public/nes/nes-waveform-capture.js | 2 +- src/lib/chips/nes/NESInstrumentEditor.svelte | 4 ++-- src/lib/chips/nes/instrument.ts | 8 ++++---- tests/public/nes-waveform-capture.test.js | 10 +++++----- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/public/nes/nes-constants.js b/public/nes/nes-constants.js index e9130c39..7161a9e8 100644 --- a/public/nes/nes-constants.js +++ b/public/nes/nes-constants.js @@ -9,4 +9,5 @@ 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; +export const NES_APU_OUTPUT_GAIN = 3.2; +export const NES_APU_OUTPUT_SCALE = NES_APU_OUTPUT_GAIN / 8192; diff --git a/public/nes/nes-waveform-capture.js b/public/nes/nes-waveform-capture.js index a23abfa6..043fb4aa 100644 --- a/public/nes/nes-waveform-capture.js +++ b/public/nes/nes-waveform-capture.js @@ -1,4 +1,4 @@ -const NES_WAVEFORM_SCALE = 0.5; +const NES_WAVEFORM_SCALE = 1; const NES_SQUARE_NOISE_MAX = 15; const NES_DMC_MAX = 127; diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index d7a90b5b..1a9d4ce6 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -196,7 +196,7 @@ icon={IconCarbonChartWinLoss} label="duty" {isExpanded} - class="w-10 min-w-10 px-1" /> + class="w-12 min-w-12 px-1" /> - - + +
- + +
@@ -209,34 +216,35 @@ class={isExpanded ? 'w-8 min-w-8 px-1' : 'w-10 px-0.5 text-[0.65rem]'} title="Tone Accumulation">
- +
+ updateBooleanRow(index, 'retrigger', value) )} onPaintOver={() => - booleanDrag.dragOver((value) => updateBooleanRow(index, 'retrigger', value))} /> + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'retrigger', value) + )} /> - updateRow(index, { pulseWidth: cyclePulseWidth(row.pulseWidth) })} /> + updateRow(index, { + pulseWidth: cyclePulseWidth(row.pulseWidth) + })} /> updateBooleanRow(index, 'envelope', value) )} onPaintOver={() => - booleanDrag.dragOver((value) => updateBooleanRow(index, 'envelope', value))} /> + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'envelope', value) + )} /> {/each} @@ -389,7 +422,11 @@ rowHeightPx={isExpanded ? 32 : 28} onAdd={() => editorSync.addRow(createDefaultNesInstrumentRow)} onRowCountChange={(count) => - editorSync.setRowCount(count, createDefaultNesInstrumentRow, ROW_EDITOR_MAX_ROWS)} /> + editorSync.setRowCount( + count, + createDefaultNesInstrumentRow, + ROW_EDITOR_MAX_ROWS + )} />
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} />
+
+ + + +
+
+
+ + +
+
+ handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => updateNumericField(index, 'toneAdd', e)} /> +
+ rate + + shift +
+ handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => updateNumericField(index, 'sweepRate', e, { min: 0, max: 7 })} /> + + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'sweepShift', e, { min: -7, max: 7 })} /> +
+ {isExpanded ? 'sound len' : 'len'} + + env + +
+ + /rate +
+
+ handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'soundLength', e, { min: 0, max: 511 })} /> + + {#if isNesVolumeOrRateEnabled(row.envelopeMode)} + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'volumeOrRate', e, { min: 0, max: 15 })} /> + {:else} +
+ — +
+ {/if} +
+ + + title="Envelope (1) or constant volume (0)"> env handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => updateNumericField(index, 'soundLength', e, { min: 0, max: 511 })} /> - { - const envelopeMode = cycleNesEnvelopeMode(row.envelopeMode); - updateRow(index, { - envelopeMode, - ...(isNesEnvelopeInfinite(envelopeMode) ? { soundLength: 0 } : {}) - }); - }} /> + title="Envelope (1) or constant volume (0)" + onPaintBegin={() => + booleanDrag.begin( + () => row.envelope, + (value) => updateBooleanRow(index, 'envelope', value) + )} + onPaintOver={() => + booleanDrag.dragOver((value) => updateBooleanRow(index, 'envelope', value))} /> - {#if isNesVolumeOrRateEnabled(row.envelopeMode)} - handleNumericKeyDown(index, e)} - onfocus={(e) => (e.target as HTMLInputElement).select()} - oninput={(e) => - updateNumericField(index, 'volumeOrRate', e, { min: 0, max: 15 })} /> - {:else} -
- — -
- {/if} + handleNumericKeyDown(index, e)} + onfocus={(e) => (e.target as HTMLInputElement).select()} + oninput={(e) => + updateNumericField(index, 'volumeOrRate', e, { min: 0, max: 15 })} />
- - @@ -283,7 +283,7 @@ booleanDrag.dragOver((value) => updateBooleanRow(index, 'retrigger', value))} /> = { - 0: '⅛', - 1: '¼', - 2: '½', - 3: '¾' + 0: '12.5%', + 1: '25%', + 2: '50%', + 3: '75%' }; const TONE_ADD_MIN = -4096; diff --git a/tests/public/nes-waveform-capture.test.js b/tests/public/nes-waveform-capture.test.js index c1fd637b..af58e2ec 100644 --- a/tests/public/nes-waveform-capture.test.js +++ b/tests/public/nes-waveform-capture.test.js @@ -6,9 +6,9 @@ import { describe('NesWaveformCapture', () => { it('normalizes emulator channel output around zero', () => { - expect(normalizeNesChannelWaveformSample(0, 0)).toBe(-0.25); - expect(normalizeNesChannelWaveformSample(0, 15)).toBe(0.25); - expect(normalizeNesChannelWaveformSample(0, 7)).toBeCloseTo(-0.016666, 5); + expect(normalizeNesChannelWaveformSample(0, 0)).toBe(-0.5); + expect(normalizeNesChannelWaveformSample(0, 15)).toBe(0.5); + expect(normalizeNesChannelWaveformSample(0, 7)).toBeCloseTo(-0.033333, 5); }); it('prefers emulator channel outputs when available', () => { @@ -18,7 +18,7 @@ describe('NesWaveformCapture', () => { getChannelRawOut: (channelIndex) => (channelIndex === 0 ? 15 : 0) }; const outputs = capture.readChannelOutputs(apuEngine); - expect(outputs[0]).toBe(0.25); - expect(outputs[1]).toBe(-0.25); + expect(outputs[0]).toBe(0.5); + expect(outputs[1]).toBe(-0.5); }); }); From 8df1dc1965fdec40e390affece4ed4726ad9103c Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sun, 21 Jun 2026 11:37:20 +0200 Subject: [PATCH 20/28] Add instrument tabs --- src/App.svelte | 1 + .../Instruments/InstrumentsView.svelte | 82 ++++++++++++++++--- src/lib/components/Song/SongView.svelte | 10 +-- .../services/instrument/instrument-filter.ts | 35 +++++++- src/lib/stores/project.svelte.ts | 2 + .../instrument/instrument-filter.test.ts | 28 +++++++ 6 files changed, 139 insertions(+), 19 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 4196314c..702716d9 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -219,6 +219,7 @@ removeSong: (index) => { projectStore.removeSong(index); container.audioService.removeChipProcessor(index); + container.audioService.updateInstruments(projectStore.instruments); syncChipProcessors(); }, addSong: (song) => { diff --git a/src/lib/components/Instruments/InstrumentsView.svelte b/src/lib/components/Instruments/InstrumentsView.svelte index a71241bf..00d905a1 100644 --- a/src/lib/components/Instruments/InstrumentsView.svelte +++ b/src/lib/components/Instruments/InstrumentsView.svelte @@ -21,7 +21,9 @@ import EditableIdField from '../EditableIdField/EditableIdField.svelte'; import { getContext, tick, untrack } from 'svelte'; import type { AudioService } from '../../services/audio/audio-service'; - import type { Chip } from '../../chips/types'; + import type { ChipProcessor } from '../../chips/base/processor'; + import { getChipByType } from '../../chips/registry'; + import PillTabs, { type PillTab } from '../PillTabs/PillTabs.svelte'; import { isValidInstrumentId, normalizeInstrumentId, @@ -30,7 +32,11 @@ MAX_INSTRUMENT_ID_NUM } from '../../utils/instrument-id'; import { migrateInstrumentIdInSong } from '../../services/project/id-migration'; - import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; + import { + filterInstrumentsForChip, + getOrderedProjectChipTypes, + resolveInstrumentChipType + } from '../../services/instrument/instrument-filter'; import { editorStateStore } from '../../stores/editor-state.svelte'; import { projectStore } from '../../stores/project.svelte'; import { computeGridRows } from '../../utils/compute-grid-rows'; @@ -48,14 +54,32 @@ let { isExpanded = $bindable(false), - chip + chipProcessors, + syncChipType, + activeEditorIndex = 0 }: { isExpanded: boolean; - chip: Chip; + chipProcessors: ChipProcessor[]; + syncChipType?: string; + activeEditorIndex?: number; } = $props(); let allInstruments = $derived(projectStore.instruments); - const chipInstruments = $derived(filterInstrumentsForChip(allInstruments, chip.type)); + const chipTypeTabs = $derived.by((): PillTab[] => { + return getOrderedProjectChipTypes(chipProcessors).flatMap((chipType) => { + const chip = getChipByType(chipType); + return chip ? [{ id: chipType, label: chip.name }] : []; + }); + }); + let selectedChipType = $state(''); + let lastSyncedEditorIndex = $state(-1); + const chip = $derived.by(() => { + const chipType = selectedChipType || chipTypeTabs[0]?.id || syncChipType || 'ay'; + return getChipByType(chipType); + }); + const chipInstruments = $derived( + chip ? filterInstrumentsForChip(allInstruments, chip.type) : [] + ); const songs = $derived(projectStore.songs); const instrumentListResize = createPersistedResizableListHeight({ @@ -80,7 +104,32 @@ let instrumentListScrollRef: HTMLDivElement | null = $state(null); $effect(() => { - chip.type; + if (chipTypeTabs.length === 0) return; + if (!selectedChipType || !chipTypeTabs.some((tab) => tab.id === selectedChipType)) { + selectedChipType = syncChipType ?? chipTypeTabs[0].id; + } + }); + + $effect(() => { + const editorIndex = activeEditorIndex; + if (editorIndex === lastSyncedEditorIndex) return; + lastSyncedEditorIndex = editorIndex; + if (syncChipType) { + selectedChipType = syncChipType; + } + }); + + $effect(() => { + const requestId = editorStateStore.selectInstrumentRequest; + if (!requestId) return; + const instrument = allInstruments.find((inst) => inst.id === requestId); + if (instrument) { + selectedChipType = resolveInstrumentChipType(instrument); + } + }); + + $effect(() => { + chip?.type; if (editorStateStore.selectInstrumentRequest) return; if (chipInstruments.length > 0 && chipInstruments[selectedInstrumentIndex]) { const instrumentId = chipInstruments[selectedInstrumentIndex].id; @@ -117,7 +166,7 @@ }); }); - const InstrumentEditor = $derived(chip.instrumentEditor); + const InstrumentEditor = $derived(chip?.instrumentEditor); const hexIcon = $derived(asHex ? IconCarbonHexagonSolid : IconCarbonHexagonOutline); const expandIcon = $derived(isExpanded ? IconCarbonMinimize : IconCarbonMaximize); @@ -147,7 +196,7 @@ if (needsSort) { projectStore.instruments = sorted; } - if (selectedId !== undefined) { + if (selectedId !== undefined && chip) { const filtered = filterInstrumentsForChip(projectStore.instruments, chip.type); const newIndex = filtered.findIndex((inst) => inst.id === selectedId); if (newIndex >= 0) selectedInstrumentIndex = newIndex; @@ -225,6 +274,7 @@ const existingIds = allInstruments.map((inst) => inst.id); const newId = getNextAvailableInstrumentId(existingIds); if (!newId) return; + if (!chip) return; const newInstrument = new InstrumentModel(newId, [], 0, `Instrument ${newId}`, chip.type); const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); projectStore.instruments = [...allInstruments, newInstrument]; @@ -268,7 +318,7 @@ async function copyInstrument(copiedIndex: number): Promise { flushInstrumentUpdateHistory(); const instrument = chipInstruments[copiedIndex]; - if (!instrument) return; + if (!instrument || !chip) return; const existingIds = allInstruments.map((inst) => inst.id); const newId = getNextAvailableInstrumentId(existingIds); if (!newId) return; @@ -375,7 +425,7 @@ async function loadInstrument(): Promise { flushInstrumentUpdateHistory(); - if (chipInstruments.length === 0) return; + if (!chip || chipInstruments.length === 0) return; try { const text = await pickFileAsText(); const parsed: unknown = JSON.parse(text); @@ -436,7 +486,7 @@ async function openPresets(): Promise { flushInstrumentUpdateHistory(); - if (chipInstruments.length === 0) return; + if (!chip || chipInstruments.length === 0) return; const item = await open(PresetsModal, { presetType: 'instrument' }); if ( item == null || @@ -514,12 +564,18 @@
{#snippet children()} + {#if chipTypeTabs.length > 1} +
+ +
+ {/if}
@@ -622,7 +678,7 @@ onInstrumentChange={handleInstrumentChange} bind:selectedRowIndices={selectedInstrumentRowIndices} /> {/key} - {:else if chipInstruments[selectedInstrumentIndex]} + {:else if chipInstruments[selectedInstrumentIndex] && chip}

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

diff --git a/src/lib/components/Song/SongView.svelte b/src/lib/components/Song/SongView.svelte index 0c733a4a..7678ad40 100644 --- a/src/lib/components/Song/SongView.svelte +++ b/src/lib/components/Song/SongView.svelte @@ -667,11 +667,11 @@ {#if tabId === 'tables'} {:else if tabId === 'instruments'} - {#if activeChipProcessor} - - {/if} + {:else if tabId === 'details'} resolveInstrumentChipType(instrument) === chipType); } + +export function getActiveChipTypes(songs: Song[]): Set { + const types = new Set(); + for (const song of songs) { + if (song.chipType) { + types.add(song.chipType); + } + } + return types; +} + +export function filterInstrumentsForActiveChipTypes( + songs: Song[], + instruments: Instrument[] +): Instrument[] { + const activeChipTypes = getActiveChipTypes(songs); + return instruments.filter((instrument) => + activeChipTypes.has(resolveInstrumentChipType(instrument)) + ); +} + +export function getOrderedProjectChipTypes( + chipProcessors: { chip: { type: string } }[] +): string[] { + const types = new Set(); + for (const processor of chipProcessors) { + types.add(processor.chip.type); + } + return getAllChips() + .map((chip) => chip.type) + .filter((type) => types.has(type)); +} diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index bbb3ea67..d8342084 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -1,5 +1,6 @@ import type { Song, Instrument, Pattern } from '../models/song'; import { Project, Table } from '../models/project'; +import { filterInstrumentsForActiveChipTypes } from '../services/instrument/instrument-filter'; import { undoRedoStore } from './undo-redo.svelte'; import type { ProjectDiff, ProjectHistoryEntry, ProjectHistoryMetadata } from '../models/history'; import { HistoryClone } from '../services/history/history-clone'; @@ -108,6 +109,7 @@ class ProjectStore { removeSong(index: number): void { this.songs = this.songs.filter((_, i) => i !== index); this.patterns = this.patterns.filter((_, i) => i !== index); + this.instruments = filterInstrumentsForActiveChipTypes(this.songs, this.instruments); } addSong(song: Song): void { diff --git a/tests/lib/services/instrument/instrument-filter.test.ts b/tests/lib/services/instrument/instrument-filter.test.ts index 163b113a..d8b2fe4a 100644 --- a/tests/lib/services/instrument/instrument-filter.test.ts +++ b/tests/lib/services/instrument/instrument-filter.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { Instrument } from '@/lib/models/song'; import { + filterInstrumentsForActiveChipTypes, filterInstrumentsForChip, + getOrderedProjectChipTypes, resolveInstrumentChipType } from '@/lib/services/instrument/instrument-filter'; @@ -20,4 +22,30 @@ describe('instrument-filter', () => { expect(filterInstrumentsForChip(instruments, 'ay').map((inst) => inst.id)).toEqual(['01']); expect(filterInstrumentsForChip(instruments, 'nes').map((inst) => inst.id)).toEqual(['02']); }); + + it('returns ordered chip types from active songs only', () => { + const chipProcessors = [{ chip: { type: 'ay' } }, { chip: { type: 'nes' } }]; + + expect(getOrderedProjectChipTypes(chipProcessors)).toEqual(['ay', 'nes']); + expect(getOrderedProjectChipTypes([{ chip: { type: 'nes' } }])).toEqual(['nes']); + }); + + it('removes instruments only when no songs remain for that chip type', () => { + const instruments = [ + new Instrument('01', [], 0, 'AY', 'ay'), + new Instrument('02', [], 0, 'NES', 'nes') + ]; + const songsAfterRemovingLastAy = [{ chipType: 'nes' } as import('@/lib/models/song').Song]; + const songsWithRemainingAy = [ + { chipType: 'ay' } as import('@/lib/models/song').Song, + { chipType: 'nes' } as import('@/lib/models/song').Song + ]; + + expect( + filterInstrumentsForActiveChipTypes(songsAfterRemovingLastAy, instruments).map((inst) => inst.id) + ).toEqual(['02']); + expect( + filterInstrumentsForActiveChipTypes(songsWithRemainingAy, instruments).map((inst) => inst.id) + ).toEqual(['01', '02']); + }); }); From 059e8dd3ae68bf851e6304aee6f70f5512a064a9 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sun, 21 Jun 2026 11:40:23 +0200 Subject: [PATCH 21/28] Update noise channel --- public/nes/nes-audio-driver.js | 30 +++----- tests/public/nes-audio-driver-noise.test.js | 84 +++++++++++++++++++++ 2 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 tests/public/nes-audio-driver-noise.test.js diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 27b304e1..78b31591 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -28,20 +28,14 @@ import { } 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 -]; +const NES_NOISE_PERIOD_COUNT = 16; + +function resolveNesNoisePeriodFromSemitoneOffset(semitoneOffset) { + return ( + ((semitoneOffset % NES_NOISE_PERIOD_COUNT) + NES_NOISE_PERIOD_COUNT) % + NES_NOISE_PERIOD_COUNT + ); +} class NesAudioDriver { resetChannelMixerState() {} @@ -182,14 +176,11 @@ class NesAudioDriver { 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]; + const semitoneOffset = noteIndex + toneSliding + vibratoSliding + detune; + return resolveNesNoisePeriodFromSemitoneOffset(semitoneOffset); } resolveInstrumentRow(state, channelIndex) { @@ -272,3 +263,4 @@ class NesAudioDriver { } export default NesAudioDriver; +export { resolveNesNoisePeriodFromSemitoneOffset }; diff --git a/tests/public/nes-audio-driver-noise.test.js b/tests/public/nes-audio-driver-noise.test.js new file mode 100644 index 00000000..0e3cdb84 --- /dev/null +++ b/tests/public/nes-audio-driver-noise.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import NesAudioDriver, { + resolveNesNoisePeriodFromSemitoneOffset +} from '../../public/nes/nes-audio-driver.js'; +import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; + +describe('resolveNesNoisePeriodFromSemitoneOffset', () => { + it('maps C-1 to period 0 and each semitone increments by 1 through 15', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(0)).toBe(0); + expect(resolveNesNoisePeriodFromSemitoneOffset(1)).toBe(1); + expect(resolveNesNoisePeriodFromSemitoneOffset(14)).toBe(14); + expect(resolveNesNoisePeriodFromSemitoneOffset(15)).toBe(15); + }); + + it('loops back to 0 after period 15', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(16)).toBe(0); + expect(resolveNesNoisePeriodFromSemitoneOffset(17)).toBe(1); + expect(resolveNesNoisePeriodFromSemitoneOffset(32)).toBe(0); + }); + + it('wraps negative offsets', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(-1)).toBe(15); + expect(resolveNesNoisePeriodFromSemitoneOffset(-16)).toBe(0); + }); +}); + +describe('NesAudioDriver noise period', () => { + it('derives noise period from channel note index', () => { + const driver = new NesAudioDriver(); + const state = { + channelCurrentNotes: [0, 0, 0, 5, 0], + channelToneSliding: [0, 0, 0, 0, 0], + channelVibratoSliding: [0, 0, 0, 0, 0], + channelDetune: [0, 0, 0, 0, 0] + }; + + expect(driver.resolveNoisePeriod(state, 3)).toBe(5); + }); + + it('writes mapped noise period to register state', () => { + const driver = new NesAudioDriver(); + const registerState = new NesChipRegisterState(); + const state = { + channelMuted: [false, false, false, false, false], + channelSoundEnabled: [false, false, false, true, false], + channelInstruments: [-1, -1, -1, 0, -1], + instruments: [ + { + rows: [ + { + pulseWidth: 2, + retrigger: false, + soundLength: 0, + envelope: false, + volumeOrRate: 15, + toneAdd: 0, + toneAccumulation: false, + sweep: false, + sweepRate: 0, + sweepShift: 0 + } + ], + loop: 0 + } + ], + instrumentPositions: [0, 0, 0, 0, 0], + channelPatternVolumes: [15, 15, 15, 15, 15], + channelCurrentNotes: [0, 0, 0, 0, 0], + currentTuningTable: Array.from({ length: 96 }, (_, i) => 400 + i), + channelToneSliding: [0, 0, 0, 0, 0], + channelVibratoSliding: [0, 0, 0, 0, 0], + channelDetune: [0, 0, 0, 0, 0], + channelKeyOn: [false, false, false, false, false], + channelToneAccumulator: [0, 0, 0, 0, 0], + channelOnOffCounter: [0, 0, 0, 0, 0], + channelOnDuration: [0, 0, 0, 0, 0], + channelOffDuration: [0, 0, 0, 0, 0] + }; + + driver.processInstruments(state, registerState); + + expect(registerState.channels[3].noisePeriod).toBe(0); + }); +}); From 7e6cb0cf82de5c204f099373a7142a69c1d39313 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sun, 21 Jun 2026 12:50:55 +0200 Subject: [PATCH 22/28] Reverse noise --- public/nes/nes-audio-driver.js | 6 ++--- tests/public/nes-audio-driver-noise.test.js | 26 ++++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index 78b31591..eac4881d 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -31,10 +31,10 @@ import { NES_CHANNEL_COUNT } from './nes-constants.js'; const NES_NOISE_PERIOD_COUNT = 16; function resolveNesNoisePeriodFromSemitoneOffset(semitoneOffset) { - return ( + const wrapped = ((semitoneOffset % NES_NOISE_PERIOD_COUNT) + NES_NOISE_PERIOD_COUNT) % - NES_NOISE_PERIOD_COUNT - ); + NES_NOISE_PERIOD_COUNT; + return NES_NOISE_PERIOD_COUNT - 1 - wrapped; } class NesAudioDriver { diff --git a/tests/public/nes-audio-driver-noise.test.js b/tests/public/nes-audio-driver-noise.test.js index 0e3cdb84..59e7adb7 100644 --- a/tests/public/nes-audio-driver-noise.test.js +++ b/tests/public/nes-audio-driver-noise.test.js @@ -5,22 +5,22 @@ import NesAudioDriver, { import NesChipRegisterState from '../../public/nes/nes-chip-register-state.js'; describe('resolveNesNoisePeriodFromSemitoneOffset', () => { - it('maps C-1 to period 0 and each semitone increments by 1 through 15', () => { - expect(resolveNesNoisePeriodFromSemitoneOffset(0)).toBe(0); - expect(resolveNesNoisePeriodFromSemitoneOffset(1)).toBe(1); - expect(resolveNesNoisePeriodFromSemitoneOffset(14)).toBe(14); - expect(resolveNesNoisePeriodFromSemitoneOffset(15)).toBe(15); + it('maps C-1 to period 15 and each semitone decrements by 1 through 0', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(0)).toBe(15); + expect(resolveNesNoisePeriodFromSemitoneOffset(1)).toBe(14); + expect(resolveNesNoisePeriodFromSemitoneOffset(14)).toBe(1); + expect(resolveNesNoisePeriodFromSemitoneOffset(15)).toBe(0); }); - it('loops back to 0 after period 15', () => { - expect(resolveNesNoisePeriodFromSemitoneOffset(16)).toBe(0); - expect(resolveNesNoisePeriodFromSemitoneOffset(17)).toBe(1); - expect(resolveNesNoisePeriodFromSemitoneOffset(32)).toBe(0); + it('loops back to 15 after period 0', () => { + expect(resolveNesNoisePeriodFromSemitoneOffset(16)).toBe(15); + expect(resolveNesNoisePeriodFromSemitoneOffset(17)).toBe(14); + expect(resolveNesNoisePeriodFromSemitoneOffset(32)).toBe(15); }); it('wraps negative offsets', () => { - expect(resolveNesNoisePeriodFromSemitoneOffset(-1)).toBe(15); - expect(resolveNesNoisePeriodFromSemitoneOffset(-16)).toBe(0); + expect(resolveNesNoisePeriodFromSemitoneOffset(-1)).toBe(0); + expect(resolveNesNoisePeriodFromSemitoneOffset(-16)).toBe(15); }); }); @@ -34,7 +34,7 @@ describe('NesAudioDriver noise period', () => { channelDetune: [0, 0, 0, 0, 0] }; - expect(driver.resolveNoisePeriod(state, 3)).toBe(5); + expect(driver.resolveNoisePeriod(state, 3)).toBe(10); }); it('writes mapped noise period to register state', () => { @@ -79,6 +79,6 @@ describe('NesAudioDriver noise period', () => { driver.processInstruments(state, registerState); - expect(registerState.channels[3].noisePeriod).toBe(0); + expect(registerState.channels[3].noisePeriod).toBe(15); }); }); From d7576a8760e63d2e2ca92dc1330fc6be58244bfa Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sun, 21 Jun 2026 15:01:21 +0200 Subject: [PATCH 23/28] Refactor instrument selection and handling for AY chip type. Update editor state management to support chip-specific instrument IDs --- src/lib/chips/ay/AYPreviewRow.svelte | 15 ++- .../ay/AYTimerPwmSweepStartEditor.svelte | 23 ++-- .../Instruments/InstrumentsView.svelte | 69 +++++++++--- src/lib/components/Song/PatternEditor.svelte | 3 +- src/lib/components/Song/SongView.svelte | 105 ++++++++++-------- .../pattern/editing/editing-context.ts | 2 + .../pattern/editing/pattern-note-input.ts | 15 ++- src/lib/stores/editor-state.svelte.ts | 33 +++++- .../editing/pattern-note-input.test.ts | 59 +++++++++- 9 files changed, 242 insertions(+), 82 deletions(-) diff --git a/src/lib/chips/ay/AYPreviewRow.svelte b/src/lib/chips/ay/AYPreviewRow.svelte index 8f8e0b22..d57377cb 100644 --- a/src/lib/chips/ay/AYPreviewRow.svelte +++ b/src/lib/chips/ay/AYPreviewRow.svelte @@ -28,6 +28,7 @@ import { ShortcutString } from '../../utils/shortcut-string'; import { ACTION_TOGGLE_PLAYBACK } from '../../config/keybindings'; import { isValidTableDisplayChar, tableDisplayCharToId } from '../../utils/table-id'; + import { filterInstrumentsForChip } from '../../services/instrument/instrument-filter'; let { chip, @@ -124,7 +125,9 @@ $effect(() => { return () => { if (savedStereoLayout !== undefined) { - containerContext.audioService.chipSettings.forChip(chip.type).set('stereoLayout', savedStereoLayout); + containerContext.audioService.chipSettings + .forChip(chip.type) + .set('stereoLayout', savedStereoLayout); savedStereoLayout = undefined; } }; @@ -158,10 +161,12 @@ if (chipIndices.length > 0) { containerContext.audioService.setPreviewActiveForChips(chipIndices); } - const normalizedId = (instrumentId || '01').toUpperCase().padStart(2, '0'); - const currentInstrument = projectStore.instruments.find( - (i) => i.id.toUpperCase().padStart(2, '0') === normalizedId - ); + const normalizedId = instrumentId.toUpperCase().padStart(2, '0'); + const currentInstrument = instrumentId + ? filterInstrumentsForChip(projectStore.instruments, chip.type).find( + (i) => i.id.toUpperCase().padStart(2, '0') === normalizedId + ) + : undefined; processors.forEach((proc, processorIndex) => { const start = processorIndex * 3; const channelNotes = [ diff --git a/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte b/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte index e000aba9..440a213d 100644 --- a/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte +++ b/src/lib/chips/ay/AYTimerPwmSweepStartEditor.svelte @@ -7,6 +7,7 @@ import { playbackToneDebugStore } from '../../stores/playback-tone-debug.svelte'; import { projectStore } from '../../stores/project.svelte'; import { editorStateStore } from '../../stores/editor-state.svelte'; + import { resolveInstrumentChipType } from '../../services/instrument/instrument-filter'; import { AY_TIMER_PWM_SWEEP_SHAPE_LABELS, AY_TIMER_PWM_SWEEP_SHAPES, @@ -43,9 +44,7 @@ let isDragging = $state(false); let isHovering = $state(false); - const minDuty = $derived( - controller.timerPwmSweep() > 0 ? controller.timerPwmSweepMin() : 0 - ); + const minDuty = $derived(controller.timerPwmSweep() > 0 ? controller.timerPwmSweepMin() : 0); const maxDuty = $derived(controller.timerPwmDuty()); const sweepSpeed = $derived(controller.timerPwmSweep()); const startPhase = $derived(controller.timerPwmSweepStartPhase()); @@ -53,7 +52,9 @@ const shapeLabel = $derived(AY_TIMER_PWM_SWEEP_SHAPE_LABELS[sweepShape]); const instrumentIndex = $derived( projectStore.instruments.findIndex( - (instrument) => instrument.id === editorStateStore.currentInstrument + (instrument) => + instrument.id === editorStateStore.getCurrentInstrument('ay') && + resolveInstrumentChipType(instrument) === 'ay' ) ); @@ -273,7 +274,9 @@ bind:this={svgEl} viewBox="0 0 {VIEW_WIDTH} {VIEW_HEIGHT}" preserveAspectRatio="none" - class="block h-full w-full touch-none {enabled ? 'cursor-crosshair' : 'cursor-not-allowed'}" + class="block h-full w-full touch-none {enabled + ? 'cursor-crosshair' + : 'cursor-not-allowed'}" role="slider" tabindex={enabled ? 0 : -1} aria-label="PWM sweep start position on automation curve" @@ -289,8 +292,14 @@ onpointerleave={handlePointerLeave}> - - + + diff --git a/src/lib/components/Instruments/InstrumentsView.svelte b/src/lib/components/Instruments/InstrumentsView.svelte index 00d905a1..172a85d6 100644 --- a/src/lib/components/Instruments/InstrumentsView.svelte +++ b/src/lib/components/Instruments/InstrumentsView.svelte @@ -120,9 +120,13 @@ }); $effect(() => { - const requestId = editorStateStore.selectInstrumentRequest; - if (!requestId) return; - const instrument = allInstruments.find((inst) => inst.id === requestId); + const request = editorStateStore.selectInstrumentRequest; + if (!request) return; + if (request.chipType) { + selectedChipType = request.chipType; + return; + } + const instrument = allInstruments.find((inst) => inst.id === request.instrumentId); if (instrument) { selectedChipType = resolveInstrumentChipType(instrument); } @@ -134,20 +138,26 @@ if (chipInstruments.length > 0 && chipInstruments[selectedInstrumentIndex]) { const instrumentId = chipInstruments[selectedInstrumentIndex].id; untrack(() => { - editorStateStore.setCurrentInstrument(instrumentId); + editorStateStore.setCurrentInstrumentForChip( + chipInstruments[selectedInstrumentIndex].chipType, + instrumentId + ); }); } }); $effect(() => { - const targetId = editorStateStore.currentInstrument; + const targetId = chip ? editorStateStore.getCurrentInstrument(chip.type) : null; const idx = chipInstruments.findIndex((inst) => inst.id === targetId); if (idx >= 0 && idx !== selectedInstrumentIndex) { selectedInstrumentIndex = idx; } else if (idx < 0 && chipInstruments.length > 0) { selectedInstrumentIndex = 0; untrack(() => { - editorStateStore.setCurrentInstrument(chipInstruments[0].id); + editorStateStore.setCurrentInstrumentForChip( + chipInstruments[0].chipType, + chipInstruments[0].id + ); }); } if (editorStateStore.selectInstrumentRequest) { @@ -279,14 +289,20 @@ const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); projectStore.instruments = [...allInstruments, newInstrument]; sortInstrumentsAndSyncSelection(newId); - editorStateStore.setCurrentInstrument(newId); + editorStateStore.setCurrentInstrumentForChip(chip.type, newId); projectStore.recordHistory( { type: 'instrument.add', label: `Add instrument ${newId}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); services.audioService.updateInstruments(projectStore.instruments); await tick(); @@ -310,7 +326,13 @@ label: `Remove instrument ${toRemove.id}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); services.audioService.updateInstruments(projectStore.instruments); } @@ -335,14 +357,20 @@ const beforeInstruments = projectStore.cloneForHistory(projectStore.instruments); projectStore.instruments = [...allInstruments, copy]; sortInstrumentsAndSyncSelection(newId); - editorStateStore.setCurrentInstrument(newId); + editorStateStore.setCurrentInstrumentForChip(chip.type, newId); projectStore.recordHistory( { type: 'instrument.copy', label: `Copy instrument ${instrument.id}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); services.audioService.updateInstruments(projectStore.instruments); await tick(); @@ -381,7 +409,11 @@ affectedDomains: ['instruments', 'patterns'] }, [ - projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments), + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ), projectStore.createSetDiff(['songs'], beforeSongs, projectStore.songs), projectStore.createSetDiff(['patterns'], beforePatterns, projectStore.patterns) ] @@ -524,7 +556,13 @@ label: `Apply preset to instrument ${currentId}`, affectedDomains: ['instruments'] }, - [projectStore.createSetDiff(['instruments'], beforeInstruments, projectStore.instruments)] + [ + projectStore.createSetDiff( + ['instruments'], + beforeInstruments, + projectStore.instruments + ) + ] ); } services.audioService.updateInstruments(projectStore.instruments); @@ -615,7 +653,10 @@ { + const chipType = activeChipProcessor?.chip.type; + if (!chipType) return ''; + const chipInstruments = filterInstrumentsForChip(projectStore.instruments, chipType); + const selectedId = editorStateStore.getCurrentInstrument(chipType); + if (selectedId && chipInstruments.some((instrument) => instrument.id === selectedId)) { + return selectedId; + } + return chipInstruments[0]?.id ?? ''; + }); const services: { audioService: AudioService } = getContext('container'); @@ -211,19 +221,23 @@ const el = rightPanelEl; const handler = previewSpaceHandler; if (!el) return; - return addScopedShortcutListener(el, previewPlaybackActionIds, (event, _action, container) => { - if (event.repeat) return; - if (handler) { - event.preventDefault(); - event.stopPropagation(); - const active = document.activeElement as HTMLElement | null; - if (active && active !== container) { - active.blur?.(); - container.focus(); + return addScopedShortcutListener( + el, + previewPlaybackActionIds, + (event, _action, container) => { + if (event.repeat) return; + if (handler) { + event.preventDefault(); + event.stopPropagation(); + const active = document.activeElement as HTMLElement | null; + if (active && active !== container) { + active.blur?.(); + container.focus(); + } + handler(); } - handler(); } - }); + ); }); const SPEED_EFFECT_TYPE = 'S'.charCodeAt(0); @@ -249,7 +263,11 @@ }, [ projectStore.createSetDiff(['patterns'], beforePatterns, projectStore.patterns), - projectStore.createSetDiff(['patternOrder'], beforePatternOrder, projectStore.patternOrder) + projectStore.createSetDiff( + ['patternOrder'], + beforePatternOrder, + projectStore.patternOrder + ) ] ); if (index === sharedPatternOrderIndex) { @@ -327,10 +345,7 @@ const withVirtual = chipProcessor as ChipProcessor & Partial; if (withVirtual.sendVirtualChannelConfig) { const hwLabels = chipProcessor.chip?.schema?.channelLabels ?? ['A', 'B', 'C']; - withVirtual.sendVirtualChannelConfig( - song.virtualChannelMap ?? {}, - hwLabels.length - ); + withVirtual.sendVirtualChannelConfig(song.virtualChannelMap ?? {}, hwLabels.length); } chipProcessor.sendInitTables(projectStore.tables); @@ -361,10 +376,7 @@ projectStore.patternOrder; projectStore.loopPointId; if (services.audioService.getPlayPatternId() !== null) return; - services.audioService.updateOrder( - [...projectStore.patternOrder], - projectStore.loopPointId - ); + services.audioService.updateOrder([...projectStore.patternOrder], projectStore.loopPointId); }); function initAllChipsForPlayback() { @@ -606,27 +618,30 @@ {/snippet}
{#key `${i}-${chipProcessor.chip.type}`} - { - activeEditorIndex = i; - patternEditor = patternEditors[i]; - }} - canFocusOnHover={() => - !patternEditors.some((e) => e?.getCanvas?.() === document.activeElement)} - {onaction} - initAllChips={initAllChipsForPlayback} - {initAllChipsForPlayPattern} - {getSpeedForChip} - {getSpeedForPlayPattern} - {tuningTableVersion} - chip={chipProcessor.chip} - {chipProcessor} /> + { + activeEditorIndex = i; + patternEditor = patternEditors[i]; + }} + canFocusOnHover={() => + !patternEditors.some( + (e) => + e?.getCanvas?.() === document.activeElement + )} + {onaction} + initAllChips={initAllChipsForPlayback} + {initAllChipsForPlayPattern} + {getSpeedForChip} + {getSpeedForPlayPattern} + {tuningTableVersion} + chip={chipProcessor.chip} + {chipProcessor} /> {/key}
@@ -652,12 +667,14 @@ role="region" aria-label="Instruments and tables" tabindex={0} - class="relative z-10 flex h-full shrink-0 flex-col border-l border-[var(--color-app-border)] bg-[var(--color-app-surface-secondary)] outline-none transition-all duration-300 focus:outline-none {isRightPanelExpanded + class="relative z-10 flex h-full shrink-0 flex-col border-l border-[var(--color-app-border)] bg-[var(--color-app-surface-secondary)] transition-all duration-300 outline-none focus:outline-none {isRightPanelExpanded ? 'w-[1200px]' : 'w-[32rem]'}" onmousedown={(e: MouseEvent) => { const target = e.target as HTMLElement; - if (!target.closest('input, textarea, button, select, [contenteditable="true"], a')) { + if ( + !target.closest('input, textarea, button, select, [contenteditable="true"], a') + ) { rightPanelEl?.focus(); } }}> @@ -689,7 +706,7 @@
diff --git a/src/lib/services/pattern/editing/editing-context.ts b/src/lib/services/pattern/editing/editing-context.ts index 4ad56951..8c64503a 100644 --- a/src/lib/services/pattern/editing/editing-context.ts +++ b/src/lib/services/pattern/editing/editing-context.ts @@ -1,4 +1,5 @@ import type { Pattern } from '../../../models/song'; +import type { Instrument } from '../../../models/song'; import type { Chip } from '../../../chips/types'; import type { PatternConverter } from '../../../chips/base/adapter'; import type { PatternFormatter } from '../../../chips/base/formatter-interface'; @@ -13,6 +14,7 @@ export interface EditingContext { converter: PatternConverter; formatter: PatternFormatter; schema: Chip['schema']; + instruments?: Instrument[]; tuningTable?: number[]; } diff --git a/src/lib/services/pattern/editing/pattern-note-input.ts b/src/lib/services/pattern/editing/pattern-note-input.ts index 2d91f10a..ac629d06 100644 --- a/src/lib/services/pattern/editing/pattern-note-input.ts +++ b/src/lib/services/pattern/editing/pattern-note-input.ts @@ -11,6 +11,7 @@ import { PatternValueUpdates } from './pattern-value-updates'; import { editorStateStore } from '../../../stores/editor-state.svelte'; import { settingsStore } from '../../../stores/settings.svelte'; import { parseSymbol } from '../../../chips/base/field-formatters'; +import { resolveInstrumentChipType } from '../../instrument/instrument-filter'; export class PatternNoteInput { private static readonly PIANO_KEYBOARD_MAP: Record< @@ -142,7 +143,19 @@ export class PatternNoteInput { return pattern; } - const currentInstrumentId = editorStateStore.currentInstrument; + const chipType = context.schema.chipType; + const currentInstrumentId = editorStateStore.getCurrentInstrument(chipType); + if ( + !currentInstrumentId || + !context.instruments?.some( + (instrument) => + instrument.id === currentInstrumentId && + resolveInstrumentChipType(instrument) === chipType + ) + ) { + return pattern; + } + const instrumentValue = parseSymbol(currentInstrumentId, instrumentFieldDef.length); const instrumentFieldInfo: FieldInfo = { diff --git a/src/lib/stores/editor-state.svelte.ts b/src/lib/stores/editor-state.svelte.ts index f8457969..2c3361b0 100644 --- a/src/lib/stores/editor-state.svelte.ts +++ b/src/lib/stores/editor-state.svelte.ts @@ -7,11 +7,20 @@ interface StoredEditorState { step?: number; } +interface SelectInstrumentRequest { + instrumentId: string; + chipType?: string; +} + class EditorStateStore { octave = $state(4); step = $state(0); envelopeAsNote = $state(false); - currentInstrument = $state('01'); + currentInstrumentByChip = $state>({ ay: '01' }); + + get currentInstrument(): string { + return this.getCurrentInstrument('ay') ?? '01'; + } init(): void { this.envelopeAsNote = settingsStore.envelopeAsNote; @@ -57,14 +66,26 @@ class EditorStateStore { } setCurrentInstrument(instrument: string): void { - this.currentInstrument = instrument; + this.setCurrentInstrumentForChip('ay', instrument); } - selectInstrumentRequest = $state(null); + getCurrentInstrument(chipType: string): string | null { + return this.currentInstrumentByChip[chipType] ?? null; + } - requestSelectInstrument(instrumentId: string): void { - this.currentInstrument = instrumentId; - this.selectInstrumentRequest = instrumentId; + setCurrentInstrumentForChip(chipType: string, instrumentId: string): void { + this.currentInstrumentByChip[chipType] = instrumentId; + } + + selectInstrumentRequest = $state(null); + + requestSelectInstrument(instrumentId: string, chipType?: string): void { + if (chipType) { + this.setCurrentInstrumentForChip(chipType, instrumentId); + } else { + this.setCurrentInstrument(instrumentId); + } + this.selectInstrumentRequest = { instrumentId, chipType }; } clearSelectInstrumentRequest(): void { diff --git a/tests/lib/services/pattern/editing/pattern-note-input.test.ts b/tests/lib/services/pattern/editing/pattern-note-input.test.ts index 6be6058f..aacbf88f 100644 --- a/tests/lib/services/pattern/editing/pattern-note-input.test.ts +++ b/tests/lib/services/pattern/editing/pattern-note-input.test.ts @@ -1,19 +1,29 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PatternNoteInput } from '../../../../../src/lib/services/pattern/editing/pattern-note-input'; -import { Pattern, Note, NoteName } from '../../../../../src/lib/models/song'; +import { Pattern, Note, NoteName, Instrument } from '../../../../../src/lib/models/song'; +import { AY_CHIP_SCHEMA } from '../../../../../src/lib/chips/ay/schema'; import type { EditingContext, FieldInfo } from '../../../../../src/lib/services/pattern/editing/editing-context'; import { PatternValueUpdates } from '../../../../../src/lib/services/pattern/editing/pattern-value-updates'; import { parseNoteFromString, formatNoteFromEnum } from '../../../../../src/lib/utils/note-utils'; +import { editorStateStore } from '../../../../../src/lib/stores/editor-state.svelte'; +import { settingsStore } from '../../../../../src/lib/stores/settings.svelte'; vi.mock('../../../../../src/lib/stores/editor-state.svelte', () => ({ editorStateStore: { octave: 3, step: 1, envelopeAsNote: false, - currentInstrument: '01' + currentInstrument: '01', + getCurrentInstrument: vi.fn(() => '01') + } +})); + +vi.mock('../../../../../src/lib/stores/settings.svelte', () => ({ + settingsStore: { + autoEnterInstrument: false } })); @@ -86,6 +96,8 @@ describe('PatternNoteInput', () => { beforeEach(() => { vi.clearAllMocks(); + settingsStore.autoEnterInstrument = false; + vi.mocked(editorStateStore.getCurrentInstrument).mockReturnValue('01'); mockUpdateFieldValue = vi.fn( (context: EditingContext, fieldInfo: FieldInfo, value: string | number) => { @@ -184,7 +196,8 @@ describe('PatternNoteInput', () => { const createMockContext = ( pattern: Pattern, - selectedRow: number = DEFAULT_ROW_INDEX + selectedRow: number = DEFAULT_ROW_INDEX, + overrides: Partial = {} ): EditingContext => { return { pattern, @@ -193,7 +206,8 @@ describe('PatternNoteInput', () => { cellPositions: [], converter: {} as EditingContext['converter'], formatter: {} as EditingContext['formatter'], - schema: {} as EditingContext['schema'] + schema: {} as EditingContext['schema'], + ...overrides }; }; @@ -340,6 +354,43 @@ describe('PatternNoteInput', () => { expect(result?.shouldMoveNext).toBe(false); }); }); + + describe('auto-enter instrument', () => { + it('writes the selected instrument when it exists for the active chip', () => { + settingsStore.autoEnterInstrument = true; + const pattern = new Pattern(DEFAULT_PATTERN_ID, DEFAULT_PATTERN_LENGTH); + const context = createMockContext(pattern, DEFAULT_ROW_INDEX, { + schema: AY_CHIP_SCHEMA, + instruments: [new Instrument('01', [], 0, 'AY 01', 'ay')] + }); + const fieldInfo = createFieldInfo(DEFAULT_CHANNEL_INDEX); + + const result = PatternNoteInput.handleNoteInput(context, fieldInfo, 'q', 'KeyQ'); + + expect(result).not.toBeNull(); + expect(mockUpdateFieldValue).toHaveBeenCalledTimes(2); + expect(mockUpdateFieldValue.mock.calls[1][1]).toEqual( + expect.objectContaining({ fieldKey: 'instrument' }) + ); + expect(mockUpdateFieldValue.mock.calls[1][2]).toBe(1); + }); + + it('skips stale selected instruments that are missing from the active chip list', () => { + settingsStore.autoEnterInstrument = true; + const pattern = new Pattern(DEFAULT_PATTERN_ID, DEFAULT_PATTERN_LENGTH); + const context = createMockContext(pattern, DEFAULT_ROW_INDEX, { + schema: AY_CHIP_SCHEMA, + instruments: [new Instrument('02', [], 0, 'AY 02', 'ay')] + }); + const fieldInfo = createFieldInfo(DEFAULT_CHANNEL_INDEX); + + const result = PatternNoteInput.handleNoteInput(context, fieldInfo, 'q', 'KeyQ'); + + expect(result).not.toBeNull(); + expect(mockUpdateFieldValue).toHaveBeenCalledTimes(1); + expect(mockUpdateFieldValue).toHaveBeenCalledWith(context, fieldInfo, 'C-4'); + }); + }); }); describe('handleMidiNoteInput', () => { From 5dacc3bb7203cc29247a68056dbeeda1ab31bc01 Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Sun, 21 Jun 2026 17:23:11 +0200 Subject: [PATCH 24/28] Fix windows build --- build-wasm.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build-wasm.ps1 b/build-wasm.ps1 index 0ad60714..affab714 100644 --- a/build-wasm.ps1 +++ b/build-wasm.ps1 @@ -3,13 +3,13 @@ if ($env:EMSDK) { if (Test-Path $envScript) { & $envScript } } -$emccArgs = "-O3", +$emccArgs = "-O3", "-DNDEBUG", "-s", "WASM=1", "-s", "ALLOW_MEMORY_GROWTH=1", "-s", "INITIAL_MEMORY=16777216", "-s", "MAXIMUM_MEMORY=16777216", "-s", "ENVIRONMENT=web", "-s", "STANDALONE_WASM=1", "--no-entry" $ayumiArgs = "external/ayumi/ayumi.c", "-o", "public/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\`"]`"", + "-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/nes_apu.wasm", "-s", "EXPORTED_FUNCTIONS=`"[\`"_nes_apu_Init\`", \`"_nes_apu_Reset\`", \`"_nes_apu_Tick\`", \`"_nes_apu_Render\`", \`"_nes_apu_Write\`", \`"_nes_apu_SetMask\`", \`"_nes_apu_SetStereoMix\`", \`"_nes_apu_GetOut\`", \`"_nes_dmc_Init\`", \`"_nes_dmc_Reset\`", \`"_nes_dmc_Tick\`", \`"_nes_dmc_Render\`", \`"_nes_dmc_Write\`", \`"_nes_dmc_SetMask\`", \`"_nes_dmc_SetStereoMix\`", \`"_nes_dmc_SetPal\`", \`"_nes_dmc_SetAPU\`", \`"_nes_dmc_SetMemory_Read\`", \`"_nes_dmc_TickFrameSequence\`", \`"_nes_dmc_GetOut\`", \`"_malloc\`", \`"_free\`"]`"" From 3a70789e42a70dcf5b74766a05d4f9ad69df6b4d Mon Sep 17 00:00:00 2001 From: Kamil Patecki Date: Mon, 22 Jun 2026 16:25:33 +0200 Subject: [PATCH 25/28] Oscilloscope fix --- src/lib/components/Song/PatternEditor.svelte | 10 ++++-- src/lib/services/audio/audio-service.ts | 23 +++++++++----- src/lib/stores/waveform.svelte.ts | 24 ++++++++++++-- tests/lib/stores/waveform.test.ts | 33 ++++++++++++++++++++ 4 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 tests/lib/stores/waveform.test.ts diff --git a/src/lib/components/Song/PatternEditor.svelte b/src/lib/components/Song/PatternEditor.svelte index 457d3383..2f0cba63 100644 --- a/src/lib/components/Song/PatternEditor.svelte +++ b/src/lib/components/Song/PatternEditor.svelte @@ -1464,7 +1464,10 @@ 'playPreviewRow' in chipProcessor && !pressedKeyChannels.has(previewKey) ) { - services.audioService.setPreviewActiveForChips(songIndex); + const previewChipIndex = getChipIndex(); + if (previewChipIndex >= 0) { + services.audioService.setPreviewActiveForChips(previewChipIndex); + } const processor = chipProcessor as ChipProcessor & PreviewNoteSupport; const isNoteField = fieldInfoBeforeEdit.fieldType === 'note' || @@ -2683,7 +2686,10 @@ previewChannel >= 0 && (fieldInfo.channelIndex >= 0 || fieldInfo.fieldKey === 'envelopeValue'); if (shouldPreview && chipProcessor && 'playPreviewRow' in chipProcessor) { - services.audioService.setPreviewActiveForChips(songIndex); + const previewChipIndex = getChipIndex(); + if (previewChipIndex >= 0) { + services.audioService.setPreviewActiveForChips(previewChipIndex); + } const processor = chipProcessor as ChipProcessor & PreviewNoteSupport; const isNoteField = fieldInfo.fieldType === 'note' || fieldInfo.fieldKey === 'envelopeValue'; diff --git a/src/lib/services/audio/audio-service.ts b/src/lib/services/audio/audio-service.ts index 7231ac23..be46ea20 100644 --- a/src/lib/services/audio/audio-service.ts +++ b/src/lib/services/audio/audio-service.ts @@ -174,13 +174,17 @@ export class AudioService { }) => void) => void; }; processorWithWaveform.setWaveformCallback?.((channels: Float32Array[]) => { - const showWaveform = this._isPlaying || this._previewChipIndices.has(chipIndex); - if (showWaveform) waveformStore.setChannels(chipIndex, channels); + const idx = this.chipProcessors.indexOf(processor); + if (idx < 0) return; + const showWaveform = this._isPlaying || this._previewChipIndices.has(idx); + if (showWaveform) waveformStore.setChannels(idx, channels); }); processorWithWaveform.setChannelToneHzCallback?.((payload) => { - const showToneDebug = this._isPlaying || this._previewChipIndices.has(chipIndex); + const idx = this.chipProcessors.indexOf(processor); + if (idx < 0) return; + const showToneDebug = this._isPlaying || this._previewChipIndices.has(idx); if (showToneDebug) { - playbackToneDebugStore.setChipPlaybackHz(chipIndex, { + playbackToneDebugStore.setChipPlaybackHz(idx, { toneHz: payload.frequencies, sidTimerHz: payload.sidTimerHz, syncbuzzerTimerHz: payload.syncbuzzerTimerHz, @@ -191,10 +195,12 @@ export class AudioService { } }); processorWithWaveform.setTimerPwmSweepPhaseCallback?.((payload) => { - const showToneDebug = this._isPlaying || this._previewChipIndices.has(chipIndex); + const idx = this.chipProcessors.indexOf(processor); + if (idx < 0) return; + const showToneDebug = this._isPlaying || this._previewChipIndices.has(idx); if (showToneDebug) { playbackToneDebugStore.updateChipTimerPwmSweepPhase( - chipIndex, + idx, payload.timerPwmSweepPhase, payload.channelInstrumentIndex ); @@ -213,7 +219,10 @@ export class AudioService { } const arr = Array.isArray(indices) ? indices : [indices]; if (!this._isPlaying) { - waveformStore.clear(); + const layout = this.chipProcessors.map( + (p) => p.chip.schema.channelLabels?.length ?? 3 + ); + waveformStore.prepareLayout(layout); playbackToneDebugStore.clear(); } this._previewChipIndices = new Set(arr); diff --git a/src/lib/stores/waveform.svelte.ts b/src/lib/stores/waveform.svelte.ts index 63bb7c83..20e2f8b5 100644 --- a/src/lib/stores/waveform.svelte.ts +++ b/src/lib/stores/waveform.svelte.ts @@ -1,14 +1,21 @@ const WAVEFORM_FRAME_SIZE = 512; const WAVEFORM_DISPLAY_LENGTH = 1536; +const ZERO_WAVEFORM = new Float32Array(WAVEFORM_DISPLAY_LENGTH); class WaveformStore { private channelDataByChip: Float32Array[][] = $state([]); private writeIndexByChip: number[] = $state([]); + private channelCountByChip: number[] = $state([]); get channels(): Float32Array[] { - return this.channelDataByChip.flatMap((chipBuffers, chipIndex) => { + return this.channelCountByChip.flatMap((count, chipIndex) => { + const chipBuffers = this.channelDataByChip[chipIndex] ?? []; const writeIndex = this.writeIndexByChip[chipIndex] ?? 0; - return chipBuffers.map((buf) => { + return Array.from({ length: count }, (_, ch) => { + const buf = chipBuffers[ch]; + if (!buf || buf.length === 0) { + return ZERO_WAVEFORM; + } const out = new Float32Array(buf.length); for (let i = 0; i < buf.length; i++) { out[i] = buf[(writeIndex + i) % buf.length]; @@ -18,11 +25,23 @@ class WaveformStore { }); } + prepareLayout(channelCounts: number[]): void { + const ringSize = WAVEFORM_DISPLAY_LENGTH; + this.channelCountByChip = [...channelCounts]; + this.channelDataByChip = channelCounts.map((count) => + Array.from({ length: count }, () => new Float32Array(ringSize)) + ); + this.writeIndexByChip = channelCounts.map(() => 0); + } + setChannels(chipIndex: number, channels: Float32Array[]): void { while (this.channelDataByChip.length <= chipIndex) { this.channelDataByChip = [...this.channelDataByChip, []]; this.writeIndexByChip = [...this.writeIndexByChip, 0]; + this.channelCountByChip = [...this.channelCountByChip, 0]; } + this.channelCountByChip = this.channelCountByChip.slice(); + this.channelCountByChip[chipIndex] = channels.length; const ringSize = WAVEFORM_DISPLAY_LENGTH; let writeIndex = this.writeIndexByChip[chipIndex] ?? 0; const existing = this.channelDataByChip[chipIndex]; @@ -44,6 +63,7 @@ class WaveformStore { clear(): void { this.channelDataByChip = []; this.writeIndexByChip = []; + this.channelCountByChip = []; } } diff --git a/tests/lib/stores/waveform.test.ts b/tests/lib/stores/waveform.test.ts new file mode 100644 index 00000000..cfdc17d0 --- /dev/null +++ b/tests/lib/stores/waveform.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { waveformStore } from '@/lib/stores/waveform.svelte'; + +describe('WaveformStore', () => { + it('keeps oscilloscope slot alignment when only a later chip sends preview data', () => { + waveformStore.clear(); + waveformStore.prepareLayout([3, 5]); + waveformStore.setChannels( + 1, + Array.from({ length: 5 }, () => new Float32Array(512)) + ); + + expect(waveformStore.channels).toHaveLength(8); + }); + + it('collapses channel indices without a prepared layout', () => { + waveformStore.clear(); + waveformStore.setChannels( + 1, + Array.from({ length: 5 }, () => new Float32Array(512)) + ); + + expect(waveformStore.channels).toHaveLength(5); + }); + + it('returns zero-filled channels for chips without data after prepareLayout', () => { + waveformStore.clear(); + waveformStore.prepareLayout([3, 2]); + + expect(waveformStore.channels).toHaveLength(5); + expect(waveformStore.channels.every((channel) => channel[0] === 0)).toBe(true); + }); +}); From 50997ce2f883943bb73efe2544499d06674475d1 Mon Sep 17 00:00:00 2001 From: Natt Akuma Date: Tue, 23 Jun 2026 04:35:18 +0700 Subject: [PATCH 26/28] Allow NES envelope 0 and triangle volume 0 --- public/nes/nes-audio-driver.js | 2 +- public/nes/nes-instrument-utils.js | 21 ++--- src/lib/chips/nes/NESInstrumentEditor.svelte | 81 ++++++++++++++------ 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index eac4881d..e3341c91 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -235,7 +235,7 @@ class NesAudioDriver { channel.retrigger = row.retrigger || keyOn; state.channelKeyOn[channelIndex] = false; } else if (channelIndex === 2) { - channel.enabled = period > 0 && patternVolume > 0; + channel.enabled = period > 0 && combinedVolume > 0; channel.period = period; channel.retrigger = row.retrigger || keyOn; state.channelKeyOn[channelIndex] = false; diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js index 8fc81c68..71c8562b 100644 --- a/public/nes/nes-instrument-utils.js +++ b/public/nes/nes-instrument-utils.js @@ -16,15 +16,15 @@ export const NES_LENGTH_COUNTER_LENGTHS = [ export const NES_REGISTER_UNCHANGED = -1; export function buildSquareSilentVolumeReg(duty = 2) { - return ((duty & 3) << 6) | (1 << 4); + return ((duty & 3) << 6) | (3 << 4); } export function buildNoiseSilentVolumeReg() { - return 1 << 4; + return 3 << 4; } export function buildTriangleSilentLinearReg() { - return (1 << 7) | 0x7f; + return 0; } const SOUND_LENGTH_MIN = 0; @@ -118,10 +118,7 @@ export function buildSquareEnvelopeVolumeReg(duty, envelope, volumeOrRate, sound const volume = volumeOrRate & 15; const dutyBits = (duty & 3) << 6; return ( - dutyBits | - resolveEnvelopeLoopBit(soundLength) | - resolveConstantVolumeBit(envelope) | - volume + dutyBits | resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume ); } @@ -167,12 +164,14 @@ export function isChannelAudible(envelope, patternVolume, volumeOrRate, combined if (!envelope) { return combinedVolume > 0; } - return patternVolume > 0 && volumeOrRate > 0; + return patternVolume > 0; } export function normalizeNesInstrumentRow(row) { const defaults = createDefaultNesInstrumentRow(); - const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) ? row.pulseWidth : defaults.pulseWidth; + const pulseWidth = NES_PULSE_WIDTHS.includes(row?.pulseWidth) + ? row.pulseWidth + : defaults.pulseWidth; return { pulseWidth, retrigger: row?.retrigger !== undefined ? Boolean(row.retrigger) : defaults.retrigger, @@ -194,7 +193,9 @@ export function normalizeNesInstrumentRow(row) { sweepRate: row?.sweepRate !== undefined ? normalizeSweepRate(row.sweepRate) : defaults.sweepRate, sweepShift: - row?.sweepShift !== undefined ? normalizeSweepShift(row.sweepShift) : defaults.sweepShift + row?.sweepShift !== undefined + ? normalizeSweepShift(row.sweepShift) + : defaults.sweepShift }; } diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index 1a9d4ce6..ed68dbc6 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -93,7 +93,8 @@ const next = { ...current, ...patch }; if ( Object.keys(patch).every( - (key) => current[key as keyof NesInstrumentRow] === next[key as keyof NesInstrumentRow] + (key) => + current[key as keyof NesInstrumentRow] === next[key as keyof NesInstrumentRow] ) ) { return; @@ -189,10 +190,15 @@
row {isExpanded ? 'loop' : 'lp'}{isExpanded ? 'loop' : 'lp'} + title="Hardware sweep rate (0–7), pulse channels only"> rate + title="Hardware sweep shift (−7–7), pulse channels only"> shift + title="Sound length (0–511, 0 is infinite and enables looping envelope)"> {isExpanded ? 'sound len' : 'len'} + title="Envelope (1) or constant volume (0), pulse and noise channels only"> env {#each editorSync.rows as row, index (index)} {@const selected = selection.isRowSelected(index)} -
booleanDrag.dragOver((value) => - updateBooleanRow(index, 'toneAccumulation', value))} /> + updateBooleanRow(index, 'toneAccumulation', value) + )} /> updateBooleanRow(index, 'sweep', value) )} onPaintOver={() => - booleanDrag.dragOver((value) => updateBooleanRow(index, 'sweep', value))} /> + booleanDrag.dragOver((value) => + updateBooleanRow(index, 'sweep', value) + )} /> handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} - oninput={(e) => updateNumericField(index, 'sweepRate', e, { min: 0, max: 7 })} /> + oninput={(e) => + updateNumericField(index, 'sweepRate', e, { + min: 0, + max: 7 + })} /> handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => - updateNumericField(index, 'sweepShift', e, { min: -7, max: 7 })} /> + updateNumericField(index, 'sweepShift', e, { + min: -7, + max: 7 + })} /> handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => - updateNumericField(index, 'soundLength', e, { min: 0, max: 511 })} /> + updateNumericField(index, 'soundLength', e, { + min: 0, + max: 511 + })} /> handleNumericKeyDown(index, e)} onfocus={(e) => (e.target as HTMLInputElement).select()} oninput={(e) => - updateNumericField(index, 'volumeOrRate', e, { min: 0, max: 15 })} /> + updateNumericField(index, 'volumeOrRate', e, { + min: 0, + max: 15 + })} />
From dcf8e6e79f5d2615c50d1ea76495b6da2dc26e31 Mon Sep 17 00:00:00 2001 From: Natt Akuma Date: Tue, 23 Jun 2026 04:52:58 +0700 Subject: [PATCH 27/28] Enable NES noise mode setting through the pulse width row --- public/nes/nes-audio-driver.js | 2 +- src/lib/chips/nes/NESInstrumentEditor.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/nes/nes-audio-driver.js b/public/nes/nes-audio-driver.js index e3341c91..edd7b91c 100644 --- a/public/nes/nes-audio-driver.js +++ b/public/nes/nes-audio-driver.js @@ -113,6 +113,7 @@ class NesAudioDriver { volumeNibble, row.soundLength ); + channel.noiseMode = row.pulseWidth > 0; channel.lengthNibble = buildLengthCounterNibble(row.soundLength); channel.linearReg = NES_REGISTER_UNCHANGED; } @@ -242,7 +243,6 @@ class NesAudioDriver { } else if (channelIndex === 3) { channel.enabled = audible; channel.noisePeriod = this.resolveNoisePeriod(state, channelIndex); - channel.noiseMode = false; channel.retrigger = row.retrigger || keyOn; state.channelKeyOn[channelIndex] = false; } else { diff --git a/src/lib/chips/nes/NESInstrumentEditor.svelte b/src/lib/chips/nes/NESInstrumentEditor.svelte index ed68dbc6..9a26be9d 100644 --- a/src/lib/chips/nes/NESInstrumentEditor.svelte +++ b/src/lib/chips/nes/NESInstrumentEditor.svelte @@ -299,7 +299,7 @@ labelClass="font-sans text-[0.65rem] leading-none" {selected} {isExpanded} - title="Pulse width" + title="Pulse width / Noise type" onclick={() => updateRow(index, { pulseWidth: cyclePulseWidth(row.pulseWidth) From ae0a737ac123446eb6d0c0cd11c514b311f8ce8f Mon Sep 17 00:00:00 2001 From: Natt Akuma Date: Tue, 23 Jun 2026 05:08:29 +0700 Subject: [PATCH 28/28] Fix NES sound length lookup --- public/nes/nes-instrument-utils.js | 4 ++-- src/lib/chips/nes/instrument.ts | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/public/nes/nes-instrument-utils.js b/public/nes/nes-instrument-utils.js index 71c8562b..c0dd890f 100644 --- a/public/nes/nes-instrument-utils.js +++ b/public/nes/nes-instrument-utils.js @@ -9,8 +9,8 @@ const SWEEP_SHIFT_MAX = 7; export const NES_SQUARE_SWEEP_DISABLED = 0x08; export const NES_LENGTH_COUNTER_LENGTHS = [ - 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, - 192, 24, 72, 26, 16, 28, 32, 30 + 20, 508, 40, 4, 80, 8, 160, 12, 320, 16, 120, 20, 28, 24, 52, 28, 24, 32, 48, 36, 96, 40, 192, + 44, 384, 48, 144, 52, 32, 56, 64, 60 ]; export const NES_REGISTER_UNCHANGED = -1; diff --git a/src/lib/chips/nes/instrument.ts b/src/lib/chips/nes/instrument.ts index e70d31bc..b5eaacfa 100644 --- a/src/lib/chips/nes/instrument.ts +++ b/src/lib/chips/nes/instrument.ts @@ -18,8 +18,8 @@ const SWEEP_SHIFT_MAX = 7; export const NES_SQUARE_SWEEP_DISABLED = 0x08; export const NES_LENGTH_COUNTER_LENGTHS = [ - 10, 254, 20, 2, 40, 4, 80, 6, 160, 8, 60, 10, 14, 12, 26, 14, 12, 16, 24, 18, 48, 20, 96, 22, - 192, 24, 72, 26, 16, 28, 32, 30 + 20, 508, 40, 4, 80, 8, 160, 12, 320, 16, 120, 20, 28, 24, 52, 28, 24, 32, 48, 36, 96, 40, 192, + 44, 384, 48, 144, 52, 32, 56, 64, 60 ] as const; const SOUND_LENGTH_MIN = 0; @@ -137,10 +137,7 @@ export function buildSquareEnvelopeVolumeReg( const volume = volumeOrRate & 15; const dutyBits = (duty & 3) << 6; return ( - dutyBits | - resolveEnvelopeLoopBit(soundLength) | - resolveConstantVolumeBit(envelope) | - volume + dutyBits | resolveEnvelopeLoopBit(soundLength) | resolveConstantVolumeBit(envelope) | volume ); }