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
+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;