feat: added audio recording feature
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user