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

- +
- handleMidiAutomationInterpolationIntervalChange(e.target.value)} + > + + + + +
+ Controls how densely MIDI automation are baked for playback and bounce. Smaller intervals sound smoother but schedule more events. +
+
+