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
64 changes: 51 additions & 13 deletions src/engine/SynthEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
FmTrackInstrument,
InstrumentLfoTarget,
LegacySynthVoicePreset,
MidiNote,
SubtractiveTrackInstrument,
SynthPreset,
TrackInstrument,
Expand All @@ -20,6 +21,13 @@ const DEFAULT_TRACK_GAIN = 0.55;
const MIN_LINEAR_GAIN = 0.0001;
const MAX_FAT_SPREAD_CENTS = 120;

type SlidePlaybackSynth = {
set: (options: Record<string, unknown>) => void;
triggerAttack: (note: number, time?: string | number, velocity?: number) => void;
triggerRelease: (note: number, time?: string | number) => void;
triggerAttackRelease: (note: number, duration: number, time?: string | number, velocity?: number) => void;
};

interface RuntimeModulationRack {
node: Tone.ToneAudioNode;
retriggerOnNote: boolean;
Expand Down Expand Up @@ -238,6 +246,40 @@ export function resolveSlidePortamentoSeconds(source: SynthSource, duration: num
: getLegacySlidePortamentoSeconds(duration);
}

export function findSlideSourceNote(notes: MidiNote[], noteIndex: number): MidiNote | undefined {
const note = notes[noteIndex];
if (!note?.isSlide) return undefined;

return notes
.slice(0, noteIndex)
.reverse()
.find((candidate) => candidate.startBeat + candidate.durationBeats >= note.startBeat);
}

export function triggerSlidePlayback(
synth: SlidePlaybackSynth,
fromPitch: number,
toPitch: number,
velocity: number,
duration: number,
source: SynthSource,
time?: string | number,
): number {
const glideTime = resolveSlidePortamentoSeconds(source, duration);
const fromFreq = Tone.Frequency(fromPitch, 'midi').toFrequency();
const toFreq = Tone.Frequency(toPitch, 'midi').toFrequency();
const glideAt = typeof time === 'number'
? time + glideTime
: `+${glideTime}`;

synth.set({ portamento: glideTime });
synth.triggerAttack(fromFreq, time, velocity);
synth.triggerRelease(fromFreq, glideAt);
synth.triggerAttackRelease(toFreq, Math.max(0.04, duration), glideAt, velocity);

return glideTime;
}

export function createSynthModulationSpec(source: SynthSource): SynthModulationSpec | null {
const instrument = resolveSubtractiveLfoInstrument(source);
if (!instrument) return null;
Expand Down Expand Up @@ -514,20 +556,16 @@ class SynthEngine {
source: SynthSource,
) {
await this.ensureStarted();
const synth = this.ensureTrackSynth(trackId, source) as unknown as {
set: (options: Record<string, unknown>) => void;
triggerAttack: (note: number, time?: string | number, velocity?: number) => void;
triggerRelease: (note: number, time?: string | number) => void;
triggerAttackRelease: (note: number, duration: number, time?: string | number, velocity?: number) => void;
};
const synth = this.ensureTrackSynth(trackId, source) as unknown as SlidePlaybackSynth;
restartPlaybackModulation(this.synths.get(trackId));
const glideTime = resolveSlidePortamentoSeconds(source, duration);
const fromFreq = Tone.Frequency(fromPitch, 'midi').toFrequency();
const toFreq = Tone.Frequency(toPitch, 'midi').toFrequency();
synth.set({ portamento: glideTime });
synth.triggerAttack(fromFreq, undefined, velocity / 127);
synth.triggerRelease(fromFreq, `+${glideTime}`);
synth.triggerAttackRelease(toFreq, Math.max(0.04, duration), `+${glideTime}`, velocity / 127);
triggerSlidePlayback(
synth,
fromPitch,
toPitch,
velocity / 127,
duration,
source,
);
}

/** Trigger note on for a track synth (for live playing / recording). */
Expand Down
78 changes: 76 additions & 2 deletions src/engine/__tests__/SynthEngine.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { createSynthModulationSpec, createSynthRuntimeSpec, resolveSlidePortamentoSeconds } from '../SynthEngine';
import { describe, expect, it, vi } from 'vitest';
import {
createSynthModulationSpec,
createSynthRuntimeSpec,
findSlideSourceNote,
resolveSlidePortamentoSeconds,
triggerSlidePlayback,
} from '../SynthEngine';
import {
createDefaultFmInstrument,
createDefaultSamplerInstrument,
Expand Down Expand Up @@ -269,3 +275,71 @@ describe('resolveSlidePortamentoSeconds', () => {
expect(resolveSlidePortamentoSeconds('lead', 0.04)).toBeCloseTo(0.03, 5);
});
});

describe('findSlideSourceNote', () => {
it('returns the closest earlier overlapping note for slide notes only', () => {
const notes = [
{ id: 'n1', pitch: 60, startBeat: 0, durationBeats: 1.5, velocity: 0.8 },
{ id: 'n2', pitch: 64, startBeat: 1, durationBeats: 1, velocity: 0.8 },
{ id: 'n3', pitch: 67, startBeat: 1.5, durationBeats: 0.5, velocity: 0.8, isSlide: true },
{ id: 'n4', pitch: 69, startBeat: 3, durationBeats: 0.5, velocity: 0.8, isSlide: true },
];

expect(findSlideSourceNote(notes, 0)).toBeUndefined();
expect(findSlideSourceNote(notes, 2)?.id).toBe('n2');
expect(findSlideSourceNote(notes, 3)).toBeUndefined();
});
});

describe('triggerSlidePlayback', () => {
it('uses canonical glide time for absolute-time slide scheduling', () => {
const synth = {
set: vi.fn(),
triggerAttack: vi.fn(),
triggerRelease: vi.fn(),
triggerAttackRelease: vi.fn(),
};
const instrument = createDefaultSubtractiveInstrument('lead', {
settings: {
glideTime: 0.48,
},
});

const glideTime = triggerSlidePlayback(synth, 60, 64, 0.75, 0.2, instrument, 12);

expect(glideTime).toBeCloseTo(0.48, 5);
expect(synth.set).toHaveBeenCalledWith({ portamento: 0.48 });

const attackCall = synth.triggerAttack.mock.calls[0];
const releaseCall = synth.triggerRelease.mock.calls[0];
const attackReleaseCall = synth.triggerAttackRelease.mock.calls[0];

expect(attackCall[0]).toBeCloseTo(261.6256, 3);
expect(attackCall[1]).toBe(12);
expect(attackCall[2]).toBe(0.75);
expect(releaseCall[0]).toBeCloseTo(261.6256, 3);
expect(releaseCall[1]).toBeCloseTo(12.48, 5);
expect(attackReleaseCall[0]).toBeCloseTo(329.6276, 3);
expect(attackReleaseCall[1]).toBeCloseTo(0.2, 5);
expect(attackReleaseCall[2]).toBeCloseTo(12.48, 5);
expect(attackReleaseCall[3]).toBe(0.75);
});

it('falls back to relative-time legacy glide scheduling when no canonical glide is set', () => {
const synth = {
set: vi.fn(),
triggerAttack: vi.fn(),
triggerRelease: vi.fn(),
triggerAttackRelease: vi.fn(),
};

const glideTime = triggerSlidePlayback(synth, 60, 62, 0.5, 0.02, 'lead');

expect(glideTime).toBeCloseTo(0.03, 5);
expect(synth.set).toHaveBeenCalledWith({ portamento: 0.03 });
expect(synth.triggerAttack.mock.calls[0][1]).toBeUndefined();
expect(synth.triggerRelease.mock.calls[0][1]).toBe('+0.03');
expect(synth.triggerAttackRelease.mock.calls[0][1]).toBeCloseTo(0.04, 5);
expect(synth.triggerAttackRelease.mock.calls[0][2]).toBe('+0.03');
});
});
18 changes: 17 additions & 1 deletion src/engine/offlineRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ToneAudioBuffer } from 'tone';
import { createDrumVoicesForKit } from './DrumEngine';
import {
createSynthPlaybackChain,
findSlideSourceNote,
triggerSlidePlayback,
type SynthPlaybackChain,
type SynthSource,
} from './SynthEngine';
Expand Down Expand Up @@ -56,16 +58,30 @@ export async function renderMidiTrackOffline(
transport.bpm.value = bpm;
const secondsPerBeat = 60 / bpm;

for (const note of notes) {
for (const [noteIndex, note] of notes.entries()) {
const noteDuration = Math.max(0, note.durationBeats * secondsPerBeat);
const noteStart = clipStartTime + note.startBeat * secondsPerBeat;
const noteEnd = noteStart + noteDuration;
if (noteDuration <= 0 || noteEnd <= 0 || noteStart >= totalDuration) continue;

const velocity = Math.max(0, Math.min(1, note.velocity));
const frequency = Tone.Frequency(note.pitch, 'midi').toFrequency();
const previousOverlap = findSlideSourceNote(notes, noteIndex);
transport.schedule((time) => {
playback?.restartModulation(time);
if (previousOverlap && playback) {
triggerSlidePlayback(
playback.synth,
previousOverlap.pitch,
note.pitch,
velocity,
noteDuration,
source,
time,
);
return;
}

playback?.synth.triggerAttackRelease(frequency, noteDuration, time, velocity);
}, noteStart);
}
Expand Down
8 changes: 2 additions & 6 deletions src/hooks/useTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useUIStore } from '../store/uiStore';
import { getAudioEngine } from './useAudioEngine';
import { loadAudioBlobByKey } from '../services/audioFileManager';
import { synthEngine } from '../engine/SynthEngine';
import { findSlideSourceNote } from '../engine/SynthEngine';
import { samplerEngine } from '../engine/SamplerEngine';
import { drumEngine } from '../engine/DrumEngine';
import { automationEngine } from '../engine/AutomationEngine';
Expand Down Expand Up @@ -454,12 +455,7 @@ export function useTransport() {
});
} else {
const freq = Tone.Frequency(note.pitch, 'midi').toFrequency();
const previousOverlap = note.isSlide
? [...notes]
.slice(0, noteIndex)
.reverse()
.find((candidate) => candidate.startBeat + candidate.durationBeats >= note.startBeat)
: undefined;
const previousOverlap = findSlideSourceNote(notes, noteIndex);
engine.scheduleMidiEvent(scheduledStart, () => {
if (previousOverlap) {
void synthEngine.playSlideNote(
Expand Down