From 795a58981f04ea8cb983a82a60437e1b8c9db77f Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Fri, 27 Mar 2026 19:01:38 +0800 Subject: [PATCH] feat: connect synth runtime to canonical voices --- .../pianoroll/SynthInstrumentEditor.tsx | 4 +- src/engine/SynthEngine.ts | 171 ++++++++++++++++-- src/engine/__tests__/SynthEngine.test.ts | 114 ++++++++++++ 3 files changed, 268 insertions(+), 21 deletions(-) create mode 100644 src/engine/__tests__/SynthEngine.test.ts 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); + }); +});