From ea42a7c8afda4464a1040a8a33d3ad7265c96cc8 Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Fri, 27 Mar 2026 21:16:05 +0800 Subject: [PATCH] feat: align offline slide playback with live synth behavior --- src/engine/SynthEngine.ts | 64 +++++++++++++++---- src/engine/__tests__/SynthEngine.test.ts | 78 +++++++++++++++++++++++- src/engine/offlineRender.ts | 18 +++++- src/hooks/useTransport.ts | 8 +-- 4 files changed, 146 insertions(+), 22 deletions(-) diff --git a/src/engine/SynthEngine.ts b/src/engine/SynthEngine.ts index e3a997ef..44a5c8ff 100644 --- a/src/engine/SynthEngine.ts +++ b/src/engine/SynthEngine.ts @@ -3,6 +3,7 @@ import type { FmTrackInstrument, InstrumentLfoTarget, LegacySynthVoicePreset, + MidiNote, SubtractiveTrackInstrument, SynthPreset, TrackInstrument, @@ -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) => 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; @@ -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; @@ -514,20 +556,16 @@ class SynthEngine { source: SynthSource, ) { await this.ensureStarted(); - const synth = this.ensureTrackSynth(trackId, source) as unknown as { - set: (options: Record) => 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). */ diff --git a/src/engine/__tests__/SynthEngine.test.ts b/src/engine/__tests__/SynthEngine.test.ts index 007fc397..d02f8b9f 100644 --- a/src/engine/__tests__/SynthEngine.test.ts +++ b/src/engine/__tests__/SynthEngine.test.ts @@ -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, @@ -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'); + }); +}); diff --git a/src/engine/offlineRender.ts b/src/engine/offlineRender.ts index 837d90e8..64bf4b19 100644 --- a/src/engine/offlineRender.ts +++ b/src/engine/offlineRender.ts @@ -3,6 +3,8 @@ import type { ToneAudioBuffer } from 'tone'; import { createDrumVoicesForKit } from './DrumEngine'; import { createSynthPlaybackChain, + findSlideSourceNote, + triggerSlidePlayback, type SynthPlaybackChain, type SynthSource, } from './SynthEngine'; @@ -56,7 +58,7 @@ 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; @@ -64,8 +66,22 @@ export async function renderMidiTrackOffline( 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); } diff --git a/src/hooks/useTransport.ts b/src/hooks/useTransport.ts index e62912f6..f7171b1c 100644 --- a/src/hooks/useTransport.ts +++ b/src/hooks/useTransport.ts @@ -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'; @@ -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(