diff --git a/src/engine/InstrumentEngine.ts b/src/engine/InstrumentEngine.ts new file mode 100644 index 00000000..8211d417 --- /dev/null +++ b/src/engine/InstrumentEngine.ts @@ -0,0 +1,34 @@ +/** + * Unified interface for all instrument engines (subtractive synth, sampler, FM). + * + * Every instrument engine implementation must conform to this interface so that + * playback, recording, and automation code can operate on any instrument kind + * without branching on the concrete type. + */ +export interface InstrumentEngine { + /** Trigger note-on for a track (for live playing / recording). */ + noteOn(trackId: string, pitch: number, velocity: number): void; + + /** Trigger note-off for a track. */ + noteOff(trackId: string, pitch: number): void; + + /** Play a note with a fixed duration (for sequenced playback). */ + triggerAttackRelease(trackId: string, pitch: number, duration: number, velocity: number): void; + + /** + * Set an engine-specific parameter by name. + * + * This is a generic escape hatch for automation and preset changes. + * Implementations may ignore unknown parameter names. + */ + setParameter(trackId: string, name: string, value: number | string | boolean): void; + + /** Release all currently sounding notes across every track. */ + releaseAll(): void; + + /** Tear down resources associated with a single track. */ + removeTrack(trackId: string): void; + + /** Dispose the entire engine and release all resources. */ + dispose(): void; +} diff --git a/src/engine/InstrumentFactory.ts b/src/engine/InstrumentFactory.ts new file mode 100644 index 00000000..b47c9785 --- /dev/null +++ b/src/engine/InstrumentFactory.ts @@ -0,0 +1,138 @@ +import type { TrackInstrument } from '../types/project'; +import type { InstrumentEngine } from './InstrumentEngine'; +import { synthEngine } from './SynthEngine'; +import { samplerEngine } from './SamplerEngine'; + +/** + * Adapter that wraps the legacy {@link SynthEngine} singleton to conform to + * the {@link InstrumentEngine} interface. + */ +class SynthEngineAdapter implements InstrumentEngine { + noteOn(trackId: string, pitch: number, velocity: number): void { + synthEngine.noteOn(trackId, pitch, velocity); + } + + noteOff(trackId: string, pitch: number): void { + synthEngine.noteOff(trackId, pitch); + } + + triggerAttackRelease(trackId: string, pitch: number, duration: number, velocity: number): void { + // Delegate to the singleton which handles MIDI-to-freq conversion and preset routing. + void synthEngine.playNote(trackId, pitch, velocity, duration, 'piano'); + } + + setParameter(_trackId: string, _name: string, _value: number | string | boolean): void { + // Subtractive synth parameters are not yet dynamically settable. + // This will be wired up when the synth UI gets real-time param control. + } + + releaseAll(): void { + synthEngine.releaseAll(); + } + + removeTrack(trackId: string): void { + synthEngine.removeTrackSynth(trackId); + } + + dispose(): void { + synthEngine.dispose(); + } +} + +/** + * Adapter that wraps the {@link SamplerEngine} singleton to conform to + * the {@link InstrumentEngine} interface. + */ +class SamplerEngineAdapter implements InstrumentEngine { + noteOn(trackId: string, pitch: number, velocity: number): void { + samplerEngine.noteOn(trackId, pitch, velocity); + } + + noteOff(trackId: string, pitch: number): void { + samplerEngine.noteOff(trackId, pitch); + } + + triggerAttackRelease(trackId: string, pitch: number, duration: number, velocity: number): void { + samplerEngine.triggerAttackRelease(trackId, pitch, duration, velocity); + } + + setParameter(_trackId: string, _name: string, _value: number | string | boolean): void { + // Sampler parameters (ADSR, root note, etc.) are set via ensureTrackSampler config. + // A future PR will wire individual param updates here. + } + + releaseAll(): void { + samplerEngine.releaseAll(); + } + + removeTrack(trackId: string): void { + samplerEngine.removeTrack(trackId); + } + + dispose(): void { + samplerEngine.dispose(); + } +} + +/** + * Stub adapter for FM synthesis. + * + * FM synthesis is not yet implemented as a standalone engine — tracks with + * `kind: 'fm'` fall back to the subtractive synth using {@link FmTrackInstrument.fallbackPreset}. + * Once a dedicated FM engine exists this adapter will delegate to it. + */ +class FmEngineAdapter implements InstrumentEngine { + private readonly fallback = new SynthEngineAdapter(); + + noteOn(trackId: string, pitch: number, velocity: number): void { + this.fallback.noteOn(trackId, pitch, velocity); + } + + noteOff(trackId: string, pitch: number): void { + this.fallback.noteOff(trackId, pitch); + } + + triggerAttackRelease(trackId: string, pitch: number, duration: number, velocity: number): void { + this.fallback.triggerAttackRelease(trackId, pitch, duration, velocity); + } + + setParameter(trackId: string, name: string, value: number | string | boolean): void { + this.fallback.setParameter(trackId, name, value); + } + + releaseAll(): void { + this.fallback.releaseAll(); + } + + removeTrack(trackId: string): void { + this.fallback.removeTrack(trackId); + } + + dispose(): void { + this.fallback.dispose(); + } +} + +// ── Singletons (one adapter per kind) ────────────────────────────────────── + +const subtractiveAdapter = new SynthEngineAdapter(); +const samplerAdapter = new SamplerEngineAdapter(); +const fmAdapter = new FmEngineAdapter(); + +/** + * Return the {@link InstrumentEngine} that should handle playback for the + * given track instrument descriptor. + */ +export function getEngineForInstrument(instrument: TrackInstrument): InstrumentEngine { + switch (instrument.kind) { + case 'subtractive': + return subtractiveAdapter; + case 'sampler': + return samplerAdapter; + case 'fm': + return fmAdapter; + } +} + +// Re-export the adapters for direct testing. +export { SynthEngineAdapter, SamplerEngineAdapter, FmEngineAdapter }; diff --git a/src/engine/SamplerEngine.ts b/src/engine/SamplerEngine.ts index 20d92f73..c3a50b4e 100644 --- a/src/engine/SamplerEngine.ts +++ b/src/engine/SamplerEngine.ts @@ -233,6 +233,17 @@ class SamplerEngine { this.removeTrackSampler(trackId); } + /** + * Set a named parameter on the sampler for a track. + * + * This is a stub that will be wired up when real-time parameter automation + * is added to the sampler engine. For now, parameter changes should go + * through {@link ensureTrackSampler} with an updated config. + */ + setParameter(_trackId: string, _name: string, _value: number | string | boolean): void { + // No-op stub — see InstrumentEngine interface. + } + stopAll() { this.releaseAll(); } diff --git a/src/engine/SynthEngine.ts b/src/engine/SynthEngine.ts index 3b5e75c2..293bdc4f 100644 --- a/src/engine/SynthEngine.ts +++ b/src/engine/SynthEngine.ts @@ -88,6 +88,10 @@ function fmParamsEqual(a: FmInstrumentSettings, b: FmInstrumentSettings): boolea ); } +/** + * @deprecated Use {@link InstrumentEngine} via {@link getEngineForInstrument} instead. + * This function will be removed once all call-sites migrate to the unified interface. + */ export function createSynthForPreset(preset: SynthPreset): Tone.PolySynth { const synth = new Tone.PolySynth(Tone.Synth); @@ -156,6 +160,10 @@ function computeUnisonOffsets( return offsets; } +/** + * @deprecated Use {@link InstrumentEngine} via {@link getEngineForInstrument} instead. + * This class will be removed once all call-sites migrate to the unified interface. + */ class SynthEngine { private synths = new Map(); private fmSynths = new Map(); diff --git a/src/engine/__tests__/InstrumentFactory.test.ts b/src/engine/__tests__/InstrumentFactory.test.ts new file mode 100644 index 00000000..a879bed2 --- /dev/null +++ b/src/engine/__tests__/InstrumentFactory.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { + getEngineForInstrument, + SynthEngineAdapter, + SamplerEngineAdapter, + FmEngineAdapter, +} from '../InstrumentFactory'; +import type { InstrumentEngine } from '../InstrumentEngine'; +import type { + SubtractiveTrackInstrument, + SamplerTrackInstrument, + FmTrackInstrument, +} from '../../types/project'; + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +const subtractiveInstrument: SubtractiveTrackInstrument = { + kind: 'subtractive', + preset: 'piano', + name: 'Test Piano', + settings: { + oscillator: { waveform: 'triangle', octave: 0, detuneCents: 0, level: 1 }, + ampEnvelope: { attack: 0.005, decay: 0.3, sustain: 0.2, release: 1.2 }, + filter: { enabled: false, type: 'lowpass', cutoffHz: 20000, resonance: 1, drive: 0, keyTracking: 0 }, + filterEnvelope: { attack: 0.01, decay: 0.1, sustain: 1, release: 0.3, amount: 0 }, + lfo: { enabled: false, waveform: 'sine', target: 'off', rateHz: 1, depth: 0, retrigger: false }, + unison: { voices: 1, detuneCents: 0, stereoSpread: 0, blend: 0 }, + glideTime: 0, + outputGain: 1, + }, +}; + +const samplerInstrument: SamplerTrackInstrument = { + kind: 'sampler', + preset: 'sampler', + name: 'Test Sampler', + settings: { + audioKey: 'test-key', + rootNote: 60, + trimStart: 0, + trimEnd: 1, + playbackMode: 'classic', + loopStart: 0, + loopEnd: 1, + ampEnvelope: { attack: 0.005, decay: 0.1, sustain: 1, release: 0.3 }, + }, +}; + +const fmInstrument: FmTrackInstrument = { + kind: 'fm', + preset: 'fm', + name: 'Test FM', + fallbackPreset: 'organ', + settings: { + carrier: { waveform: 'sine', ratio: 1, level: 1 }, + modulator: { waveform: 'sine', ratio: 2, level: 0.5 }, + modulationIndex: 5, + feedback: 0, + ampEnvelope: { attack: 0.01, decay: 0.1, sustain: 0.8, release: 0.3 }, + outputGain: 1, + }, +}; + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('getEngineForInstrument', () => { + it('returns a SynthEngineAdapter for subtractive instruments', () => { + const engine = getEngineForInstrument(subtractiveInstrument); + expect(engine).toBeInstanceOf(SynthEngineAdapter); + }); + + it('returns a SamplerEngineAdapter for sampler instruments', () => { + const engine = getEngineForInstrument(samplerInstrument); + expect(engine).toBeInstanceOf(SamplerEngineAdapter); + }); + + it('returns a FmEngineAdapter for fm instruments', () => { + const engine = getEngineForInstrument(fmInstrument); + expect(engine).toBeInstanceOf(FmEngineAdapter); + }); + + it('returns the same instance for repeated calls with the same kind', () => { + const a = getEngineForInstrument(subtractiveInstrument); + const b = getEngineForInstrument(subtractiveInstrument); + expect(a).toBe(b); + }); + + it('returns different instances for different instrument kinds', () => { + const synth = getEngineForInstrument(subtractiveInstrument); + const sampler = getEngineForInstrument(samplerInstrument); + const fm = getEngineForInstrument(fmInstrument); + expect(synth).not.toBe(sampler); + expect(synth).not.toBe(fm); + expect(sampler).not.toBe(fm); + }); +}); + +describe('InstrumentEngine interface conformance', () => { + const engines: Array<[string, InstrumentEngine]> = [ + ['SynthEngineAdapter', getEngineForInstrument(subtractiveInstrument)], + ['SamplerEngineAdapter', getEngineForInstrument(samplerInstrument)], + ['FmEngineAdapter', getEngineForInstrument(fmInstrument)], + ]; + + it.each(engines)('%s implements all InstrumentEngine methods', (_name, engine) => { + expect(typeof engine.noteOn).toBe('function'); + expect(typeof engine.noteOff).toBe('function'); + expect(typeof engine.triggerAttackRelease).toBe('function'); + expect(typeof engine.setParameter).toBe('function'); + expect(typeof engine.releaseAll).toBe('function'); + expect(typeof engine.removeTrack).toBe('function'); + expect(typeof engine.dispose).toBe('function'); + }); + + it.each(engines)('%s.setParameter does not throw for unknown params', (_name, engine) => { + expect(() => engine.setParameter('test-track', 'unknownParam', 42)).not.toThrow(); + }); +});