Skip to content
Closed
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
4 changes: 2 additions & 2 deletions src/components/pianoroll/SynthInstrumentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,8 @@ function renderFmEditor(
</SelectField>
<div className="rounded border border-white/8 bg-black/15 px-2 py-2 text-[10px] text-zinc-400">
<div className="uppercase tracking-[0.14em] text-zinc-500">Playback</div>
<div className="mt-1 text-zinc-200">FM parameter shell on canonical state</div>
<div className="mt-1 text-zinc-500">Current engine falls back to the selected legacy voice.</div>
<div className="mt-1 text-zinc-200">FM parameters drive the live playback voice</div>
<div className="mt-1 text-zinc-500">Fallback preset stays as compatibility metadata for legacy paths.</div>
</div>
</div>

Expand Down
171 changes: 152 additions & 19 deletions src/engine/SynthEngine.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as Tone from 'tone';
import type {
FmTrackInstrument,
LegacySynthVoicePreset,
SubtractiveTrackInstrument,
SynthPreset,
Expand All @@ -11,14 +12,33 @@ import {
} from '../utils/trackInstrument';

type SynthSource = TrackInstrument | SynthPreset;
type RuntimeInstrument = SubtractiveTrackInstrument | FmTrackInstrument;
type SynthVoiceType = 'mono' | 'fm';
const DEFAULT_TRACK_GAIN = 0.55;
const MIN_LINEAR_GAIN = 0.0001;
const MAX_FAT_SPREAD_CENTS = 120;

interface SynthInstance {
synth: Tone.PolySynth;
signature: string;
gain: Tone.Gain;
}

export interface SynthRuntimeSpec {
engine: 'subtractive' | 'fm';
voiceType: SynthVoiceType;
options: Record<string, unknown>;
gainLevel: number;
}

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

function linearGainToDb(value: number): number {
return 20 * Math.log10(Math.max(MIN_LINEAR_GAIN, value));
}

function toLegacySubtractivePreset(preset: SynthPreset): LegacySynthVoicePreset {
return preset === 'sampler' ? 'piano' : preset;
}
Expand All @@ -41,30 +61,143 @@ function getSynthSignature(source: SynthSource): string {
: `instrument:${JSON.stringify(source)}`;
}

function getTrackGainLevel(instrument: SubtractiveTrackInstrument, baseGain = DEFAULT_TRACK_GAIN): number {
function getTrackGainLevel(instrument: RuntimeInstrument, baseGain = DEFAULT_TRACK_GAIN): number {
const outputGainScale = Math.pow(10, instrument.settings.outputGain / 20);
return Math.max(0, Math.min(2, baseGain * outputGainScale));
}

function getFatOscillatorType(waveform: SubtractiveTrackInstrument['settings']['oscillator']['waveform']) {
return `fat${waveform}` as const;
}

function createSubtractiveRuntimeSpec(instrument: SubtractiveTrackInstrument): SynthRuntimeSpec {
const { oscillator, ampEnvelope, filter, filterEnvelope, unison, glideTime } = instrument.settings;
const unisonVoices = Math.max(1, Math.round(unison.voices));
const filterEnabled = filter.enabled;
const filterCutoff = filterEnabled ? clamp(filter.cutoffHz, 40, 18000) : 20000;
const filterAmount = clamp(filterEnvelope.amount, 0, 1);
const oscillatorType = unisonVoices > 1
? getFatOscillatorType(oscillator.waveform)
: oscillator.waveform;
const oscillatorOptions = unisonVoices > 1
? {
type: oscillatorType,
count: unisonVoices,
spread: clamp(unison.detuneCents + (unison.stereoSpread * 40), 1, MAX_FAT_SPREAD_CENTS),
}
: {
type: oscillatorType,
};
const filterBaseFrequency = filterEnabled
? Math.max(30, filterCutoff * Math.max(0.06, 1 - filterAmount))
: 20000;
const filterOctaves = filterEnabled
? clamp((filterAmount * 6) + (filter.keyTracking * 2), 0, 8)
: 0;
const voiceLevel = clamp(
oscillator.level * (0.6 + (unison.blend * 0.4)),
MIN_LINEAR_GAIN,
1.25,
);

return {
engine: 'subtractive',
voiceType: 'mono',
gainLevel: getTrackGainLevel(instrument),
options: {
oscillator: oscillatorOptions,
envelope: {
attack: ampEnvelope.attack,
decay: ampEnvelope.decay,
sustain: ampEnvelope.sustain,
release: ampEnvelope.release,
},
filter: {
type: filter.type,
frequency: filterCutoff,
Q: clamp(filter.resonance, 0.1, 20),
gain: clamp(filter.drive * 12, 0, 12),
},
filterEnvelope: {
attack: filterEnvelope.attack,
decay: filterEnvelope.decay,
sustain: filterEnvelope.sustain,
release: filterEnvelope.release,
baseFrequency: filterBaseFrequency,
octaves: filterOctaves,
exponent: 1 + (filter.drive * 2),
},
detune: (oscillator.octave * 1200) + oscillator.detuneCents,
portamento: glideTime,
volume: linearGainToDb(voiceLevel),
},
};
}

function createFmRuntimeSpec(instrument: FmTrackInstrument): SynthRuntimeSpec {
const { carrier, modulator, modulationIndex, feedback, ampEnvelope } = instrument.settings;
const harmonicity = clamp(modulator.ratio / Math.max(0.25, carrier.ratio), 0.25, 8);
const carrierDetune = 1200 * Math.log2(Math.max(0.25, carrier.ratio));
const effectiveModulationIndex = clamp(
(modulationIndex * (0.4 + (modulator.level * 0.9))) + (feedback * 2),
0,
20,
);

return {
engine: 'fm',
voiceType: 'fm',
gainLevel: getTrackGainLevel(instrument),
options: {
oscillator: {
type: carrier.waveform,
},
modulation: {
type: modulator.waveform,
},
envelope: {
attack: ampEnvelope.attack,
decay: ampEnvelope.decay,
sustain: ampEnvelope.sustain,
release: ampEnvelope.release,
},
modulationEnvelope: {
attack: Math.max(0.001, ampEnvelope.attack * 0.8),
decay: Math.max(0.01, ampEnvelope.decay),
sustain: clamp(modulator.level, 0, 1),
release: ampEnvelope.release,
},
harmonicity,
modulationIndex: effectiveModulationIndex,
detune: carrierDetune,
volume: linearGainToDb(clamp(carrier.level, MIN_LINEAR_GAIN, 1.25)),
},
};
}

export function createSynthRuntimeSpec(source: SynthSource): SynthRuntimeSpec {
if (typeof source === 'string') {
return createSubtractiveRuntimeSpec(
createDefaultSubtractiveInstrument(toLegacySubtractivePreset(source)),
);
}

if (source.kind === 'fm') {
return createFmRuntimeSpec(source);
}

return createSubtractiveRuntimeSpec(resolveSubtractiveInstrument(source));
}

export function createSynthForPreset(preset: SynthPreset): Tone.PolySynth {
return createSynthForSource(preset);
}

export function createSynthForSource(source: SynthSource): Tone.PolySynth {
const instrument = resolveSubtractiveInstrument(source);
const synth = new Tone.PolySynth(Tone.Synth);
synth.set({
oscillator: { type: instrument.settings.oscillator.waveform },
envelope: {
attack: instrument.settings.ampEnvelope.attack,
decay: instrument.settings.ampEnvelope.decay,
sustain: instrument.settings.ampEnvelope.sustain,
release: instrument.settings.ampEnvelope.release,
},
portamento: instrument.settings.glideTime,
});

return synth;
const spec = createSynthRuntimeSpec(source);
return spec.voiceType === 'fm'
? new Tone.PolySynth(Tone.FMSynth, spec.options as never)
: new Tone.PolySynth(Tone.MonoSynth, spec.options as never);
}

class SynthEngine {
Expand All @@ -90,9 +223,9 @@ class SynthEngine {
existing.gain.dispose();
}

const instrument = resolveSubtractiveInstrument(source);
const spec = createSynthRuntimeSpec(source);
const synth = createSynthForSource(source);
const gain = new Tone.Gain(getTrackGainLevel(instrument));
const gain = new Tone.Gain(spec.gainLevel);
synth.connect(gain);
if (connectTo) {
gain.connect(connectTo);
Expand All @@ -114,9 +247,9 @@ class SynthEngine {
if (!this.previewSynth || !this.previewGain || this.previewSignature !== signature) {
this.previewSynth?.dispose();
this.previewGain?.dispose();
const instrument = resolveSubtractiveInstrument(source);
const spec = createSynthRuntimeSpec(source);
this.previewSynth = createSynthForSource(source);
this.previewGain = new Tone.Gain(getTrackGainLevel(instrument, 0.3)).toDestination();
this.previewGain = new Tone.Gain(Math.max(0, Math.min(1, spec.gainLevel * 0.55))).toDestination();
this.previewSynth.connect(this.previewGain);
this.previewSignature = signature;
}
Expand Down
114 changes: 114 additions & 0 deletions src/engine/__tests__/SynthEngine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { createSynthRuntimeSpec } from '../SynthEngine';
import { createDefaultFmInstrument, createDefaultSubtractiveInstrument } from '../../utils/trackInstrument';

describe('createSynthRuntimeSpec', () => {
it('maps subtractive instruments to MonoSynth runtime options with filter and unison support', () => {
const instrument = createDefaultSubtractiveInstrument('lead', {
settings: {
oscillator: {
waveform: 'square',
octave: -1,
detuneCents: 7,
level: 0.72,
},
filter: {
enabled: true,
type: 'lowpass',
cutoffHz: 2400,
resonance: 6,
drive: 0.35,
keyTracking: 0.4,
},
filterEnvelope: {
attack: 0.05,
decay: 0.25,
sustain: 0.35,
release: 0.4,
amount: 0.3,
},
unison: {
voices: 4,
detuneCents: 18,
stereoSpread: 0.5,
blend: 0.8,
},
glideTime: 0.12,
outputGain: -3,
},
});

const spec = createSynthRuntimeSpec(instrument);

expect(spec.engine).toBe('subtractive');
expect(spec.voiceType).toBe('mono');
expect(spec.options).toMatchObject({
oscillator: {
type: 'fatsquare',
count: 4,
},
filter: {
type: 'lowpass',
frequency: 2400,
Q: 6,
},
detune: -1193,
portamento: 0.12,
});

const oscillatorOptions = spec.options.oscillator as { spread: number };
const filterEnvelope = spec.options.filterEnvelope as { baseFrequency: number; octaves: number };

expect(oscillatorOptions.spread).toBeGreaterThan(18);
expect(filterEnvelope.baseFrequency).toBeLessThan(2400);
expect(filterEnvelope.octaves).toBeGreaterThan(1);
expect(spec.gainLevel).toBeCloseTo(0.38937, 4);
});

it('maps FM instruments to FMSynth runtime options instead of legacy fallback synths', () => {
const instrument = createDefaultFmInstrument({
fallbackPreset: 'bass',
settings: {
carrier: {
waveform: 'square',
ratio: 1,
level: 0.82,
},
modulator: {
waveform: 'triangle',
ratio: 3,
level: 0.5,
},
modulationIndex: 4,
feedback: 0.25,
ampEnvelope: {
attack: 0.02,
decay: 0.3,
sustain: 0.55,
release: 0.8,
},
outputGain: 2,
},
});

const spec = createSynthRuntimeSpec(instrument);

expect(spec.engine).toBe('fm');
expect(spec.voiceType).toBe('fm');
expect(spec.options).toMatchObject({
oscillator: {
type: 'square',
},
modulation: {
type: 'triangle',
},
harmonicity: 3,
detune: 0,
});

const modulationEnvelope = spec.options.modulationEnvelope as { sustain: number };
expect(modulationEnvelope.sustain).toBeCloseTo(0.5, 5);
expect(spec.options.modulationIndex).toBeCloseTo(3.9, 5);
expect(spec.gainLevel).toBeCloseTo(0.6924, 4);
});
});