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
79 changes: 76 additions & 3 deletions src/engine/SynthEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -63,6 +70,15 @@ export interface SynthModulationSpec {
options: Record<string, unknown>;
}

export interface SynthCharacterSpec {
effectType: SynthCharacterEffectType;
drive: number;
amount: number;
wet: number;
preGain: number;
outputTrim: number;
}

interface CreateSynthPlaybackChainOptions {
gainScale?: number;
connectTo?: Tone.InputNode;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -479,6 +551,7 @@ export function createSynthPlaybackChain(
dispose: () => {
synth.releaseAll();
synth.dispose();
character?.dispose();
modulation?.dispose();
gain.dispose();
},
Expand Down
62 changes: 62 additions & 0 deletions src/engine/__tests__/SynthEngine.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import {
createSynthCharacterSpec,
createSynthModulationSpec,
createSynthRuntimeSpec,
findSlideSourceNote,
Expand Down Expand Up @@ -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', {
Expand Down