Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/engine/InstrumentEngine.ts
Original file line number Diff line number Diff line change
@@ -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). */
Comment on lines +7 to +15
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InstrumentEngine doesn’t specify the expected velocity range (MIDI 0–127 vs normalized 0–1), but existing engines use both conventions (SynthEngine.noteOn treats velocity as 0–127, while sampler playback often uses 0–1). Please document a single convention in this interface and ensure all adapters/engines convert to it consistently to avoid very quiet notes or extremely loud sampler playback.

Suggested change
*/
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). */
*
* Velocity convention:
* - All methods on this interface that take a `velocity` argument expect a
* normalized value in the range [0, 1], where 0 is silent and 1 is the
* maximum playing intensity supported by the engine.
* - Adapters or callers that receive MIDI velocities in the range [0, 127]
* must convert them to this normalized range before calling into an
* `InstrumentEngine` implementation (e.g. `normalized = midi / 127`).
* - Individual engines are free to internally map this normalized value to
* whatever gain/velocity model they use, but they must treat the input as
* already normalized.
*/
export interface InstrumentEngine {
/**
* Trigger note-on for a track (for live playing / recording).
*
* @param trackId Identifier of the track the note belongs to.
* @param pitch Pitch of the note (MIDI note number or engine-specific).
* @param velocity Normalized velocity in the range [0, 1], where 0 is
* silent and 1 is maximum intensity. Callers passing MIDI
* velocities [0, 127] must convert to this range first.
*/
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).
*
* @param trackId Identifier of the track the note belongs to.
* @param pitch Pitch of the note (MIDI note number or engine-specific).
* @param duration Duration of the note in seconds (or engine-specific units).
* @param velocity Normalized velocity in the range [0, 1], where 0 is
* silent and 1 is maximum intensity. Adapters using MIDI
* velocities [0, 127] must normalize before calling this.
*/

Copilot uses AI. Check for mistakes.
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;
}
138 changes: 138 additions & 0 deletions src/engine/InstrumentFactory.ts
Original file line number Diff line number Diff line change
@@ -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');
Comment on lines +20 to +21
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SynthEngineAdapter.triggerAttackRelease hardcodes the preset to 'piano', which means sequenced playback will ignore the track’s actual SubtractiveTrackInstrument.preset (and can also overwrite an already-configured synth for the track via ensureTrackSynth). Consider deriving the preset from per-track configuration (e.g., via setParameter('preset', ...)), or avoid calling playNote (which requires a preset) and instead trigger on the already-ensured synth instance for the track.

Suggested change
// Delegate to the singleton which handles MIDI-to-freq conversion and preset routing.
void synthEngine.playNote(trackId, pitch, velocity, duration, 'piano');
// Use the already-configured synth for this track instead of forcing a specific preset.
this.noteOn(trackId, pitch, velocity);
setTimeout(() => {
this.noteOff(trackId, pitch);
}, duration);

Copilot uses AI. Check for mistakes.
}

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);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SamplerEngineAdapter.triggerAttackRelease forwards velocity directly, but SamplerEngine.triggerAttackRelease appears to expect a normalized 0–1 velocity (call sites clamp note.velocity to 0–1, and noteOn converts MIDI 0–127 to 0–1). If a consumer uses MIDI velocity (0–127) like SynthEngineAdapter does, this will drive the sampler gain envelope far above 1. Please standardize the velocity scale for InstrumentEngine and convert here accordingly.

Suggested change
samplerEngine.triggerAttackRelease(trackId, pitch, duration, velocity);
// InstrumentEngine uses MIDI velocity (0–127); SamplerEngine.triggerAttackRelease expects 0–1.
const clampedVelocity = Math.max(0, Math.min(127, velocity));
const normalizedVelocity = clampedVelocity / 127;
samplerEngine.triggerAttackRelease(trackId, pitch, duration, normalizedVelocity);

Copilot uses AI. Check for mistakes.
}

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.
Comment on lines +59 to +61
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SamplerEngineAdapter.setParameter is a no-op even though SamplerEngine now defines a setParameter method. Delegating to samplerEngine.setParameter(...) would keep parameter-handling behavior in one place and prevents the adapter and engine from drifting as real-time automation gets implemented.

Suggested change
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.
setParameter(trackId: string, name: string, value: number | string | boolean): void {
samplerEngine.setParameter(trackId, name, value);

Copilot uses AI. Check for mistakes.
}

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.
Comment on lines +81 to +82
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FM adapter’s doc says kind: 'fm' tracks fall back to the subtractive synth using FmTrackInstrument.fallbackPreset, but the implementation never receives or applies fallbackPreset (it always delegates to the subtractive adapter behavior). Either remove/adjust this documentation or add a mechanism to pass/apply fallbackPreset (e.g., via a standardized setParameter name).

Suggested change
* `kind: 'fm'` fall back to the subtractive synth using {@link FmTrackInstrument.fallbackPreset}.
* Once a dedicated FM engine exists this adapter will delegate to it.
* `kind: 'fm'` currently fall back to the subtractive synth via
* {@link SynthEngineAdapter}. FM-specific parameters (including any
* fallback preset) are not yet applied here. Once a dedicated FM engine
* exists this adapter will delegate to it.

Copilot uses AI. Check for mistakes.
*/
class FmEngineAdapter implements InstrumentEngine {
private readonly fallback = new SynthEngineAdapter();

Comment on lines +84 to +86
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FmEngineAdapter creates a new SynthEngineAdapter instance for fallback even though the factory already maintains a singleton subtractiveAdapter. Reusing the existing subtractive adapter avoids duplicated wrapper instances and reduces the risk of divergent behavior if the adapter later gains per-track state/configuration.

Copilot uses AI. Check for mistakes.
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 {

Check failure on line 126 in src/engine/InstrumentFactory.ts

View workflow job for this annotation

GitHub Actions / type-check

Function lacks ending return statement and return type does not include 'undefined'.
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 };
11 changes: 11 additions & 0 deletions src/engine/SamplerEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
8 changes: 8 additions & 0 deletions src/engine/SynthEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<string, SynthInstance>();
private fmSynths = new Map<string, FmSynthInstance>();
Expand Down
118 changes: 118 additions & 0 deletions src/engine/__tests__/InstrumentFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading