From f20f1170c94ce7d62e458e173e17193cba4315eb Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Fri, 27 Mar 2026 16:16:29 +0800 Subject: [PATCH 1/2] feat: route piano roll playback through canonical instruments --- src/components/dialogs/InstrumentPicker.tsx | 2 +- src/components/midi/VirtualKeyboard.tsx | 12 +- src/components/pianoroll/PianoRoll.tsx | 39 +++++- src/components/pianoroll/PianoRollCanvas.tsx | 18 ++- src/engine/SynthEngine.ts | 133 +++++++++++-------- src/hooks/useTransport.ts | 25 ++-- src/utils/__tests__/trackInstrument.test.ts | 64 +++++++++ src/utils/trackInstrument.ts | 22 +++ tests/unit/PianoRoll.test.tsx | 23 ++++ tests/unit/VirtualKeyboard.test.tsx | 8 +- 10 files changed, 267 insertions(+), 79 deletions(-) create mode 100644 src/utils/__tests__/trackInstrument.test.ts 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/tests/unit/PianoRoll.test.tsx b/tests/unit/PianoRoll.test.tsx index 7b26d0fd..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, })); @@ -163,6 +169,8 @@ describe('PianoRoll', () => { 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]; @@ -171,6 +179,17 @@ describe('PianoRoll', () => { 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' } }); @@ -179,6 +198,6 @@ describe('PianoRoll', () => { kind: 'subtractive', preset: 'pad', }); - expect(screen.queryByText('sampler')).not.toBeInTheDocument(); + 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, + }), + })); + }); +});