diff --git a/src/components/dialogs/InstrumentPicker.tsx b/src/components/dialogs/InstrumentPicker.tsx index 49969936..5b18e088 100644 --- a/src/components/dialogs/InstrumentPicker.tsx +++ b/src/components/dialogs/InstrumentPicker.tsx @@ -278,7 +278,7 @@ export function InstrumentPicker() { + ); +} + +function EditorKnob(props: ComponentProps) { + return ; +} + +function renderWaveformOptions() { + return WAVEFORM_OPTIONS.map((waveform) => ( + + )); +} + +function renderPresetOptions() { + return LEGACY_PRESET_OPTIONS.map((preset) => ( + + )); +} + +function renderSubtractiveEditor( + instrument: SubtractiveTrackInstrument, + onInstrumentChange: (instrument: SubtractiveTrackInstrument) => void, +) { + const { oscillator, ampEnvelope, filter, lfo, unison, glideTime, outputGain } = instrument.settings; + + const updateSettings = (settings: SubtractiveTrackInstrument['settings']) => { + onInstrumentChange({ + ...instrument, + settings, + }); + }; + + return ( + <> +
+ {instrument.preset} + + )} + > +
+ updateSettings({ + ...instrument.settings, + oscillator: { + ...oscillator, + waveform: waveform as InstrumentWaveform, + }, + })} + > + {renderWaveformOptions()} + +
+
Voice
+
Canonical subtractive synth state
+
Edits write directly into `track.instrument`.
+
+
+ +
+ updateSettings({ + ...instrument.settings, + oscillator: { + ...oscillator, + octave: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + oscillator: { + ...oscillator, + detuneCents: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + oscillator: { + ...oscillator, + level: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + outputGain: value, + })} + /> +
+ +
+
Amp Envelope
+
+ updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + attack: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + decay: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + sustain: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + release: value, + }, + })} + /> +
+
+
+ +
+ updateSettings({ + ...instrument.settings, + filter: { + ...filter, + enabled: !filter.enabled, + }, + })} + /> + updateSettings({ + ...instrument.settings, + lfo: { + ...lfo, + enabled: !lfo.enabled, + }, + })} + /> + + )} + > +
+ updateSettings({ + ...instrument.settings, + filter: { + ...filter, + type: type as SubtractiveTrackInstrument['settings']['filter']['type'], + }, + })} + > + {FILTER_TYPE_OPTIONS.map((type) => ( + + ))} + + updateSettings({ + ...instrument.settings, + lfo: { + ...lfo, + target: target as SubtractiveTrackInstrument['settings']['lfo']['target'], + }, + })} + > + {LFO_TARGET_OPTIONS.map((target) => ( + + ))} + + updateSettings({ + ...instrument.settings, + lfo: { + ...lfo, + waveform: waveform as InstrumentWaveform, + }, + })} + > + {renderWaveformOptions()} + + + + +
+ +
+ updateSettings({ + ...instrument.settings, + filter: { + ...filter, + cutoffHz: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + filter: { + ...filter, + resonance: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + filter: { + ...filter, + drive: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + filter: { + ...filter, + keyTracking: value, + }, + })} + /> +
+ +
+ updateSettings({ + ...instrument.settings, + lfo: { + ...lfo, + rateHz: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + lfo: { + ...lfo, + depth: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + glideTime: value, + })} + /> + updateSettings({ + ...instrument.settings, + unison: { + ...unison, + detuneCents: value, + }, + })} + /> +
+ +
+ updateSettings({ + ...instrument.settings, + unison: { + ...unison, + voices: Number(voices), + }, + })} + > + {UNISON_VOICE_OPTIONS.map((voices) => ( + + ))} + +
+ updateSettings({ + ...instrument.settings, + unison: { + ...unison, + stereoSpread: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + unison: { + ...unison, + blend: value, + }, + })} + /> +
+
+
+ + ); +} + +function renderFmEditor( + instrument: FmTrackInstrument, + onInstrumentChange: (instrument: FmTrackInstrument) => void, +) { + const { carrier, modulator, modulationIndex, feedback, ampEnvelope, outputGain } = instrument.settings; + const { fallbackPreset } = instrument; + + const updateSettings = (settings: FmTrackInstrument['settings']) => { + onInstrumentChange({ + ...instrument, + settings, + }); + }; + + return ( + <> +
+ FM + + )} + > +
+ onInstrumentChange({ + ...instrument, + fallbackPreset: preset as LegacySynthVoicePreset, + })} + > + {renderPresetOptions()} + +
+
Playback
+
FM parameter shell on canonical state
+
Current engine falls back to the selected legacy voice.
+
+
+ +
+
+
Carrier
+
+ updateSettings({ + ...instrument.settings, + carrier: { + ...carrier, + waveform: waveform as InstrumentWaveform, + }, + })} + > + {renderWaveformOptions()} + +
+
+ updateSettings({ + ...instrument.settings, + carrier: { + ...carrier, + ratio: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + carrier: { + ...carrier, + level: value, + }, + })} + /> +
+
+ +
+
Modulator
+
+ updateSettings({ + ...instrument.settings, + modulator: { + ...modulator, + waveform: waveform as InstrumentWaveform, + }, + })} + > + {renderWaveformOptions()} + +
+
+ updateSettings({ + ...instrument.settings, + modulator: { + ...modulator, + ratio: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + modulator: { + ...modulator, + level: value, + }, + })} + /> +
+
+
+
+ +
+
+ updateSettings({ + ...instrument.settings, + modulationIndex: value, + })} + /> + updateSettings({ + ...instrument.settings, + feedback: value, + })} + /> + updateSettings({ + ...instrument.settings, + outputGain: value, + })} + /> +
+ +
+
Amp Envelope
+
+ updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + attack: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + decay: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + sustain: value, + }, + })} + /> + updateSettings({ + ...instrument.settings, + ampEnvelope: { + ...ampEnvelope, + release: value, + }, + })} + /> +
+
+
+ + ); +} + +export function SynthInstrumentEditor({ instrument, onInstrumentChange }: SynthInstrumentEditorProps) { + return ( +
+ {instrument.kind === 'fm' + ? renderFmEditor(instrument, onInstrumentChange) + : renderSubtractiveEditor(instrument, onInstrumentChange)} +
+ ); +} diff --git a/src/engine/SynthEngine.ts b/src/engine/SynthEngine.ts index 4183d32c..35b78786 100644 --- a/src/engine/SynthEngine.ts +++ b/src/engine/SynthEngine.ts @@ -1,54 +1,69 @@ import * as Tone from 'tone'; -import type { SynthPreset } from '../types/project'; +import type { + LegacySynthVoicePreset, + SubtractiveTrackInstrument, + SynthPreset, + TrackInstrument, +} from '../types/project'; +import { + createDefaultSubtractiveInstrument, + getLegacySynthPresetFromInstrument, +} from '../utils/trackInstrument'; + +type SynthSource = TrackInstrument | SynthPreset; +const DEFAULT_TRACK_GAIN = 0.55; interface SynthInstance { synth: Tone.PolySynth; - preset: SynthPreset; + signature: string; gain: Tone.Gain; } -export function createSynthForPreset(preset: SynthPreset): Tone.PolySynth { - const synth = new Tone.PolySynth(Tone.Synth); +function toLegacySubtractivePreset(preset: SynthPreset): LegacySynthVoicePreset { + return preset === 'sampler' ? 'piano' : preset; +} - switch (preset) { - case 'piano': - synth.set({ - oscillator: { type: 'triangle8' }, - envelope: { attack: 0.005, decay: 0.3, sustain: 0.2, release: 1.2 }, - }); - break; - case 'strings': - synth.set({ - oscillator: { type: 'sawtooth' }, - envelope: { attack: 0.4, decay: 0.2, sustain: 0.8, release: 1.5 }, - }); - break; - case 'pad': - synth.set({ - oscillator: { type: 'sine' }, - envelope: { attack: 0.8, decay: 0.5, sustain: 0.9, release: 2.0 }, - }); - break; - case 'lead': - synth.set({ - oscillator: { type: 'square' }, - envelope: { attack: 0.01, decay: 0.1, sustain: 0.6, release: 0.3 }, - }); - break; - case 'bass': - synth.set({ - oscillator: { type: 'sawtooth' }, - envelope: { attack: 0.01, decay: 0.2, sustain: 0.4, release: 0.5 }, - }); - break; - case 'organ': - synth.set({ - oscillator: { type: 'sine' }, - envelope: { attack: 0.01, decay: 0.01, sustain: 1, release: 0.1 }, - }); - break; +function resolveSubtractiveInstrument(source: SynthSource): SubtractiveTrackInstrument { + if (typeof source === 'string') { + return createDefaultSubtractiveInstrument(toLegacySubtractivePreset(source)); } + if (source.kind === 'subtractive') return source; + + return createDefaultSubtractiveInstrument( + toLegacySubtractivePreset(getLegacySynthPresetFromInstrument(source)), + ); +} + +function getSynthSignature(source: SynthSource): string { + return typeof source === 'string' + ? `preset:${source}` + : `instrument:${JSON.stringify(source)}`; +} + +function getTrackGainLevel(instrument: SubtractiveTrackInstrument, baseGain = DEFAULT_TRACK_GAIN): number { + const outputGainScale = Math.pow(10, instrument.settings.outputGain / 20); + return Math.max(0, Math.min(2, baseGain * outputGainScale)); +} + +export function createSynthForPreset(preset: SynthPreset): Tone.PolySynth { + return createSynthForSource(preset); +} + +export function createSynthForSource(source: SynthSource): Tone.PolySynth { + const instrument = resolveSubtractiveInstrument(source); + const synth = new Tone.PolySynth(Tone.Synth); + synth.set({ + oscillator: { type: instrument.settings.oscillator.waveform }, + envelope: { + attack: instrument.settings.ampEnvelope.attack, + decay: instrument.settings.ampEnvelope.decay, + sustain: instrument.settings.ampEnvelope.sustain, + release: instrument.settings.ampEnvelope.release, + }, + portamento: instrument.settings.glideTime, + }); + return synth; } @@ -56,6 +71,7 @@ class SynthEngine { private synths = new Map(); private previewSynth: Tone.PolySynth | null = null; private previewGain: Tone.Gain | null = null; + private previewSignature: string | null = null; async ensureStarted() { if (Tone.getContext().state !== 'running') { @@ -63,9 +79,10 @@ class SynthEngine { } } - ensureTrackSynth(trackId: string, preset: SynthPreset, connectTo?: Tone.InputNode): Tone.PolySynth { + ensureTrackSynth(trackId: string, source: SynthSource, connectTo?: Tone.InputNode): Tone.PolySynth { + const signature = getSynthSignature(source); const existing = this.synths.get(trackId); - if (existing && existing.preset === preset) return existing.synth; + if (existing && existing.signature === signature) return existing.synth; if (existing) { existing.synth.releaseAll(); @@ -73,15 +90,16 @@ class SynthEngine { existing.gain.dispose(); } - const synth = createSynthForPreset(preset); - const gain = new Tone.Gain(0.55); + const instrument = resolveSubtractiveInstrument(source); + const synth = createSynthForSource(source); + const gain = new Tone.Gain(getTrackGainLevel(instrument)); synth.connect(gain); if (connectTo) { gain.connect(connectTo); } else { gain.toDestination(); } - this.synths.set(trackId, { synth, preset, gain }); + this.synths.set(trackId, { synth, signature, gain }); return synth; } @@ -89,20 +107,26 @@ class SynthEngine { return this.synths.get(trackId)?.synth ?? null; } - async previewNote(pitch: number, velocity = 100, duration = 0.3, preset: SynthPreset = 'piano') { + async previewNote(pitch: number, velocity = 100, duration = 0.3, source: SynthSource = 'piano') { await this.ensureStarted(); - if (!this.previewSynth || !this.previewGain) { - this.previewSynth = createSynthForPreset(preset); - this.previewGain = new Tone.Gain(0.3).toDestination(); + const signature = getSynthSignature(source); + + if (!this.previewSynth || !this.previewGain || this.previewSignature !== signature) { + this.previewSynth?.dispose(); + this.previewGain?.dispose(); + const instrument = resolveSubtractiveInstrument(source); + this.previewSynth = createSynthForSource(source); + this.previewGain = new Tone.Gain(getTrackGainLevel(instrument, 0.3)).toDestination(); this.previewSynth.connect(this.previewGain); + this.previewSignature = signature; } const freq = Tone.Frequency(pitch, 'midi').toFrequency(); this.previewSynth.triggerAttackRelease(freq, duration, undefined, velocity / 127); } - async playNote(trackId: string, pitch: number, velocity: number, duration: number, preset: SynthPreset) { + async playNote(trackId: string, pitch: number, velocity: number, duration: number, source: SynthSource) { await this.ensureStarted(); - const synth = this.ensureTrackSynth(trackId, preset); + const synth = this.ensureTrackSynth(trackId, source); const freq = Tone.Frequency(pitch, 'midi').toFrequency(); synth.triggerAttackRelease(freq, duration, undefined, velocity / 127); } @@ -113,10 +137,10 @@ class SynthEngine { toPitch: number, velocity: number, duration: number, - preset: SynthPreset, + source: SynthSource, ) { await this.ensureStarted(); - const synth = this.ensureTrackSynth(trackId, preset) as unknown as { + const synth = this.ensureTrackSynth(trackId, source) as unknown as { set: (options: Record) => void; triggerAttack: (note: number, time?: string | number, velocity?: number) => void; triggerRelease: (note: number, time?: string | number) => void; @@ -171,6 +195,7 @@ class SynthEngine { this.previewGain?.dispose(); this.previewSynth = null; this.previewGain = null; + this.previewSignature = null; } } diff --git a/src/hooks/useTransport.ts b/src/hooks/useTransport.ts index 9eeecc20..e62912f6 100644 --- a/src/hooks/useTransport.ts +++ b/src/hooks/useTransport.ts @@ -6,7 +6,7 @@ import { useUIStore } from '../store/uiStore'; import { getAudioEngine } from './useAudioEngine'; import { loadAudioBlobByKey } from '../services/audioFileManager'; import { synthEngine } from '../engine/SynthEngine'; -import { createSamplerConfig, samplerEngine } from '../engine/SamplerEngine'; +import { samplerEngine } from '../engine/SamplerEngine'; import { drumEngine } from '../engine/DrumEngine'; import { automationEngine } from '../engine/AutomationEngine'; import { @@ -25,6 +25,10 @@ import { getClipAudibleStartTime, getClipAudibleTimelineDuration, } from '../utils/clipAudio'; +import { + getTrackSamplerConfigFromInstrument, + resolveTrackInstrument, +} from '../utils/trackInstrument'; import { toastInfo } from './useToast'; import type { TimelineScrubClip } from '../engine/AudioEngine'; import { useVST3Store } from '../store/vst3Store'; @@ -360,16 +364,9 @@ export function useTransport() { (inst) => inst.trackId === track.id && inst.enabled && inst.online, ); - const preset = track.synthPreset ?? 'piano'; - const samplerConfig = track.samplerConfig - ?? (preset === 'sampler' && track.sampler?.audioKey - ? createSamplerConfig(track.sampler.audioKey, { - rootNote: track.sampler.rootNote ?? 60, - trimEnd: track.sampler.sampleDuration, - loopEnd: track.sampler.sampleDuration, - }) - : null); - const useSampler = !vst3Instrument && !!samplerConfig; + const instrument = resolveTrackInstrument(track); + const samplerConfig = getTrackSamplerConfigFromInstrument(track); + const useSampler = !vst3Instrument && instrument?.kind === 'sampler' && !!samplerConfig; synthEngine.removeTrackSynth(track.id); samplerEngine.removeTrackSampler(track.id); @@ -384,8 +381,8 @@ export function useTransport() { trackNode.inputGain as unknown as Tone.InputNode, ); } - } else if (preset !== 'sampler') { - synthEngine.ensureTrackSynth(track.id, preset); + } else if (instrument?.kind !== 'sampler') { + synthEngine.ensureTrackSynth(track.id, instrument ?? (track.synthPreset ?? 'piano')); } const midiClips = mainView === 'session' @@ -471,7 +468,7 @@ export function useTransport() { note.pitch, Math.max(1, Math.round(velocity * 127)), scheduledDuration, - preset, + instrument ?? (track.synthPreset ?? 'piano'), ); return; } diff --git a/src/utils/__tests__/trackInstrument.test.ts b/src/utils/__tests__/trackInstrument.test.ts new file mode 100644 index 00000000..fdf93fde --- /dev/null +++ b/src/utils/__tests__/trackInstrument.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + createDefaultFmInstrument, + createDefaultSamplerInstrument, + getTrackInstrumentSelectValue, + getTrackSamplerConfigFromInstrument, + resolveTrackInstrument, +} from '../trackInstrument'; + +describe('trackInstrument helpers', () => { + it('resolves a subtractive instrument from legacy synth preset state', () => { + const instrument = resolveTrackInstrument({ + trackName: 'keyboard', + trackType: 'pianoRoll', + instrument: undefined, + synthPreset: 'pad', + sampler: undefined, + samplerConfig: undefined, + }); + + expect(instrument).toMatchObject({ + kind: 'subtractive', + preset: 'pad', + }); + }); + + it('derives sampler config from a canonical sampler instrument', () => { + const samplerConfig = getTrackSamplerConfigFromInstrument({ + trackName: 'keyboard', + trackType: 'pianoRoll', + instrument: createDefaultSamplerInstrument({ + audioKey: 'audio:test:sampler', + sampleName: 'Glass Vox', + rootNote: 48, + sampleDuration: 1.5, + trimEnd: 1.25, + loopEnd: 1.1, + }), + synthPreset: undefined, + sampler: undefined, + samplerConfig: undefined, + }); + + expect(samplerConfig).toMatchObject({ + audioKey: 'audio:test:sampler', + rootNote: 48, + trimEnd: 1.25, + loopEnd: 1.1, + }); + }); + + it('returns an fm selector value for canonical fm instruments', () => { + const selectValue = getTrackInstrumentSelectValue({ + trackName: 'synth', + trackType: 'pianoRoll', + instrument: createDefaultFmInstrument({ fallbackPreset: 'lead' }), + synthPreset: undefined, + sampler: undefined, + samplerConfig: undefined, + }); + + expect(selectValue).toBe('fm'); + }); +}); diff --git a/src/utils/trackInstrument.ts b/src/utils/trackInstrument.ts index e5e9081b..5f83d350 100644 --- a/src/utils/trackInstrument.ts +++ b/src/utils/trackInstrument.ts @@ -19,6 +19,7 @@ type TrackInstrumentSyncInput = Pick< >; type TrackInstrumentSyncState = Pick; +export type TrackInstrumentSelectValue = LegacySynthVoicePreset | 'sampler' | 'fm'; const DEFAULT_AMP_ENVELOPE: InstrumentEnvelope = { attack: 0.01, @@ -299,6 +300,27 @@ export function getLegacySynthPresetFromInstrument(instrument: TrackInstrument): } } +export function resolveTrackInstrument(input: TrackInstrumentSyncInput): TrackInstrument | undefined { + return syncTrackInstrumentState(input).instrument; +} + +export function getTrackInstrumentSelectValue(input: TrackInstrumentSyncInput): TrackInstrumentSelectValue { + const instrument = resolveTrackInstrument(input); + + if (instrument?.kind === 'subtractive') return instrument.preset; + if (instrument?.kind === 'sampler') return 'sampler'; + if (instrument?.kind === 'fm') return 'fm'; + + const fallbackPreset = input.synthPreset ?? getDefaultTrackInstrumentPreset(input.trackName); + return fallbackPreset === 'sampler' ? 'sampler' : fallbackPreset; +} + +export function getTrackSamplerConfigFromInstrument(input: TrackInstrumentSyncInput): SamplerConfig | null { + const instrument = resolveTrackInstrument(input); + if (instrument?.kind !== 'sampler') return null; + return buildLegacySamplerState(instrument).samplerConfig ?? null; +} + function normalizeExistingInstrument( input: TrackInstrumentSyncInput, instrument: TrackInstrument, diff --git a/tests/unit/PianoRoll.test.tsx b/tests/unit/PianoRoll.test.tsx index 4d5a0764..4396bcdd 100644 --- a/tests/unit/PianoRoll.test.tsx +++ b/tests/unit/PianoRoll.test.tsx @@ -38,6 +38,12 @@ vi.mock('../../src/components/pianoroll/QuickSamplerEditor', () => ({ QuickSamplerEditor: () =>
sampler
, })); +vi.mock('../../src/components/pianoroll/SynthInstrumentEditor', () => ({ + SynthInstrumentEditor: ({ instrument }: { instrument: { kind: string } }) => ( +
{instrument.kind}
+ ), +})); + vi.mock('../../src/components/pianoroll/GeneratePatternDialog', () => ({ GeneratePatternDialog: () => null, })); @@ -158,4 +164,40 @@ describe('PianoRoll', () => { expect(screen.getByLabelText('Piano roll navigation status')).toHaveTextContent('1 note selected'); expect(screen.getByLabelText('Piano roll canvas stub')).toHaveAttribute('data-selected-note-ids', 'note-c4'); }); + + it('writes canonical instrument state when the piano-roll instrument selector changes', () => { + render(); + + const instrumentSelect = screen.getByLabelText('Track synth preset'); + expect(screen.getByTestId('synth-editor')).toHaveTextContent('subtractive'); + + fireEvent.change(instrumentSelect, { target: { value: 'sampler' } }); + + let track = useProjectStore.getState().project?.tracks[0]; + expect(track?.instrument).toMatchObject({ + kind: 'sampler', + preset: 'sampler', + }); + expect(screen.getByText('sampler')).toBeInTheDocument(); + expect(screen.queryByTestId('synth-editor')).not.toBeInTheDocument(); + + fireEvent.change(instrumentSelect, { target: { value: 'fm' } }); + + track = useProjectStore.getState().project?.tracks[0]; + expect(track?.instrument).toMatchObject({ + kind: 'fm', + preset: 'fm', + }); + expect(screen.getByTestId('synth-editor')).toHaveTextContent('fm'); + expect(screen.queryByText('sampler')).not.toBeInTheDocument(); + + fireEvent.change(instrumentSelect, { target: { value: 'pad' } }); + + track = useProjectStore.getState().project?.tracks[0]; + expect(track?.instrument).toMatchObject({ + kind: 'subtractive', + preset: 'pad', + }); + expect(screen.getByTestId('synth-editor')).toHaveTextContent('subtractive'); + }); }); diff --git a/tests/unit/SynthInstrumentEditor.test.tsx b/tests/unit/SynthInstrumentEditor.test.tsx new file mode 100644 index 00000000..0e12605e --- /dev/null +++ b/tests/unit/SynthInstrumentEditor.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { SynthInstrumentEditor } from '../../src/components/pianoroll/SynthInstrumentEditor'; +import { createDefaultFmInstrument, createDefaultSubtractiveInstrument } from '../../src/utils/trackInstrument'; + +describe('SynthInstrumentEditor', () => { + it('updates subtractive oscillator waveform and filter cutoff through canonical instrument state', () => { + const onInstrumentChange = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText('Instrument waveform'), { target: { value: 'square' } }); + expect(onInstrumentChange).toHaveBeenLastCalledWith(expect.objectContaining({ + kind: 'subtractive', + settings: expect.objectContaining({ + oscillator: expect.objectContaining({ waveform: 'square' }), + }), + })); + + fireEvent.contextMenu(screen.getByLabelText('Filter Cutoff knob')); + fireEvent.change(screen.getByLabelText('Filter Cutoff exact value'), { target: { value: '3200' } }); + fireEvent.keyDown(screen.getByLabelText('Filter Cutoff exact value'), { key: 'Enter' }); + + expect(onInstrumentChange).toHaveBeenLastCalledWith(expect.objectContaining({ + kind: 'subtractive', + settings: expect.objectContaining({ + filter: expect.objectContaining({ cutoffHz: 3200 }), + }), + })); + }); + + it('updates FM fallback preset and modulation index through canonical instrument state', () => { + const onInstrumentChange = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByLabelText('Instrument FM fallback preset'), { target: { value: 'bass' } }); + expect(onInstrumentChange).toHaveBeenLastCalledWith(expect.objectContaining({ + kind: 'fm', + fallbackPreset: 'bass', + })); + + fireEvent.contextMenu(screen.getByLabelText('Modulation Index knob')); + fireEvent.change(screen.getByLabelText('Modulation Index exact value'), { target: { value: '7.5' } }); + fireEvent.keyDown(screen.getByLabelText('Modulation Index exact value'), { key: 'Enter' }); + + expect(onInstrumentChange).toHaveBeenLastCalledWith(expect.objectContaining({ + kind: 'fm', + settings: expect.objectContaining({ + modulationIndex: 7.5, + }), + })); + }); +}); diff --git a/tests/unit/VirtualKeyboard.test.tsx b/tests/unit/VirtualKeyboard.test.tsx index 73644804..e435a034 100644 --- a/tests/unit/VirtualKeyboard.test.tsx +++ b/tests/unit/VirtualKeyboard.test.tsx @@ -57,7 +57,13 @@ describe('VirtualKeyboard', () => { render(); fireEvent.keyDown(window, { code: 'KeyA' }); - expect(synthEngineSpies.ensureTrackSynth).toHaveBeenCalledWith(track.id, 'organ'); + expect(synthEngineSpies.ensureTrackSynth).toHaveBeenCalledWith( + track.id, + expect.objectContaining({ + kind: 'subtractive', + preset: 'organ', + }), + ); expect(synthEngineSpies.noteOn).toHaveBeenCalledWith(track.id, 60, 96); expect(useUIStore.getState().virtualKeyboardPressedPitches).toEqual([60]);