diff --git a/src/components/pianoroll/SynthInstrumentEditor.tsx b/src/components/pianoroll/SynthInstrumentEditor.tsx
index 74269220..57e9edec 100644
--- a/src/components/pianoroll/SynthInstrumentEditor.tsx
+++ b/src/components/pianoroll/SynthInstrumentEditor.tsx
@@ -648,8 +648,8 @@ function renderFmEditor(
Playback
-
FM parameter shell on canonical state
-
Current engine falls back to the selected legacy voice.
+
FM parameters drive the live playback voice
+
Fallback preset stays as compatibility metadata for legacy paths.
diff --git a/src/engine/SynthEngine.ts b/src/engine/SynthEngine.ts
index 35b78786..95719033 100644
--- a/src/engine/SynthEngine.ts
+++ b/src/engine/SynthEngine.ts
@@ -1,5 +1,6 @@
import * as Tone from 'tone';
import type {
+ FmTrackInstrument,
LegacySynthVoicePreset,
SubtractiveTrackInstrument,
SynthPreset,
@@ -11,7 +12,11 @@ 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;
@@ -19,6 +24,21 @@ interface SynthInstance {
gain: Tone.Gain;
}
+export interface SynthRuntimeSpec {
+ engine: 'subtractive' | 'fm';
+ voiceType: SynthVoiceType;
+ options: Record;
+ 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;
}
@@ -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 {
@@ -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);
@@ -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;
}
diff --git a/src/engine/__tests__/SynthEngine.test.ts b/src/engine/__tests__/SynthEngine.test.ts
new file mode 100644
index 00000000..0196811f
--- /dev/null
+++ b/src/engine/__tests__/SynthEngine.test.ts
@@ -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);
+ });
+});