From d11578044a615ea53456b21e2db304cf1a012820 Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Fri, 27 Mar 2026 20:25:38 +0800 Subject: [PATCH] feat: add SubtractiveInstrumentEngine for pianoroll synth tracks Creates SubtractiveEngine that maps SubtractiveInstrumentSettings to Tone.js PolySynth with filter, LFO, and unison support. Integrates with useTransport to route pianoroll tracks with instrument.kind === 'subtractive' through the new engine instead of the legacy preset-based SynthEngine. Closes #1020 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/SubtractiveEngine.ts | 340 +++++++++++++++ .../__tests__/SubtractiveEngine.test.ts | 386 ++++++++++++++++++ src/hooks/useTransport.ts | 32 ++ 3 files changed, 758 insertions(+) create mode 100644 src/engine/SubtractiveEngine.ts create mode 100644 src/engine/__tests__/SubtractiveEngine.test.ts diff --git a/src/engine/SubtractiveEngine.ts b/src/engine/SubtractiveEngine.ts new file mode 100644 index 00000000..65ced0ad --- /dev/null +++ b/src/engine/SubtractiveEngine.ts @@ -0,0 +1,340 @@ +import * as Tone from 'tone'; +import type { + SubtractiveInstrumentSettings, +} from '../types/project'; + +interface SubtractiveInstance { + synth: Tone.PolySynth; + filter: Tone.Filter | null; + lfo: Tone.LFO | null; + output: Tone.Gain; + settings: SubtractiveInstrumentSettings; +} + +/** + * Engine for subtractive synthesis tracks. + * Maps SubtractiveInstrumentSettings to Tone.js PolySynth with filter, LFO, and unison. + */ +class SubtractiveEngine { + private instances = new Map(); + private previewInstance: SubtractiveInstance | null = null; + + async ensureStarted() { + if (Tone.getContext().state !== 'running') { + await Tone.start(); + } + } + + ensureTrackSynth( + trackId: string, + settings: SubtractiveInstrumentSettings, + connectTo?: Tone.InputNode, + ): SubtractiveInstance { + const existing = this.instances.get(trackId); + if (existing) { + this._updateSettings(existing, settings); + return existing; + } + + const instance = this._createInstance(settings, connectTo); + this.instances.set(trackId, instance); + return instance; + } + + getSynth(trackId: string): Tone.PolySynth | null { + return this.instances.get(trackId)?.synth ?? null; + } + + setParameter(trackId: string, name: string, value: number | string | boolean) { + const instance = this.instances.get(trackId); + if (!instance) return; + this._applyParameter(instance, name, value); + } + + triggerAttackRelease(trackId: string, pitch: number, duration: number, velocity = 1) { + const instance = this.instances.get(trackId); + if (!instance) return; + const freq = this._pitchToFreq(pitch, instance.settings.oscillator.octave); + instance.synth.triggerAttackRelease(freq, duration, undefined, velocity); + } + + noteOn(trackId: string, pitch: number, velocity = 100) { + const instance = this.instances.get(trackId); + if (!instance) return; + const freq = this._pitchToFreq(pitch, instance.settings.oscillator.octave); + instance.synth.triggerAttack(freq, undefined, velocity / 127); + } + + noteOff(trackId: string, pitch: number) { + const instance = this.instances.get(trackId); + if (!instance) return; + const freq = this._pitchToFreq(pitch, instance.settings.oscillator.octave); + instance.synth.triggerRelease(freq); + } + + playSlideNote( + trackId: string, + fromPitch: number, + toPitch: number, + velocity: number, + duration: number, + ) { + const instance = this.instances.get(trackId); + if (!instance) return; + + const octave = instance.settings.oscillator.octave; + const glideTime = instance.settings.glideTime > 0 + ? instance.settings.glideTime + : Math.max(0.03, Math.min(0.12, duration * 0.35)); + const fromFreq = this._pitchToFreq(fromPitch, octave); + const toFreq = this._pitchToFreq(toPitch, octave); + + const synth = instance.synth as unknown as { + set: (options: Record) => void; + triggerAttack: (note: number, time?: string | number, velocity?: number) => void; + triggerRelease: (note: number, time?: string | number) => void; + triggerAttackRelease: (note: number, duration: number, time?: string | number, velocity?: number) => void; + }; + synth.set({ portamento: glideTime }); + synth.triggerAttack(fromFreq, undefined, velocity / 127); + synth.triggerRelease(fromFreq, `+${glideTime}`); + synth.triggerAttackRelease(toFreq, Math.max(0.04, duration), `+${glideTime}`, velocity / 127); + } + + async previewNote( + pitch: number, + velocity = 100, + duration = 0.3, + settings: SubtractiveInstrumentSettings, + ) { + await this.ensureStarted(); + if (this.previewInstance) { + this._disposeInstance(this.previewInstance); + } + this.previewInstance = this._createInstance(settings); + this.previewInstance.output.toDestination(); + const freq = this._pitchToFreq(pitch, settings.oscillator.octave); + this.previewInstance.synth.triggerAttackRelease(freq, duration, undefined, velocity / 127); + } + + releaseAll() { + for (const instance of this.instances.values()) { + instance.synth.releaseAll(); + } + } + + removeTrackSynth(trackId: string) { + const instance = this.instances.get(trackId); + if (!instance) return; + instance.synth.releaseAll(); + this._disposeInstance(instance); + this.instances.delete(trackId); + } + + dispose() { + for (const trackId of this.instances.keys()) { + this.removeTrackSynth(trackId); + } + if (this.previewInstance) { + this._disposeInstance(this.previewInstance); + this.previewInstance = null; + } + } + + // --- Private helpers --- + + private _pitchToFreq(pitch: number, octaveOffset: number): number { + return Tone.Frequency(pitch + octaveOffset * 12, 'midi').toFrequency(); + } + + private _createInstance( + settings: SubtractiveInstrumentSettings, + connectTo?: Tone.InputNode, + ): SubtractiveInstance { + const { oscillator, ampEnvelope, filter, lfo, unison } = settings; + + const voiceCount = Math.max(1, Math.min(8, unison.voices)); + const detuneValue = oscillator.detuneCents + (voiceCount > 1 ? unison.detuneCents : 0); + const synth = new Tone.PolySynth(Tone.Synth); + synth.set({ + oscillator: { type: oscillator.waveform as OscillatorType }, + envelope: { + attack: ampEnvelope.attack, + decay: ampEnvelope.decay, + sustain: ampEnvelope.sustain, + release: ampEnvelope.release, + }, + portamento: settings.glideTime, + volume: oscillator.level < 1 ? 20 * Math.log10(Math.max(0.0001, oscillator.level)) : 0, + }); + if (detuneValue !== 0) { + synth.set({ detune: detuneValue }); + } + + // Output gain (outputGain is in dB; 0 dB → use default 0.55 for legacy compat) + const outputLevel = settings.outputGain !== 0 + ? Math.pow(10, settings.outputGain / 20) + : 0.55; + const output = new Tone.Gain(outputLevel); + + // Signal chain: synth → [filter] → output → connectTo/destination + let toneFilter: Tone.Filter | null = null; + + if (filter.enabled) { + toneFilter = new Tone.Filter({ + type: filter.type, + frequency: filter.cutoffHz, + Q: filter.resonance * 30, + rolloff: -12, + }); + synth.connect(toneFilter); + toneFilter.connect(output); + } else { + synth.connect(output); + } + + if (connectTo) { + output.connect(connectTo); + } else { + output.toDestination(); + } + + // LFO modulation + let lfoNode: Tone.LFO | null = null; + + if (lfo.enabled && lfo.target !== 'off' && lfo.depth > 0) { + switch (lfo.target) { + case 'amp': { + lfoNode = new Tone.LFO({ + frequency: lfo.rateHz, + type: lfo.waveform as Tone.ToneOscillatorType, + min: Math.max(0, outputLevel - lfo.depth * outputLevel), + max: outputLevel + lfo.depth * outputLevel, + }); + lfoNode.connect(output.gain as unknown as Tone.InputNode); + lfoNode.start(); + break; + } + case 'filterCutoff': { + if (toneFilter) { + const range = lfo.depth * filter.cutoffHz; + lfoNode = new Tone.LFO({ + frequency: lfo.rateHz, + type: lfo.waveform as Tone.ToneOscillatorType, + min: Math.max(20, filter.cutoffHz - range), + max: Math.min(20000, filter.cutoffHz + range), + }); + lfoNode.connect(toneFilter.frequency as unknown as Tone.InputNode); + lfoNode.start(); + } + break; + } + // pitch and pan LFO targets require per-voice modulation, + // which Tone.PolySynth doesn't easily expose. Deferred to instrument consolidation (#1031). + } + } + + return { + synth, + filter: toneFilter, + lfo: lfoNode, + output, + settings: { ...settings }, + }; + } + + private _updateSettings(instance: SubtractiveInstance, settings: SubtractiveInstrumentSettings) { + const { oscillator, ampEnvelope, unison } = settings; + + instance.synth.set({ + oscillator: { type: oscillator.waveform as OscillatorType }, + envelope: { + attack: ampEnvelope.attack, + decay: ampEnvelope.decay, + sustain: ampEnvelope.sustain, + release: ampEnvelope.release, + }, + portamento: settings.glideTime, + }); + const detuneValue = oscillator.detuneCents + (unison.voices > 1 ? unison.detuneCents : 0); + if (detuneValue !== 0) { + instance.synth.set({ detune: detuneValue }); + } + + if (instance.filter && settings.filter.enabled) { + instance.filter.frequency.value = settings.filter.cutoffHz; + instance.filter.Q.value = settings.filter.resonance * 30; + instance.filter.type = settings.filter.type; + } + + if (instance.lfo && settings.lfo.enabled) { + instance.lfo.frequency.value = settings.lfo.rateHz; + } + + const outputLevel = settings.outputGain !== 0 + ? Math.pow(10, settings.outputGain / 20) + : 0.55; + instance.output.gain.value = outputLevel; + + instance.settings = { ...settings }; + } + + private _applyParameter(instance: SubtractiveInstance, name: string, value: number | string | boolean) { + switch (name) { + case 'oscillator.waveform': + instance.synth.set({ oscillator: { type: value as OscillatorType } }); + break; + case 'oscillator.detuneCents': + instance.synth.set({ detune: value as number }); + break; + case 'ampEnvelope.attack': + instance.synth.set({ envelope: { attack: value as number } }); + break; + case 'ampEnvelope.decay': + instance.synth.set({ envelope: { decay: value as number } }); + break; + case 'ampEnvelope.sustain': + instance.synth.set({ envelope: { sustain: value as number } }); + break; + case 'ampEnvelope.release': + instance.synth.set({ envelope: { release: value as number } }); + break; + case 'filter.cutoffHz': + if (instance.filter) instance.filter.frequency.value = value as number; + break; + case 'filter.resonance': + if (instance.filter) instance.filter.Q.value = (value as number) * 30; + break; + case 'lfo.rateHz': + if (instance.lfo) instance.lfo.frequency.value = value as number; + break; + case 'lfo.depth': + if (instance.lfo) { + instance.lfo.min = -(value as number); + instance.lfo.max = value as number; + } + break; + case 'outputGain': { + const level = (value as number) !== 0 + ? Math.pow(10, (value as number) / 20) + : 0.55; + instance.output.gain.value = level; + break; + } + case 'glideTime': + instance.synth.set({ portamento: value as number }); + break; + } + } + + private _disposeInstance(instance: SubtractiveInstance) { + instance.synth.releaseAll(); + instance.synth.dispose(); + instance.lfo?.stop(); + instance.lfo?.dispose(); + instance.filter?.dispose(); + instance.output.dispose(); + } +} + +export const subtractiveEngine = new SubtractiveEngine(); diff --git a/src/engine/__tests__/SubtractiveEngine.test.ts b/src/engine/__tests__/SubtractiveEngine.test.ts new file mode 100644 index 00000000..6f9adb84 --- /dev/null +++ b/src/engine/__tests__/SubtractiveEngine.test.ts @@ -0,0 +1,386 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Create factory functions for mock instances +function createMockSynth() { + return { + set: vi.fn(), + connect: vi.fn(), + triggerAttack: vi.fn(), + triggerRelease: vi.fn(), + triggerAttackRelease: vi.fn(), + releaseAll: vi.fn(), + dispose: vi.fn(), + }; +} + +function createMockFilter() { + return { + connect: vi.fn(), + dispose: vi.fn(), + frequency: { value: 1000 }, + Q: { value: 0 }, + type: 'lowpass' as string, + }; +} + +function createMockLFO() { + return { + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + dispose: vi.fn(), + frequency: { value: 5 }, + min: 0, + max: 1, + }; +} + +function createMockGain() { + return { + connect: vi.fn(), + toDestination: vi.fn(), + dispose: vi.fn(), + gain: { value: 1 }, + }; +} + +// Track created instances for assertions +let lastCreatedSynth: ReturnType; +let lastCreatedFilter: ReturnType; +let lastCreatedLFO: ReturnType; +const polySynthCalls: unknown[][] = []; + +vi.mock('tone', () => { + // PolySynth must be a proper constructor + function MockPolySynth(...args: unknown[]) { + polySynthCalls.push(args); + lastCreatedSynth = createMockSynth(); + return lastCreatedSynth; + } + function MockSynth() {} + + return { + PolySynth: MockPolySynth, + Synth: MockSynth, + Filter: function MockFilter() { + lastCreatedFilter = createMockFilter(); + return lastCreatedFilter; + }, + LFO: function MockLFO() { + lastCreatedLFO = createMockLFO(); + return lastCreatedLFO; + }, + Gain: function MockGain() { + return createMockGain(); + }, + Frequency: vi.fn((pitch: number, _type: string) => ({ + toFrequency: () => 440 * Math.pow(2, (pitch - 69) / 12), + })), + getContext: vi.fn(() => ({ + state: 'running', + })), + getDestination: vi.fn(() => ({})), + start: vi.fn(), + now: vi.fn(() => 0), + }; +}); + +import type { SubtractiveInstrumentSettings } from '../../types/project'; + +function makeSettings(overrides?: Partial): SubtractiveInstrumentSettings { + return { + oscillator: { waveform: 'sawtooth', octave: 0, detuneCents: 0, level: 0.9 }, + ampEnvelope: { attack: 0.01, decay: 0.2, sustain: 0.7, release: 0.5 }, + filter: { enabled: false, type: 'lowpass', cutoffHz: 5000, resonance: 0.2, drive: 0, keyTracking: 0 }, + filterEnvelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 0.5, amount: 0 }, + lfo: { enabled: false, waveform: 'sine', target: 'off', rateHz: 5, depth: 0, retrigger: true }, + unison: { voices: 1, detuneCents: 0, stereoSpread: 0, blend: 1 }, + glideTime: 0, + outputGain: 0, + ...overrides, + }; +} + +// We need a fresh engine for each test — the module is a singleton +// so we dynamically import it +async function createFreshEngine() { + // Clear module cache by appending a query param trick won't work in vitest + // Instead, just use the exported singleton and manually dispose/reset between tests + const mod = await import('../SubtractiveEngine'); + mod.subtractiveEngine.dispose(); + return mod.subtractiveEngine; +} + +describe('SubtractiveEngine', () => { + let engine: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + polySynthCalls.length = 0; + engine = await createFreshEngine(); + }); + + describe('ensureTrackSynth', () => { + it('creates a new instance for a track', () => { + const settings = makeSettings(); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance).toBeDefined(); + expect(instance.synth).toBeDefined(); + expect(instance.output).toBeDefined(); + expect(instance.settings.oscillator.waveform).toBe('sawtooth'); + }); + + it('returns existing instance and updates settings on second call', () => { + const settings = makeSettings(); + const first = engine.ensureTrackSynth('track-1', settings); + const updated = makeSettings({ glideTime: 0.1 }); + const second = engine.ensureTrackSynth('track-1', updated); + expect(second).toBe(first); + expect(second.settings.glideTime).toBe(0.1); + }); + + it('creates filter when filter.enabled is true', () => { + const settings = makeSettings({ + filter: { enabled: true, type: 'lowpass', cutoffHz: 2000, resonance: 0.5, drive: 0, keyTracking: 0 }, + }); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance.filter).not.toBeNull(); + }); + + it('does not create filter when filter.enabled is false', () => { + const settings = makeSettings(); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance.filter).toBeNull(); + }); + + it('creates LFO for amp target', () => { + const settings = makeSettings({ + lfo: { enabled: true, waveform: 'sine', target: 'amp', rateHz: 4, depth: 0.3, retrigger: true }, + }); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance.lfo).not.toBeNull(); + }); + + it('creates LFO for filterCutoff target when filter enabled', () => { + const settings = makeSettings({ + filter: { enabled: true, type: 'lowpass', cutoffHz: 5000, resonance: 0.2, drive: 0, keyTracking: 0 }, + lfo: { enabled: true, waveform: 'sine', target: 'filterCutoff', rateHz: 2, depth: 0.5, retrigger: true }, + }); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance.lfo).not.toBeNull(); + }); + + it('does not create LFO when disabled', () => { + const settings = makeSettings(); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance.lfo).toBeNull(); + }); + + it('does not create LFO when depth is 0', () => { + const settings = makeSettings({ + lfo: { enabled: true, waveform: 'sine', target: 'amp', rateHz: 4, depth: 0, retrigger: true }, + }); + const instance = engine.ensureTrackSynth('track-1', settings); + expect(instance.lfo).toBeNull(); + }); + }); + + describe('note triggering', () => { + it('triggerAttackRelease calls synth with computed frequency', () => { + const settings = makeSettings(); + engine.ensureTrackSynth('track-1', settings); + engine.triggerAttackRelease('track-1', 60, 0.5, 0.8); + // C4 = MIDI 60 → freq ≈ 261.6 Hz + const expectedFreq = 440 * Math.pow(2, (60 - 69) / 12); + expect(lastCreatedSynth.triggerAttackRelease).toHaveBeenCalledWith( + expectedFreq, 0.5, undefined, 0.8, + ); + }); + + it('noteOn calls synth triggerAttack with velocity / 127', () => { + const settings = makeSettings(); + engine.ensureTrackSynth('track-1', settings); + engine.noteOn('track-1', 60, 127); + const expectedFreq = 440 * Math.pow(2, (60 - 69) / 12); + expect(lastCreatedSynth.triggerAttack).toHaveBeenCalledWith( + expectedFreq, undefined, 1, // 127/127 + ); + }); + + it('noteOff calls synth triggerRelease', () => { + const settings = makeSettings(); + engine.ensureTrackSynth('track-1', settings); + engine.noteOff('track-1', 60); + const expectedFreq = 440 * Math.pow(2, (60 - 69) / 12); + expect(lastCreatedSynth.triggerRelease).toHaveBeenCalledWith(expectedFreq); + }); + + it('does nothing for nonexistent track', () => { + engine.triggerAttackRelease('nonexistent', 60, 0.5, 0.8); + engine.noteOn('nonexistent', 60, 100); + engine.noteOff('nonexistent', 60); + // No error thrown + }); + }); + + describe('octave offset', () => { + it('shifts pitch by octave offset (octave -1 means MIDI note - 12)', () => { + const settings = makeSettings({ + oscillator: { waveform: 'sawtooth', octave: -1, detuneCents: 0, level: 0.9 }, + }); + engine.ensureTrackSynth('track-1', settings); + engine.triggerAttackRelease('track-1', 60, 0.5, 0.8); + // MIDI 60 with octave -1 → effectively MIDI 48 + const expectedFreq = 440 * Math.pow(2, (48 - 69) / 12); + expect(lastCreatedSynth.triggerAttackRelease).toHaveBeenCalledWith( + expectedFreq, 0.5, undefined, 0.8, + ); + }); + + it('shifts pitch up with positive octave', () => { + const settings = makeSettings({ + oscillator: { waveform: 'sine', octave: 1, detuneCents: 0, level: 0.9 }, + }); + engine.ensureTrackSynth('track-1', settings); + engine.triggerAttackRelease('track-1', 60, 0.5, 0.8); + const expectedFreq = 440 * Math.pow(2, (72 - 69) / 12); + expect(lastCreatedSynth.triggerAttackRelease).toHaveBeenCalledWith( + expectedFreq, 0.5, undefined, 0.8, + ); + }); + }); + + describe('setParameter', () => { + it('updates oscillator waveform', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + engine.setParameter('track-1', 'oscillator.waveform', 'square'); + expect(lastCreatedSynth.set).toHaveBeenCalledWith( + expect.objectContaining({ oscillator: { type: 'square' } }), + ); + }); + + it('updates amp envelope attack', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + engine.setParameter('track-1', 'ampEnvelope.attack', 0.1); + expect(lastCreatedSynth.set).toHaveBeenCalledWith( + expect.objectContaining({ envelope: { attack: 0.1 } }), + ); + }); + + it('updates filter cutoff', () => { + const settings = makeSettings({ + filter: { enabled: true, type: 'lowpass', cutoffHz: 5000, resonance: 0.2, drive: 0, keyTracking: 0 }, + }); + engine.ensureTrackSynth('track-1', settings); + engine.setParameter('track-1', 'filter.cutoffHz', 3000); + expect(lastCreatedFilter.frequency.value).toBe(3000); + }); + + it('updates filter resonance', () => { + const settings = makeSettings({ + filter: { enabled: true, type: 'lowpass', cutoffHz: 5000, resonance: 0.2, drive: 0, keyTracking: 0 }, + }); + engine.ensureTrackSynth('track-1', settings); + engine.setParameter('track-1', 'filter.resonance', 0.7); + expect(lastCreatedFilter.Q.value).toBe(21); // 0.7 * 30 + }); + + it('updates glide time via portamento', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + engine.setParameter('track-1', 'glideTime', 0.05); + expect(lastCreatedSynth.set).toHaveBeenCalledWith({ portamento: 0.05 }); + }); + + it('updates output gain from dB', () => { + const instance = engine.ensureTrackSynth('track-1', makeSettings()); + engine.setParameter('track-1', 'outputGain', -6); + // -6 dB → ~0.501 + expect(instance.output.gain.value).toBeCloseTo(Math.pow(10, -6 / 20), 2); + }); + + it('does nothing for nonexistent track', () => { + engine.setParameter('nonexistent', 'oscillator.waveform', 'square'); + // No error + }); + }); + + describe('getSynth', () => { + it('returns synth for existing track', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + expect(engine.getSynth('track-1')).toBe(lastCreatedSynth); + }); + + it('returns null for nonexistent track', () => { + expect(engine.getSynth('nonexistent')).toBeNull(); + }); + }); + + describe('releaseAll', () => { + it('calls releaseAll on all instances', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + const synth1 = lastCreatedSynth; + engine.ensureTrackSynth('track-2', makeSettings()); + const synth2 = lastCreatedSynth; + + engine.releaseAll(); + expect(synth1.releaseAll).toHaveBeenCalled(); + expect(synth2.releaseAll).toHaveBeenCalled(); + }); + }); + + describe('removeTrackSynth', () => { + it('disposes and removes the instance', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + const synth = lastCreatedSynth; + + engine.removeTrackSynth('track-1'); + expect(synth.releaseAll).toHaveBeenCalled(); + expect(synth.dispose).toHaveBeenCalled(); + expect(engine.getSynth('track-1')).toBeNull(); + }); + + it('does nothing for nonexistent track', () => { + engine.removeTrackSynth('nonexistent'); + // No error + }); + }); + + describe('dispose', () => { + it('disposes all track instances', () => { + engine.ensureTrackSynth('track-1', makeSettings()); + const synth1 = lastCreatedSynth; + engine.ensureTrackSynth('track-2', makeSettings()); + const synth2 = lastCreatedSynth; + + engine.dispose(); + expect(synth1.dispose).toHaveBeenCalled(); + expect(synth2.dispose).toHaveBeenCalled(); + }); + }); + + describe('playSlideNote', () => { + it('uses glideTime from settings when > 0', () => { + const settings = makeSettings({ glideTime: 0.08 }); + engine.ensureTrackSynth('track-1', settings); + engine.playSlideNote('track-1', 60, 64, 100, 0.5); + expect(lastCreatedSynth.set).toHaveBeenCalledWith( + expect.objectContaining({ portamento: 0.08 }), + ); + }); + + it('computes auto glide time when glideTime is 0', () => { + const settings = makeSettings({ glideTime: 0 }); + engine.ensureTrackSynth('track-1', settings); + engine.playSlideNote('track-1', 60, 64, 100, 0.5); + // Auto: max(0.03, min(0.12, 0.5 * 0.35)) = 0.175 → clamped to 0.12 + expect(lastCreatedSynth.set).toHaveBeenCalledWith( + expect.objectContaining({ portamento: 0.12 }), + ); + }); + + it('does nothing for nonexistent track', () => { + engine.playSlideNote('nonexistent', 60, 64, 100, 0.5); + // No error + }); + }); +}); diff --git a/src/hooks/useTransport.ts b/src/hooks/useTransport.ts index 9eeecc20..210145b0 100644 --- a/src/hooks/useTransport.ts +++ b/src/hooks/useTransport.ts @@ -6,6 +6,7 @@ import { useUIStore } from '../store/uiStore'; import { getAudioEngine } from './useAudioEngine'; import { loadAudioBlobByKey } from '../services/audioFileManager'; import { synthEngine } from '../engine/SynthEngine'; +import { subtractiveEngine } from '../engine/SubtractiveEngine'; import { createSamplerConfig, samplerEngine } from '../engine/SamplerEngine'; import { drumEngine } from '../engine/DrumEngine'; import { automationEngine } from '../engine/AutomationEngine'; @@ -153,6 +154,7 @@ export function useTransport() { stopAllStrudelTracks(); await engine.resume(); await synthEngine.ensureStarted(); + await subtractiveEngine.ensureStarted(); await samplerEngine.ensureStarted(); await drumEngine.ensureStarted(); @@ -372,6 +374,7 @@ export function useTransport() { const useSampler = !vst3Instrument && !!samplerConfig; synthEngine.removeTrackSynth(track.id); + subtractiveEngine.removeTrackSynth(track.id); samplerEngine.removeTrackSampler(track.id); if (useSampler && samplerConfig) { @@ -384,6 +387,13 @@ export function useTransport() { trackNode.inputGain as unknown as Tone.InputNode, ); } + } else if (!vst3Instrument && track.instrument?.kind === 'subtractive') { + const trackNode = engine.getOrCreateTrackNode(track.id); + subtractiveEngine.ensureTrackSynth( + track.id, + track.instrument.settings, + trackNode.inputGain as unknown as Tone.InputNode, + ); } else if (preset !== 'sampler') { synthEngine.ensureTrackSynth(track.id, preset); } @@ -455,6 +465,26 @@ export function useTransport() { engine.scheduleMidiEvent(scheduledStart, () => { samplerEngine.triggerAttackRelease(trackId, note.pitch, scheduledDuration, velocity); }); + } else if (track.instrument?.kind === 'subtractive') { + const previousOverlap = note.isSlide + ? [...notes] + .slice(0, noteIndex) + .reverse() + .find((candidate) => candidate.startBeat + candidate.durationBeats >= note.startBeat) + : undefined; + engine.scheduleMidiEvent(scheduledStart, () => { + if (previousOverlap) { + subtractiveEngine.playSlideNote( + trackId, + previousOverlap.pitch, + note.pitch, + Math.max(1, Math.round(velocity * 127)), + scheduledDuration, + ); + return; + } + subtractiveEngine.triggerAttackRelease(trackId, note.pitch, scheduledDuration, velocity); + }); } else { const freq = Tone.Frequency(note.pitch, 'midi').toFrequency(); const previousOverlap = note.isSlide @@ -614,6 +644,7 @@ export function useTransport() { stopAllStrudelTracks(); engine.stop(); synthEngine.releaseAll(); + subtractiveEngine.releaseAll(); samplerEngine.stopAll(); automationEngine.stop(); useTransportStore.getState().pause(); @@ -631,6 +662,7 @@ export function useTransport() { stopStrudelEditorPlayback(); engine.stop(); synthEngine.releaseAll(); + subtractiveEngine.releaseAll(); samplerEngine.stopAll(); automationEngine.stop(); stopAllStrudelTracks();