From 490cd19f687b7d7ff0e9d2ed3a7e19d0a7a857c2 Mon Sep 17 00:00:00 2001
From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com>
Date: Sat, 9 May 2026 15:57:46 -0700
Subject: [PATCH] feat: added audio recording feature
---
README.md | 17 +-
public/config.json | 2 +
src/components/Toolbar.tsx | 12 +-
src/components/settings/SettingsPanel.tsx | 7 +-
src/components/settings/SettingsSidebar.tsx | 3 +-
src/components/settings/index.ts | 3 +-
.../sections/AudioIOSettings.test.tsx | 92 ++++++
.../settings/sections/AudioIOSettings.tsx | 243 +++++++++++++++
src/components/track/Region.css | 5 +
src/components/track/RegionItem.test.tsx | 40 +++
src/components/track/RegionItem.tsx | 72 ++++-
src/components/track/TrackGridItem.test.tsx | 81 +++++
src/components/track/TrackGridItem.tsx | 32 +-
src/core/KGCore.ts | 13 +
src/core/audio-interface/KGAudioInterface.ts | 50 +++
src/core/audio-interface/KGAudioRecorder.ts | 289 ++++++++++++++++++
src/core/config/ConfigManager.ts | 4 +
src/hooks/useGlobalKeyboardHandler.ts | 9 +-
src/main.tsx | 33 ++
src/stores/projectStore.test.ts | 79 ++++-
src/stores/projectStore.ts | 280 ++++++++++++++++-
src/test/mocks/audio-interface.ts | 4 +
src/util/audioDeviceUtil.test.ts | 48 +++
src/util/audioDeviceUtil.ts | 160 ++++++++++
24 files changed, 1540 insertions(+), 38 deletions(-)
create mode 100644 src/components/settings/sections/AudioIOSettings.test.tsx
create mode 100644 src/components/settings/sections/AudioIOSettings.tsx
create mode 100644 src/components/track/TrackGridItem.test.tsx
create mode 100644 src/core/audio-interface/KGAudioRecorder.ts
create mode 100644 src/util/audioDeviceUtil.test.ts
create mode 100644 src/util/audioDeviceUtil.ts
diff --git a/README.md b/README.md
index f9c2180..a702683 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
## Latest Updates
+- **2026.05.09**: Added **audio recording** — record directly from your microphone into an audio track. A live waveform preview grows in real time as you record, and the region is committed to the timeline as a standard audio region when you stop. Added **audio I/O device selection** in Settings so you can choose your preferred microphone input and audio output device.
+
- **2026.05.08**: Added **MIDI automation** — draw and edit pitch bend and MIDI CC curves (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) in an editable automation lane below the piano grid. Added **track-level automation**: each track now has a dedicated automation panel where you can view and edit the same curves directly on the timeline. Real-time MIDI controller input (pitch wheel, CC pedals) is recorded and played back with per-lane interpolation. Added the **List Event Panel** — a tabbed sidebar (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. Added **region multi-select** with lasso and bulk move/resize, and **merge MIDI regions**.
@@ -314,16 +316,25 @@ Split an existing audio region into individual stems (e.g. vocals, instruments,
Feature priorities might change.
+### 1.0
+
- [X] More instruments
- [X] Automated testing (unit tests, integration tests, etc.)
- [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D)
- [X] Support track control automations (e.g. sustain, volume, pan, etc.)
- [X] Support MIDI control events (e.g. CC, pitch bend, etc.)
- [X] Support WAV audio tracks
-- [ ] Filters and effects
-- [ ] MCP Support
+- [X] Recording
+- [ ] List Event + List Region
- [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`)
-- [ ] Automatically compact conversations when the context window runs low on space
+
+### Post 1.0
+
+- [ ] Stuff notation
+- [ ] Filters and effects
+- [ ] Virtual MIDI device output
+- [ ] Enhanced AI Music Assistant Agent
+
## Help Needed
diff --git a/public/config.json b/public/config.json
index 08c71f8..df321dc 100644
--- a/public/config.json
+++ b/public/config.json
@@ -74,8 +74,10 @@
},
"audio": {
"enable_audio_capture_for_screen_sharing": false,
+ "input_device_id": "default",
"lookahead_time": 0.05,
"midi_automation_interpolation_interval_ms": 10,
+ "output_device_id": "default",
"playback_delay": 0.2,
"recording_offset": 0
},
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx
index aabae6c..1abddae 100644
--- a/src/components/Toolbar.tsx
+++ b/src/components/Toolbar.tsx
@@ -17,6 +17,7 @@ import {
import { KGProject, type KeySignature } from '../core/KGProject';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
+import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { plainToInstance } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState';
@@ -53,7 +54,7 @@ const Toolbar: React.FC = () => {
// Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
// Selection state
- selectedRegionIds,
+ selectedRegionIds, selectedTrackId,
// Playhead and refresh
playheadPosition, refreshProjectState,
requestMainContentScroll, requestPianoRollScroll
@@ -987,7 +988,14 @@ const Toolbar: React.FC = () => {
const handleRecordClick = async () => {
if (isRecording) {
await stopRecording();
- setStatus("Recording stopped — notes committed");
+ setStatus("Recording stopped");
+ return;
+ }
+
+ const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
+ if (selectedTrack instanceof KGAudioTrack) {
+ await startRecording();
+ setStatus("Audio recording started...");
return;
}
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
index b4e6c5a..8a4a1ab 100644
--- a/src/components/settings/SettingsPanel.tsx
+++ b/src/components/settings/SettingsPanel.tsx
@@ -3,10 +3,11 @@ import './Settings.css';
import SettingsSidebar from './SettingsSidebar';
import GeneralSettings from './sections/GeneralSettings';
import BehaviorSettings from './sections/BehaviorSettings';
+import AudioIOSettings from './sections/AudioIOSettings';
import TemplatesSettings from './sections/TemplatesSettings';
import ChordGuideSettings from './sections/ChordGuideSettings';
-export type SettingsSection = 'general' | 'behavior' | 'templates' | 'chord_guide';
+export type SettingsSection = 'general' | 'audio_io' | 'behavior' | 'templates' | 'chord_guide';
interface SettingsPanelProps {
onClose: () => void;
@@ -21,6 +22,8 @@ const SettingsPanel: React.FC
= ({ onClose }) => {
return ;
case 'behavior':
return ;
+ case 'audio_io':
+ return ;
case 'templates':
return ;
case 'chord_guide':
@@ -46,4 +49,4 @@ const SettingsPanel: React.FC = ({ onClose }) => {
);
};
-export default SettingsPanel;
\ No newline at end of file
+export default SettingsPanel;
diff --git a/src/components/settings/SettingsSidebar.tsx b/src/components/settings/SettingsSidebar.tsx
index d85f1c1..e192acd 100644
--- a/src/components/settings/SettingsSidebar.tsx
+++ b/src/components/settings/SettingsSidebar.tsx
@@ -15,6 +15,7 @@ const SettingsSidebar: React.FC = ({
}) => {
const sections = [
{ id: 'general' as SettingsSection, label: 'General' },
+ { id: 'audio_io' as SettingsSection, label: 'Audio I/O' },
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
{ id: 'templates' as SettingsSection, label: 'Templates' },
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
@@ -48,4 +49,4 @@ const SettingsSidebar: React.FC = ({
);
};
-export default SettingsSidebar;
\ No newline at end of file
+export default SettingsSidebar;
diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts
index 5da492f..277c806 100644
--- a/src/components/settings/index.ts
+++ b/src/components/settings/index.ts
@@ -1,5 +1,6 @@
export { default as SettingsPanel } from './SettingsPanel.tsx';
export { default as SettingsSidebar } from './SettingsSidebar.tsx';
export { default as GeneralSettings } from './sections/GeneralSettings.tsx';
+export { default as AudioIOSettings } from './sections/AudioIOSettings.tsx';
export { default as BehaviorSettings } from './sections/BehaviorSettings.tsx';
-export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
\ No newline at end of file
+export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
diff --git a/src/components/settings/sections/AudioIOSettings.test.tsx b/src/components/settings/sections/AudioIOSettings.test.tsx
new file mode 100644
index 0000000..ac83bae
--- /dev/null
+++ b/src/components/settings/sections/AudioIOSettings.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import AudioIOSettings from './AudioIOSettings';
+
+const configState = new Map([
+ ['audio.input_device_id', 'default'],
+ ['audio.output_device_id', 'default'],
+]);
+
+const configManagerMock = {
+ getIsInitialized: vi.fn(() => true),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ get: vi.fn((key: string) => configState.get(key)),
+ set: vi.fn(async (key: string, value: unknown) => {
+ configState.set(key, value);
+ }),
+};
+
+vi.mock('../../../core/config/ConfigManager', () => ({
+ ConfigManager: {
+ instance: () => configManagerMock,
+ },
+}));
+
+vi.mock('../../../util/audioDeviceUtil', () => ({
+ enumerateAudioDevices: vi.fn().mockResolvedValue({
+ inputs: [
+ { deviceId: 'default', label: 'System Default', kind: 'audioinput', isDefault: true },
+ { deviceId: 'mic-1', label: 'Studio Mic', kind: 'audioinput', isDefault: false },
+ ],
+ outputs: [
+ { deviceId: 'default', label: 'System Default', kind: 'audiooutput', isDefault: true },
+ { deviceId: 'speaker-1', label: 'Monitor Out', kind: 'audiooutput', isDefault: false },
+ ],
+ labelsAvailable: true,
+ canSelectOutput: true,
+ canWatchDeviceChanges: false,
+ }),
+ getDefaultAudioDeviceOption: vi.fn((kind: 'audioinput' | 'audiooutput') => ({
+ deviceId: 'default',
+ label: 'System Default',
+ kind,
+ isDefault: true,
+ })),
+ promptForAudioOutputDevice: vi.fn().mockResolvedValue({
+ deviceId: 'speaker-1',
+ label: 'Monitor Out',
+ kind: 'audiooutput',
+ isDefault: false,
+ }),
+ supportsAudioContextSinkSelection: vi.fn(() => false),
+}));
+
+describe('AudioIOSettings', () => {
+ beforeEach(() => {
+ configState.set('audio.input_device_id', 'default');
+ configState.set('audio.output_device_id', 'default');
+ configManagerMock.get.mockClear();
+ configManagerMock.set.mockClear();
+ });
+
+ it('renders input/output selectors and refresh action', async () => {
+ render( );
+
+ expect(await screen.findByText('Audio I/O')).toBeTruthy();
+ expect(screen.getByLabelText('Audio Input Device')).toBeTruthy();
+ expect(screen.getByLabelText('Audio Output Device')).toBeTruthy();
+ expect(screen.getByText('Refresh Device List')).toBeTruthy();
+ });
+
+ it('persists input device changes', async () => {
+ render( );
+
+ const select = await screen.findByLabelText('Audio Input Device');
+ fireEvent.change(select, { target: { value: 'mic-1' } });
+
+ await waitFor(() => {
+ expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'mic-1');
+ });
+ });
+
+ it('resolves missing saved devices to System Default when validation is conclusive', async () => {
+ configState.set('audio.input_device_id', 'missing-input');
+
+ render( );
+
+ await waitFor(() => {
+ expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'default');
+ });
+ });
+});
diff --git a/src/components/settings/sections/AudioIOSettings.tsx b/src/components/settings/sections/AudioIOSettings.tsx
new file mode 100644
index 0000000..f5b67f4
--- /dev/null
+++ b/src/components/settings/sections/AudioIOSettings.tsx
@@ -0,0 +1,243 @@
+import React, { useEffect, useState } from 'react';
+import { ConfigManager } from '../../../core/config/ConfigManager';
+import {
+ enumerateAudioDevices,
+ getDefaultAudioDeviceOption,
+ promptForAudioOutputDevice,
+ supportsAudioContextSinkSelection,
+ type AudioDeviceOption,
+} from '../../../util/audioDeviceUtil';
+
+const AudioIOSettings: React.FC = () => {
+ const [inputDeviceId, setInputDeviceId] = useState('default');
+ const [outputDeviceId, setOutputDeviceId] = useState('default');
+ const [inputs, setInputs] = useState([getDefaultAudioDeviceOption('audioinput')]);
+ const [outputs, setOutputs] = useState([getDefaultAudioDeviceOption('audiooutput')]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [deviceStatus, setDeviceStatus] = useState('');
+ const [supportsOutputPrompt, setSupportsOutputPrompt] = useState(false);
+ const [supportsOutputSink, setSupportsOutputSink] = useState(false);
+
+ const configManager = ConfigManager.instance();
+
+ useEffect(() => {
+ const initialize = async () => {
+ if (!configManager.getIsInitialized()) {
+ await configManager.initialize();
+ }
+
+ setInputDeviceId((configManager.get('audio.input_device_id') as string | undefined) ?? 'default');
+ setOutputDeviceId((configManager.get('audio.output_device_id') as string | undefined) ?? 'default');
+ setSupportsOutputSink(supportsAudioContextSinkSelection());
+ await refreshDevices();
+ setLoading(false);
+ };
+
+ void initialize();
+
+ const mediaDevices = navigator.mediaDevices;
+ const handleDeviceChange = () => {
+ void refreshDevices(false);
+ };
+
+ if (mediaDevices && typeof mediaDevices.addEventListener === 'function') {
+ mediaDevices.addEventListener('devicechange', handleDeviceChange);
+ return () => {
+ mediaDevices.removeEventListener('devicechange', handleDeviceChange);
+ };
+ }
+
+ return undefined;
+ }, [configManager]);
+
+ const refreshDevices = async (showStatus: boolean = true) => {
+ setRefreshing(true);
+ try {
+ const snapshot = await enumerateAudioDevices();
+ setSupportsOutputPrompt(snapshot.canSelectOutput);
+
+ const configuredInputId = (configManager.get('audio.input_device_id') as string | undefined) ?? 'default';
+ const configuredOutputId = (configManager.get('audio.output_device_id') as string | undefined) ?? 'default';
+
+ const hasInput = snapshot.inputs.some(device => device.deviceId === configuredInputId);
+ const hasOutput = snapshot.outputs.some(device => device.deviceId === configuredOutputId);
+ const nextInputs = !hasInput && configuredInputId !== 'default' && !snapshot.labelsAvailable
+ ? [
+ ...snapshot.inputs,
+ {
+ deviceId: configuredInputId,
+ label: 'Previously Selected Input (permission required to verify)',
+ kind: 'audioinput' as const,
+ isDefault: false,
+ },
+ ]
+ : snapshot.inputs;
+ const nextOutputs = !hasOutput && configuredOutputId !== 'default' && !snapshot.labelsAvailable
+ ? [
+ ...snapshot.outputs,
+ {
+ deviceId: configuredOutputId,
+ label: 'Previously Selected Output (permission required to verify)',
+ kind: 'audiooutput' as const,
+ isDefault: false,
+ },
+ ]
+ : snapshot.outputs;
+
+ setInputs(nextInputs);
+ setOutputs(nextOutputs);
+
+ if (!hasInput && configuredInputId !== 'default' && snapshot.labelsAvailable) {
+ setInputDeviceId('default');
+ await configManager.set('audio.input_device_id', 'default');
+ setDeviceStatus('Previously selected audio input device is unavailable; using System Default.');
+ } else {
+ setInputDeviceId(hasInput || !snapshot.labelsAvailable ? configuredInputId : 'default');
+ }
+
+ if (!hasOutput && configuredOutputId !== 'default' && snapshot.labelsAvailable) {
+ setOutputDeviceId('default');
+ await configManager.set('audio.output_device_id', 'default');
+ setDeviceStatus('Previously selected audio output device is unavailable; using System Default.');
+ } else {
+ setOutputDeviceId(hasOutput || !snapshot.labelsAvailable ? configuredOutputId : 'default');
+ }
+
+ if (showStatus) {
+ setDeviceStatus(current => current || 'Audio device list refreshed.');
+ }
+ } catch (error) {
+ console.error('Unable to refresh audio devices:', error);
+ setDeviceStatus('Unable to read audio devices from the browser.');
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ const handleInputDeviceChange = async (value: string) => {
+ setInputDeviceId(value);
+ await configManager.set('audio.input_device_id', value);
+ setDeviceStatus(value === 'default'
+ ? 'Audio input will use System Default on the next recording session.'
+ : 'Audio input will change on the next recording session.');
+ };
+
+ const handleOutputDeviceChange = async (value: string) => {
+ setOutputDeviceId(value);
+ await configManager.set('audio.output_device_id', value);
+ setDeviceStatus(value === 'default'
+ ? 'Audio output will use System Default after refresh.'
+ : 'Audio output device saved. Refresh the page to apply it in v1.');
+ };
+
+ const handleChooseOutputDevice = async () => {
+ try {
+ const selectedDevice = await promptForAudioOutputDevice();
+ if (!selectedDevice) {
+ setDeviceStatus('This browser does not support prompting for audio output devices.');
+ return;
+ }
+
+ setOutputDeviceId(selectedDevice.deviceId);
+ await configManager.set('audio.output_device_id', selectedDevice.deviceId);
+ await refreshDevices(false);
+ setDeviceStatus('Output device selected. Refresh the page to apply it in v1.');
+ } catch (error) {
+ console.error('Unable to choose audio output device:', error);
+ setDeviceStatus('The browser did not allow selecting a non-default output device.');
+ }
+ };
+
+ return (
+
+
+
Audio I/O
+
+
+
+
+
+
Device Routing
+ void refreshDevices()}
+ disabled={refreshing}
+ >
+ {refreshing ? 'Refreshing…' : 'Refresh Device List'}
+
+
+
+
+ Choose the devices KGStudio should use for recording and playback. Input changes apply to the next recording session. Output changes require a page refresh in v1.
+
+
+
+
+ Audio Input Device
+
+
void handleInputDeviceChange(e.target.value)}
+ disabled={loading}
+ >
+ {inputs.map(device => (
+
+ {device.label}
+
+ ))}
+
+
+ Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.
+
+
+
+
+
+ Audio Output Device
+
+
void handleOutputDeviceChange(e.target.value)}
+ disabled={loading}
+ >
+ {outputs.map(device => (
+
+ {device.label}
+
+ ))}
+
+ {supportsOutputPrompt && (
+
+ void handleChooseOutputDevice()}>
+ Choose Output Device…
+
+
+ )}
+
+ Output-device changes require refresh in v1. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported.
+
+ {!supportsOutputSink && (
+
+ This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.
+
+ )}
+
+
+ {deviceStatus && (
+
+ {deviceStatus}
+
+ )}
+
+
+
+ );
+};
+
+export default AudioIOSettings;
diff --git a/src/components/track/Region.css b/src/components/track/Region.css
index 620911c..e112248 100644
--- a/src/components/track/Region.css
+++ b/src/components/track/Region.css
@@ -97,6 +97,11 @@
border-color: rgba(255, 255, 255, 0.7);
}
+.track-region[data-preview-region='true'] {
+ opacity: 0.55;
+ pointer-events: none;
+}
+
.track-region.audio-region .region-header {
background-color: #4a8b5a;
}
diff --git a/src/components/track/RegionItem.test.tsx b/src/components/track/RegionItem.test.tsx
index ee903b9..5076ff0 100644
--- a/src/components/track/RegionItem.test.tsx
+++ b/src/components/track/RegionItem.test.tsx
@@ -19,6 +19,10 @@ describe('RegionItem', () => {
value: vi.fn(() => ({
clearRect: vi.fn(),
fillRect: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ stroke: vi.fn(),
})),
});
@@ -107,4 +111,40 @@ describe('RegionItem', () => {
expect(onDrag).toHaveBeenCalledWith('midi-1', 10, 0);
expect(onDragEnd).toHaveBeenCalledWith('midi-1');
});
+
+ it('renders preview waveform peaks on the canvas for recording previews', () => {
+ const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext');
+ const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 120,
+ bottom: 60,
+ width: 120,
+ height: 60,
+ toJSON: () => ({}),
+ });
+ const { container } = renderRegion({
+ audioRegion: undefined,
+ midiRegion: undefined,
+ previewWaveformPeaks: [
+ { min: -0.25, max: 0.5 },
+ { min: -0.5, max: 0.25 },
+ ],
+ isPreview: true,
+ });
+
+ const context = getContextSpy.mock.results[0]?.value as {
+ beginPath: ReturnType;
+ lineTo: ReturnType;
+ stroke: ReturnType;
+ };
+
+ expect(container.querySelector('[data-preview-region="true"]')).toBeTruthy();
+ expect(context.beginPath).toHaveBeenCalled();
+ expect(context.lineTo).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ rectSpy.mockRestore();
+ });
});
diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx
index 81072b5..9f92bd5 100644
--- a/src/components/track/RegionItem.tsx
+++ b/src/components/track/RegionItem.tsx
@@ -8,6 +8,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState';
+import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
const DRAG_START_THRESHOLD_PX = 4;
@@ -42,6 +43,8 @@ interface RegionItemProps {
// Audio region data for rendering waveform
audioRegion?: KGAudioRegion;
audioBuffer?: AudioBuffer;
+ previewWaveformPeaks?: AudioRecordingPeak[];
+ isPreview?: boolean;
}
const RegionItem: React.FC = ({
@@ -65,7 +68,9 @@ const RegionItem: React.FC = ({
onFineMoveEnd,
midiRegion,
audioRegion,
- audioBuffer
+ audioBuffer,
+ previewWaveformPeaks,
+ isPreview = false,
}) => {
// Get selection state and time signature from store
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
@@ -303,6 +308,41 @@ const RegionItem: React.FC = ({
ctx.stroke();
};
+ const renderPreviewWaveformOnCanvas = () => {
+ if (!canvasRef.current || !regionContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return;
+
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const contentRect = regionContentRef.current.getBoundingClientRect();
+ const width = contentRect.width;
+ const height = contentRect.height;
+
+ canvas.width = width;
+ canvas.height = height;
+ ctx.clearRect(0, 0, width, height);
+
+ const centerY = height / 2;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+
+ for (let x = 0; x < width; x++) {
+ const peakIndex = Math.min(
+ previewWaveformPeaks.length - 1,
+ Math.floor((x / Math.max(1, width)) * previewWaveformPeaks.length)
+ );
+ const peak = previewWaveformPeaks[peakIndex];
+ const yMin = centerY - peak.max * centerY;
+ const yMax = centerY - peak.min * centerY;
+ ctx.moveTo(x, yMin);
+ ctx.lineTo(x, yMax);
+ }
+
+ ctx.stroke();
+ };
+
// Create a stable reference to track note changes
const notesRef = useRef('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
@@ -325,19 +365,23 @@ const RegionItem: React.FC = ({
// Set up canvas when component mounts or updates
useEffect(() => {
- if (audioRegion && audioBuffer) {
+ if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
+ renderPreviewWaveformOnCanvas();
+ } else if (audioRegion && audioBuffer) {
renderWaveformOnCanvas();
} else {
renderNotesOnCanvas();
}
- }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
+ }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
// Re-render canvas when region content size changes
useEffect(() => {
if (!regionContentRef.current) return;
const resizeObserver = new ResizeObserver(() => {
- if (audioRegion && audioBuffer) {
+ if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
+ renderPreviewWaveformOnCanvas();
+ } else if (audioRegion && audioBuffer) {
renderWaveformOnCanvas();
} else {
renderNotesOnCanvas();
@@ -351,12 +395,13 @@ const RegionItem: React.FC = ({
resizeObserver.unobserve(regionContentRef.current);
}
};
- }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
+ }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent) => {
// Skip if already resizing or dragging
if (isResizingRef.current || isDraggingRef.current) return;
+ if (isPreview) return;
// Disable move and resize when pencil tool is active
const activeTool = KGMainContentState.instance().getActiveTool();
@@ -402,6 +447,10 @@ const RegionItem: React.FC = ({
// Handle mouse down for resize or drag
const handleMouseDown = (e: React.MouseEvent) => {
// Disable move and resize when pencil tool is active
+ if (isPreview) {
+ return;
+ }
+
const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') {
// Still allow click events to pass through for region selection
@@ -604,11 +653,12 @@ const RegionItem: React.FC = ({
= ({
{name}
-
+ {!isPreview &&
{!audioRegion && (
= ({
{fineDeltaDisplay}
)}
-
+
}
diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx
new file mode 100644
index 0000000..b92c91f
--- /dev/null
+++ b/src/components/track/TrackGridItem.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import TrackGridItem from './TrackGridItem';
+import { KGAudioTrack } from '../../core/track/KGAudioTrack';
+
+vi.mock('../../stores/projectStore', () => ({
+ useProjectStore: (selector?: (state: {
+ selectedRegionIds: string[];
+ activeTrackAutomationTrackId: string | null;
+ activeTrackAutomationType: null;
+ trackAutomationRedrawVersion: number;
+ recordingMode: 'audio' | 'midi' | null;
+ recordingTargetTrackIndex: number | null;
+ recordingCommitStartBeatAbsolute: number;
+ recordingAudioPreviewCurrentBeat: number;
+ recordingAudioPreviewPeaks: Array<{ min: number; max: number }>;
+ recordingAudioPreviewFileName: string | null;
+ timeSignature: { numerator: number; denominator: number };
+ }) => unknown) => {
+ const state = {
+ selectedRegionIds: [],
+ activeTrackAutomationTrackId: null,
+ activeTrackAutomationType: null,
+ trackAutomationRedrawVersion: 0,
+ recordingMode: 'audio' as const,
+ recordingTargetTrackIndex: 0,
+ recordingCommitStartBeatAbsolute: 4,
+ recordingAudioPreviewCurrentBeat: 8,
+ recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }],
+ recordingAudioPreviewFileName: 'Recording',
+ timeSignature: { numerator: 4, denominator: 4 },
+ };
+ return selector ? selector(state) : state;
+ },
+}));
+
+describe('TrackGridItem recording preview', () => {
+ beforeAll(() => {
+ Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
+ value: vi.fn(() => ({
+ clearRect: vi.fn(),
+ fillRect: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ stroke: vi.fn(),
+ })),
+ });
+
+ class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
+ });
+
+ it('renders a non-interactive preview region on the recording audio track', () => {
+ const track = new KGAudioTrack('Audio Track', 1);
+ track.setTrackIndex(0);
+
+ const view = render(
+