feat: added audio recording feature

This commit is contained in:
Xiaohan-Tian
2026-05-09 15:57:46 -07:00
parent aa6ffd32dd
commit 490cd19f68
24 changed files with 1540 additions and 38 deletions
+10 -2
View File
@@ -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;
}
+5 -2
View File
@@ -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<SettingsPanelProps> = ({ onClose }) => {
return <GeneralSettings />;
case 'behavior':
return <BehaviorSettings />;
case 'audio_io':
return <AudioIOSettings />;
case 'templates':
return <TemplatesSettings />;
case 'chord_guide':
@@ -46,4 +49,4 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
);
};
export default SettingsPanel;
export default SettingsPanel;
+2 -1
View File
@@ -15,6 +15,7 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
}) => {
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<SettingsSidebarProps> = ({
);
};
export default SettingsSidebar;
export default SettingsSidebar;
+2 -1
View File
@@ -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';
export { default as TemplatesSettings } from './sections/TemplatesSettings.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<string, unknown>([
['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(<AudioIOSettings />);
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(<AudioIOSettings />);
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(<AudioIOSettings />);
await waitFor(() => {
expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'default');
});
});
});
@@ -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<string>('default');
const [outputDeviceId, setOutputDeviceId] = useState<string>('default');
const [inputs, setInputs] = useState<AudioDeviceOption[]>([getDefaultAudioDeviceOption('audioinput')]);
const [outputs, setOutputs] = useState<AudioDeviceOption[]>([getDefaultAudioDeviceOption('audiooutput')]);
const [loading, setLoading] = useState<boolean>(true);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [deviceStatus, setDeviceStatus] = useState<string>('');
const [supportsOutputPrompt, setSupportsOutputPrompt] = useState<boolean>(false);
const [supportsOutputSink, setSupportsOutputSink] = useState<boolean>(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 (
<div className="settings-section">
<div className="settings-section-header">
<h3>Audio I/O</h3>
</div>
<div className="settings-section-content">
<div className="settings-group">
<div className="settings-group-header">
<h4>Device Routing</h4>
<button
type="button"
className="settings-btn"
onClick={() => void refreshDevices()}
disabled={refreshing}
>
{refreshing ? 'Refreshing…' : 'Refresh Device List'}
</button>
</div>
<div className="settings-description">
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.
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="audio-input-device-select">
Audio Input Device
</label>
<select
id="audio-input-device-select"
className="settings-select"
value={inputDeviceId}
onChange={(e) => void handleInputDeviceChange(e.target.value)}
disabled={loading}
>
{inputs.map(device => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.
</div>
</div>
<div className="settings-item">
<label className="settings-label" htmlFor="audio-output-device-select">
Audio Output Device
</label>
<select
id="audio-output-device-select"
className="settings-select"
value={outputDeviceId}
onChange={(e) => void handleOutputDeviceChange(e.target.value)}
disabled={loading}
>
{outputs.map(device => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
{supportsOutputPrompt && (
<div style={{ marginTop: '10px' }}>
<button type="button" className="settings-btn" onClick={() => void handleChooseOutputDevice()}>
Choose Output Device
</button>
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
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.
</div>
{!supportsOutputSink && (
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '6px' }}>
This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.
</div>
)}
</div>
{deviceStatus && (
<div className="settings-help" style={{ fontSize: '12px', color: '#9bc17c', marginTop: '8px' }}>
{deviceStatus}
</div>
)}
</div>
</div>
</div>
);
};
export default AudioIOSettings;
+5
View File
@@ -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;
}
+40
View File
@@ -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<typeof vi.fn>;
lineTo: ReturnType<typeof vi.fn>;
stroke: ReturnType<typeof vi.fn>;
};
expect(container.querySelector('[data-preview-region="true"]')).toBeTruthy();
expect(context.beginPath).toHaveBeenCalled();
expect(context.lineTo).toHaveBeenCalled();
expect(context.stroke).toHaveBeenCalled();
rectSpy.mockRestore();
});
});
+61 -11
View File
@@ -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<RegionItemProps> = ({
@@ -65,7 +68,9 @@ const RegionItem: React.FC<RegionItemProps> = ({
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<RegionItemProps> = ({
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<string>('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
@@ -325,19 +365,23 @@ const RegionItem: React.FC<RegionItemProps> = ({
// 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<RegionItemProps> = ({
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<HTMLDivElement>) => {
// 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<RegionItemProps> = ({
// Handle mouse down for resize or drag
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
// 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<RegionItemProps> = ({
<div
key={id}
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? (isPrimarySelected ? 'selected' : 'selected-secondary') : ''} ${audioRegion ? 'audio-region' : ''}`}
style={{ ...style, cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
style={{ ...style, cursor: isPreview ? 'default' : cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
onMouseMove={isPreview ? undefined : handleMouseMove}
onMouseLeave={isPreview ? undefined : handleMouseLeave}
onMouseDown={isPreview ? undefined : handleMouseDown}
data-region-id={id}
data-preview-region={isPreview ? 'true' : 'false'}
data-resize-edge={resizeEdge}
data-is-resizing={isResizing}
data-is-dragging={isDragging}
@@ -617,7 +667,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
{name}
</div>
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
<div className="region-left-buttons">
{!isPreview && <div className="region-left-buttons">
{!audioRegion && (
<button
className="region-pencil-btn"
@@ -718,7 +768,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
<span className="region-fine-move-label">{fineDeltaDisplay}</span>
)}
</div>
</div>
</div>}
<canvas ref={canvasRef} />
</div>
</div>
@@ -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(
<TrackGridItem
track={track}
index={0}
isDragging={false}
isDragOver={false}
regions={[]}
maxBars={8}
selectedRegionId={null}
gridContainerRef={{ current: document.createElement('div') }}
onDoubleClick={vi.fn()}
/>
);
const previewRegion = view.container.querySelector('[data-preview-region="true"]');
expect(previewRegion).toBeTruthy();
});
});
+31 -1
View File
@@ -66,6 +66,13 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
const recordingMode = useProjectStore(state => state.recordingMode);
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
const recordingAudioPreviewCurrentBeat = useProjectStore(state => state.recordingAudioPreviewCurrentBeat);
const recordingAudioPreviewPeaks = useProjectStore(state => state.recordingAudioPreviewPeaks);
const recordingAudioPreviewFileName = useProjectStore(state => state.recordingAudioPreviewFileName);
const storeTimeSignature = useProjectStore(state => state.timeSignature);
const [containerWidth, setContainerWidth] = useState(0);
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
@@ -543,6 +550,16 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
// Filter regions for this track
const trackRegions = regions.filter(region => region.trackIndex === index);
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
const shouldRenderRecordingPreview = recordingMode === 'audio'
&& recordingTargetTrackIndex === index
&& recordingAudioPreviewCurrentBeat >= recordingCommitStartBeatAbsolute;
const previewRegionStyle = shouldRenderRecordingPreview
? {
left: `${(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) * (containerWidth / maxBars)}px`,
width: `${Math.max(0, ((recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator) * (containerWidth / maxBars))}px`,
position: 'absolute' as const,
}
: null;
return (
<div
@@ -626,12 +643,25 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
/>
);
})}
{shouldRenderRecordingPreview && previewRegionStyle && (
<RegionItem
key="audio-recording-preview"
id="audio-recording-preview"
name={recordingAudioPreviewFileName ?? 'Recording'}
style={previewRegionStyle}
barNumber={(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) + 1}
length={(recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator}
trackIndex={index}
previewWaveformPeaks={recordingAudioPreviewPeaks}
isPreview
/>
)}
{isAutomationActive && activeTrackAutomationType && (
<TrackAutomationLane
track={track}
automationType={activeTrackAutomationType}
maxBars={maxBars}
timeSignature={useProjectStore.getState().timeSignature}
timeSignature={storeTimeSignature}
redrawVersion={trackAutomationRedrawVersion}
/>
)}
+13
View File
@@ -49,6 +49,7 @@ export class KGCore {
// Callback for external state updates (e.g., store)
private playheadUpdateCallback: ((position: number) => void) | null = null;
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
private loopBoundaryReachedCallback: ((loopEndBeat: number) => void) | null = null;
// Selection change callbacks for store synchronization
private selectionChangeCallbacks: (() => void)[] = [];
@@ -200,6 +201,10 @@ export class KGCore {
this.playbackStateChangeCallback = callback;
}
public setLoopBoundaryReachedCallback(callback: ((loopEndBeat: number) => void) | null): void {
this.loopBoundaryReachedCallback = callback;
}
// Selection change callback management
public onSelectionChanged(callback: () => void): void {
this.selectionChangeCallbacks.push(callback);
@@ -414,6 +419,14 @@ export class KGCore {
// Wrap playhead position within loop range
if (newPosition >= loopEndBeats) {
if (this.loopBoundaryReachedCallback) {
const callback = this.loopBoundaryReachedCallback;
this.loopBoundaryReachedCallback = null;
this.setPlayheadPosition(loopEndBeats);
callback(loopEndBeats);
return;
}
// Calculate how far we've overshot and wrap back
const overshot = newPosition - loopEndBeats;
newPosition = loopStartBeats + (overshot % loopLengthBeats);
@@ -24,6 +24,12 @@ import {
import * as Tone from 'tone';
import { KGAudioBus } from './KGAudioBus';
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
import {
KGAudioRecorder,
type AudioRecordingPeak,
type AudioRecordingResult,
type AudioRecordingStartResult,
} from './KGAudioRecorder';
import type { InstrumentType } from '../track/KGMidiTrack';
import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
@@ -80,6 +86,9 @@ export class KGAudioInterface {
private captureDestination: MediaStreamAudioDestinationNode | null = null;
private captureStream: MediaStream | null = null;
// Microphone recorder
private audioRecorder: KGAudioRecorder = new KGAudioRecorder();
// Private constructor to prevent direct instantiation
private constructor() {
console.log("KGAudioInterface initialized");
@@ -194,6 +203,8 @@ export class KGAudioInterface {
this.captureDestination = null;
this.captureStream = null;
}
await this.audioRecorder.cancel();
this.isInitialized = false;
this.isAudioContextStarted = false;
@@ -1071,6 +1082,45 @@ export class KGAudioInterface {
}
}
public async startAudioRecording(
inputDeviceId: string = 'default',
onPeaks?: (peaks: AudioRecordingPeak[]) => void
): Promise<AudioRecordingStartResult> {
return await this.audioRecorder.start(inputDeviceId, onPeaks);
}
public async stopAudioRecording(): Promise<AudioRecordingResult | null> {
return await this.audioRecorder.stop();
}
public async cancelAudioRecording(): Promise<void> {
await this.audioRecorder.cancel();
}
public async applyConfiguredOutputDevice(outputDeviceId: string): Promise<boolean> {
if (outputDeviceId === 'default') {
return false;
}
const rawContext = Tone.getContext().rawContext as AudioContext & {
setSinkId?: (sinkId: string) => Promise<void>;
sinkId?: string;
};
if (typeof rawContext.setSinkId !== 'function') {
return false;
}
try {
await rawContext.setSinkId(outputDeviceId);
console.log(`Applied audio output device ${outputDeviceId}`);
return true;
} catch (error) {
console.warn(`Unable to apply audio output device ${outputDeviceId}:`, error);
return false;
}
}
/**
* Clear all scheduled events
*/
+289
View File
@@ -0,0 +1,289 @@
import * as Tone from 'tone';
export interface AudioRecordingPeak {
min: number;
max: number;
}
export interface AudioRecordingResult {
blob: Blob;
mimeType: string;
durationSeconds: number;
peaks: AudioRecordingPeak[];
}
export interface AudioRecordingStartResult {
usedDeviceId: string;
fellBackToDefault: boolean;
}
export class KGAudioRecorder {
private static readonly DEBUG_LOG_PREFIX = '[KGAudioRecorder]';
private static readonly PEAK_LOG_EVERY_FRAMES = 20;
private mediaStream: MediaStream | null = null;
private mediaRecorder: MediaRecorder | null = null;
private audioSource: MediaStreamAudioSourceNode | null = null;
private analyserNode: AnalyserNode | null = null;
private peakPollIntervalId: number | null = null;
private chunks: Blob[] = [];
private peaks: AudioRecordingPeak[] = [];
private recordingStartedAtMs: number | null = null;
private onPeaks: ((peaks: AudioRecordingPeak[]) => void) | null = null;
private peakFrameCount: number = 0;
public async start(
inputDeviceId: string = 'default',
onPeaks?: (peaks: AudioRecordingPeak[]) => void
): Promise<AudioRecordingStartResult> {
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
throw new Error('Audio recording is already in progress.');
}
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('This browser does not support microphone recording.');
}
if (typeof MediaRecorder === 'undefined') {
throw new Error('This browser does not support MediaRecorder.');
}
this.cleanup(false);
this.onPeaks = onPeaks ?? null;
this.chunks = [];
this.peaks = [];
this.peakFrameCount = 0;
let stream: MediaStream;
let usedDeviceId = inputDeviceId;
let fellBackToDefault = false;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: inputDeviceId === 'default'
? true
: { deviceId: { exact: inputDeviceId } },
});
} catch (error) {
if (inputDeviceId === 'default') {
throw error;
}
console.warn(`${KGAudioRecorder.DEBUG_LOG_PREFIX} selected input unavailable, retrying default input`, {
inputDeviceId,
error,
});
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
usedDeviceId = 'default';
fellBackToDefault = true;
}
this.mediaStream = stream;
this.logInputTracks(stream);
const audioContext = Tone.getContext().rawContext as AudioContext;
this.audioSource = audioContext.createMediaStreamSource(stream);
this.analyserNode = audioContext.createAnalyser();
this.analyserNode.fftSize = 2048;
this.audioSource.connect(this.analyserNode);
const mimeType = this.getPreferredMimeType();
this.mediaRecorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder created`,
{
mimeType: this.mediaRecorder.mimeType || mimeType || 'browser-default',
audioContextState: audioContext.state,
sampleRate: audioContext.sampleRate,
}
);
this.mediaRecorder.ondataavailable = (event: BlobEvent) => {
if (event.data.size > 0) {
this.chunks.push(event.data);
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} dataavailable`,
{ chunkBytes: event.data.size, totalChunks: this.chunks.length }
);
}
};
this.mediaRecorder.onerror = (event) => {
console.error(`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder error`, event);
};
this.mediaRecorder.start(250);
this.recordingStartedAtMs = performance.now();
console.info(`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording started`);
this.startPeakPolling();
return {
usedDeviceId,
fellBackToDefault,
};
}
public async stop(): Promise<AudioRecordingResult | null> {
const recorder = this.mediaRecorder;
if (!recorder) {
return null;
}
if (recorder.state === 'inactive') {
const blob = this.chunks.length > 0
? new Blob(this.chunks, { type: recorder.mimeType || 'audio/webm' })
: null;
const durationSeconds = this.recordingStartedAtMs === null
? 0
: Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
this.cleanup(false);
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} stop requested on inactive recorder`,
{ blobBytes: blob?.size ?? 0, peakFrames: this.peaks.length, durationSeconds }
);
return blob
? { blob, mimeType: blob.type || recorder.mimeType || 'audio/webm', durationSeconds, peaks: [...this.peaks] }
: null;
}
return await new Promise<AudioRecordingResult | null>((resolve) => {
const handleStop = () => {
recorder.removeEventListener('stop', handleStop);
this.capturePeakFrame();
const mimeType = recorder.mimeType || 'audio/webm';
const blob = new Blob(this.chunks, { type: mimeType });
const durationSeconds = this.recordingStartedAtMs === null
? 0
: Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
const peaks = [...this.peaks];
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording stopped`,
{
mimeType,
blobBytes: blob.size,
peakFrames: peaks.length,
durationSeconds,
}
);
this.cleanup(false);
resolve(blob.size > 0 ? { blob, mimeType: blob.type || mimeType, durationSeconds, peaks } : null);
};
recorder.addEventListener('stop', handleStop, { once: true });
recorder.stop();
});
}
public async cancel(): Promise<void> {
const recorder = this.mediaRecorder;
if (recorder && recorder.state !== 'inactive') {
await new Promise<void>((resolve) => {
recorder.addEventListener('stop', () => resolve(), { once: true });
recorder.stop();
});
}
this.cleanup(false);
}
private startPeakPolling(): void {
this.stopPeakPolling();
this.peakPollIntervalId = window.setInterval(() => {
this.capturePeakFrame();
}, 50);
}
private stopPeakPolling(): void {
if (this.peakPollIntervalId !== null) {
window.clearInterval(this.peakPollIntervalId);
this.peakPollIntervalId = null;
}
}
private capturePeakFrame(): void {
if (!this.analyserNode) {
return;
}
const buffer = new Float32Array(this.analyserNode.fftSize);
this.analyserNode.getFloatTimeDomainData(buffer);
let min = 1;
let max = -1;
for (const sample of buffer) {
if (sample < min) min = sample;
if (sample > max) max = sample;
}
this.peaks.push({ min, max });
this.peakFrameCount += 1;
if (this.peakFrameCount === 1 || this.peakFrameCount % KGAudioRecorder.PEAK_LOG_EVERY_FRAMES === 0) {
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} analyser peak`,
{
frame: this.peakFrameCount,
min: Number(min.toFixed(4)),
max: Number(max.toFixed(4)),
span: Number((max - min).toFixed(4)),
}
);
}
this.onPeaks?.([...this.peaks]);
}
private getPreferredMimeType(): string | undefined {
const candidates = [
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/mp4',
'audio/webm',
];
return candidates.find(type => typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(type));
}
private cleanup(resetPeaks: boolean): void {
this.stopPeakPolling();
if (this.audioSource) {
this.audioSource.disconnect();
this.audioSource = null;
}
if (this.analyserNode) {
this.analyserNode.disconnect();
this.analyserNode = null;
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop());
this.mediaStream = null;
}
this.mediaRecorder = null;
this.recordingStartedAtMs = null;
this.onPeaks = null;
this.peakFrameCount = 0;
if (resetPeaks) {
this.peaks = [];
this.chunks = [];
}
}
private logInputTracks(stream: MediaStream): void {
const tracks = stream.getAudioTracks();
console.info(
`${KGAudioRecorder.DEBUG_LOG_PREFIX} acquired audio stream`,
tracks.map(track => ({
id: track.id,
label: track.label,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
settings: typeof track.getSettings === 'function' ? track.getSettings() : {},
}))
);
}
}
+4
View File
@@ -78,8 +78,10 @@ interface AppConfig {
};
audio: {
enable_audio_capture_for_screen_sharing: boolean;
input_device_id: string;
lookahead_time: number;
midi_automation_interpolation_interval_ms: number;
output_device_id: string;
playback_delay: number;
recording_offset: number;
};
@@ -253,8 +255,10 @@ export class ConfigManager {
},
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
},
+8 -1
View File
@@ -8,6 +8,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
import { KGCore } from '../core/KGCore';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { showAlert } from '../util/dialogUtil';
/**
@@ -164,7 +165,13 @@ export const useGlobalKeyboardHandler = () => {
event.preventDefault();
if (isRecording) {
stopRecording();
setStatus('Recording stopped — notes committed');
setStatus('Recording stopped');
return;
}
const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === useProjectStore.getState().selectedTrackId) ?? null;
if (selectedTrack instanceof KGAudioTrack) {
startRecording();
setStatus('Audio recording started...');
return;
}
const candidateId = activeRegionId ?? lastSelectedRegionId;
+33
View File
@@ -7,8 +7,10 @@ import App from './App.tsx';
import DialogProvider from './components/common/DialogProvider';
import { KGCore } from './core/KGCore';
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
import { ConfigManager } from './core/config/ConfigManager';
import { KGMidiInput } from './core/midi-input/KGMidiInput';
import { KGDebugger } from './core/KGDebugger';
import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
const root = createRoot(document.getElementById('root')!);
@@ -58,6 +60,37 @@ if (!window.isSecureContext) {
// Initialize KGCore instance
await KGCore.instance().initialize();
const configManager = ConfigManager.instance();
try {
const deviceSnapshot = await enumerateAudioDevices();
const validatedDevices = validateConfiguredAudioDevices(
configManager.get('audio.input_device_id') as string | undefined,
configManager.get('audio.output_device_id') as string | undefined,
deviceSnapshot
);
const updates: Array<Promise<void>> = [];
const statusMessages: string[] = [];
if (validatedDevices.inputFellBackToDefault) {
updates.push(configManager.set('audio.input_device_id', 'default'));
statusMessages.push('Previously selected audio input device is unavailable; using System Default.');
}
if (validatedDevices.outputFellBackToDefault) {
updates.push(configManager.set('audio.output_device_id', 'default'));
statusMessages.push('Previously selected audio output device is unavailable; using System Default.');
}
if (updates.length > 0) {
await Promise.all(updates);
KGCore.instance().setStatus(statusMessages.join(' '));
}
await KGAudioInterface.instance().applyConfiguredOutputDevice(validatedDevices.outputDeviceId);
} catch (error) {
console.warn('Audio device validation skipped:', error);
}
// Initialize KGMidiInput instance
await KGMidiInput.instance().initialize();
+76 -3
View File
@@ -1,24 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { KGTrack } from '../core/track/KGTrack';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
const mockProject = {
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
getMaxBars: () => 32,
getBarWidthMultiplier: () => 1,
getTracks: () => [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')],
getTracks: () => mockTracks,
getBpm: () => 120,
getKeySignature: () => 'C major',
getName: () => 'Test Project',
getSelectedMode: () => 'major',
getIsLooping: () => false,
getLoopingRange: () => null,
getLoopingRange: () => [0, 0] as [number, number],
};
const mockAudioInterface = {
getTransportPosition: vi.fn().mockReturnValue(8),
startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }),
stopAudioRecording: vi.fn().mockResolvedValue(null),
cancelAudioRecording: vi.fn().mockResolvedValue(undefined),
};
const configValues = new Map<string, unknown>([
['audio.input_device_id', 'default'],
]);
const mockCore = {
getCurrentProject: () => mockProject,
setPlayheadUpdateCallback: vi.fn(),
setPlaybackStateChangeCallback: vi.fn(),
setLoopBoundaryReachedCallback: vi.fn(),
getSelectedItems: () => [],
onSelectionChanged: vi.fn(),
canUndo: () => false,
@@ -30,6 +44,7 @@ const mockCore = {
clearSelectedItems: vi.fn(),
getStatus: () => 'Ready',
getPlayheadPosition: () => 0,
setPlayheadPosition: vi.fn(),
getIsPlaying: () => false,
startPlaying: vi.fn().mockResolvedValue(undefined),
stopPlaying: vi.fn().mockResolvedValue(undefined),
@@ -41,22 +56,43 @@ vi.mock('../core/KGCore', () => ({
},
}));
vi.mock('../core/audio-interface/KGAudioInterface', () => ({
KGAudioInterface: {
instance: () => mockAudioInterface,
},
}));
vi.mock('../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => ({
getIsInitialized: () => true,
get: () => false,
get: (key: string) => configValues.get(key),
set: vi.fn(async (key: string, value: unknown) => {
configValues.set(key, value);
}),
}),
},
}));
describe('projectStore piano roll state', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
mockCore.startPlaying.mockReset();
mockCore.startPlaying.mockResolvedValue(undefined);
mockCore.stopPlaying.mockReset();
mockCore.stopPlaying.mockResolvedValue(undefined);
mockCore.setLoopBoundaryReachedCallback.mockReset();
mockAudioInterface.startAudioRecording.mockReset();
mockAudioInterface.startAudioRecording.mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false });
mockAudioInterface.stopAudioRecording.mockReset();
mockAudioInterface.stopAudioRecording.mockResolvedValue(null);
mockAudioInterface.cancelAudioRecording.mockReset();
mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
mockAudioInterface.getTransportPosition.mockReset();
mockAudioInterface.getTransportPosition.mockReturnValue(8);
configValues.set('audio.input_device_id', 'default');
});
it('clears hybrid state when opening a MIDI region', async () => {
@@ -153,4 +189,41 @@ describe('projectStore piano roll state', () => {
await startPromise;
});
});
it('starts and stops audio-track recording without requiring a selected region', async () => {
const { KGAudioTrack } = await import('../core/track/KGAudioTrack');
const audioTrack = new KGAudioTrack('Audio 1', 1);
audioTrack.setTrackIndex(0);
mockTracks = [audioTrack];
const { useProjectStore } = await import('./projectStore');
act(() => {
useProjectStore.getState().setSelectedTrack('1');
useProjectStore.getState().setPlayheadPosition(8);
});
await act(async () => {
await useProjectStore.getState().startRecording();
});
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
});
expect(mockCore.startPlaying).toHaveBeenCalledWith({ preserveLoopPreroll: false });
expect(mockAudioInterface.startAudioRecording).toHaveBeenCalled();
expect(useProjectStore.getState().recordingMode).toBe('audio');
expect(useProjectStore.getState().recordingCommitStartBeatAbsolute).toBe(8);
await act(async () => {
await useProjectStore.getState().stopTransport();
});
expect(mockAudioInterface.stopAudioRecording).toHaveBeenCalled();
expect(useProjectStore.getState().isRecording).toBe(false);
expect(useProjectStore.getState().recordingMode).toBeNull();
expect(useProjectStore.getState().playheadPosition).toBe(8);
});
});
+267 -13
View File
@@ -25,6 +25,7 @@ import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
/**
* Update CSS custom property for time signature numerator
@@ -114,11 +115,19 @@ interface ProjectState {
// Recording state
isRecording: boolean;
recordingMode: 'midi' | 'audio' | null;
recordingTargetRegionId: string | null;
recordingTargetTrackId: string | null;
recordingTargetTrackIndex: number | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
recordingPitchBends: Array<{ beat: number; value: number }>;
recordingControllerEventsByType: Array<Array<{ beat: number; value: number }>>;
recordingOriginalPlayhead: number;
recordingStartBeatAbsolute: number;
recordingCommitStartBeatAbsolute: number;
recordingAudioPreviewPeaks: AudioRecordingPeak[];
recordingAudioPreviewCurrentBeat: number;
recordingAudioPreviewFileName: string | null;
// Undo/redo state
canUndo: boolean;
@@ -225,6 +234,9 @@ let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }>
let _recordingRegionStartBeat: number = 0;
let _lastRecordedPitchBendValue: number | null = null;
let _lastRecordedControllerValues: Map<number, number> = new Map();
let _audioRecordingStartTimeoutId: number | null = null;
let _audioRecordingForcedStopBeatAbsolute: number | null = null;
let _audioRecordingHasStarted: boolean = false;
function createEmptyRecordedControllerBuckets(): Array<Array<{ beat: number; value: number }>> {
return Array.from({ length: 128 }, () => []);
@@ -253,6 +265,20 @@ function finalizeRecordedNote(startBeat: number, candidateEndBeat: number): numb
return candidateEndBeat;
}
function clearPendingAudioRecordingStart(): void {
if (_audioRecordingStartTimeoutId !== null) {
window.clearTimeout(_audioRecordingStartTimeoutId);
_audioRecordingStartTimeoutId = null;
}
}
function getAudioRecordingExtension(mimeType: string): string {
if (mimeType.includes('ogg')) return 'ogg';
if (mimeType.includes('mp4')) return 'm4a';
if (mimeType.includes('wav')) return 'wav';
return 'webm';
}
// Create the store
export const useProjectStore = create<ProjectState>((set, get) => {
const currentProject = KGCore.instance().getCurrentProject();
@@ -273,10 +299,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Set up playhead update callback to keep store in sync during playback
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
const { bpm, timeSignature } = get();
set({
set(state => ({
playheadPosition: position,
currentTime: beatsToTimeString(position, bpm, timeSignature)
});
currentTime: beatsToTimeString(position, bpm, timeSignature),
recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio'
? Math.max(state.recordingCommitStartBeatAbsolute, position)
: state.recordingAudioPreviewCurrentBeat,
}));
});
// Keep store isPlaying in sync when core auto-stops (e.g., at maxBars)
@@ -420,11 +449,19 @@ export const useProjectStore = create<ProjectState>((set, get) => {
// Initial recording state
isRecording: false,
recordingMode: null,
recordingTargetRegionId: null,
recordingTargetTrackId: null,
recordingTargetTrackIndex: null,
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingOriginalPlayhead: 0,
recordingStartBeatAbsolute: 0,
recordingCommitStartBeatAbsolute: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
// Initial cross-component scroll request state
mainContentScrollRequest: null,
@@ -847,6 +884,20 @@ export const useProjectStore = create<ProjectState>((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
isRecording: false,
recordingMode: null,
recordingTargetRegionId: null,
recordingTargetTrackId: null,
recordingTargetTrackIndex: null,
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingOriginalPlayhead: 0,
recordingStartBeatAbsolute: 0,
recordingCommitStartBeatAbsolute: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
playheadPosition: 0, // Ensure store state is also updated
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
});
@@ -930,7 +981,99 @@ export const useProjectStore = create<ProjectState>((set, get) => {
},
startRecording: async () => {
const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
const {
activeRegionId,
timeSignature,
playheadPosition,
setPlayheadPosition,
selectedTrackId,
tracks,
} = get();
const selectedTrack = tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
if (selectedTrack instanceof KGAudioTrack) {
const project = KGCore.instance().getCurrentProject();
const beatsPerBar = timeSignature.numerator;
const projectLooping = project.getIsLooping();
const [loopStartBar] = project.getLoopingRange();
const loopStartBeat = loopStartBar * beatsPerBar;
const recordingCommitStartBeatAbsolute = projectLooping ? loopStartBeat : playheadPosition;
const recordingStartBeatAbsolute = recordingCommitStartBeatAbsolute - beatsPerBar;
const previewFileName = `Recording_${new Date().toISOString().replace(/[:.]/g, '-')}`;
clearPendingAudioRecordingStart();
_audioRecordingForcedStopBeatAbsolute = null;
_audioRecordingHasStarted = false;
set({
isRecording: true,
recordingMode: 'audio',
recordingTargetRegionId: null,
recordingTargetTrackId: selectedTrack.getId().toString(),
recordingTargetTrackIndex: selectedTrack.getTrackIndex(),
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingOriginalPlayhead: playheadPosition,
recordingStartBeatAbsolute,
recordingCommitStartBeatAbsolute,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: recordingCommitStartBeatAbsolute,
recordingAudioPreviewFileName: previewFileName,
});
KGCore.instance().setLoopBoundaryReachedCallback(projectLooping
? (loopEndBeat: number) => {
_audioRecordingForcedStopBeatAbsolute = loopEndBeat;
void get().stopRecording();
}
: null);
setPlayheadPosition(recordingStartBeatAbsolute);
set({ isPreparingPlayback: true });
try {
await KGCore.instance().startPlaying({
preserveLoopPreroll: projectLooping,
});
set({ isPlaying: true, autoScrollEnabled: true });
const prerollMs = Math.max(0, ((recordingCommitStartBeatAbsolute - recordingStartBeatAbsolute) * (60 / project.getBpm())) * 1000);
_audioRecordingStartTimeoutId = window.setTimeout(() => {
_audioRecordingStartTimeoutId = null;
const inputDeviceId = (ConfigManager.instance().get('audio.input_device_id') as string | undefined) ?? 'default';
void KGAudioInterface.instance().startAudioRecording(inputDeviceId, (peaks) => {
set({ recordingAudioPreviewPeaks: peaks });
}).then((startResult) => {
_audioRecordingHasStarted = true;
if (startResult.fellBackToDefault) {
void ConfigManager.instance().set('audio.input_device_id', 'default');
get().setStatus('Previously selected audio input device is unavailable; using System Default.');
}
}).catch(async (error) => {
console.error('Failed to start audio recording:', error);
await KGAudioInterface.instance().cancelAudioRecording();
KGCore.instance().setLoopBoundaryReachedCallback(null);
await get().stopPlaying();
setPlayheadPosition(playheadPosition);
set({
isRecording: false,
recordingMode: null,
recordingTargetTrackId: null,
recordingTargetTrackIndex: null,
recordingStartBeatAbsolute: 0,
recordingCommitStartBeatAbsolute: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
});
get().setStatus(error instanceof Error ? error.message : 'Unable to start audio recording.');
});
}, prerollMs);
} finally {
set({ isPreparingPlayback: false });
}
return;
}
const project = KGCore.instance().getCurrentProject();
let targetRegion: KGMidiRegion | null = null;
@@ -945,13 +1088,28 @@ export const useProjectStore = create<ProjectState>((set, get) => {
_lastRecordedPitchBendValue = null;
_lastRecordedControllerValues = new Map();
const projectLooping = project.getIsLooping();
const [loopStartBar] = project.getLoopingRange();
const loopStartBeat = loopStartBar * timeSignature.numerator;
const recordingStartBeat = projectLooping
? loopStartBeat - timeSignature.numerator
: playheadPosition - timeSignature.numerator;
set({
isRecording: true,
recordingMode: 'midi',
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingTargetRegionId: activeRegionId,
recordingTargetTrackId: null,
recordingTargetTrackIndex: null,
recordingOriginalPlayhead: playheadPosition,
recordingStartBeatAbsolute: recordingStartBeat,
recordingCommitStartBeatAbsolute: targetRegion.getStartFromBeat(),
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
});
const buildCorrectedBeat = (): number => {
@@ -1009,13 +1167,6 @@ export const useProjectStore = create<ProjectState>((set, get) => {
}
);
const projectLooping = project.getIsLooping();
const [loopStartBar] = project.getLoopingRange();
const loopStartBeat = loopStartBar * timeSignature.numerator;
const recordingStartBeat = projectLooping
? loopStartBeat - timeSignature.numerator
: playheadPosition - timeSignature.numerator;
setPlayheadPosition(recordingStartBeat);
set({ isPreparingPlayback: true });
try {
@@ -1030,16 +1181,109 @@ export const useProjectStore = create<ProjectState>((set, get) => {
stopRecording: async () => {
const {
recordingMode,
recordingNotes,
recordingPitchBends,
recordingControllerEventsByType,
recordingTargetRegionId,
recordingTargetTrackId,
recordingTargetTrackIndex,
recordingOriginalPlayhead,
recordingCommitStartBeatAbsolute,
recordingAudioPreviewFileName,
stopPlaying,
setPlayheadPosition,
refreshProjectState
refreshProjectState,
projectName,
maxBars,
} = get();
if (recordingMode === 'audio') {
clearPendingAudioRecordingStart();
KGCore.instance().setLoopBoundaryReachedCallback(null);
const stopBeatAbsolute = _audioRecordingForcedStopBeatAbsolute
?? Math.max(recordingCommitStartBeatAbsolute, KGAudioInterface.instance().getTransportPosition());
_audioRecordingForcedStopBeatAbsolute = null;
const recordingResult = _audioRecordingHasStarted
? await KGAudioInterface.instance().stopAudioRecording()
: (await KGAudioInterface.instance().cancelAudioRecording(), null);
_audioRecordingHasStarted = false;
if (
recordingResult &&
recordingTargetTrackId &&
recordingTargetTrackIndex !== null &&
stopBeatAbsolute > recordingCommitStartBeatAbsolute
) {
try {
const extension = getAudioRecordingExtension(recordingResult.mimeType);
const fileName = `${recordingAudioPreviewFileName ?? 'Recording'}.${extension}`;
const audioFile = new File([recordingResult.blob], fileName, { type: recordingResult.mimeType });
const fileId = KGAudioFileStorage.generateAudioFileId(fileName);
const arrayBuffer = await audioFile.arrayBuffer();
const toneBuffer = new Tone.ToneAudioBuffer();
await new Promise<void>((resolve, reject) => {
const audioContext = Tone.getContext().rawContext as AudioContext;
audioContext.decodeAudioData(
arrayBuffer.slice(0),
(decoded) => { toneBuffer.set(decoded); resolve(); },
(err) => reject(err)
);
});
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId().toString() === recordingTargetTrackId);
if (track) {
await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile);
KGAudioInterface.instance().loadAudioBufferForTrack(recordingTargetTrackId, fileId, toneBuffer);
const prevMaxBars = maxBars;
const durationInBeats = stopBeatAbsolute - recordingCommitStartBeatAbsolute;
const beatsPerBar = KGCore.instance().getCurrentProject().getTimeSignature().numerator;
const endBarNumber = Math.ceil((recordingCommitStartBeatAbsolute + durationInBeats) / beatsPerBar);
const newMaxBars = Math.max(prevMaxBars, endBarNumber);
const command = new ImportAudioCommand(
track.getId(),
recordingTargetTrackIndex,
fileId,
fileName,
toneBuffer.duration,
recordingCommitStartBeatAbsolute,
durationInBeats,
prevMaxBars,
newMaxBars
);
KGCore.instance().executeCommand(command);
refreshProjectState();
}
} catch (error) {
console.error('Failed to finalize audio recording:', error);
}
}
await stopPlaying();
setPlayheadPosition(recordingOriginalPlayhead);
set({
isRecording: false,
recordingMode: null,
isPreparingPlayback: false,
recordingTargetRegionId: null,
recordingTargetTrackId: null,
recordingTargetTrackIndex: null,
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingStartBeatAbsolute: 0,
recordingCommitStartBeatAbsolute: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
});
return;
}
// Finalize any held keys
const finalNotes = [...recordingNotes];
const finalPitchBends = [...recordingPitchBends];
@@ -1109,11 +1353,19 @@ export const useProjectStore = create<ProjectState>((set, get) => {
setPlayheadPosition(recordingOriginalPlayhead);
set({
isRecording: false,
recordingMode: null,
isPreparingPlayback: false,
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingTargetRegionId: null
recordingTargetRegionId: null,
recordingTargetTrackId: null,
recordingTargetTrackIndex: null,
recordingStartBeatAbsolute: 0,
recordingCommitStartBeatAbsolute: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
recordingAudioPreviewFileName: null,
});
_lastRecordedPitchBendValue = null;
_lastRecordedControllerValues = new Map();
@@ -1315,6 +1567,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
recordingAudioPreviewPeaks: [],
recordingAudioPreviewCurrentBeat: 0,
});
// Clear any selected items
+4
View File
@@ -35,6 +35,10 @@ export const mockAudioInterface = {
getCurrentBeat: vi.fn().mockReturnValue(0),
getTransportPosition: vi.fn().mockReturnValue(0),
setBpm: vi.fn().mockReturnValue(undefined),
startAudioRecording: vi.fn().mockResolvedValue(undefined),
stopAudioRecording: vi.fn().mockResolvedValue(null),
cancelAudioRecording: vi.fn().mockResolvedValue(undefined),
applyConfiguredOutputDevice: vi.fn().mockResolvedValue(false),
// Singleton pattern
getInstance: vi.fn().mockReturnThis(),
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
getDefaultAudioDeviceOption,
validateConfiguredAudioDevices,
type AudioDeviceSnapshot,
} from './audioDeviceUtil';
describe('audioDeviceUtil', () => {
it('falls back to System Default when saved devices are missing and labels are available', () => {
const snapshot: AudioDeviceSnapshot = {
inputs: [
getDefaultAudioDeviceOption('audioinput'),
{ deviceId: 'mic-1', label: 'Mic 1', kind: 'audioinput', isDefault: false },
],
outputs: [
getDefaultAudioDeviceOption('audiooutput'),
{ deviceId: 'speaker-1', label: 'Speaker 1', kind: 'audiooutput', isDefault: false },
],
labelsAvailable: true,
canSelectOutput: true,
canWatchDeviceChanges: true,
};
const result = validateConfiguredAudioDevices('missing-input', 'missing-output', snapshot);
expect(result.inputDeviceId).toBe('default');
expect(result.outputDeviceId).toBe('default');
expect(result.inputFellBackToDefault).toBe(true);
expect(result.outputFellBackToDefault).toBe(true);
});
it('preserves saved devices when labels are unavailable and validation is inconclusive', () => {
const snapshot: AudioDeviceSnapshot = {
inputs: [getDefaultAudioDeviceOption('audioinput')],
outputs: [getDefaultAudioDeviceOption('audiooutput')],
labelsAvailable: false,
canSelectOutput: false,
canWatchDeviceChanges: false,
};
const result = validateConfiguredAudioDevices('saved-input', 'saved-output', snapshot);
expect(result.inputDeviceId).toBe('saved-input');
expect(result.outputDeviceId).toBe('saved-output');
expect(result.inputFellBackToDefault).toBe(false);
expect(result.outputFellBackToDefault).toBe(false);
});
});
+160
View File
@@ -0,0 +1,160 @@
export type AudioDeviceKind = 'audioinput' | 'audiooutput';
export interface AudioDeviceOption {
deviceId: string;
label: string;
kind: AudioDeviceKind;
isDefault: boolean;
}
export interface AudioDeviceSnapshot {
inputs: AudioDeviceOption[];
outputs: AudioDeviceOption[];
labelsAvailable: boolean;
canSelectOutput: boolean;
canWatchDeviceChanges: boolean;
}
export interface AudioDeviceValidationResult {
inputDeviceId: string;
outputDeviceId: string;
inputFellBackToDefault: boolean;
outputFellBackToDefault: boolean;
}
const DEFAULT_DEVICE_ID = 'default';
const COMMUNICATIONS_DEVICE_ID = 'communications';
export function getDefaultAudioDeviceOption(kind: AudioDeviceKind): AudioDeviceOption {
return {
deviceId: DEFAULT_DEVICE_ID,
label: 'System Default',
kind,
isDefault: true,
};
}
export function supportsDeviceEnumeration(): boolean {
return typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.enumerateDevices === 'function';
}
export function supportsDeviceChangeEvents(): boolean {
return typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.addEventListener === 'function';
}
export function supportsAudioOutputSelection(): boolean {
return typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof (navigator.mediaDevices as MediaDevices & {
selectAudioOutput?: () => Promise<MediaDeviceInfo>;
}).selectAudioOutput === 'function';
}
export function supportsAudioContextSinkSelection(): boolean {
if (typeof window === 'undefined') {
return false;
}
return typeof (window.AudioContext?.prototype as AudioContext & {
setSinkId?: (sinkId: string) => Promise<void>;
} | undefined)?.setSinkId === 'function';
}
export async function enumerateAudioDevices(): Promise<AudioDeviceSnapshot> {
const fallback: AudioDeviceSnapshot = {
inputs: [getDefaultAudioDeviceOption('audioinput')],
outputs: [getDefaultAudioDeviceOption('audiooutput')],
labelsAvailable: false,
canSelectOutput: supportsAudioOutputSelection(),
canWatchDeviceChanges: supportsDeviceChangeEvents(),
};
if (!supportsDeviceEnumeration()) {
return fallback;
}
const devices = await navigator.mediaDevices.enumerateDevices();
const labelsAvailable = devices.some(device => device.label.trim().length > 0);
const inputs = normalizeAudioDevices(devices, 'audioinput');
const outputs = normalizeAudioDevices(devices, 'audiooutput');
return {
inputs,
outputs,
labelsAvailable,
canSelectOutput: supportsAudioOutputSelection(),
canWatchDeviceChanges: supportsDeviceChangeEvents(),
};
}
export function validateConfiguredAudioDevices(
inputDeviceId: string | undefined,
outputDeviceId: string | undefined,
snapshot: AudioDeviceSnapshot
): AudioDeviceValidationResult {
const normalizedInputId = inputDeviceId ?? DEFAULT_DEVICE_ID;
const normalizedOutputId = outputDeviceId ?? DEFAULT_DEVICE_ID;
const canValidateInput = snapshot.labelsAvailable || snapshot.inputs.some(device => device.deviceId === normalizedInputId);
const canValidateOutput = snapshot.labelsAvailable || snapshot.outputs.some(device => device.deviceId === normalizedOutputId);
const validInput = normalizedInputId === DEFAULT_DEVICE_ID ||
!canValidateInput ||
snapshot.inputs.some(device => device.deviceId === normalizedInputId);
const validOutput = normalizedOutputId === DEFAULT_DEVICE_ID ||
!canValidateOutput ||
snapshot.outputs.some(device => device.deviceId === normalizedOutputId);
return {
inputDeviceId: validInput ? normalizedInputId : DEFAULT_DEVICE_ID,
outputDeviceId: validOutput ? normalizedOutputId : DEFAULT_DEVICE_ID,
inputFellBackToDefault: !validInput,
outputFellBackToDefault: !validOutput,
};
}
export async function promptForAudioOutputDevice(): Promise<AudioDeviceOption | null> {
if (!supportsAudioOutputSelection()) {
return null;
}
const selected = await (navigator.mediaDevices as MediaDevices & {
selectAudioOutput: () => Promise<MediaDeviceInfo>;
}).selectAudioOutput();
return {
deviceId: selected.deviceId,
label: selected.label || 'Selected Output Device',
kind: 'audiooutput',
isDefault: false,
};
}
function normalizeAudioDevices(
devices: MediaDeviceInfo[],
kind: AudioDeviceKind
): AudioDeviceOption[] {
const normalized: AudioDeviceOption[] = [getDefaultAudioDeviceOption(kind)];
let unnamedCount = 1;
devices
.filter(device => device.kind === kind)
.forEach(device => {
if (device.deviceId === DEFAULT_DEVICE_ID || device.deviceId === COMMUNICATIONS_DEVICE_ID) {
return;
}
normalized.push({
deviceId: device.deviceId,
label: device.label || `${kind === 'audioinput' ? 'Input' : 'Output'} Device ${unnamedCount++}`,
kind,
isDefault: false,
});
});
return normalized;
}