From 490cd19f687b7d7ff0e9d2ed3a7e19d0a7a857c2 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Sat, 9 May 2026 15:57:46 -0700 Subject: [PATCH] feat: added audio recording feature --- README.md | 17 +- public/config.json | 2 + src/components/Toolbar.tsx | 12 +- src/components/settings/SettingsPanel.tsx | 7 +- src/components/settings/SettingsSidebar.tsx | 3 +- src/components/settings/index.ts | 3 +- .../sections/AudioIOSettings.test.tsx | 92 ++++++ .../settings/sections/AudioIOSettings.tsx | 243 +++++++++++++++ src/components/track/Region.css | 5 + src/components/track/RegionItem.test.tsx | 40 +++ src/components/track/RegionItem.tsx | 72 ++++- src/components/track/TrackGridItem.test.tsx | 81 +++++ src/components/track/TrackGridItem.tsx | 32 +- src/core/KGCore.ts | 13 + src/core/audio-interface/KGAudioInterface.ts | 50 +++ src/core/audio-interface/KGAudioRecorder.ts | 289 ++++++++++++++++++ src/core/config/ConfigManager.ts | 4 + src/hooks/useGlobalKeyboardHandler.ts | 9 +- src/main.tsx | 33 ++ src/stores/projectStore.test.ts | 79 ++++- src/stores/projectStore.ts | 280 ++++++++++++++++- src/test/mocks/audio-interface.ts | 4 + src/util/audioDeviceUtil.test.ts | 48 +++ src/util/audioDeviceUtil.ts | 160 ++++++++++ 24 files changed, 1540 insertions(+), 38 deletions(-) create mode 100644 src/components/settings/sections/AudioIOSettings.test.tsx create mode 100644 src/components/settings/sections/AudioIOSettings.tsx create mode 100644 src/components/track/TrackGridItem.test.tsx create mode 100644 src/core/audio-interface/KGAudioRecorder.ts create mode 100644 src/util/audioDeviceUtil.test.ts create mode 100644 src/util/audioDeviceUtil.ts diff --git a/README.md b/README.md index f9c2180..a702683 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with * ## Latest Updates +- **2026.05.09**: Added **audio recording** — record directly from your microphone into an audio track. A live waveform preview grows in real time as you record, and the region is committed to the timeline as a standard audio region when you stop. Added **audio I/O device selection** in Settings so you can choose your preferred microphone input and audio output device. + - **2026.05.08**: Added **MIDI automation** — draw and edit pitch bend and MIDI CC curves (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) in an editable automation lane below the piano grid. Added **track-level automation**: each track now has a dedicated automation panel where you can view and edit the same curves directly on the timeline. Real-time MIDI controller input (pitch wheel, CC pedals) is recorded and played back with per-lane interpolation. Added the **List Event Panel** — a tabbed sidebar (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. Added **region multi-select** with lasso and bulk move/resize, and **merge MIDI regions**.
K.G.Studio Logo @@ -314,16 +316,25 @@ Split an existing audio region into individual stems (e.g. vocals, instruments, Feature priorities might change. +### 1.0 + - [X] More instruments - [X] Automated testing (unit tests, integration tests, etc.) - [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D) - [X] Support track control automations (e.g. sustain, volume, pan, etc.) - [X] Support MIDI control events (e.g. CC, pitch bend, etc.) - [X] Support WAV audio tracks -- [ ] Filters and effects -- [ ] MCP Support +- [X] Recording +- [ ] List Event + List Region - [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`) -- [ ] Automatically compact conversations when the context window runs low on space + +### Post 1.0 + +- [ ] Stuff notation +- [ ] Filters and effects +- [ ] Virtual MIDI device output +- [ ] Enhanced AI Music Assistant Agent + ## Help Needed diff --git a/public/config.json b/public/config.json index 08c71f8..df321dc 100644 --- a/public/config.json +++ b/public/config.json @@ -74,8 +74,10 @@ }, "audio": { "enable_audio_capture_for_screen_sharing": false, + "input_device_id": "default", "lookahead_time": 0.05, "midi_automation_interpolation_interval_ms": 10, + "output_device_id": "default", "playback_delay": 0.2, "recording_offset": 0 }, diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index aabae6c..1abddae 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -17,6 +17,7 @@ import { import { KGProject, type KeySignature } from '../core/KGProject'; import { KGMidiInput } from '../core/midi-input/KGMidiInput'; import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; import { plainToInstance } from 'class-transformer'; import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; @@ -53,7 +54,7 @@ const Toolbar: React.FC = () => { // Piano roll state/actions showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId, // Selection state - selectedRegionIds, + selectedRegionIds, selectedTrackId, // Playhead and refresh playheadPosition, refreshProjectState, requestMainContentScroll, requestPianoRollScroll @@ -987,7 +988,14 @@ const Toolbar: React.FC = () => { const handleRecordClick = async () => { if (isRecording) { await stopRecording(); - setStatus("Recording stopped — notes committed"); + setStatus("Recording stopped"); + return; + } + + const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === selectedTrackId) ?? null; + if (selectedTrack instanceof KGAudioTrack) { + await startRecording(); + setStatus("Audio recording started..."); return; } diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index b4e6c5a..8a4a1ab 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -3,10 +3,11 @@ import './Settings.css'; import SettingsSidebar from './SettingsSidebar'; import GeneralSettings from './sections/GeneralSettings'; import BehaviorSettings from './sections/BehaviorSettings'; +import AudioIOSettings from './sections/AudioIOSettings'; import TemplatesSettings from './sections/TemplatesSettings'; import ChordGuideSettings from './sections/ChordGuideSettings'; -export type SettingsSection = 'general' | 'behavior' | 'templates' | 'chord_guide'; +export type SettingsSection = 'general' | 'audio_io' | 'behavior' | 'templates' | 'chord_guide'; interface SettingsPanelProps { onClose: () => void; @@ -21,6 +22,8 @@ const SettingsPanel: React.FC = ({ onClose }) => { return ; case 'behavior': return ; + case 'audio_io': + return ; case 'templates': return ; case 'chord_guide': @@ -46,4 +49,4 @@ const SettingsPanel: React.FC = ({ onClose }) => { ); }; -export default SettingsPanel; \ No newline at end of file +export default SettingsPanel; diff --git a/src/components/settings/SettingsSidebar.tsx b/src/components/settings/SettingsSidebar.tsx index d85f1c1..e192acd 100644 --- a/src/components/settings/SettingsSidebar.tsx +++ b/src/components/settings/SettingsSidebar.tsx @@ -15,6 +15,7 @@ const SettingsSidebar: React.FC = ({ }) => { const sections = [ { id: 'general' as SettingsSection, label: 'General' }, + { id: 'audio_io' as SettingsSection, label: 'Audio I/O' }, { id: 'behavior' as SettingsSection, label: 'Behavior' }, { id: 'templates' as SettingsSection, label: 'Templates' }, { id: 'chord_guide' as SettingsSection, label: 'Chord Guide' } @@ -48,4 +49,4 @@ const SettingsSidebar: React.FC = ({ ); }; -export default SettingsSidebar; \ No newline at end of file +export default SettingsSidebar; diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 5da492f..277c806 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -1,5 +1,6 @@ export { default as SettingsPanel } from './SettingsPanel.tsx'; export { default as SettingsSidebar } from './SettingsSidebar.tsx'; export { default as GeneralSettings } from './sections/GeneralSettings.tsx'; +export { default as AudioIOSettings } from './sections/AudioIOSettings.tsx'; export { default as BehaviorSettings } from './sections/BehaviorSettings.tsx'; -export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx'; \ No newline at end of file +export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx'; diff --git a/src/components/settings/sections/AudioIOSettings.test.tsx b/src/components/settings/sections/AudioIOSettings.test.tsx new file mode 100644 index 0000000..ac83bae --- /dev/null +++ b/src/components/settings/sections/AudioIOSettings.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import AudioIOSettings from './AudioIOSettings'; + +const configState = new Map([ + ['audio.input_device_id', 'default'], + ['audio.output_device_id', 'default'], +]); + +const configManagerMock = { + getIsInitialized: vi.fn(() => true), + initialize: vi.fn().mockResolvedValue(undefined), + get: vi.fn((key: string) => configState.get(key)), + set: vi.fn(async (key: string, value: unknown) => { + configState.set(key, value); + }), +}; + +vi.mock('../../../core/config/ConfigManager', () => ({ + ConfigManager: { + instance: () => configManagerMock, + }, +})); + +vi.mock('../../../util/audioDeviceUtil', () => ({ + enumerateAudioDevices: vi.fn().mockResolvedValue({ + inputs: [ + { deviceId: 'default', label: 'System Default', kind: 'audioinput', isDefault: true }, + { deviceId: 'mic-1', label: 'Studio Mic', kind: 'audioinput', isDefault: false }, + ], + outputs: [ + { deviceId: 'default', label: 'System Default', kind: 'audiooutput', isDefault: true }, + { deviceId: 'speaker-1', label: 'Monitor Out', kind: 'audiooutput', isDefault: false }, + ], + labelsAvailable: true, + canSelectOutput: true, + canWatchDeviceChanges: false, + }), + getDefaultAudioDeviceOption: vi.fn((kind: 'audioinput' | 'audiooutput') => ({ + deviceId: 'default', + label: 'System Default', + kind, + isDefault: true, + })), + promptForAudioOutputDevice: vi.fn().mockResolvedValue({ + deviceId: 'speaker-1', + label: 'Monitor Out', + kind: 'audiooutput', + isDefault: false, + }), + supportsAudioContextSinkSelection: vi.fn(() => false), +})); + +describe('AudioIOSettings', () => { + beforeEach(() => { + configState.set('audio.input_device_id', 'default'); + configState.set('audio.output_device_id', 'default'); + configManagerMock.get.mockClear(); + configManagerMock.set.mockClear(); + }); + + it('renders input/output selectors and refresh action', async () => { + render(); + + expect(await screen.findByText('Audio I/O')).toBeTruthy(); + expect(screen.getByLabelText('Audio Input Device')).toBeTruthy(); + expect(screen.getByLabelText('Audio Output Device')).toBeTruthy(); + expect(screen.getByText('Refresh Device List')).toBeTruthy(); + }); + + it('persists input device changes', async () => { + render(); + + const select = await screen.findByLabelText('Audio Input Device'); + fireEvent.change(select, { target: { value: 'mic-1' } }); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'mic-1'); + }); + }); + + it('resolves missing saved devices to System Default when validation is conclusive', async () => { + configState.set('audio.input_device_id', 'missing-input'); + + render(); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'default'); + }); + }); +}); diff --git a/src/components/settings/sections/AudioIOSettings.tsx b/src/components/settings/sections/AudioIOSettings.tsx new file mode 100644 index 0000000..f5b67f4 --- /dev/null +++ b/src/components/settings/sections/AudioIOSettings.tsx @@ -0,0 +1,243 @@ +import React, { useEffect, useState } from 'react'; +import { ConfigManager } from '../../../core/config/ConfigManager'; +import { + enumerateAudioDevices, + getDefaultAudioDeviceOption, + promptForAudioOutputDevice, + supportsAudioContextSinkSelection, + type AudioDeviceOption, +} from '../../../util/audioDeviceUtil'; + +const AudioIOSettings: React.FC = () => { + const [inputDeviceId, setInputDeviceId] = useState('default'); + const [outputDeviceId, setOutputDeviceId] = useState('default'); + const [inputs, setInputs] = useState([getDefaultAudioDeviceOption('audioinput')]); + const [outputs, setOutputs] = useState([getDefaultAudioDeviceOption('audiooutput')]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [deviceStatus, setDeviceStatus] = useState(''); + const [supportsOutputPrompt, setSupportsOutputPrompt] = useState(false); + const [supportsOutputSink, setSupportsOutputSink] = useState(false); + + const configManager = ConfigManager.instance(); + + useEffect(() => { + const initialize = async () => { + if (!configManager.getIsInitialized()) { + await configManager.initialize(); + } + + setInputDeviceId((configManager.get('audio.input_device_id') as string | undefined) ?? 'default'); + setOutputDeviceId((configManager.get('audio.output_device_id') as string | undefined) ?? 'default'); + setSupportsOutputSink(supportsAudioContextSinkSelection()); + await refreshDevices(); + setLoading(false); + }; + + void initialize(); + + const mediaDevices = navigator.mediaDevices; + const handleDeviceChange = () => { + void refreshDevices(false); + }; + + if (mediaDevices && typeof mediaDevices.addEventListener === 'function') { + mediaDevices.addEventListener('devicechange', handleDeviceChange); + return () => { + mediaDevices.removeEventListener('devicechange', handleDeviceChange); + }; + } + + return undefined; + }, [configManager]); + + const refreshDevices = async (showStatus: boolean = true) => { + setRefreshing(true); + try { + const snapshot = await enumerateAudioDevices(); + setSupportsOutputPrompt(snapshot.canSelectOutput); + + const configuredInputId = (configManager.get('audio.input_device_id') as string | undefined) ?? 'default'; + const configuredOutputId = (configManager.get('audio.output_device_id') as string | undefined) ?? 'default'; + + const hasInput = snapshot.inputs.some(device => device.deviceId === configuredInputId); + const hasOutput = snapshot.outputs.some(device => device.deviceId === configuredOutputId); + const nextInputs = !hasInput && configuredInputId !== 'default' && !snapshot.labelsAvailable + ? [ + ...snapshot.inputs, + { + deviceId: configuredInputId, + label: 'Previously Selected Input (permission required to verify)', + kind: 'audioinput' as const, + isDefault: false, + }, + ] + : snapshot.inputs; + const nextOutputs = !hasOutput && configuredOutputId !== 'default' && !snapshot.labelsAvailable + ? [ + ...snapshot.outputs, + { + deviceId: configuredOutputId, + label: 'Previously Selected Output (permission required to verify)', + kind: 'audiooutput' as const, + isDefault: false, + }, + ] + : snapshot.outputs; + + setInputs(nextInputs); + setOutputs(nextOutputs); + + if (!hasInput && configuredInputId !== 'default' && snapshot.labelsAvailable) { + setInputDeviceId('default'); + await configManager.set('audio.input_device_id', 'default'); + setDeviceStatus('Previously selected audio input device is unavailable; using System Default.'); + } else { + setInputDeviceId(hasInput || !snapshot.labelsAvailable ? configuredInputId : 'default'); + } + + if (!hasOutput && configuredOutputId !== 'default' && snapshot.labelsAvailable) { + setOutputDeviceId('default'); + await configManager.set('audio.output_device_id', 'default'); + setDeviceStatus('Previously selected audio output device is unavailable; using System Default.'); + } else { + setOutputDeviceId(hasOutput || !snapshot.labelsAvailable ? configuredOutputId : 'default'); + } + + if (showStatus) { + setDeviceStatus(current => current || 'Audio device list refreshed.'); + } + } catch (error) { + console.error('Unable to refresh audio devices:', error); + setDeviceStatus('Unable to read audio devices from the browser.'); + } finally { + setRefreshing(false); + } + }; + + const handleInputDeviceChange = async (value: string) => { + setInputDeviceId(value); + await configManager.set('audio.input_device_id', value); + setDeviceStatus(value === 'default' + ? 'Audio input will use System Default on the next recording session.' + : 'Audio input will change on the next recording session.'); + }; + + const handleOutputDeviceChange = async (value: string) => { + setOutputDeviceId(value); + await configManager.set('audio.output_device_id', value); + setDeviceStatus(value === 'default' + ? 'Audio output will use System Default after refresh.' + : 'Audio output device saved. Refresh the page to apply it in v1.'); + }; + + const handleChooseOutputDevice = async () => { + try { + const selectedDevice = await promptForAudioOutputDevice(); + if (!selectedDevice) { + setDeviceStatus('This browser does not support prompting for audio output devices.'); + return; + } + + setOutputDeviceId(selectedDevice.deviceId); + await configManager.set('audio.output_device_id', selectedDevice.deviceId); + await refreshDevices(false); + setDeviceStatus('Output device selected. Refresh the page to apply it in v1.'); + } catch (error) { + console.error('Unable to choose audio output device:', error); + setDeviceStatus('The browser did not allow selecting a non-default output device.'); + } + }; + + return ( +
+
+

Audio I/O

+
+ +
+
+
+

Device Routing

+ +
+ +
+ 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. +
+ +
+ + +
+ Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default. +
+
+ +
+ + + {supportsOutputPrompt && ( +
+ +
+ )} +
+ Output-device changes require refresh in v1. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported. +
+ {!supportsOutputSink && ( +
+ This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default. +
+ )} +
+ + {deviceStatus && ( +
+ {deviceStatus} +
+ )} +
+
+
+ ); +}; + +export default AudioIOSettings; diff --git a/src/components/track/Region.css b/src/components/track/Region.css index 620911c..e112248 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -97,6 +97,11 @@ border-color: rgba(255, 255, 255, 0.7); } +.track-region[data-preview-region='true'] { + opacity: 0.55; + pointer-events: none; +} + .track-region.audio-region .region-header { background-color: #4a8b5a; } diff --git a/src/components/track/RegionItem.test.tsx b/src/components/track/RegionItem.test.tsx index ee903b9..5076ff0 100644 --- a/src/components/track/RegionItem.test.tsx +++ b/src/components/track/RegionItem.test.tsx @@ -19,6 +19,10 @@ describe('RegionItem', () => { value: vi.fn(() => ({ clearRect: vi.fn(), fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), })), }); @@ -107,4 +111,40 @@ describe('RegionItem', () => { expect(onDrag).toHaveBeenCalledWith('midi-1', 10, 0); expect(onDragEnd).toHaveBeenCalledWith('midi-1'); }); + + it('renders preview waveform peaks on the canvas for recording previews', () => { + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext'); + const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 120, + bottom: 60, + width: 120, + height: 60, + toJSON: () => ({}), + }); + const { container } = renderRegion({ + audioRegion: undefined, + midiRegion: undefined, + previewWaveformPeaks: [ + { min: -0.25, max: 0.5 }, + { min: -0.5, max: 0.25 }, + ], + isPreview: true, + }); + + const context = getContextSpy.mock.results[0]?.value as { + beginPath: ReturnType; + lineTo: ReturnType; + stroke: ReturnType; + }; + + expect(container.querySelector('[data-preview-region="true"]')).toBeTruthy(); + expect(context.beginPath).toHaveBeenCalled(); + expect(context.lineTo).toHaveBeenCalled(); + expect(context.stroke).toHaveBeenCalled(); + rectSpy.mockRestore(); + }); }); diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index 81072b5..9f92bd5 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -8,6 +8,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { useProjectStore } from '../../stores/projectStore'; import { KGMainContentState } from '../../core/state/KGMainContentState'; +import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder'; const DRAG_START_THRESHOLD_PX = 4; @@ -42,6 +43,8 @@ interface RegionItemProps { // Audio region data for rendering waveform audioRegion?: KGAudioRegion; audioBuffer?: AudioBuffer; + previewWaveformPeaks?: AudioRecordingPeak[]; + isPreview?: boolean; } const RegionItem: React.FC = ({ @@ -65,7 +68,9 @@ const RegionItem: React.FC = ({ onFineMoveEnd, midiRegion, audioRegion, - audioBuffer + audioBuffer, + previewWaveformPeaks, + isPreview = false, }) => { // Get selection state and time signature from store const { selectedRegionIds, timeSignature, bpm } = useProjectStore(); @@ -303,6 +308,41 @@ const RegionItem: React.FC = ({ ctx.stroke(); }; + const renderPreviewWaveformOnCanvas = () => { + if (!canvasRef.current || !regionContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const contentRect = regionContentRef.current.getBoundingClientRect(); + const width = contentRect.width; + const height = contentRect.height; + + canvas.width = width; + canvas.height = height; + ctx.clearRect(0, 0, width, height); + + const centerY = height / 2; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)'; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let x = 0; x < width; x++) { + const peakIndex = Math.min( + previewWaveformPeaks.length - 1, + Math.floor((x / Math.max(1, width)) * previewWaveformPeaks.length) + ); + const peak = previewWaveformPeaks[peakIndex]; + const yMin = centerY - peak.max * centerY; + const yMax = centerY - peak.min * centerY; + ctx.moveTo(x, yMin); + ctx.lineTo(x, yMax); + } + + ctx.stroke(); + }; + // Create a stable reference to track note changes const notesRef = useRef(''); const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0); @@ -325,19 +365,23 @@ const RegionItem: React.FC = ({ // Set up canvas when component mounts or updates useEffect(() => { - if (audioRegion && audioBuffer) { + if (previewWaveformPeaks && previewWaveformPeaks.length > 0) { + renderPreviewWaveformOnCanvas(); + } else if (audioRegion && audioBuffer) { renderWaveformOnCanvas(); } else { renderNotesOnCanvas(); } - }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]); + }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]); // Re-render canvas when region content size changes useEffect(() => { if (!regionContentRef.current) return; const resizeObserver = new ResizeObserver(() => { - if (audioRegion && audioBuffer) { + if (previewWaveformPeaks && previewWaveformPeaks.length > 0) { + renderPreviewWaveformOnCanvas(); + } else if (audioRegion && audioBuffer) { renderWaveformOnCanvas(); } else { renderNotesOnCanvas(); @@ -351,12 +395,13 @@ const RegionItem: React.FC = ({ resizeObserver.unobserve(regionContentRef.current); } }; - }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]); + }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]); // Handle mouse movement to detect edge proximity const handleMouseMove = (e: React.MouseEvent) => { // Skip if already resizing or dragging if (isResizingRef.current || isDraggingRef.current) return; + if (isPreview) return; // Disable move and resize when pencil tool is active const activeTool = KGMainContentState.instance().getActiveTool(); @@ -402,6 +447,10 @@ const RegionItem: React.FC = ({ // Handle mouse down for resize or drag const handleMouseDown = (e: React.MouseEvent) => { // Disable move and resize when pencil tool is active + if (isPreview) { + return; + } + const activeTool = KGMainContentState.instance().getActiveTool(); if (activeTool === 'pencil') { // Still allow click events to pass through for region selection @@ -604,11 +653,12 @@ const RegionItem: React.FC = ({
= ({ {name}
-
+ {!isPreview &&
{!audioRegion && (
-
+
}
diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx new file mode 100644 index 0000000..b92c91f --- /dev/null +++ b/src/components/track/TrackGridItem.test.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import TrackGridItem from './TrackGridItem'; +import { KGAudioTrack } from '../../core/track/KGAudioTrack'; + +vi.mock('../../stores/projectStore', () => ({ + useProjectStore: (selector?: (state: { + selectedRegionIds: string[]; + activeTrackAutomationTrackId: string | null; + activeTrackAutomationType: null; + trackAutomationRedrawVersion: number; + recordingMode: 'audio' | 'midi' | null; + recordingTargetTrackIndex: number | null; + recordingCommitStartBeatAbsolute: number; + recordingAudioPreviewCurrentBeat: number; + recordingAudioPreviewPeaks: Array<{ min: number; max: number }>; + recordingAudioPreviewFileName: string | null; + timeSignature: { numerator: number; denominator: number }; + }) => unknown) => { + const state = { + selectedRegionIds: [], + activeTrackAutomationTrackId: null, + activeTrackAutomationType: null, + trackAutomationRedrawVersion: 0, + recordingMode: 'audio' as const, + recordingTargetTrackIndex: 0, + recordingCommitStartBeatAbsolute: 4, + recordingAudioPreviewCurrentBeat: 8, + recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }], + recordingAudioPreviewFileName: 'Recording', + timeSignature: { numerator: 4, denominator: 4 }, + }; + return selector ? selector(state) : state; + }, +})); + +describe('TrackGridItem recording preview', () => { + beforeAll(() => { + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: vi.fn(() => ({ + clearRect: vi.fn(), + fillRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + })), + }); + + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + + it('renders a non-interactive preview region on the recording audio track', () => { + const track = new KGAudioTrack('Audio Track', 1); + track.setTrackIndex(0); + + const view = render( + + ); + + const previewRegion = view.container.querySelector('[data-preview-region="true"]'); + expect(previewRegion).toBeTruthy(); + }); +}); diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index 1b3742f..8345a8d 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -66,6 +66,13 @@ const TrackGridItem: React.FC = ({ 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(null); const [draggingRegion, setDraggingRegion] = useState(null); @@ -543,6 +550,16 @@ const TrackGridItem: React.FC = ({ // 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 (
= ({ /> ); })} + {shouldRenderRecordingPreview && previewRegionStyle && ( + + )} {isAutomationActive && activeTrackAutomationType && ( )} diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 6ee0b81..43eabf0 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -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); diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts index ba86350..bf785ea 100644 --- a/src/core/audio-interface/KGAudioInterface.ts +++ b/src/core/audio-interface/KGAudioInterface.ts @@ -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 { + return await this.audioRecorder.start(inputDeviceId, onPeaks); + } + + public async stopAudioRecording(): Promise { + return await this.audioRecorder.stop(); + } + + public async cancelAudioRecording(): Promise { + await this.audioRecorder.cancel(); + } + + public async applyConfiguredOutputDevice(outputDeviceId: string): Promise { + if (outputDeviceId === 'default') { + return false; + } + + const rawContext = Tone.getContext().rawContext as AudioContext & { + setSinkId?: (sinkId: string) => Promise; + 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 */ diff --git a/src/core/audio-interface/KGAudioRecorder.ts b/src/core/audio-interface/KGAudioRecorder.ts new file mode 100644 index 0000000..2bdf24a --- /dev/null +++ b/src/core/audio-interface/KGAudioRecorder.ts @@ -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 { + 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 { + 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((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 { + const recorder = this.mediaRecorder; + if (recorder && recorder.state !== 'inactive') { + await new Promise((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() : {}, + })) + ); + } +} diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 4527fb6..b38884f 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -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 }, diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts index f2d7c90..2e005c9 100644 --- a/src/hooks/useGlobalKeyboardHandler.ts +++ b/src/hooks/useGlobalKeyboardHandler.ts @@ -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; diff --git a/src/main.tsx b/src/main.tsx index 7ef77d8..1740c22 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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> = []; + 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(); diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts index d3f2109..2cfa240 100644 --- a/src/stores/projectStore.test.ts +++ b/src/stores/projectStore.test.ts @@ -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([ + ['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); + }); }); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index c95f0d9..fbf5f1e 100644 --- a/src/stores/projectStore.ts +++ b/src/stores/projectStore.ts @@ -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>; 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 let _recordingRegionStartBeat: number = 0; let _lastRecordedPitchBendValue: number | null = null; let _lastRecordedControllerValues: Map = new Map(); +let _audioRecordingStartTimeoutId: number | null = null; +let _audioRecordingForcedStopBeatAbsolute: number | null = null; +let _audioRecordingHasStarted: boolean = false; function createEmptyRecordedControllerBuckets(): Array> { 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((set, get) => { const currentProject = KGCore.instance().getCurrentProject(); @@ -273,10 +299,13 @@ export const useProjectStore = create((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((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((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((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((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((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((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((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((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((set, get) => { activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, + recordingAudioPreviewPeaks: [], + recordingAudioPreviewCurrentBeat: 0, }); // Clear any selected items diff --git a/src/test/mocks/audio-interface.ts b/src/test/mocks/audio-interface.ts index 94fb438..9febee7 100644 --- a/src/test/mocks/audio-interface.ts +++ b/src/test/mocks/audio-interface.ts @@ -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(), diff --git a/src/util/audioDeviceUtil.test.ts b/src/util/audioDeviceUtil.test.ts new file mode 100644 index 0000000..6dc293b --- /dev/null +++ b/src/util/audioDeviceUtil.test.ts @@ -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); + }); +}); diff --git a/src/util/audioDeviceUtil.ts b/src/util/audioDeviceUtil.ts new file mode 100644 index 0000000..6cefff7 --- /dev/null +++ b/src/util/audioDeviceUtil.ts @@ -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; + }).selectAudioOutput === 'function'; +} + +export function supportsAudioContextSinkSelection(): boolean { + if (typeof window === 'undefined') { + return false; + } + + return typeof (window.AudioContext?.prototype as AudioContext & { + setSinkId?: (sinkId: string) => Promise; + } | undefined)?.setSinkId === 'function'; +} + +export async function enumerateAudioDevices(): Promise { + 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 { + if (!supportsAudioOutputSelection()) { + return null; + } + + const selected = await (navigator.mediaDevices as MediaDevices & { + selectAudioOutput: () => Promise; + }).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; +}