From 738628fc76a7b242f2187a2c4d447a29770965bc Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 6 May 2026 18:25:14 -0700 Subject: [PATCH] feat: added pitch bend automation (linear interpolation) --- public/config.json | 1 + src/App.test.tsx | 114 ++++++++++++ src/App.tsx | 42 +++++ src/components/Toolbar.tsx | 4 +- .../settings/sections/BehaviorSettings.tsx | 40 +++- src/core/audio-interface/KGAudioBus.test.ts | 11 ++ src/core/audio-interface/KGAudioBus.ts | 54 ++++-- .../audio-interface/KGAudioInterface.test.ts | 79 +++++++- src/core/audio-interface/KGAudioInterface.ts | 82 +++++---- .../audio-interface/KGOfflineRenderer.test.ts | 31 +++- src/core/audio-interface/KGOfflineRenderer.ts | 146 ++++++++++++++- src/core/config/ConfigManager.ts | 2 + src/stores/projectStore.test.ts | 78 ++++++++ src/stores/projectStore.ts | 50 ++++- src/test/mocks/tone-js.ts | 6 +- src/test/mocks/tone.ts | 1 + src/util/midiAutomationUtil.test.ts | 127 +++++++++++++ src/util/midiAutomationUtil.ts | 171 ++++++++++++++++++ 18 files changed, 963 insertions(+), 76 deletions(-) create mode 100644 src/App.test.tsx create mode 100644 src/util/midiAutomationUtil.test.ts create mode 100644 src/util/midiAutomationUtil.ts diff --git a/public/config.json b/public/config.json index 6302f28..08c71f8 100644 --- a/public/config.json +++ b/public/config.json @@ -75,6 +75,7 @@ "audio": { "enable_audio_capture_for_screen_sharing": false, "lookahead_time": 0.05, + "midi_automation_interpolation_interval_ms": 10, "playback_delay": 0.2, "recording_offset": 0 }, diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..a0f4d40 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,114 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let mockState = { isPreparingPlayback: false }; + +vi.mock('./stores/projectStore', () => ({ + useProjectStore: (selector?: (state: typeof mockState) => unknown) => ( + selector ? selector(mockState) : mockState + ), +})); + +vi.mock('./hooks/useGlobalKeyboardHandler', () => ({ + useGlobalKeyboardHandler: () => undefined, +})); + +vi.mock('./components/Toolbar', () => ({ default: () => null })); +vi.mock('./components/StatusBar', () => ({ default: () => null })); +vi.mock('./components/MainContent', () => ({ default: () => null })); +vi.mock('./components/InstrumentSelection', () => ({ default: () => null })); +vi.mock('./components/ChatBox', () => ({ default: () => null })); +vi.mock('./components/KGOnePanel', () => ({ default: () => null })); +vi.mock('./components/ListEventPanel', () => ({ default: () => null })); +vi.mock('./components/settings', () => ({ SettingsPanel: () => null })); +vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({ + KGToneBuffersPool: { + instance: () => ({ + getActiveLoadCount: () => 0, + addLoadingListener: () => undefined, + removeLoadingListener: () => undefined, + }), + }, +})); +vi.mock('./core/audio-interface/KGOfflineRenderer', () => ({ + KGOfflineRenderer: { + instance: () => ({ + addRenderingListener: () => undefined, + removeRenderingListener: () => undefined, + }), + }, +})); +vi.mock('./core/KGCore', () => ({ + KGCore: { + instance: () => ({ + getIsMigrating: () => false, + setMigrationStateChangeCallback: () => undefined, + }), + }, +})); + +import { PlaybackPreparationOverlayContainer } from './App'; + +describe('PlaybackPreparationOverlayContainer', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockState = { isPreparingPlayback: false }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not appear if preparation finishes before the delay', () => { + const { rerender } = render(); + + mockState = { isPreparingPlayback: true }; + rerender(); + + act(() => { + vi.advanceTimersByTime(100); + }); + + mockState = { isPreparingPlayback: false }; + rerender(); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(screen.queryByText('Preparing playback...')).not.toBeInTheDocument(); + }); + + it('appears after 150ms while preparation is still in progress', () => { + const { rerender } = render(); + + mockState = { isPreparingPlayback: true }; + rerender(); + + act(() => { + vi.advanceTimersByTime(149); + }); + expect(screen.queryByText('Preparing playback...')).not.toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByText('Preparing playback...')).toBeInTheDocument(); + }); + + it('hides once preparation completes after becoming visible', () => { + const { rerender } = render(); + + mockState = { isPreparingPlayback: true }; + rerender(); + act(() => { + vi.advanceTimersByTime(150); + }); + expect(screen.getByText('Preparing playback...')).toBeInTheDocument(); + + mockState = { isPreparingPlayback: false }; + rerender(); + + expect(screen.queryByText('Preparing playback...')).not.toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index fc5038c..c066b6d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -185,6 +185,9 @@ function App() { {/* Bounce/Render Overlay */} + + {/* Playback Preparation Overlay */} + ); } @@ -295,3 +298,42 @@ const BounceOverlayContainer: React.FC = () => { /> ); }; + +const PLAYBACK_PREPARATION_OVERLAY_DELAY_MS = 150; + +export const PlaybackPreparationOverlayContainer: React.FC = () => { + const isPreparingPlayback = useProjectStore(state => state.isPreparingPlayback); + const [visible, setVisible] = useState(false); + const timeoutRef = useRef(null); + + useEffectReact(() => { + if (isPreparingPlayback) { + if (timeoutRef.current === null) { + timeoutRef.current = window.setTimeout(() => { + setVisible(true); + timeoutRef.current = null; + }, PLAYBACK_PREPARATION_OVERLAY_DELAY_MS); + } + } else { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setVisible(false); + } + + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [isPreparingPlayback]); + + return ( + + ); +}; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 78a0225..c65bc63 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -42,7 +42,7 @@ const Toolbar: React.FC = () => { projectName, setProjectName, savedProjectName, setSavedProjectName, bpm, timeSignature, keySignature, setStatus, - isPlaying, startPlaying, stopTransport, setPlayheadPosition, + isPlaying, isPreparingPlayback, startPlaying, stopTransport, setPlayheadPosition, currentTime, setBpm, setTimeSignature, setKeySignature, maxBars, setMaxBars, barWidthMultiplier, setBarWidthMultiplier, @@ -1110,7 +1110,7 @@ const Toolbar: React.FC = () => { {!isPlaying ? ( - + ) : ( )} diff --git a/src/components/settings/sections/BehaviorSettings.tsx b/src/components/settings/sections/BehaviorSettings.tsx index 711f9c1..892a30b 100644 --- a/src/components/settings/sections/BehaviorSettings.tsx +++ b/src/components/settings/sections/BehaviorSettings.tsx @@ -11,6 +11,7 @@ const BehaviorSettings: React.FC = () => { const [spectrogramHeightResolution, setSpectrogramHeightResolution] = useState(3); const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState(true); const [audioLookaheadTime, setAudioLookaheadTime] = useState('50'); + const [midiAutomationInterpolationIntervalMs, setMidiAutomationInterpolationIntervalMs] = useState(10); const [playbackDelay, setPlaybackDelay] = useState('200'); const [recordingOffset, setRecordingOffset] = useState('0'); const [enableAudioCapture, setEnableAudioCapture] = useState(false); @@ -34,6 +35,9 @@ const BehaviorSettings: React.FC = () => { setChatboxDefaultOpen((configManager.get('chatbox.default_open') as boolean) ?? true); const lookaheadTimeSeconds = (configManager.get('audio.lookahead_time') as number) ?? 0.05; setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0))); + setMidiAutomationInterpolationIntervalMs( + (configManager.get('audio.midi_automation_interpolation_interval_ms') as number) ?? 10 + ); const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2; setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0))); const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0; @@ -120,6 +124,12 @@ const BehaviorSettings: React.FC = () => { } }; + const handleMidiAutomationInterpolationIntervalChange = async (value: string) => { + const nextValue = parseInt(value, 10); + setMidiAutomationInterpolationIntervalMs(nextValue); + await configManager.set('audio.midi_automation_interpolation_interval_ms', nextValue); + }; + const handleRecordingOffsetChange = async (value: string) => { const numValueMs = value === '' ? 0 : parseFloat(value); const numValueSeconds = numValueMs / 1000; @@ -197,13 +207,13 @@ const BehaviorSettings: React.FC = () => { Chat Box - + Open at Start Up - handleChatboxDefaultOpenChange(e.target.value)} > @@ -270,6 +280,24 @@ const BehaviorSettings: React.FC = () => { + + + MIDI Automation Interpolation + + handleMidiAutomationInterpolationIntervalChange(e.target.value)} + > + Low-end (20 ms) + Balanced (10 ms) + High quality (5 ms) + + + Controls how densely MIDI automation are baked for playback and bounce. Smaller intervals sound smoother but schedule more events. + + + MIDI Input Latency (ms) @@ -301,8 +329,8 @@ const BehaviorSettings: React.FC = () => { Capture Audio for Screen Sharing - handleEnableAudioCaptureChange(e.target.value)} > @@ -310,7 +338,7 @@ const BehaviorSettings: React.FC = () => { Yes - Restart KGStudio (refresh the page) to take effect. Enable this option when KGStudio's audio cannot be captured during screen sharing in video calls (e.g., Zoom, Teams). This creates an additional audio stream that screen capture applications can detect. + Restart KGStudio (refresh the page) to take effect. Enable this option when KGStudio's audio cannot be captured during screen sharing in video calls (e.g., Zoom, Teams). This creates an additional audio stream that screen capture applications can detect. It is important to make sure when screen sharing in Zoom, the "Share Sound" option is enabled. diff --git a/src/core/audio-interface/KGAudioBus.test.ts b/src/core/audio-interface/KGAudioBus.test.ts index f5f8b88..674362c 100644 --- a/src/core/audio-interface/KGAudioBus.test.ts +++ b/src/core/audio-interface/KGAudioBus.test.ts @@ -66,4 +66,15 @@ describe('KGAudioBus live MIDI pitch bend', () => { audioBus.releaseLiveMidiNote(60, 1.25); expect(source.stop).toHaveBeenCalledWith(1.25); }); + + it('uses timed playback-rate automation when scheduling pitch bend updates', async () => { + const audioBus = await KGAudioBus.create('acoustic_grand_piano'); + + audioBus.triggerLiveMidiAttack(60, 0, 1); + + const source = MockBufferSource.mock.results[0].value; + audioBus.scheduleLiveMidiPitchBend(1, 2); + + expect(source.playbackRate.setValueAtTime).toHaveBeenCalledWith(Math.pow(2, 2 / 12), 2); + }); }); diff --git a/src/core/audio-interface/KGAudioBus.ts b/src/core/audio-interface/KGAudioBus.ts index 6f6e7c5..7317d4b 100644 --- a/src/core/audio-interface/KGAudioBus.ts +++ b/src/core/audio-interface/KGAudioBus.ts @@ -20,7 +20,7 @@ interface LiveMidiSource { export class KGAudioBus { // Fixed at +/-2 semitones for now. Future work: make this user-configurable // or honor MIDI RPN 0,0 (Pitch Bend Sensitivity). - private static readonly LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES = 2; + public static readonly LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES = 2; private static readonly LIVE_MIDI_NOTE_NAMES = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; // Core audio components @@ -218,7 +218,17 @@ export class KGAudioBus { for (const activeSources of this.liveMidiSources.values()) { activeSources.forEach(({ source, basePlaybackRate }) => { - source.playbackRate.value = this.applyPitchBendToPlaybackRate(basePlaybackRate); + this.setPlaybackRateValue(source, this.applyPitchBendToPlaybackRate(basePlaybackRate)); + }); + } + } + + public scheduleLiveMidiPitchBend(normalizedBend: number, time: number): void { + this.liveMidiPitchBend = Math.max(-1, Math.min(1, normalizedBend)); + + for (const activeSources of this.liveMidiSources.values()) { + activeSources.forEach(({ source, basePlaybackRate }) => { + this.setPlaybackRateValue(source, this.applyPitchBendToPlaybackRate(basePlaybackRate), time); }); } } @@ -474,13 +484,13 @@ export class KGAudioBus { velocity?: number, duration?: number ): LiveMidiSource | null { - const closestPitch = this.findClosestBufferedPitch(pitch); + const closestPitch = KGAudioBus.findClosestBufferedPitch(this.instrument, this.audioBuffers, pitch); if (closestPitch === null) { console.warn(`No audio buffer found for live MIDI pitch ${pitch} on ${this.instrument}`); return null; } - const bufferKey = this.midiPitchToBufferKey(closestPitch); + const bufferKey = KGAudioBus.midiPitchToBufferKey(closestPitch); const buffer = this.audioBuffers.get(bufferKey); if (!buffer) { console.warn(`Missing audio buffer ${bufferKey} for ${this.instrument}`); @@ -514,23 +524,31 @@ export class KGAudioBus { return { source, basePlaybackRate }; } - private applyPitchBendToPlaybackRate(basePlaybackRate: number): number { - const bendSemitones = this.liveMidiPitchBend * KGAudioBus.LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES; + public static applyNormalizedPitchBendToPlaybackRate(basePlaybackRate: number, normalizedBend: number): number { + const bendSemitones = normalizedBend * KGAudioBus.LIVE_MIDI_PITCH_BEND_RANGE_SEMITONES; return basePlaybackRate * Math.pow(2, bendSemitones / 12); } - private findClosestBufferedPitch(targetPitch: number): number | null { - const [minPitch, maxPitch] = FLUIDR3_INSTRUMENT_MAP[this.instrument]?.pitchRange || [21, 108]; + private applyPitchBendToPlaybackRate(basePlaybackRate: number): number { + return KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, this.liveMidiPitchBend); + } + + public static findClosestBufferedPitch( + instrument: InstrumentType, + audioBuffers: Tone.ToneAudioBuffers, + targetPitch: number + ): number | null { + const [minPitch, maxPitch] = FLUIDR3_INSTRUMENT_MAP[instrument]?.pitchRange || [21, 108]; const boundedPitch = Math.max(minPitch, Math.min(maxPitch, targetPitch)); for (let offset = 0; offset <= 96; offset++) { const upwardPitch = boundedPitch + offset; - if (upwardPitch <= maxPitch && this.audioBuffers.has(this.midiPitchToBufferKey(upwardPitch))) { + if (upwardPitch <= maxPitch && audioBuffers.has(KGAudioBus.midiPitchToBufferKey(upwardPitch))) { return upwardPitch; } const downwardPitch = boundedPitch - offset; - if (downwardPitch >= minPitch && this.audioBuffers.has(this.midiPitchToBufferKey(downwardPitch))) { + if (downwardPitch >= minPitch && audioBuffers.has(KGAudioBus.midiPitchToBufferKey(downwardPitch))) { return downwardPitch; } } @@ -538,9 +556,23 @@ export class KGAudioBus { return null; } - private midiPitchToBufferKey(pitch: number): string { + public static midiPitchToBufferKey(pitch: number): string { const octave = Math.floor((pitch - 12) / 12); const noteIndex = (pitch - 12) % 12; return `${KGAudioBus.LIVE_MIDI_NOTE_NAMES[noteIndex]}${octave}`; } + + private setPlaybackRateValue(source: Tone.ToneBufferSource, value: number, time?: number): void { + const playbackRate = source.playbackRate as unknown as { + value: number; + setValueAtTime?: (nextValue: number, nextTime: number) => void; + }; + + if (time !== undefined && typeof playbackRate.setValueAtTime === 'function') { + playbackRate.setValueAtTime(value, time); + return; + } + + playbackRate.value = value; + } } diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index d087fcb..d6d373e 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createMockProject } from '../../test/utils/mock-data'; +import { createMockMidiPitchBend, createMockMidiRegion, createMockMidiTrack, createMockProject } from '../../test/utils/mock-data'; import { MockTransport } from '../../test/mocks/tone'; vi.mock('tone', async () => { @@ -22,6 +22,7 @@ vi.mock('../config/ConfigManager', () => ({ import { KGCore } from '../KGCore'; import { ConfigManager } from '../config/ConfigManager'; import { KGAudioInterface } from './KGAudioInterface'; +import { MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil'; describe('KGAudioInterface preroll playback', () => { beforeEach(() => { @@ -50,6 +51,7 @@ describe('KGAudioInterface preroll playback', () => { get: (key: string) => { if (key === 'audio.playback_delay') return 0.2; if (key === 'audio.lookahead_time') return 0.05; + if (key === 'audio.midi_automation_interpolation_interval_ms') return 250; return null; }, } as unknown as ConfigManager) @@ -112,4 +114,79 @@ describe('KGAudioInterface preroll playback', () => { expect(MockTransport.position).toBe(6); }); + + it('schedules baked pitch bend points instead of authored step changes only', () => { + const region = createMockMidiRegion({ + pitchBends: [ + createMockMidiPitchBend({ id: 'bend-1', beat: 0, value: MIDI_PITCH_BEND_CENTER }), + createMockMidiPitchBend({ id: 'bend-2', beat: 1, value: 0 }), + ], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + const project = createMockProject({ tracks: [track] }); + const audio = KGAudioInterface.instance(); + const audioBus = { + resetLiveMidiPitchBend: vi.fn(), + setLiveMidiPitchBend: vi.fn(), + scheduleLiveMidiPitchBend: vi.fn(), + shouldPlayWithSolo: vi.fn().mockReturnValue(true), + }; + + ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); + audio.preparePlayback(project, 0); + + const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time); + expect(scheduledTimes).toContain(0.25); + expect(scheduledTimes).toContain(0.5); + }); + + it('computes the initial interpolated bend for non-zero playback starts', () => { + const region = createMockMidiRegion({ + pitchBends: [ + createMockMidiPitchBend({ id: 'bend-1', beat: 0, value: MIDI_PITCH_BEND_CENTER }), + createMockMidiPitchBend({ id: 'bend-2', beat: 4, value: 0 }), + ], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + const project = createMockProject({ tracks: [track] }); + const audio = KGAudioInterface.instance(); + const audioBus = { + resetLiveMidiPitchBend: vi.fn(), + setLiveMidiPitchBend: vi.fn(), + scheduleLiveMidiPitchBend: vi.fn(), + shouldPlayWithSolo: vi.fn().mockReturnValue(true), + }; + + ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); + audio.preparePlayback(project, 2); + + expect(audioBus.setLiveMidiPitchBend).toHaveBeenCalledWith(midiPitchBendToNormalized(4096)); + }); + + it('adds a loop-start re-anchor for interpolated pitch bend state', () => { + const region = createMockMidiRegion({ + pitchBends: [ + createMockMidiPitchBend({ id: 'bend-1', beat: 2, value: 0 }), + createMockMidiPitchBend({ id: 'bend-2', beat: 6, value: MIDI_PITCH_BEND_CENTER }), + ], + }); + const track = createMockMidiTrack({ id: 1, regions: [region] }); + const project = createMockProject({ tracks: [track] }); + project.setIsLooping(true); + project.setLoopingRange([1, 1]); + + const audio = KGAudioInterface.instance(); + const audioBus = { + resetLiveMidiPitchBend: vi.fn(), + setLiveMidiPitchBend: vi.fn(), + scheduleLiveMidiPitchBend: vi.fn(), + shouldPlayWithSolo: vi.fn().mockReturnValue(true), + }; + + ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); + audio.preparePlayback(project, 5); + + const scheduledTimes = MockTransport.schedule.mock.calls.map(([, time]) => time); + expect(scheduledTimes).toContain(2); + }); }); diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index b3e9972..d55ed4a 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -3,7 +3,16 @@ import type { KGMidiNote } from '../midi/KGMidiNote'; import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend'; import { TIME_CONSTANTS, AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; -import { midiPitchBendToNormalized, pitchToNoteNameString } from '../../util/midiUtil'; +import { + MIDI_PITCH_BEND_CENTER, + midiPitchBendToNormalized, + pitchToNoteNameString, +} from '../../util/midiUtil'; +import { + bakeMidiAutomationPointsInWindow, + collectRegionMidiAutomationPoints, + resolveMidiAutomationValueAtBeat, +} from '../../util/midiAutomationUtil'; import * as Tone from 'tone'; import { KGAudioBus } from './KGAudioBus'; import { KGAudioPlayerBus } from './KGAudioPlayerBus'; @@ -473,30 +482,35 @@ export class KGAudioInterface { project.getTracks().forEach(track => { const trackId = track.getId().toString(); const audioBus = this.trackAudioBuses.get(trackId); + const interpolationIntervalMs = (configManager.get('audio.midi_automation_interpolation_interval_ms') as number) ?? 10; console.log(`Track ${trackId} has audio bus: ${audioBus ? 'true' : 'false'}; type: ${track.getType()}`); // Schedule MIDI track events if (audioBus && track.getType() === 'MIDI') { - const trackPitchBends: Array<{ pitchBend: KGMidiPitchBend; absoluteBeat: number }> = []; const trackNotes: Array<{ note: KGMidiNote; absoluteStartBeat: number; absoluteEndBeat: number }> = []; + const trackPitchBends = collectRegionMidiAutomationPoints( + track.getRegions() + .filter(region => region.getCurrentType() === 'KGMidiRegion') + .map(region => { + const midiRegion = region as unknown as { getPitchBends: () => KGMidiPitchBend[] }; + return { + startBeat: region.getStartFromBeat(), + points: midiRegion.getPitchBends().map(pitchBend => ({ + beat: pitchBend.getBeat(), + value: pitchBend.getValue(), + })), + }; + }) + ); track.getRegions().forEach(region => { console.log(`Region ${region.getId().toString()}: type: ${region.getCurrentType()}`); if (region.getCurrentType() === 'KGMidiRegion') { - const midiRegion = region as unknown as { getNotes: () => KGMidiNote[]; getPitchBends: () => KGMidiPitchBend[] }; + const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] }; const regionStartBeat = region.getStartFromBeat(); - if (midiRegion.getPitchBends) { - midiRegion.getPitchBends().forEach((pitchBend: KGMidiPitchBend) => { - trackPitchBends.push({ - pitchBend, - absoluteBeat: regionStartBeat + pitchBend.getBeat(), - }); - }); - } - // Get notes from region (assuming it has a getNotes method) if (midiRegion.getNotes) { midiRegion.getNotes().forEach((note: KGMidiNote) => { @@ -523,35 +537,37 @@ export class KGAudioInterface { } }); - const boundedTrackPitchBends = trackPitchBends - .filter(({ absoluteBeat }) => absoluteBeat >= scheduleStartBeat && absoluteBeat < scheduleEndBeat) - .sort((a, b) => a.absoluteBeat - b.absoluteBeat); - const initialPitchBend = [...boundedTrackPitchBends] - .reverse() - .find(({ absoluteBeat }) => absoluteBeat <= startPosition); - audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBend?.pitchBend.getValue() ?? 8192)); + const initialPitchBendBeat = isLooping ? Math.max(startPosition, scheduleStartBeat) : startPosition; + const initialPitchBendValue = resolveMidiAutomationValueAtBeat( + trackPitchBends, + initialPitchBendBeat, + MIDI_PITCH_BEND_CENTER + ); + audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(initialPitchBendValue)); - if (isLooping && !boundedTrackPitchBends.some(({ absoluteBeat }) => absoluteBeat === scheduleStartBeat)) { - const eventId = Tone.Transport.schedule((time) => { - const hasSoloedTracks = this.hasSoloedTracks(); - if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) { - audioBus.setLiveMidiPitchBend(0); - } - }, this.beatsToToneTime(scheduleStartBeat)); - this.scheduledEvents.add(eventId); - } + const pitchBendWindowStartBeat = isLooping ? scheduleStartBeat : Math.max(startPosition, 0); + const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow( + trackPitchBends, + pitchBendWindowStartBeat, + scheduleEndBeat, + { + maxIntervalMs: interpolationIntervalMs, + bpm: project.getBpm(), + defaultValue: MIDI_PITCH_BEND_CENTER, + } + ); - boundedTrackPitchBends.forEach(({ pitchBend, absoluteBeat }) => { - if (absoluteBeat < startPosition) { + bakedTrackPitchBends.forEach(({ beat, value }) => { + if (!isLooping && beat <= pitchBendWindowStartBeat) { return; } - const eventId = Tone.Transport.schedule(() => { + const eventId = Tone.Transport.schedule((time) => { const hasSoloedTracks = this.hasSoloedTracks(); if (audioBus.shouldPlayWithSolo(hasSoloedTracks)) { - audioBus.setLiveMidiPitchBend(midiPitchBendToNormalized(pitchBend.getValue())); + audioBus.scheduleLiveMidiPitchBend(midiPitchBendToNormalized(value), time); } - }, this.beatsToToneTime(absoluteBeat)); + }, this.beatsToToneTime(beat)); this.scheduledEvents.add(eventId); }); diff --git a/src/core/audio-interface/KGOfflineRenderer.test.ts b/src/core/audio-interface/KGOfflineRenderer.test.ts index 1428052..eeee333 100644 --- a/src/core/audio-interface/KGOfflineRenderer.test.ts +++ b/src/core/audio-interface/KGOfflineRenderer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer'; +import { applyOfflinePitchBendAutomation, encodeWav, getOfflineTrackGain, getOfflineTrackVolumeDb } from './KGOfflineRenderer'; /** * Create a minimal AudioBuffer-like object for testing. @@ -164,3 +164,32 @@ describe('offline track volume conversion', () => { expect(getOfflineTrackGain(-60, false)).toBe(0); }); }); + +describe('offline pitch bend automation', () => { + it('applies baked pitch bends to source playback rate automation', () => { + const source = { + playbackRate: { + value: 1, + setValueAtTime: (..._args: unknown[]) => undefined, + }, + } as unknown as Parameters[0]; + + const calls: Array<[number, number]> = []; + source.playbackRate.setValueAtTime = ((value: number, time: number) => { + calls.push([value, time]); + return source.playbackRate as never; + }) as typeof source.playbackRate.setValueAtTime; + + applyOfflinePitchBendAutomation( + source, + 1, + [{ beat: 1, value: 0 }], + 0, + 0.5 + ); + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(0.5); + expect(calls[0][0]).toBeCloseTo(Math.pow(2, -2 / 12), 5); + }); +}); diff --git a/src/core/audio-interface/KGOfflineRenderer.ts b/src/core/audio-interface/KGOfflineRenderer.ts index 209fa32..7554135 100644 --- a/src/core/audio-interface/KGOfflineRenderer.ts +++ b/src/core/audio-interface/KGOfflineRenderer.ts @@ -1,13 +1,24 @@ import * as Tone from 'tone'; import type { KGProject } from '../KGProject'; import type { KGMidiNote } from '../midi/KGMidiNote'; +import type { KGMidiPitchBend } from '../midi/KGMidiPitchBend'; import type { KGAudioRegion } from '../region/KGAudioRegion'; +import type { InstrumentType } from '../track/KGMidiTrack'; import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants'; import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; -import { pitchToNoteNameString } from '../../util/midiUtil'; +import { MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil'; +import { + bakeMidiAutomationPointsInWindow, + collectRegionMidiAutomationPoints, + resolveMidiAutomationValueAtBeat, + type BakedMidiAutomationPoint, + type MidiAutomationPoint, +} from '../../util/midiAutomationUtil'; import { KGToneBuffersPool } from './KGToneBuffersPool'; import { KGToneSamplerFactory } from './KGToneSamplerFactory'; import { KGAudioInterface } from './KGAudioInterface'; +import { KGAudioBus } from './KGAudioBus'; +import { ConfigManager } from '../config/ConfigManager'; import { Mp3Encoder } from '@breezystack/lamejs'; export interface RenderOptions { @@ -104,7 +115,7 @@ export class KGOfflineRenderer { // Pre-collect all the data we need before entering the offline context const midiTrackData: Array<{ trackId: string; - instrumentName: string; + instrumentName: InstrumentType; volume: number; muted: boolean; solo: boolean; @@ -112,6 +123,7 @@ export class KGOfflineRenderer { startBeat: number; notes: Array<{ startBeat: number; endBeat: number; durationBeats: number; pitch: number; velocity: number }>; }>; + pitchBends: MidiAutomationPoint[]; }> = []; const audioTrackData: Array<{ @@ -130,13 +142,14 @@ export class KGOfflineRenderer { }> = []; let hasSoloedTracks = false; + const interpolationIntervalMs = (ConfigManager.instance().get('audio.midi_automation_interpolation_interval_ms') as number) ?? 10; for (const track of tracks) { const trackId = track.getId().toString(); if (track.getType() === 'MIDI') { - const midiTrack = track as unknown as { getInstrument: () => string }; - const instrumentName = String(midiTrack.getInstrument()); + const midiTrack = track as unknown as { getInstrument: () => InstrumentType }; + const instrumentName = midiTrack.getInstrument(); // Get live bus state for volume/mute/solo via public getters const volume = audioInterface.getTrackVolume(trackId); @@ -147,7 +160,7 @@ export class KGOfflineRenderer { const regions: typeof midiTrackData[0]['regions'] = []; for (const region of track.getRegions()) { if (region.getCurrentType() === 'KGMidiRegion') { - const midiRegion = region as unknown as { getNotes: () => KGMidiNote[] }; + const midiRegion = region as unknown as { getNotes: () => KGMidiNote[]; getPitchBends: () => KGMidiPitchBend[] }; if (midiRegion.getNotes) { const notes = midiRegion.getNotes().map(note => ({ startBeat: note.getStartBeat() + region.getStartFromBeat(), @@ -160,8 +173,22 @@ export class KGOfflineRenderer { } } } + const pitchBends = collectRegionMidiAutomationPoints( + track.getRegions() + .filter(region => region.getCurrentType() === 'KGMidiRegion') + .map(region => { + const midiRegion = region as unknown as { getPitchBends: () => KGMidiPitchBend[] }; + return { + startBeat: region.getStartFromBeat(), + points: midiRegion.getPitchBends().map(pitchBend => ({ + beat: pitchBend.getBeat(), + value: pitchBend.getValue(), + })), + }; + }) + ); - midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions }); + midiTrackData.push({ trackId, instrumentName, volume, muted, solo, regions, pitchBends }); } else if (track.getType() === 'Wave') { const volume = audioInterface.getTrackVolume(trackId); const muted = audioInterface.getTrackMuted(trackId); @@ -241,7 +268,7 @@ export class KGOfflineRenderer { const promise = (async () => { try { // Get cached buffers from pool - const audioBuffers = await KGToneBuffersPool.instance().getToneAudioBuffers(trackInfo.instrumentName); + const audioBuffers = await KGToneBuffersPool.instance().getToneAudioBuffers(String(trackInfo.instrumentName)); const pitchRange = FLUIDR3_INSTRUMENT_MAP[trackInfo.instrumentName]?.pitchRange || [21, 108]; const urlMap = KGToneSamplerFactory.instance().convertBuffersToUrls(audioBuffers, pitchRange); @@ -258,6 +285,16 @@ export class KGOfflineRenderer { // Track volumes are stored in dB across the app, with 0 meaning unity gain. sampler.volume.value = getOfflineTrackVolumeDb(trackInfo.volume, trackInfo.muted); sampler.connect(masterGain); + const bakedTrackPitchBends = bakeMidiAutomationPointsInWindow( + trackInfo.pitchBends, + renderStartBeat, + renderEndBeat, + { + maxIntervalMs: interpolationIntervalMs, + bpm: project.getBpm(), + defaultValue: MIDI_PITCH_BEND_CENTER, + } + ); // Schedule all notes for this track for (const regionInfo of trackInfo.regions) { @@ -268,11 +305,32 @@ export class KGOfflineRenderer { const offsetBeat = note.startBeat - renderStartBeat; const noteStartTime = offsetBeat * secondsPerBeat; const noteDuration = note.durationBeats * secondsPerBeat; - const noteName = pitchToNoteNameString(note.pitch); const velocity = note.velocity / 127; + const initialNormalizedPitchBend = midiPitchBendToNormalized( + resolveMidiAutomationValueAtBeat(trackInfo.pitchBends, note.startBeat, MIDI_PITCH_BEND_CENTER) + ); + const offlineSource = createOfflinePitchBendAwareSource( + sampler, + audioBuffers, + trackInfo.instrumentName, + note.pitch, + initialNormalizedPitchBend + ); + if (!offlineSource) { + continue; + } + + const { source, basePlaybackRate } = offlineSource; + applyOfflinePitchBendAutomation( + source, + basePlaybackRate, + bakedTrackPitchBends.filter(point => point.beat > note.startBeat && point.beat < note.endBeat), + renderStartBeat, + secondsPerBeat + ); context.transport.schedule((time) => { - sampler.triggerAttackRelease(noteName, noteDuration, time, velocity); + source.start(time, 0, noteDuration, velocity); }, noteStartTime); } } @@ -418,6 +476,76 @@ function shouldPlay(trackInfo: { muted: boolean; solo: boolean }, hasSoloedTrack return true; } +function setOfflinePlaybackRate( + source: Tone.ToneBufferSource, + value: number, + time: number +): void { + const playbackRate = source.playbackRate as unknown as { + value: number; + setValueAtTime?: (nextValue: number, nextTime: number) => void; + }; + + if (typeof playbackRate.setValueAtTime === 'function') { + playbackRate.setValueAtTime(value, time); + return; + } + + playbackRate.value = value; +} + +function createOfflinePitchBendAwareSource( + sampler: Tone.Sampler, + audioBuffers: Tone.ToneAudioBuffers, + instrumentName: InstrumentType, + pitch: number, + initialNormalizedPitchBend: number +): { source: Tone.ToneBufferSource; basePlaybackRate: number } | null { + const closestPitch = KGAudioBus.findClosestBufferedPitch(instrumentName, audioBuffers, pitch); + if (closestPitch === null) { + return null; + } + + const bufferKey = KGAudioBus.midiPitchToBufferKey(closestPitch); + const buffer = audioBuffers.get(bufferKey); + if (!buffer) { + return null; + } + + const basePlaybackRate = Math.pow(2, (pitch - closestPitch) / 12); + const source = new Tone.ToneBufferSource({ + url: buffer, + fadeIn: sampler.attack, + fadeOut: sampler.release, + curve: sampler.curve, + playbackRate: KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend), + }).connect(sampler.output); + setOfflinePlaybackRate( + source, + KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, initialNormalizedPitchBend), + 0 + ); + + return { source, basePlaybackRate }; +} + +export function applyOfflinePitchBendAutomation( + source: Tone.ToneBufferSource, + basePlaybackRate: number, + bakedPitchBends: BakedMidiAutomationPoint[], + renderStartBeat: number, + secondsPerBeat: number +): void { + bakedPitchBends.forEach(point => { + const automationTime = (point.beat - renderStartBeat) * secondsPerBeat; + setOfflinePlaybackRate( + source, + KGAudioBus.applyNormalizedPitchBendToPlaybackRate(basePlaybackRate, midiPitchBendToNormalized(point.value)), + automationTime + ); + }); +} + export function getOfflineTrackVolumeDb(volumeDb: number, muted: boolean): number { const isSilent = muted || volumeDb <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB; return isSilent ? -Infinity : volumeDb; diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 181574a..4527fb6 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -79,6 +79,7 @@ interface AppConfig { audio: { enable_audio_capture_for_screen_sharing: boolean; lookahead_time: number; + midi_automation_interpolation_interval_ms: number; playback_delay: number; recording_offset: number; }; @@ -253,6 +254,7 @@ export class ConfigManager { audio: { enable_audio_capture_for_screen_sharing: false, lookahead_time: 0.05, + midi_automation_interpolation_interval_ms: 10, playback_delay: 0.2, recording_offset: 0 }, diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index a99935c..d3f2109 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -31,6 +31,8 @@ const mockCore = { getStatus: () => 'Ready', getPlayheadPosition: () => 0, getIsPlaying: () => false, + startPlaying: vi.fn().mockResolvedValue(undefined), + stopPlaying: vi.fn().mockResolvedValue(undefined), }; vi.mock('../core/KGCore', () => ({ @@ -51,6 +53,10 @@ vi.mock('../core/config/ConfigManager', () => ({ describe('projectStore piano roll state', () => { beforeEach(() => { vi.resetModules(); + mockCore.startPlaying.mockReset(); + mockCore.startPlaying.mockResolvedValue(undefined); + mockCore.stopPlaying.mockReset(); + mockCore.stopPlaying.mockResolvedValue(undefined); }); it('clears hybrid state when opening a MIDI region', async () => { @@ -75,4 +81,76 @@ describe('projectStore piano roll state', () => { expect(state.activeRegionId).toBe('midi-b'); expect(state.hybridAudioRegionId).toBeNull(); }); + + it('tracks playback preparation around startPlaying success', async () => { + let resolveStart: (() => void) | null = null; + mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => { + resolveStart = resolve; + })); + + const { useProjectStore } = await import('./projectStore'); + + const startPromise = useProjectStore.getState().startPlaying(); + expect(useProjectStore.getState().isPreparingPlayback).toBe(true); + + await act(async () => { + resolveStart?.(); + await startPromise; + }); + + const state = useProjectStore.getState(); + expect(state.isPreparingPlayback).toBe(false); + expect(state.isPlaying).toBe(true); + }); + + it('clears playback preparation if startPlaying fails', async () => { + mockCore.startPlaying.mockRejectedValueOnce(new Error('prepare failed')); + + const { useProjectStore } = await import('./projectStore'); + + await expect(useProjectStore.getState().startPlaying()).rejects.toThrow('prepare failed'); + expect(useProjectStore.getState().isPreparingPlayback).toBe(false); + }); + + it('ignores repeated startPlaying calls while already preparing', async () => { + let resolveStart: (() => void) | null = null; + mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => { + resolveStart = resolve; + })); + + const { useProjectStore } = await import('./projectStore'); + + const firstStart = useProjectStore.getState().startPlaying(); + const secondStart = useProjectStore.getState().startPlaying(); + + expect(mockCore.startPlaying).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveStart?.(); + await Promise.all([firstStart, secondStart]); + }); + }); + + it('clears playback preparation when stopTransport is called during prepare', async () => { + let resolveStart: (() => void) | null = null; + mockCore.startPlaying.mockImplementationOnce(() => new Promise((resolve) => { + resolveStart = resolve; + })); + + const { useProjectStore } = await import('./projectStore'); + + const startPromise = useProjectStore.getState().startPlaying(); + expect(useProjectStore.getState().isPreparingPlayback).toBe(true); + + await act(async () => { + await useProjectStore.getState().stopTransport(); + }); + + expect(useProjectStore.getState().isPreparingPlayback).toBe(false); + + await act(async () => { + resolveStart?.(); + await startPromise; + }); + }); }); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index 0206d39..907749f 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -68,6 +68,7 @@ interface ProjectState { loopingRange: [number, number]; // [startBar, endBar] - bar indices (0-based) playheadPosition: number; // in beats isPlaying: boolean; + isPreparingPlayback: boolean; autoScrollEnabled: boolean; currentTime: string; // formatted time string @@ -341,6 +342,7 @@ export const useProjectStore = create((set, get) => { loopingRange: currentProject.getLoopingRange(), playheadPosition: KGCore.instance().getPlayheadPosition(), isPlaying: KGCore.instance().getIsPlaying(), + isPreparingPlayback: false, autoScrollEnabled: true, currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), @@ -854,13 +856,26 @@ export const useProjectStore = create((set, get) => { }, startPlaying: async () => { - await KGCore.instance().startPlaying(); - set({ isPlaying: true, autoScrollEnabled: true }); + if (get().isPreparingPlayback) { + return; + } + + set({ isPreparingPlayback: true }); + try { + await KGCore.instance().startPlaying(); + set({ isPlaying: true, autoScrollEnabled: true }); + } finally { + set({ isPreparingPlayback: false }); + } }, stopPlaying: async () => { - await KGCore.instance().stopPlaying(); - set({ isPlaying: false }); + try { + await KGCore.instance().stopPlaying(); + set({ isPlaying: false }); + } finally { + set({ isPreparingPlayback: false }); + } }, stopTransport: async () => { @@ -868,7 +883,11 @@ export const useProjectStore = create((set, get) => { await get().stopRecording(); return; } - await get().stopPlaying(); + try { + await get().stopPlaying(); + } finally { + set({ isPreparingPlayback: false }); + } }, startRecording: async () => { @@ -944,10 +963,15 @@ export const useProjectStore = create((set, get) => { : playheadPosition - timeSignature.numerator; setPlayheadPosition(recordingStartBeat); - await KGCore.instance().startPlaying({ - preserveLoopPreroll: projectLooping, - }); - set({ isPlaying: true, autoScrollEnabled: true }); + set({ isPreparingPlayback: true }); + try { + await KGCore.instance().startPlaying({ + preserveLoopPreroll: projectLooping, + }); + set({ isPlaying: true, autoScrollEnabled: true }); + } finally { + set({ isPreparingPlayback: false }); + } }, stopRecording: async () => { @@ -1002,7 +1026,13 @@ export const useProjectStore = create((set, get) => { await stopPlaying(); setPlayheadPosition(recordingOriginalPlayhead); - set({ isRecording: false, recordingNotes: [], recordingPitchBends: [], recordingTargetRegionId: null }); + set({ + isRecording: false, + isPreparingPlayback: false, + recordingNotes: [], + recordingPitchBends: [], + recordingTargetRegionId: null + }); _lastRecordedPitchBendValue = null; }, diff --git a/src/test/mocks/tone-js.ts b/src/test/mocks/tone-js.ts index 7ab023a..46feb39 100644 --- a/src/test/mocks/tone-js.ts +++ b/src/test/mocks/tone-js.ts @@ -23,7 +23,7 @@ export const mockSampler = { }; export const mockBufferSource = { - playbackRate: { value: 1 }, + playbackRate: { value: 1, setValueAtTime: vi.fn() }, connect: vi.fn(), start: vi.fn(), stop: vi.fn(), @@ -50,7 +50,7 @@ export const mockTone = { BufferSource: vi.fn().mockImplementation(() => { const instance = { ...mockBufferSource, - playbackRate: { value: 1 }, + playbackRate: { value: 1, setValueAtTime: vi.fn() }, }; instance.connect.mockImplementation(() => instance); instance.start.mockImplementation(() => instance); @@ -60,7 +60,7 @@ export const mockTone = { ToneBufferSource: vi.fn().mockImplementation(() => { const instance = { ...mockBufferSource, - playbackRate: { value: 1 }, + playbackRate: { value: 1, setValueAtTime: vi.fn() }, }; instance.connect.mockImplementation(() => instance); instance.start.mockImplementation(() => instance); diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts index 44e26b7..ebc66a3 100644 --- a/src/test/mocks/tone.ts +++ b/src/test/mocks/tone.ts @@ -28,6 +28,7 @@ export const MockBufferSource = vi.fn().mockImplementation((options?: { playback const instance = { playbackRate: { value: options?.playbackRate ?? 1, + setValueAtTime: vi.fn(), }, connect: vi.fn(() => instance), start: vi.fn(() => instance), diff --git a/src/util/midiAutomationUtil.test.ts b/src/util/midiAutomationUtil.test.ts new file mode 100644 index 0000000..50da516 --- /dev/null +++ b/src/util/midiAutomationUtil.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + bakeMidiAutomationPointsInWindow, + collectRegionMidiAutomationPoints, + normalizeMidiAutomationPoints, + resolveMidiAutomationValueAtBeat, + type MidiAutomationPoint, +} from './midiAutomationUtil'; + +const defaultOptions = { + maxIntervalMs: 10, + bpm: 120, + defaultValue: 8192, +}; + +describe('midiAutomationUtil', () => { + it('normalizes points and keeps the last duplicate beat', () => { + const points: MidiAutomationPoint[] = [ + { beat: 2, value: 1000 }, + { beat: 1, value: 2000 }, + { beat: 2, value: 3000 }, + ]; + + expect(normalizeMidiAutomationPoints(points)).toEqual([ + { beat: 1, value: 2000 }, + { beat: 2, value: 3000 }, + ]); + }); + + it('holds the default value before the first point', () => { + const points = [{ beat: 4, value: 0 }]; + + expect(resolveMidiAutomationValueAtBeat(points, 2, 8192)).toBe(8192); + }); + + it('interpolates linearly between two points', () => { + const points = [ + { beat: 0, value: 8192 }, + { beat: 4, value: 0 }, + ]; + + expect(resolveMidiAutomationValueAtBeat(points, 2, 8192)).toBe(4096); + }); + + it('holds the last value after the final point', () => { + const points = [{ beat: 1, value: 2048 }]; + + expect(resolveMidiAutomationValueAtBeat(points, 8, 8192)).toBe(2048); + }); + + it('collects region-local points into absolute beats', () => { + const collected = collectRegionMidiAutomationPoints([ + { startBeat: 4, points: [{ beat: 0.5, value: 7000 }] }, + { startBeat: 1, points: [{ beat: 0.25, value: 6000 }] }, + ]); + + expect(collected).toEqual([ + { beat: 1.25, value: 6000 }, + { beat: 4.5, value: 7000 }, + ]); + }); + + it('bakes dense points according to the requested ms interval', () => { + const points = [ + { beat: 0, value: 8192 }, + { beat: 1, value: 0 }, + ]; + + expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 20 })).toHaveLength(25); + expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 10 })).toHaveLength(50); + expect(bakeMidiAutomationPointsInWindow(points, 0, 1, { ...defaultOptions, maxIntervalMs: 5 })).toHaveLength(100); + }); + + it('treats flat segments as holds without adding interior baked points', () => { + const points = [ + { beat: 0, value: 4096 }, + { beat: 4, value: 4096 }, + ]; + + expect(bakeMidiAutomationPointsInWindow(points, 0, 4, { ...defaultOptions, maxIntervalMs: 10 })).toEqual([ + { beat: 0, value: 4096 }, + ]); + }); + + it('collapses consecutive baked points with the same value', () => { + const points = [ + { beat: 0.5, value: 8192 }, + { beat: 1, value: 4096 }, + { beat: 2, value: 4096 }, + { beat: 3, value: 0 }, + ]; + + expect(bakeMidiAutomationPointsInWindow(points, 0, 4, { ...defaultOptions, maxIntervalMs: 500 })).toEqual([ + { beat: 0, value: 8192 }, + { beat: 1, value: 4096 }, + { beat: 3, value: 0 }, + ]); + }); + + it('keeps later changing segments after skipping a flat segment', () => { + const points = [ + { beat: 0, value: 4096 }, + { beat: 2, value: 4096 }, + { beat: 4, value: 0 }, + ]; + + const baked = bakeMidiAutomationPointsInWindow(points, 0, 4, { ...defaultOptions, maxIntervalMs: 500 }); + + expect(baked).toEqual([ + { beat: 0, value: 4096 }, + { beat: 3, value: 2048 }, + ]); + }); + + it('adds a window anchor and preserves the correct loop boundary value', () => { + const points = [ + { beat: 2, value: 0 }, + { beat: 6, value: 8192 }, + ]; + + const baked = bakeMidiAutomationPointsInWindow(points, 4, 8, { ...defaultOptions, maxIntervalMs: 500 }); + + expect(baked[0]).toEqual({ beat: 4, value: 4096 }); + expect(baked.some(point => point.beat === 5 && point.value === 6144)).toBe(true); + expect(baked.some(point => point.beat === 6 && point.value === 8192)).toBe(true); + }); +}); diff --git a/src/util/midiAutomationUtil.ts b/src/util/midiAutomationUtil.ts new file mode 100644 index 0000000..dbddec3 --- /dev/null +++ b/src/util/midiAutomationUtil.ts @@ -0,0 +1,171 @@ +export interface MidiAutomationPoint { + beat: number; + value: number; +} + +export interface BakedMidiAutomationPoint { + beat: number; + value: number; +} + +export interface MidiAutomationBakeOptions { + maxIntervalMs: number; + bpm: number; + defaultValue: number; +} + +const BEAT_EPSILON = 1e-9; + +function appendPoint(points: BakedMidiAutomationPoint[], nextPoint: BakedMidiAutomationPoint): void { + const lastPoint = points[points.length - 1]; + if (!lastPoint) { + points.push(nextPoint); + return; + } + + if (Math.abs(lastPoint.beat - nextPoint.beat) <= BEAT_EPSILON) { + points[points.length - 1] = nextPoint; + return; + } + + if (lastPoint.value === nextPoint.value) { + return; + } + + points.push(nextPoint); +} + +function interpolateBetweenPoints(startPoint: MidiAutomationPoint, endPoint: MidiAutomationPoint, beat: number): number { + if (Math.abs(endPoint.beat - startPoint.beat) <= BEAT_EPSILON) { + return endPoint.value; + } + + const progress = (beat - startPoint.beat) / (endPoint.beat - startPoint.beat); + return startPoint.value + ((endPoint.value - startPoint.value) * progress); +} + +function getMaxIntervalBeats(options: MidiAutomationBakeOptions): number { + if (!Number.isFinite(options.maxIntervalMs) || options.maxIntervalMs <= 0) { + return Number.POSITIVE_INFINITY; + } + + return (options.maxIntervalMs / 1000) * (options.bpm / 60); +} + +export function normalizeMidiAutomationPoints(points: MidiAutomationPoint[]): MidiAutomationPoint[] { + const sortedPoints = [...points] + .filter(point => Number.isFinite(point.beat) && Number.isFinite(point.value)) + .sort((a, b) => a.beat - b.beat); + const normalizedPoints: MidiAutomationPoint[] = []; + + sortedPoints.forEach(point => { + const lastPoint = normalizedPoints[normalizedPoints.length - 1]; + if (lastPoint && Math.abs(lastPoint.beat - point.beat) <= BEAT_EPSILON) { + normalizedPoints[normalizedPoints.length - 1] = point; + return; + } + + normalizedPoints.push(point); + }); + + return normalizedPoints; +} + +export function collectRegionMidiAutomationPoints( + regions: Array<{ startBeat: number; points: MidiAutomationPoint[] }> +): MidiAutomationPoint[] { + return normalizeMidiAutomationPoints( + regions.flatMap(region => region.points.map(point => ({ + beat: region.startBeat + point.beat, + value: point.value, + }))) + ); +} + +export function resolveMidiAutomationValueAtBeat( + points: MidiAutomationPoint[], + beat: number, + defaultValue: number +): number { + const normalizedPoints = normalizeMidiAutomationPoints(points); + if (normalizedPoints.length === 0) { + return defaultValue; + } + + let previousPoint: MidiAutomationPoint | null = null; + for (const point of normalizedPoints) { + if (beat < point.beat) { + if (!previousPoint) { + return defaultValue; + } + + return interpolateBetweenPoints(previousPoint, point, beat); + } + + previousPoint = point; + } + + return previousPoint?.value ?? defaultValue; +} + +export function bakeMidiAutomationPointsInWindow( + points: MidiAutomationPoint[], + windowStartBeat: number, + windowEndBeat: number, + options: MidiAutomationBakeOptions +): BakedMidiAutomationPoint[] { + const normalizedPoints = normalizeMidiAutomationPoints(points); + const anchorPoint = { + beat: windowStartBeat, + value: resolveMidiAutomationValueAtBeat(normalizedPoints, windowStartBeat, options.defaultValue), + }; + + if (windowEndBeat <= windowStartBeat) { + return [anchorPoint]; + } + + const bakedPoints: BakedMidiAutomationPoint[] = [anchorPoint]; + if (normalizedPoints.length === 0) { + return bakedPoints; + } + + const firstPoint = normalizedPoints[0]; + if (windowStartBeat < firstPoint.beat && firstPoint.beat < windowEndBeat) { + appendPoint(bakedPoints, { beat: firstPoint.beat, value: firstPoint.value }); + } + + const maxIntervalBeats = getMaxIntervalBeats(options); + for (let index = 0; index < normalizedPoints.length - 1; index += 1) { + const startPoint = normalizedPoints[index]; + const endPoint = normalizedPoints[index + 1]; + const overlapStartBeat = Math.max(windowStartBeat, startPoint.beat); + const overlapEndBeat = Math.min(windowEndBeat, endPoint.beat); + + if (overlapEndBeat - overlapStartBeat <= BEAT_EPSILON) { + continue; + } + + if (startPoint.value === endPoint.value) { + continue; + } + + const segmentLengthBeats = overlapEndBeat - overlapStartBeat; + const segmentCount = Number.isFinite(maxIntervalBeats) + ? Math.max(1, Math.ceil(segmentLengthBeats / maxIntervalBeats)) + : 1; + + for (let segmentIndex = 1; segmentIndex <= segmentCount; segmentIndex += 1) { + const beat = overlapStartBeat + ((segmentLengthBeats * segmentIndex) / segmentCount); + if (beat >= windowEndBeat - BEAT_EPSILON) { + continue; + } + + appendPoint(bakedPoints, { + beat, + value: interpolateBetweenPoints(startPoint, endPoint, beat), + }); + } + } + + return bakedPoints; +}