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
340 changes: 340 additions & 0 deletions src/engine/SubtractiveEngine.ts
Original file line number Diff line number Diff line change
@@ -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<string, SubtractiveInstance>();
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);
}

Comment on lines +50 to +53
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.

setParameter() applies changes to Tone nodes but does not update instance.settings. Methods like _pitchToFreq() (octave), playSlideNote() (glideTime), and any future logic reading instance.settings will keep using stale values after real-time parameter updates. Update instance.settings in sync with setParameter() (or avoid relying on instance.settings after creation).

Suggested change
if (!instance) return;
this._applyParameter(instance, name, value);
}
if (!instance) return;
// Keep instance.settings in sync with real-time parameter changes
this._updateInstanceSettingsForParameter(instance, name, value);
this._applyParameter(instance, name, value);
}
/**
* Update the in-memory settings object to reflect a parameter change.
* Supports both top-level keys (e.g. "glideTime") and dotted paths
* (e.g. "oscillator.octave").
*/
private _updateInstanceSettingsForParameter(
instance: SubtractiveInstance,
name: string,
value: number | string | boolean,
): void {
const settings: any = instance.settings;
if (!settings) return;
// Direct top-level property
if (Object.prototype.hasOwnProperty.call(settings, name)) {
(settings as any)[name] = value as any;
return;
}
// Nested property via dotted path (e.g. "oscillator.octave")
if (name.indexOf('.') === -1) {
return;
}
const parts = name.split('.');
let current: any = settings;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i];
if (
current == null ||
typeof current !== 'object' ||
!Object.prototype.hasOwnProperty.call(current, key)
) {
// Path does not exist in settings; do not create new structure.
return;
}
current = current[key];
}
const lastKey = parts[parts.length - 1];
if (
current != null &&
typeof current === 'object' &&
Object.prototype.hasOwnProperty.call(current, lastKey)
) {
current[lastKey] = value as any;
}
}

Copilot uses AI. Check for mistakes.
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<string, unknown>) => 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);
}
Comment on lines +85 to +102
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.

playSlideNote() sets portamento to an auto-computed glide time when settings.glideTime is 0, but never restores it afterward. That means subsequent non-slide notes will unexpectedly glide. Consider restoring portamento to settings.glideTime after scheduling the slide (or set it per-event without mutating the shared synth state).

Copilot uses AI. Check for mistakes.

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();
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.

previewNote() creates the preview instance via _createInstance(settings) which already routes output to destination when connectTo is undefined, then calls this.previewInstance.output.toDestination() again. This can create duplicate connections (and increase volume) each preview; remove the extra toDestination() call or change _createInstance to not auto-connect for preview instances.

Suggested change
this.previewInstance.output.toDestination();

Copilot uses AI. Check for mistakes.
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);
Comment on lines +154 to +158
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.

Several fields in SubtractiveInstrumentSettings are currently ignored (e.g. filterEnvelope, filter.drive, filter.keyTracking, lfo.retrigger, LFO targets pitch/pan, and most unison fields like voices/stereoSpread/blend). This conflicts with the PR description / Issue #1020 acceptance criteria that runtime support exists for all defined parameters; either implement the missing mappings or update the scope/docs/types to match what is actually supported.

Copilot uses AI. Check for mistakes.
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 });
}

Comment on lines +260 to +263
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.

_updateSettings() only applies detune when the new detuneValue !== 0, but never resets detune back to 0 when settings change to remove detune (same issue for oscillator.level/volume, which is set in _createInstance() but not updated here). This can leave stale synth state after settings updates; consider always setting these fields (or explicitly resetting them when the new value is neutral).

Suggested change
if (detuneValue !== 0) {
instance.synth.set({ detune: detuneValue });
}
instance.synth.set({ detune: detuneValue });

Copilot uses AI. Check for mistakes.
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;
Comment on lines +264 to +271
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.

_updateSettings() only updates the filter/LFO if they already exist; if a track instance was created with filter.enabled === false (or LFO disabled) and settings later enable them, the nodes are never created (and disabling never tears them down). Consider recreating the instance (or rebuilding the signal chain) when filter.enabled / lfo config changes so ensureTrackSynth() actually reflects the new settings.

Suggested change
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;
// Ensure the filter node and routing reflect the current settings.
if (settings.filter.enabled) {
if (!instance.filter) {
// Create and insert a new filter between the synth and the output.
const filter = new Tone.Filter({
frequency: settings.filter.cutoffHz,
type: settings.filter.type,
Q: settings.filter.resonance * 30,
});
// Rewire: synth -> filter -> output.
instance.synth.disconnect();
instance.synth.connect(filter);
filter.connect(instance.output);
instance.filter = filter;
// If an LFO already exists, retarget it to the new filter frequency.
if (instance.lfo) {
instance.lfo.disconnect();
instance.lfo.connect(filter.frequency);
}
} else {
instance.filter.frequency.value = settings.filter.cutoffHz;
instance.filter.Q.value = settings.filter.resonance * 30;
instance.filter.type = settings.filter.type;
}
} else if (instance.filter) {
// Disable and remove the filter from the signal chain.
instance.synth.disconnect();
instance.filter.disconnect();
instance.filter.dispose();
instance.filter = null;
// Reconnect synth directly to output.
instance.synth.connect(instance.output);
}
// Ensure the LFO node and modulation reflect the current settings.
if (settings.lfo.enabled) {
const depth = settings.lfo.depth;
if (!instance.lfo) {
const lfo = new Tone.LFO(settings.lfo.rateHz, -depth, depth);
if (instance.filter) {
lfo.connect(instance.filter.frequency);
}
lfo.start();
instance.lfo = lfo;
} else {
instance.lfo.frequency.value = settings.lfo.rateHz;
instance.lfo.min = -depth;
instance.lfo.max = depth;
}
} else if (instance.lfo) {
instance.lfo.stop();
instance.lfo.disconnect();
instance.lfo.dispose();
instance.lfo = null;

Copilot uses AI. Check for mistakes.
}

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;
Comment on lines +313 to +314
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.

setParameter('lfo.depth', ...) sets lfo.min/max to ±depth, but the LFO is connected either to output.gain or filter.frequency, where the expected min/max ranges are not centered on 0. This can drive gain negative or push filter cutoff to invalid ranges. Update the depth handling to recompute min/max based on the current base value and target (similar to _createInstance()’s min/max calculations).

Suggested change
instance.lfo.min = -(value as number);
instance.lfo.max = value as number;
const depth = value as number;
const settingsAny = instance.settings as any;
const lfoTarget = settingsAny?.lfoTarget;
if (lfoTarget === 'amp') {
// LFO targets output gain: modulate around current gain, clamp to >= 0
const baseGain = instance.output.gain.value;
const minGain = Math.max(0, baseGain - depth);
const maxGain = baseGain + depth;
instance.lfo.min = minGain;
instance.lfo.max = maxGain;
} else if (lfoTarget === 'filter' && instance.filter) {
// LFO targets filter cutoff: modulate around current frequency within a safe audio range
const baseFreq = instance.filter.frequency.value;
const context = Tone.getContext();
const nyquist = context.sampleRate / 2;
const minFreq = Math.max(20, baseFreq - depth);
const maxFreq = Math.min(nyquist, baseFreq + depth);
instance.lfo.min = minFreq;
instance.lfo.max = maxFreq;
} else {
// Fallback: keep previous behavior but avoid negative values
instance.lfo.min = 0;
instance.lfo.max = Math.max(0, depth);
}

Copilot uses AI. Check for mistakes.
}
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();
Loading
Loading