diff --git a/src/engine/SynthEngine.ts b/src/engine/SynthEngine.ts index 44a5c8ff..42b75088 100644 --- a/src/engine/SynthEngine.ts +++ b/src/engine/SynthEngine.ts @@ -16,6 +16,7 @@ import { export type SynthSource = TrackInstrument | SynthPreset; type RuntimeInstrument = SubtractiveTrackInstrument | FmTrackInstrument; type SynthVoiceType = 'mono' | 'fm'; +type SynthCharacterEffectType = 'distortion'; type SynthModulationEffectType = 'tremolo' | 'autoPanner' | 'autoFilter' | 'vibrato'; const DEFAULT_TRACK_GAIN = 0.55; const MIN_LINEAR_GAIN = 0.0001; @@ -35,6 +36,12 @@ interface RuntimeModulationRack { dispose: () => void; } +interface RuntimeCharacterRack { + input: Tone.ToneAudioNode; + output: Tone.ToneAudioNode; + dispose: () => void; +} + export interface SynthPlaybackChain { synth: Tone.PolySynth; gain: Tone.Gain; @@ -63,6 +70,15 @@ export interface SynthModulationSpec { options: Record; } +export interface SynthCharacterSpec { + effectType: SynthCharacterEffectType; + drive: number; + amount: number; + wet: number; + preGain: number; + outputTrim: number; +} + interface CreateSynthPlaybackChainOptions { gainScale?: number; connectTo?: Tone.InputNode; @@ -364,6 +380,27 @@ export function createSynthModulationSpec(source: SynthSource): SynthModulationS return null; } +export function createSynthCharacterSpec(source: SynthSource): SynthCharacterSpec | null { + const instrument = typeof source !== 'string' && source.kind === 'subtractive' + ? source + : null; + + if (!instrument) return null; + + const { filter } = instrument.settings; + const drive = clamp(filter.drive, 0, 1); + if (!filter.enabled || drive <= 0.001) return null; + + return { + effectType: 'distortion', + drive, + amount: clamp((drive * 0.85) + 0.05, 0.05, 0.9), + wet: clamp(drive * 0.9, 0.05, 0.95), + preGain: 1 + (drive * 3), + outputTrim: clamp(1 - (drive * 0.22), 0.72, 1), + }; +} + export function createSynthForPreset(preset: SynthPreset): Tone.PolySynth { return createSynthForSource(preset); } @@ -437,18 +474,52 @@ function createRuntimeModulationRack(source: SynthSource): RuntimeModulationRack } } +function createRuntimeCharacterRack(source: SynthSource): RuntimeCharacterRack | null { + const spec = createSynthCharacterSpec(source); + if (!spec) return null; + + switch (spec.effectType) { + case 'distortion': { + const input = new Tone.Gain(spec.preGain); + const distortion = new Tone.Distortion({ + distortion: spec.amount, + wet: spec.wet, + }); + const output = new Tone.Gain(spec.outputTrim); + input.connect(distortion); + distortion.connect(output); + return { + input, + output, + dispose: () => { + input.dispose(); + distortion.dispose(); + output.dispose(); + }, + }; + } + } +} + function connectSynthChain( synth: Tone.PolySynth, + character: RuntimeCharacterRack | null, modulation: RuntimeModulationRack | null, gain: Tone.Gain, connectTo?: Tone.InputNode, routeToDestination: boolean = true, ) { + const sourceNode = character?.output ?? synth; + + if (character) { + synth.connect(character.input); + } + if (modulation) { - synth.connect(modulation.node); + sourceNode.connect(modulation.node); modulation.node.connect(gain); } else { - synth.connect(gain); + sourceNode.connect(gain); } if (connectTo) { @@ -465,9 +536,10 @@ export function createSynthPlaybackChain( const { gainScale = 1, connectTo, routeToDestination = true } = options; const spec = createSynthRuntimeSpec(source); const synth = createSynthForSource(source); + const character = createRuntimeCharacterRack(source); const modulation = createRuntimeModulationRack(source); const gain = new Tone.Gain(Math.max(0, Math.min(2, spec.gainLevel * gainScale))); - connectSynthChain(synth, modulation, gain, connectTo, routeToDestination); + connectSynthChain(synth, character, modulation, gain, connectTo, routeToDestination); return { synth, @@ -479,6 +551,7 @@ export function createSynthPlaybackChain( dispose: () => { synth.releaseAll(); synth.dispose(); + character?.dispose(); modulation?.dispose(); gain.dispose(); }, diff --git a/src/engine/__tests__/SynthEngine.test.ts b/src/engine/__tests__/SynthEngine.test.ts index d02f8b9f..72ff896b 100644 --- a/src/engine/__tests__/SynthEngine.test.ts +++ b/src/engine/__tests__/SynthEngine.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { + createSynthCharacterSpec, createSynthModulationSpec, createSynthRuntimeSpec, findSlideSourceNote, @@ -251,6 +252,67 @@ describe('createSynthModulationSpec', () => { }); }); +describe('createSynthCharacterSpec', () => { + it('maps subtractive filter drive to an audible distortion character stage', () => { + const instrument = createDefaultSubtractiveInstrument('lead', { + settings: { + filter: { + enabled: true, + type: 'lowpass', + cutoffHz: 1800, + resonance: 5, + drive: 0.6, + keyTracking: 0.2, + }, + }, + }); + + const spec = createSynthCharacterSpec(instrument); + + expect(spec).toMatchObject({ + effectType: 'distortion', + drive: 0.6, + amount: 0.56, + wet: 0.54, + preGain: 2.8, + }); + expect(spec?.outputTrim).toBeCloseTo(0.868, 5); + }); + + it('returns null when drive is inactive, the filter is bypassed, or the source is not subtractive', () => { + const zeroDrive = createDefaultSubtractiveInstrument('pad', { + settings: { + filter: { + enabled: true, + type: 'lowpass', + cutoffHz: 2200, + resonance: 2, + drive: 0, + keyTracking: 0.1, + }, + }, + }); + const filterBypassed = createDefaultSubtractiveInstrument('pad', { + settings: { + filter: { + enabled: false, + type: 'lowpass', + cutoffHz: 2200, + resonance: 2, + drive: 0.7, + keyTracking: 0.1, + }, + }, + }); + + expect(createSynthCharacterSpec(zeroDrive)).toBeNull(); + expect(createSynthCharacterSpec(filterBypassed)).toBeNull(); + expect(createSynthCharacterSpec(createDefaultFmInstrument())).toBeNull(); + expect(createSynthCharacterSpec(createDefaultSamplerInstrument({ audioKey: 'audio:test' }))).toBeNull(); + expect(createSynthCharacterSpec('lead')).toBeNull(); + }); +}); + describe('resolveSlidePortamentoSeconds', () => { it('uses canonical subtractive glide time when a positive glide value is configured', () => { const instrument = createDefaultSubtractiveInstrument('lead', {