diff --git a/README.md b/README.md index f9c2180..240b0c5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with * ## Latest Updates -- **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**. +- **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 **Event List 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 +- [X] Event List - [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/App.test.tsx b/src/App.test.tsx index a0f4d40..4bd3d52 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -19,7 +19,7 @@ vi.mock('./components/MainContent', () => ({ default: () => null })); vi.mock('./components/InstrumentSelection', () => ({ default: () => null })); vi.mock('./components/ChatBox', () => ({ default: () => null })); vi.mock('./components/KGOnePanel', () => ({ default: () => null })); -vi.mock('./components/ListEventPanel', () => ({ default: () => null })); +vi.mock('./components/EventListPanel', () => ({ default: () => null })); vi.mock('./components/settings', () => ({ SettingsPanel: () => null })); vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({ KGToneBuffersPool: { diff --git a/src/App.tsx b/src/App.tsx index c066b6d..c31919b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import ChatBox from './components/ChatBox'; import { SettingsPanel } from './components/settings'; import LoadingOverlay from './components/common/LoadingOverlay'; import KGOnePanel from './components/KGOnePanel'; -import ListEventPanel from './components/ListEventPanel'; +import EventListPanel from './components/EventListPanel'; import { useEffect as useEffectReact, useState, useRef } from 'react'; import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool'; import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer'; @@ -26,12 +26,12 @@ import { RESERVED_PROJECT_NAME } from './util/projectNameUtil'; function App() { // Enable global keyboard handler for copy/paste and undo/redo useGlobalKeyboardHandler(); - + // Use project store instead of local state for project name and tracks const { refreshStatus, loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig, - showInstrumentSelection, showKGOnePanel, showListEventPanel + showInstrumentSelection, showKGOnePanel, showEventListPanel } = useProjectStore(); // Track if app has been initialized to prevent multiple initializations @@ -144,12 +144,12 @@ function App() { useEffect(() => { // Initial refresh refreshStatus(); - + // Set up interval to refresh status every second const intervalId = setInterval(() => { refreshStatus(); }, 1000); - + // Clean up interval on unmount return () => clearInterval(intervalId); }, [refreshStatus]); @@ -169,7 +169,7 @@ function App() { )} - + @@ -264,7 +264,7 @@ const MigrationOverlayContainer: React.FC = () => { useEffectReact(() => { KGCore.instance().setMigrationStateChangeCallback(setIsMigrating); return () => { - KGCore.instance().setMigrationStateChangeCallback(() => {}); + KGCore.instance().setMigrationStateChangeCallback(() => { }); }; }, []); diff --git a/src/components/ListEventPanel.css b/src/components/EventListPanel.css similarity index 69% rename from src/components/ListEventPanel.css rename to src/components/EventListPanel.css index e9ed29a..bf4ef49 100644 --- a/src/components/ListEventPanel.css +++ b/src/components/EventListPanel.css @@ -1,4 +1,4 @@ -.list-event-panel { +.event-list-panel { display: flex; flex-direction: column; width: var(--chat-box-width); @@ -8,11 +8,11 @@ overflow: hidden; } -.list-event-panel.is-hidden { +.event-list-panel.is-hidden { display: none; } -.list-event-panel-header { +.event-list-panel-header { display: flex; align-items: center; justify-content: space-between; @@ -23,14 +23,14 @@ flex-shrink: 0; } -.list-event-panel-header h3 { +.event-list-panel-header h3 { color: #e0e0e0; font-size: 12px; font-weight: bold; margin: 0; } -.list-event-panel-body { +.event-list-panel-body { flex: 1; display: flex; flex-direction: column; @@ -39,13 +39,42 @@ gap: 12px; } -.list-event-tabs { +.event-list-tabs { display: flex; gap: 4px; flex-shrink: 0; } -.list-event-tab { +.event-list-scope-tabs { + display: flex; + background-color: #2d2d2d; + border-bottom: 1px solid #3a3a3a; + flex-shrink: 0; +} + +.event-list-scope-tab { + flex: 1; + background: transparent; + color: #999; + border: none; + border-bottom: 2px solid transparent; + font-size: 11px; + font-weight: 500; + padding: 8px 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.event-list-scope-tab:hover { + color: #ccc; +} + +.event-list-scope-tab.active { + color: #e0e0e0; + border-bottom-color: #5a9fd4; +} + +.event-list-tab { flex: 1; background-color: #1e1e1e; color: #999; @@ -55,20 +84,20 @@ font-weight: 500; min-height: 20px; padding: 5px 6px; - cursor: default; + cursor: pointer; transition: all 0.2s ease; } -.list-event-tab:hover { +.event-list-tab:hover { color: #e0e0e0; } -.list-event-tab.active { +.event-list-tab.active { background-color: #5a9fd4; color: #fff; } -.list-event-empty-state { +.event-list-empty-state { color: #888; font-size: 11px; line-height: 1.5; @@ -78,7 +107,7 @@ border-radius: 6px; } -.list-event-toolbar { +.event-list-toolbar { display: flex; align-items: center; justify-content: space-between; @@ -86,23 +115,23 @@ flex-shrink: 0; } -.list-event-toolbar-group { +.event-list-toolbar-group { display: flex; align-items: center; gap: 8px; min-width: 0; } -.list-event-toolbar-group:first-child { +.event-list-toolbar-group:first-child { gap: 0; } -.list-event-toolbar-group-right { +.event-list-toolbar-group-right { margin-left: auto; gap: 8px; } -.list-event-add-button { +.event-list-add-button { width: 22px; height: 22px; border: 1px solid #444; @@ -124,34 +153,34 @@ font-size: 11px; } -.list-event-add-button:hover { +.event-list-add-button:hover { background-color: #3b3b3b; border-right: 0; } -.list-event-dropdown-button, -.list-event-quant-button, -.list-event-type-button { +.event-list-dropdown-button, +.event-list-quant-button, +.event-list-type-button { font-size: 11px; } -.list-event-dropdown-button { +.event-list-dropdown-button { margin-left: 0; } -.list-event-quant-button { +.event-list-quant-button { min-width: 78px; padding: 3px 5px; } -.list-event-type-button { +.event-list-type-button { min-width: 88px; margin-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } -.list-event-delete-button { +.event-list-delete-button { width: 22px; height: 22px; border: 1px solid #444; @@ -168,17 +197,17 @@ font-size: 11px; } -.list-event-delete-button:hover:not(:disabled) { +.event-list-delete-button:hover:not(:disabled) { background-color: #464646; border-color: #5a5a5a; } -.list-event-delete-button:disabled { +.event-list-delete-button:disabled { opacity: 0.45; cursor: default; } -.list-event-table-shell { +.event-list-table-shell { flex: 1; min-height: 0; overflow: auto; @@ -187,13 +216,13 @@ border-radius: 6px; } -.list-event-table { +.event-list-table { width: 100%; border-collapse: collapse; table-layout: fixed; } -.list-event-table thead th { +.event-list-table thead th { position: sticky; top: 0; z-index: 1; @@ -209,25 +238,25 @@ text-overflow: ellipsis; } -.list-event-table tbody tr { +.event-list-table tbody tr { color: #e0e0e0; cursor: default; } -.list-event-table tbody tr:nth-child(odd) { +.event-list-table tbody tr:nth-child(odd) { background-color: #282828; } -.list-event-table tbody tr:nth-child(even) { +.event-list-table tbody tr:nth-child(even) { background-color: #303030; } -.list-event-table tbody tr.selected { +.event-list-table tbody tr.selected { background-color: #5a9fd4; color: #fff; } -.list-event-table td { +.event-list-table td { height: 20px; padding: 2px 12px; font-size: 11px; @@ -237,7 +266,7 @@ max-width: 0; } -.list-event-cell-input { +.event-list-cell-input { width: calc(100% + 8px); height: 16px; margin: 0 -4px; @@ -249,4 +278,4 @@ font-size: 11px; line-height: 16px; outline: none; -} +} \ No newline at end of file diff --git a/src/components/EventListPanel.test.tsx b/src/components/EventListPanel.test.tsx new file mode 100644 index 0000000..584cc26 --- /dev/null +++ b/src/components/EventListPanel.test.tsx @@ -0,0 +1,379 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import EventListPanel from './EventListPanel'; +import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; +import { KGMidiNote } from '../core/midi/KGMidiNote'; +import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; +import { KGRegion } from '../core/region/KGRegion'; +import { KGAudioRegion } from '../core/region/KGAudioRegion'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import { KGTrackAutomationPoint } from '../core/track/KGTrackAutomationPoint'; +import { + createMockMidiControllerEvent, + createMockMidiNote, + createMockMidiPitchBend, + createMockMidiRegion, + createMockMidiTrack, +} from '../test/utils/mock-data'; + +const clickDropdownOption = (label: string) => { + const option = Array.from(document.querySelectorAll('.quant-option')) + .find(element => element.textContent?.trim() === label); + expect(option).toBeTruthy(); + fireEvent.click(option!); +}; + +const midiRegion = createMockMidiRegion({ + id: 'region-1', + trackId: '1', + trackIndex: 0, + startFromBeat: 4, + notes: [createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 1, endBeat: 2, velocity: 96 })], + pitchBends: [createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 12288 })], + controllerEventsByType: Array.from({ length: 128 }, (_, index) => ( + index === 11 ? [createMockMidiControllerEvent({ id: 'cc11-1', beat: 0.75, value: 100 })] : [] + )), +}); +const secondMidiRegion = createMockMidiRegion({ + id: 'region-2', + trackId: '1', + trackIndex: 0, + name: 'Second Region', + startFromBeat: 12, + length: 8, +}); +const midiTrack = createMockMidiTrack({ id: 1, volume: -6, regions: [midiRegion, secondMidiRegion] }); +midiTrack.setTrackIndex(0); +midiTrack.setVolumeAutomation([ + new KGTrackAutomationPoint('vol-1', 2, -6), +]); +midiTrack.setPanAutomation([ + new KGTrackAutomationPoint('pan-1', 1, -0.5), + new KGTrackAutomationPoint('pan-2', 6, 0.25), +]); + +const audioRegion = new KGAudioRegion('audio-region-1', '2', 1, 'Audio Clip', 8, 4); +const audioTrack = new KGAudioTrack('Audio Track', 2, -3); +audioTrack.setTrackIndex(1); +audioTrack.setRegions([audioRegion]); + +type MockStoreState = { + tracks: Array; + activeRegionId: string | null; + selectedRegionIds: string[]; + selectedTrackId: string | null; + timeSignature: { numerator: number; denominator: number }; + selectedNoteIds: string[]; + selectedPitchBendIds: string[]; + selectedControllerEventIds: string[]; + selectedTrackAutomationPointIds: string[]; + playheadPosition: number; + updateTrack: ReturnType; + refreshProjectState: ReturnType; + bumpAutomationRedrawVersion: ReturnType; + bumpTrackAutomationRedrawVersion: ReturnType; +}; + +const storeState: MockStoreState = { + tracks: [midiTrack, audioTrack], + activeRegionId: 'region-1', + selectedRegionIds: ['region-1'], + selectedTrackId: '1', + timeSignature: { numerator: 4, denominator: 4 }, + selectedNoteIds: [], + selectedPitchBendIds: [], + selectedControllerEventIds: [], + selectedTrackAutomationPointIds: [], + playheadPosition: 4, + updateTrack: vi.fn().mockResolvedValue(undefined), + refreshProjectState: vi.fn(), + bumpAutomationRedrawVersion: vi.fn(), + bumpTrackAutomationRedrawVersion: vi.fn(), +}; + +let selectedItems: Array = []; + +const syncStoreSelectionFromCore = () => { + storeState.selectedRegionIds = selectedItems + .filter(item => item instanceof KGRegion) + .map(item => item.getId()); + storeState.selectedNoteIds = selectedItems + .filter(item => item instanceof KGMidiNote) + .map(item => item.getId()); + storeState.selectedPitchBendIds = selectedItems + .filter(item => item instanceof KGMidiPitchBend) + .map(item => item.getId()); + storeState.selectedControllerEventIds = selectedItems + .filter(item => item instanceof KGMidiControllerEvent) + .map(item => item.getId()); + storeState.selectedTrackAutomationPointIds = selectedItems + .filter(item => item instanceof KGTrackAutomationPoint) + .map(item => item.getId()); +}; + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: () => storeState, +})); + +vi.mock('../util/dialogUtil', () => ({ + showAlert: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../core/KGCore', () => ({ + KGCore: { + instance: vi.fn(() => ({ + getSelectedItems: () => selectedItems, + addSelectedItem: (item: typeof selectedItems[number]) => { + selectedItems = selectedItems.filter(candidate => candidate.getId() !== item.getId()); + selectedItems.push(item); + syncStoreSelectionFromCore(); + }, + addSelectedItems: (items: typeof selectedItems) => { + const nextIds = new Set(items.map(item => item.getId())); + selectedItems = [...selectedItems.filter(item => !nextIds.has(item.getId())), ...items]; + syncStoreSelectionFromCore(); + }, + removeSelectedItem: (item: typeof selectedItems[number]) => { + selectedItems = selectedItems.filter(candidate => candidate.getId() !== item.getId()); + syncStoreSelectionFromCore(); + }, + removeSelectedItems: (items: typeof selectedItems) => { + const removedIds = new Set(items.map(item => item.getId())); + selectedItems = selectedItems.filter(item => !removedIds.has(item.getId())); + syncStoreSelectionFromCore(); + }, + clearSelectedItems: () => { + selectedItems = []; + syncStoreSelectionFromCore(); + }, + executeCommand: (command: { execute: () => void }) => { + command.execute(); + syncStoreSelectionFromCore(); + }, + getCurrentProject: () => ({ + getTracks: () => storeState.tracks, + }), + })), + }, +})); + +describe('EventListPanel', () => { + beforeEach(() => { + selectedItems = [midiRegion]; + midiRegion.select(); + secondMidiRegion.deselect(); + audioRegion.deselect(); + + midiRegion.setStartFromBeat(4); + midiRegion.setLength(4); + secondMidiRegion.setStartFromBeat(12); + secondMidiRegion.setLength(8); + midiTrack.setRegions([midiRegion, secondMidiRegion]); + + midiTrack.setVolumeAutomation([new KGTrackAutomationPoint('vol-1', 2, -6)]); + midiTrack.setPanAutomation([ + new KGTrackAutomationPoint('pan-1', 1, -0.5), + new KGTrackAutomationPoint('pan-2', 6, 0.25), + ]); + + midiRegion.getNotes().forEach(note => note.deselect()); + midiRegion.getPitchBends().forEach(pitchBend => pitchBend.deselect()); + midiRegion.getControllerEventsByType().forEach(events => events.forEach(controllerEvent => controllerEvent.deselect())); + midiTrack.getVolumeAutomation().forEach(point => point.deselect()); + midiTrack.getPanAutomation().forEach(point => point.deselect()); + + storeState.activeRegionId = 'region-1'; + storeState.selectedRegionIds = ['region-1']; + storeState.selectedTrackId = '1'; + storeState.selectedNoteIds = []; + storeState.selectedPitchBendIds = []; + storeState.selectedControllerEventIds = []; + storeState.selectedTrackAutomationPointIds = []; + storeState.playheadPosition = 4; + storeState.updateTrack.mockClear(); + storeState.refreshProjectState.mockClear(); + storeState.bumpAutomationRedrawVersion.mockClear(); + storeState.bumpTrackAutomationRedrawVersion.mockClear(); + }); + + it('defaults to Region tab and preserves existing event rows', () => { + render(); + + expect(screen.getByRole('button', { name: 'Region' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Track' })).toBeInTheDocument(); + expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); + expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument(); + }); + + it('switches to Track tab and lists selected track regions', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + + expect(screen.getByRole('button', { name: 'Regions' })).toBeInTheDocument(); + expect(screen.getByText('Second Region')).toBeInTheDocument(); + expect(screen.getAllByText('MIDI')).toHaveLength(2); + expect(screen.getByText('-6.0dB')).toBeInTheDocument(); + expect(screen.getByText('-32')).toBeInTheDocument(); + }); + + it('toggles track filters independently', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByRole('button', { name: 'Regions' })); + + expect(screen.queryByText('Second Region')).not.toBeInTheDocument(); + expect(screen.getByText('-6.0dB')).toBeInTheDocument(); + expect(screen.getByText('-32')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Volume' })); + + expect(screen.queryByText('-6.0dB')).not.toBeInTheDocument(); + expect(screen.getByText('-32')).toBeInTheDocument(); + }); + + it('shows track empty state when no track is selected', () => { + storeState.selectedTrackId = null; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + + expect(screen.getByText('Please select a track to view regions and track automation.')).toBeInTheDocument(); + }); + + it('syncs Track Regions row selection to selectedRegionIds', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('Second Region').closest('tr')!); + rerender(); + + expect(storeState.selectedRegionIds).toEqual(['region-2']); + expect(screen.getByText('Second Region').closest('tr')).toHaveClass('selected'); + }); + + it('syncs Track Volume row selection to selectedTrackAutomationPointIds', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('-6.0dB').closest('tr')!); + rerender(); + + expect(storeState.selectedTrackAutomationPointIds).toEqual(['vol-1']); + expect(screen.getByText('-6.0dB').closest('tr')).toHaveClass('selected'); + }); + + it('syncs Track Pan row selection to selectedTrackAutomationPointIds', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('-32').closest('tr')!); + rerender(); + + expect(storeState.selectedTrackAutomationPointIds).toEqual(['pan-1']); + }); + + it('hides MIDI Region add option for audio tracks', () => { + storeState.selectedTrackId = '2'; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Volume' })[1]); + + expect(screen.queryByText('MIDI Region')).not.toBeInTheDocument(); + expect( + Array.from(document.querySelectorAll('.quant-option')).some(element => element.textContent?.trim() === 'Pan') + ).toBe(true); + }); + + it('creates a 1-bar MIDI region at the playhead from the Track tab', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByTitle('Add MIDI region at playhead')); + + await waitFor(() => expect(midiTrack.getRegions()).toHaveLength(3)); + + const createdRegion = midiTrack.getRegions()[2]; + expect(createdRegion.getStartFromBeat()).toBe(4); + expect(createdRegion.getLength()).toBe(4); + }); + + it('creates a volume automation point using the track base volume', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByTitle('Add MIDI region at playhead')); + fireEvent.click(screen.getByRole('button', { name: 'MIDI Region' })); + clickDropdownOption('Volume'); + fireEvent.click(screen.getByTitle('Add volume automation point at playhead')); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()).toHaveLength(2)); + + const createdPoint = midiTrack.getVolumeAutomation().find(point => point.getBeat() === 4); + expect(createdPoint?.getValue()).toBe(-6); + }); + + it('creates a pan automation point using the nearest earlier pan value or zero', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByTitle('Add MIDI region at playhead')); + fireEvent.click(screen.getByRole('button', { name: 'MIDI Region' })); + clickDropdownOption('Pan'); + fireEvent.click(screen.getByTitle('Add pan automation point at playhead')); + + await waitFor(() => expect(midiTrack.getPanAutomation()).toHaveLength(3)); + + const createdPoint = midiTrack.getPanAutomation().find(point => point.getBeat() === 4); + expect(createdPoint?.getValue()).toBe(-0.5); + }); + + it('deletes selected track automation points from the Track tab', async () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.click(screen.getByText('-6.0dB').closest('tr')!); + rerender(); + fireEvent.click(screen.getByTitle('Delete visible selected rows')); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()).toHaveLength(0)); + }); + + it('edits track region position and length inline', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.doubleClick(screen.getByText('4 1 0')); + const positionInput = screen.getByDisplayValue('4 1 0'); + fireEvent.change(positionInput, { target: { value: '3 1 0' } }); + fireEvent.keyDown(positionInput, { key: 'Enter' }); + + await waitFor(() => expect(secondMidiRegion.getStartFromBeat()).toBe(8)); + + fireEvent.doubleClick(screen.getByText('8 0')); + const lengthInput = screen.getByDisplayValue('8 0'); + fireEvent.change(lengthInput, { target: { value: '4 0' } }); + fireEvent.keyDown(lengthInput, { key: 'Enter' }); + + await waitFor(() => expect(secondMidiRegion.getLength()).toBe(4)); + }); + + it('edits track automation position and value inline', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Track' })); + fireEvent.doubleClick(screen.getByText('1 3 0')); + const positionInput = screen.getByDisplayValue('1 3 0'); + fireEvent.change(positionInput, { target: { value: '2 1 0' } }); + fireEvent.keyDown(positionInput, { key: 'Enter' }); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()[0].getBeat()).toBe(4)); + + fireEvent.doubleClick(screen.getByText('-6.0dB')); + const valueInput = screen.getByDisplayValue('-6.0dB'); + fireEvent.change(valueInput, { target: { value: '-3' } }); + fireEvent.keyDown(valueInput, { key: 'Enter' }); + + await waitFor(() => expect(midiTrack.getVolumeAutomation()[0].getValue()).toBe(-3)); + }); +}); diff --git a/src/components/EventListPanel.tsx b/src/components/EventListPanel.tsx new file mode 100644 index 0000000..67d10bf --- /dev/null +++ b/src/components/EventListPanel.tsx @@ -0,0 +1,79 @@ +import React, { useMemo, useState } from 'react'; +import './EventListPanel.css'; +import { useProjectStore } from '../stores/projectStore'; +import { KGMidiRegion } from '../core/region/KGMidiRegion'; +import { KGMidiTrack } from '../core/track/KGMidiTrack'; +import { KGAudioTrack } from '../core/track/KGAudioTrack'; +import RegionEventListTab from './event-list-panel/RegionEventListTab'; +import TrackEventListTab from './event-list-panel/TrackEventListTab'; + +interface EventListPanelProps { + isVisible: boolean; +} + +type ScopeTab = 'region' | 'track'; + +const EventListPanel: React.FC = ({ isVisible }) => { + const { tracks, activeRegionId, selectedRegionIds, selectedTrackId } = useProjectStore(); + const [scopeTab, setScopeTab] = useState('region'); + + const resolvedRegionId = selectedRegionIds.length > 1 + ? activeRegionId + : selectedRegionIds.length === 1 + ? selectedRegionIds[0] + : activeRegionId; + + const selectedTrack = useMemo(() => { + const track = tracks.find(candidate => candidate.getId().toString() === selectedTrackId) ?? null; + return track instanceof KGMidiTrack || track instanceof KGAudioTrack ? track : null; + }, [tracks, selectedTrackId]); + + let activeMidiRegion: KGMidiRegion | null = null; + let parentTrack: KGMidiTrack | null = null; + + if (resolvedRegionId) { + for (const track of tracks) { + const region = track.getRegions().find(candidate => candidate.getId() === resolvedRegionId); + if (region instanceof KGMidiRegion && track instanceof KGMidiTrack) { + activeMidiRegion = region; + parentTrack = track; + break; + } + } + } + + return ( +
+
+

Event List

+
+ +
+ + +
+ +
+ {scopeTab === 'region' ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default EventListPanel; diff --git a/src/components/ListEventPanel.test.tsx b/src/components/ListEventPanel.test.tsx deleted file mode 100644 index d76e889..0000000 --- a/src/components/ListEventPanel.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '@testing-library/react'; -import ListEventPanel from './ListEventPanel'; -import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; -import { KGMidiNote } from '../core/midi/KGMidiNote'; -import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; -import { KGRegion } from '../core/region/KGRegion'; -import { - createMockMidiControllerEvent, - createMockMidiNote, - createMockMidiPitchBend, - createMockMidiRegion, - createMockMidiTrack, -} from '../test/utils/mock-data'; - -const region = createMockMidiRegion({ - id: 'region-1', - trackId: '1', - trackIndex: 0, - startFromBeat: 4, - notes: [createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 1, endBeat: 2, velocity: 96 })], - pitchBends: [createMockMidiPitchBend({ id: 'bend-1', beat: 0.5, value: 12288 })], - controllerEventsByType: Array.from({ length: 128 }, (_, index) => ( - index === 11 ? [createMockMidiControllerEvent({ id: 'cc11-1', beat: 0.75, value: 100 })] : [] - )), -}); -const track = createMockMidiTrack({ id: 1, regions: [region] }); - -type MockStoreState = { - tracks: typeof track[]; - activeRegionId: string | null; - selectedRegionIds: string[]; - timeSignature: { numerator: number; denominator: number }; - selectedNoteIds: string[]; - selectedPitchBendIds: string[]; - selectedControllerEventIds: string[]; - playheadPosition: number; - updateTrack: ReturnType; - refreshProjectState: ReturnType; - bumpAutomationRedrawVersion: ReturnType; -}; - -const storeState: MockStoreState = { - tracks: [track], - activeRegionId: 'region-1', - selectedRegionIds: ['region-1'], - timeSignature: { numerator: 4, denominator: 4 }, - selectedNoteIds: [], - selectedPitchBendIds: [], - selectedControllerEventIds: [], - playheadPosition: 4, - updateTrack: vi.fn().mockResolvedValue(undefined), - refreshProjectState: vi.fn(), - bumpAutomationRedrawVersion: vi.fn(), -}; - -let selectedItems: Array = []; - -const syncStoreSelectionFromCore = () => { - storeState.selectedRegionIds = selectedItems - .filter(item => item instanceof KGRegion) - .map(item => item.getId()); - storeState.selectedNoteIds = selectedItems - .filter(item => item instanceof KGMidiNote) - .map(item => item.getId()); - storeState.selectedPitchBendIds = selectedItems - .filter(item => item instanceof KGMidiPitchBend) - .map(item => item.getId()); - storeState.selectedControllerEventIds = selectedItems - .filter(item => item instanceof KGMidiControllerEvent) - .map(item => item.getId()); -}; - -vi.mock('../stores/projectStore', () => ({ - useProjectStore: () => storeState, -})); - -vi.mock('../core/KGCore', () => ({ - KGCore: { - instance: vi.fn(() => ({ - getSelectedItems: () => selectedItems, - addSelectedItems: (items: typeof selectedItems) => { - const nextIds = new Set(items.map(item => item.getId())); - selectedItems = [...selectedItems.filter(item => !nextIds.has(item.getId())), ...items]; - syncStoreSelectionFromCore(); - }, - removeSelectedItems: (items: typeof selectedItems) => { - const removedIds = new Set(items.map(item => item.getId())); - selectedItems = selectedItems.filter(item => !removedIds.has(item.getId())); - syncStoreSelectionFromCore(); - }, - })), - }, -})); - -describe('ListEventPanel', () => { - beforeEach(() => { - selectedItems = [region]; - region.select(); - region.getNotes().forEach(note => note.deselect()); - region.getPitchBends().forEach(pitchBend => pitchBend.deselect()); - region.getControllerEventsByType().forEach(events => events.forEach(controllerEvent => controllerEvent.deselect())); - storeState.activeRegionId = 'region-1'; - storeState.selectedRegionIds = ['region-1']; - storeState.selectedNoteIds = []; - storeState.selectedPitchBendIds = []; - storeState.selectedControllerEventIds = []; - storeState.updateTrack.mockClear(); - storeState.refreshProjectState.mockClear(); - storeState.bumpAutomationRedrawVersion.mockClear(); - }); - - it('renders note and pitch bend rows and toggles them independently', () => { - render(); - - expect(screen.getByRole('button', { name: 'Note' })).toBeInTheDocument(); - expect(screen.getByTitle('Delete visible selected rows')).toBeDisabled(); - expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); - expect(screen.getAllByText('Note').length).toBeGreaterThan(0); - expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' })); - expect(screen.queryByText('Pitch Bend')).not.toBeInTheDocument(); - expect(screen.getByText('C4')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Notes' })); - expect(screen.queryByText('C4')).not.toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Pitch Bends' })); - expect(screen.getByText('Pitch Bend')).toBeInTheDocument(); - }); - - it('keeps the region selected while selecting rows', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('C4').closest('tr')!); - rerender(); - - expect(storeState.selectedRegionIds).toEqual(['region-1']); - expect(storeState.selectedNoteIds).toEqual(['note-1']); - expect(screen.queryByText('Please select a MIDI region, or open one in the Piano Roll, to view its event list.')).not.toBeInTheDocument(); - expect(screen.getByText('C4').closest('tr')).toHaveClass('selected'); - }); - - it('supports additive row selection without dropping the owning region', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('C4').closest('tr')!); - rerender(); - fireEvent.click(screen.getByText('Pitch Bend').closest('tr')!, { shiftKey: true }); - rerender(); - - expect(storeState.selectedRegionIds).toEqual(['region-1']); - expect(storeState.selectedNoteIds).toEqual(['note-1']); - expect(storeState.selectedPitchBendIds).toEqual(['bend-1']); - expect(screen.getByText('C4').closest('tr')).toHaveClass('selected'); - expect(screen.getByText('Raw 12288 | 0.500 | 1.00 st').closest('tr')).toHaveClass('selected'); - }); -}); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index aabae6c..34f83be 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'; @@ -48,12 +49,12 @@ const Toolbar: React.FC = () => { barWidthMultiplier, setBarWidthMultiplier, isLooping, toggleLoop, canUndo, canRedo, undoDescription, redoDescription, undo, redo, - toggleChatBox, toggleSettings, toggleKGOnePanel, toggleListEventPanel, showKGOnePanel, showListEventPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled, + toggleChatBox, toggleSettings, toggleKGOnePanel, toggleEventListPanel, showKGOnePanel, showEventListPanel, showChatBox, showSettings, cleanupProjectState, toggleMetronome, isMetronomeEnabled, isRecording, startRecording, stopRecording, // Piano roll state/actions showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId, // Selection state - selectedRegionIds, + selectedRegionIds, selectedTrackId, // Playhead and refresh playheadPosition, refreshProjectState, requestMainContentScroll, requestPianoRollScroll @@ -65,10 +66,10 @@ const Toolbar: React.FC = () => { // State for key signature dropdown const [showKeySignatureDropdown, setShowKeySignatureDropdown] = React.useState(false); - + // State for export dropdown const [showExportDropdown, setShowExportDropdown] = React.useState(false); - + // State for import modal const [showImportModal, setShowImportModal] = React.useState(false); @@ -79,7 +80,7 @@ const Toolbar: React.FC = () => { // State for open project modal const [showOpenProject, setShowOpenProject] = React.useState(false); const [isOpeningProject, setIsOpeningProject] = React.useState(false); - + // Close zoom slider on click outside React.useEffect(() => { if (!showZoomSlider) return; @@ -94,7 +95,7 @@ const Toolbar: React.FC = () => { // Key signature options const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; - + // Export options const exportOptions = ["Export to KGStudio file", "Export to MIDI file", "Export to WAV", "Export to MP3"]; const lastSelectedRegionId = selectedRegionIds[selectedRegionIds.length - 1] ?? null; @@ -251,7 +252,7 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("user selected export option:", exportType); } - + if (exportType === "Export to KGStudio file") { handleExportKGStudio(); } else if (exportType === "Export to MIDI file") { @@ -261,7 +262,7 @@ const Toolbar: React.FC = () => { } else if (exportType === "Export to MP3") { handleBounceToMp3(); } - + setShowExportDropdown(false); }; @@ -305,37 +306,37 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("exporting to MIDI file"); } - + try { // Get the current project from KGCore const currentProject = KGCore.instance().getCurrentProject(); - + // Convert project to MIDI format const midiData = convertProjectToMidi(currentProject); - + // Create a downloadable blob const blob = new Blob([midiData.buffer as ArrayBuffer], { type: 'audio/midi' }); - + // Create a temporary download link const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${projectName}.mid`; - + // Trigger download document.body.appendChild(link); link.click(); - + // Cleanup document.body.removeChild(link); URL.revokeObjectURL(url); - + setStatus(`Project "${projectName}" exported as MIDI file`); - + if (DEBUG_MODE.TOOLBAR) { console.log("MIDI export completed successfully"); } - + } catch (error) { console.error("Error exporting MIDI:", error); setStatus(`Error exporting MIDI: ${error}`); @@ -386,10 +387,10 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("file selected for import:", file.name); } - + // Get file extension const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); - + try { if (fileExtension === '.kgstudio') { // Handle KGStudio bundle import @@ -403,7 +404,7 @@ const Toolbar: React.FC = () => { } else { throw new Error(`Unsupported file type: ${fileExtension}`); } - + } catch (error) { console.error("Error importing file:", error); setStatus(`Failed to import file: ${error}`); @@ -479,35 +480,35 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Starting MIDI file import:", file.name); } - + // Show loading status setStatus(`Importing MIDI file "${file.name}"...`); - + // Read the MIDI file as binary data const arrayBuffer = await file.arrayBuffer(); const midiData = new Uint8Array(arrayBuffer); - + if (DEBUG_MODE.TOOLBAR) { console.log("MIDI file read successfully, size:", midiData.length, "bytes"); } - + // Get current project to append MIDI tracks to it const currentProject = KGCore.instance().getCurrentProject(); - + // Convert MIDI data and append to current project const updatedProject = convertMidiToProject(midiData, currentProject); - + if (DEBUG_MODE.TOOLBAR) { console.log("MIDI conversion successful, tracks added to existing project"); } - + // Load the updated project using common loading logic await loadProjectFromData(updatedProject, `MIDI file "${file.name}"`); - + if (DEBUG_MODE.TOOLBAR) { console.log("MIDI file imported successfully:", file.name); } - + } catch (error) { console.error("Error importing MIDI file:", error); const errorMessage = error instanceof Error ? error.message : String(error); @@ -584,33 +585,33 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("BPM clicked, current BPM:", bpm); } - + const newBpmStr = await showPrompt(`Enter new BPM (${TIME_CONSTANTS.MIN_BPM}-${TIME_CONSTANTS.MAX_BPM}):`, bpm.toString()); - + // Check if user cancelled if (newBpmStr === null) { return; } - + // Validate input const newBpm = parseInt(newBpmStr.trim()); - + // Check if it's a valid number if (isNaN(newBpm)) { await showAlert("Invalid input. Please enter a valid number."); return; } - + // Check if it's within valid range if (newBpm <= TIME_CONSTANTS.MIN_BPM || newBpm >= TIME_CONSTANTS.MAX_BPM) { await showAlert(`Invalid BPM. Please enter a value between ${TIME_CONSTANTS.MIN_BPM} and ${TIME_CONSTANTS.MAX_BPM}.`); return; } - + // Update BPM setBpm(newBpm); setStatus(`BPM changed to ${newBpm}`); - + if (DEBUG_MODE.TOOLBAR) { console.log(`BPM updated from ${bpm} to ${newBpm}`); } @@ -642,7 +643,7 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Key signature changed from", keySignature, "to", newKeySignature); } - + setKeySignature(newKeySignature as KeySignature); setStatus(`Key signature changed to ${newKeySignature}`); setShowKeySignatureDropdown(false); @@ -672,9 +673,9 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Copy button clicked"); } - + const copied = handleCopyOperation(); - + if (copied) { setStatus("Items copied to clipboard"); if (DEBUG_MODE.TOOLBAR) { @@ -693,9 +694,9 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Paste button clicked"); } - + const pasted = handlePasteOperation(); - + if (pasted) { setStatus("Items pasted from clipboard"); if (DEBUG_MODE.TOOLBAR) { @@ -714,9 +715,9 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Delete button clicked"); } - + const deleted = regionDeleteManager.deleteSelectedRegions(); - + if (deleted) { setStatus("Selected regions deleted"); if (DEBUG_MODE.TOOLBAR) { @@ -874,16 +875,16 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Undo button clicked"); } - + if (!canUndo) { await showAlert("Nothing to undo"); return; } - + undo(); const description = undoDescription || "action"; setStatus(`Undid: ${description}`); - + if (DEBUG_MODE.TOOLBAR) { console.log(`Undo successful: ${description}`); } @@ -894,16 +895,16 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Redo button clicked"); } - + if (!canRedo) { await showAlert("Nothing to redo"); return; } - + redo(); const description = redoDescription || "action"; setStatus(`Redid: ${description}`); - + if (DEBUG_MODE.TOOLBAR) { console.log(`Redo successful: ${description}`); } @@ -914,7 +915,7 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Chat button clicked"); } - + toggleChatBox(); setStatus("Chat toggled"); }; @@ -924,7 +925,7 @@ const Toolbar: React.FC = () => { if (DEBUG_MODE.TOOLBAR) { console.log("Settings button clicked"); } - + toggleSettings(); setStatus("Settings toggled"); }; @@ -939,11 +940,11 @@ const Toolbar: React.FC = () => { toggleKGOnePanel(); }; - const handleListEventClick = () => { + const handleEventListClick = () => { if (DEBUG_MODE.TOOLBAR) { - console.log("List Event button clicked"); + console.log("Event List button clicked"); } - toggleListEventPanel(); + toggleEventListPanel(); }; // Handle Piano button click: open piano roll if closed, targeting active or selected region @@ -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; } @@ -1031,226 +1039,226 @@ const Toolbar: React.FC = () => {
DAW Logo
-
{projectName}
- -
- - - -
- -
- -
-
- - -
- - -
- - - - - -
- - - -
- - {!isPlaying ? ( - - ) : ( - - )} - - -
- - - {/* - */} -
- -
-
-
- setShowZoomSlider(!showZoomSlider)} - style={{ cursor: 'pointer' }} + +
+ + + +
+
-
- {currentTime} -
-
- {bpm} -
-
- {timeSignature.numerator + "/" + timeSignature.denominator} -
-
- setShowKeySignatureDropdown(!showKeySignatureDropdown)} - style={{ cursor: 'pointer' }} - > - {keySignature} - + +
+ + +
+ + +
+ + + + + +
+ + + +
+ + {!isPlaying ? ( + + ) : ( + + )} + + +
+ + + {/* + */} +
+ +
+
+
+ setShowZoomSlider(!showZoomSlider)} + style={{ cursor: 'pointer' }} + > + {barWidthMultiplier}x + + {showZoomSlider && ( +
+ setBarWidthMultiplier(parseInt(e.target.value))} + /> + {barWidthMultiplier}x +
+ )} +
+
+ {currentTime} +
+
+ {bpm} +
+
+ {timeSignature.numerator + "/" + timeSignature.denominator} +
+
+ setShowKeySignatureDropdown(!showKeySignatureDropdown)} + style={{ cursor: 'pointer' }} + > + {keySignature} + +
+ +
+
+
+ + +
- - -
-
- - setShowImportModal(false)} - onFileImport={handleFileImport} - acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']} - title="Import Project" - description="Drag and drop your project file here" - /> - - - {showOpenProject && ( - setShowOpenProject(false)} - onConfirmOpenProject={handleConfirmOpenProject} - onOpenProject={handleOpenProjectSelect} - currentProjectName={savedProjectName} - onCreateNewProject={createNewProject} + setShowImportModal(false)} + onFileImport={handleFileImport} + acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']} + title="Import Project" + description="Drag and drop your project file here" /> - )} + + + + {showOpenProject && ( + setShowOpenProject(false)} + onConfirmOpenProject={handleConfirmOpenProject} + onOpenProject={handleOpenProjectSelect} + currentProjectName={savedProjectName} + onCreateNewProject={createNewProject} + /> + )} ); }; diff --git a/src/components/ListEventPanel.tsx b/src/components/event-list-panel/RegionEventListTab.tsx similarity index 70% rename from src/components/ListEventPanel.tsx rename to src/components/event-list-panel/RegionEventListTab.tsx index 34e0f07..d202dd0 100644 --- a/src/components/ListEventPanel.tsx +++ b/src/components/event-list-panel/RegionEventListTab.tsx @@ -1,14 +1,14 @@ import React, { useEffect, useRef, useState } from 'react'; -import './ListEventPanel.css'; import { FaPlus, FaTrash } from 'react-icons/fa'; -import KGDropdown from './common/KGDropdown'; -import { useProjectStore } from '../stores/projectStore'; -import { KGCore } from '../core/KGCore'; -import { KGMidiRegion } from '../core/region/KGMidiRegion'; -import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent'; -import { KGMidiNote } from '../core/midi/KGMidiNote'; -import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend'; -import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import KGDropdown from '../common/KGDropdown'; +import { useProjectStore } from '../../stores/projectStore'; +import { KGCore } from '../../core/KGCore'; +import { KGMidiRegion } from '../../core/region/KGMidiRegion'; +import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent'; +import { KGMidiNote } from '../../core/midi/KGMidiNote'; +import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGPianoRollState } from '../../core/state/KGPianoRollState'; import { clampMidiControllerValue, clampMidiPitchBendValue, @@ -27,17 +27,18 @@ import { parseMidiEventPosition, pitchToNoteNameString, signedPitchBendToMidiValue -} from '../util/midiUtil'; -import { isModifierKeyPressed } from '../util/osUtil'; -import { PIANO_ROLL_CONSTANTS } from '../constants'; -import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands'; -import { UpdateControllerEventPropertiesCommand } from '../core/commands/note/UpdateControllerEventPropertiesCommand'; -import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand'; -import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand'; -import { showAlert } from '../util/dialogUtil'; +} from '../../util/midiUtil'; +import { isModifierKeyPressed } from '../../util/osUtil'; +import { PIANO_ROLL_CONSTANTS } from '../../constants'; +import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../../core/commands'; +import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note/UpdateControllerEventPropertiesCommand'; +import { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand'; +import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand'; +import { showAlert } from '../../util/dialogUtil'; -interface ListEventPanelProps { - isVisible: boolean; +interface RegionEventListTabProps { + activeMidiRegion: KGMidiRegion | null; + parentTrack: KGMidiTrack | null; } interface NoteRowData { @@ -65,6 +66,7 @@ interface ControllerRowData { type EventRowData = NoteRowData | PitchBendRowData | ControllerRowData; type EditableColumn = 'position' | 'num' | 'val' | 'length'; +type AddEventType = 'note' | 'pitch-bend' | 'controller'; interface EditingCell { eventId: string; @@ -72,8 +74,6 @@ interface EditingCell { value: string; } -type AddEventType = 'note' | 'pitch-bend' | 'controller'; - const ADD_EVENT_TYPE_OPTIONS = [ { label: 'Note', value: 'note' }, { label: 'Pitch Bend', value: 'pitch-bend' }, @@ -181,15 +181,14 @@ const parseControllerValueDeltaInput = (raw: string): { delta: number } | { erro return { delta: parseInt(trimmed, 10) }; }; -const ListEventPanel: React.FC = ({ isVisible }) => { +const RegionEventListTab: React.FC = ({ activeMidiRegion, parentTrack }) => { const { - tracks, - activeRegionId, - selectedRegionIds, - timeSignature, selectedNoteIds, selectedPitchBendIds, selectedControllerEventIds, + selectedRegionIds, + activeRegionId, + timeSignature, playheadPosition, updateTrack, refreshProjectState, @@ -208,32 +207,12 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const suppressBlurCommitRef = useRef(false); const pendingSingleClickSelectionRef = useRef(null); - const resolvedRegionId = selectedRegionIds.length > 1 - ? activeRegionId - : selectedRegionIds.length === 1 - ? selectedRegionIds[0] - : activeRegionId; - - let activeMidiRegion: KGMidiRegion | null = null; - let parentTrack = null as typeof tracks[number] | null; - - if (resolvedRegionId) { - for (const track of tracks) { - const region = track.getRegions().find(candidate => candidate.getId() === resolvedRegionId); - if (region instanceof KGMidiRegion) { - activeMidiRegion = region; - parentTrack = track; - break; - } - } - } - const noteRows: NoteRowData[] = activeMidiRegion ? activeMidiRegion.getNotes().map(note => ({ id: note.getId(), type: 'note', note, - absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(), + absoluteStartBeat: activeMidiRegion.getStartFromBeat() + note.getStartBeat(), durationBeats: note.getEndBeat() - note.getStartBeat(), })) : []; @@ -243,7 +222,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { id: pitchBend.getId(), type: 'pitch-bend', pitchBend, - absoluteBeat: activeMidiRegion!.getStartFromBeat() + pitchBend.getBeat(), + absoluteBeat: activeMidiRegion.getStartFromBeat() + pitchBend.getBeat(), })) : []; @@ -253,7 +232,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { type: 'controller', controller, controllerEvent: event, - absoluteBeat: activeMidiRegion!.getStartFromBeat() + event.getBeat(), + absoluteBeat: activeMidiRegion.getStartFromBeat() + event.getBeat(), })) : []; @@ -774,10 +753,6 @@ const ListEventPanel: React.FC = ({ isVisible }) => { commitSelection(new Set()); }; - const handleTableShellClick = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - const handleEditInputKeyDown = async (event: React.KeyboardEvent) => { event.stopPropagation(); @@ -808,10 +783,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const denominator = parseInt(quantValue.split('/')[1], 10); if (Number.isNaN(denominator)) return; - const selectedNotes = activeMidiRegion - .getNotes() - .filter(note => selectedNoteIdSet.has(note.getId())); - + const selectedNotes = activeMidiRegion.getNotes().filter(note => selectedNoteIdSet.has(note.getId())); if (selectedNotes.length === 0) return; const quantizationStep = 4 / denominator; @@ -833,10 +805,7 @@ const ListEventPanel: React.FC = ({ isVisible }) => { const denominator = parseInt(quantValue.split('/')[1], 10); if (Number.isNaN(denominator)) return; - const selectedNotes = activeMidiRegion - .getNotes() - .filter(note => selectedNoteIdSet.has(note.getId())); - + const selectedNotes = activeMidiRegion.getNotes().filter(note => selectedNoteIdSet.has(note.getId())); if (selectedNotes.length === 0) return; const quantizationStep = 4 / denominator; @@ -889,6 +858,10 @@ const ListEventPanel: React.FC = ({ isVisible }) => { if (createdNote) { createdNote.select(); KGCore.instance().clearSelectedItems(); + if (selectedRegionIds.includes(activeRegionId ?? '')) { + activeMidiRegion.select(); + KGCore.instance().addSelectedItem(activeMidiRegion); + } KGCore.instance().addSelectedItem(createdNote); rangeAnchorEventIdRef.current = createdNote.getId(); } @@ -905,6 +878,10 @@ const ListEventPanel: React.FC = ({ isVisible }) => { if (createdPitchBend) { createdPitchBend.select(); KGCore.instance().clearSelectedItems(); + if (selectedRegionIds.includes(activeRegionId ?? '')) { + activeMidiRegion.select(); + KGCore.instance().addSelectedItem(activeMidiRegion); + } KGCore.instance().addSelectedItem(createdPitchBend); rangeAnchorEventIdRef.current = createdPitchBend.getId(); } @@ -926,6 +903,10 @@ const ListEventPanel: React.FC = ({ isVisible }) => { if (createdControllerEvent) { createdControllerEvent.select(); KGCore.instance().clearSelectedItems(); + if (selectedRegionIds.includes(activeRegionId ?? '')) { + activeMidiRegion.select(); + KGCore.instance().addSelectedItem(activeMidiRegion); + } KGCore.instance().addSelectedItem(createdControllerEvent); rangeAnchorEventIdRef.current = createdControllerEvent.getId(); } @@ -959,249 +940,196 @@ const ListEventPanel: React.FC = ({ isVisible }) => { }; return ( -
-
-

List Event

+ <> +
+ + +
-
-
- - - + {!activeMidiRegion ? ( +
+ Please select a MIDI region, or open one in the Piano Roll, to view its event list.
+ ) : ( + <> +
+
+ + setAddEventType(value as AddEventType)} + label="Note" + buttonClassName="event-list-type-button" + showValueAsLabel + /> +
- {!activeMidiRegion ? ( -
- Please select a MIDI region, or open one in the Piano Roll, to view its event list. +
+ { + setQuantPosition(value); + quantizeSelectedNotes(value); + }} + label="Qua. Pos." + buttonClassName="event-list-quant-button" + /> + { + setQuantLength(value); + quantizeSelectedNoteLengths(value); + }} + label="Qua. Len." + buttonClassName="event-list-quant-button" + /> + +
- ) : ( - <> -
-
- - setAddEventType(value as AddEventType)} - label="Note" - buttonClassName="list-event-type-button" - showValueAsLabel - /> -
-
- { - setQuantPosition(value); - quantizeSelectedNotes(value); - }} - label="Qua. Pos." - buttonClassName="list-event-quant-button" - /> - { - setQuantLength(value); - quantizeSelectedNoteLengths(value); - }} - label="Qua. Len." - buttonClassName="list-event-quant-button" - /> - -
-
+
+ + + + + + + + + + + + {eventRows.map((row, index) => { + const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat; + const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller'; + const numText = row.type === 'note' + ? pitchToNoteNameString(row.note.getPitch()) + : row.type === 'controller' + ? String(row.controller) + : ''; + const valText = row.type === 'note' + ? String(row.note.getVelocity()) + : row.type === 'pitch-bend' + ? String(midiPitchBendToSignedValue(row.pitchBend.getValue())) + : String(row.controllerEvent.getValue()); + const lengthText = row.type === 'note' + ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) + : row.type === 'pitch-bend' + ? formatPitchBendInfo(row.pitchBend.getValue()) + : `Raw ${row.controllerEvent.getValue()}`; + const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position'; + const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num'; + const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val'; + const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length'; -
-
PositionStatusNumValLength/Info
- - - - - - - - - - - {eventRows.map((row, index) => { - const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat; - const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); - const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller'; - const numText = row.type === 'note' - ? pitchToNoteNameString(row.note.getPitch()) - : row.type === 'controller' - ? String(row.controller) - : ''; - const valText = row.type === 'note' - ? String(row.note.getVelocity()) - : row.type === 'pitch-bend' - ? String(midiPitchBendToSignedValue(row.pitchBend.getValue())) - : String(row.controllerEvent.getValue()); - const lengthText = row.type === 'note' - ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) - : row.type === 'pitch-bend' - ? formatPitchBendInfo(row.pitchBend.getValue()) - : `Raw ${row.controllerEvent.getValue()}`; - const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position'; - const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num'; - const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val'; - const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length'; - - return ( - handleRowClick(row.id, index, event)} - onDoubleClick={(event) => { - event.stopPropagation(); - clearPendingSingleClickSelection(); - }} - > - - - - - - - ); - })} - -
PositionStatusNumValLength/Info
{ - event.stopPropagation(); - startEditingCell(row.id, 'position', positionText); - }} - > - {isEditingPosition ? ( - setEditingCell({ ...editingCell, value: event.target.value })} - onBlur={handleEditInputBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { void handleEditInputKeyDown(event); }} - /> - ) : positionText} - {statusText} { - if (row.type === 'pitch-bend') return; - event.stopPropagation(); - startEditingCell(row.id, 'num', numText); - }} - > - {isEditingNum ? ( - setEditingCell({ ...editingCell, value: event.target.value })} - onBlur={handleEditInputBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { void handleEditInputKeyDown(event); }} - /> - ) : numText} - { - event.stopPropagation(); - startEditingCell(row.id, 'val', valText); - }} - > - {isEditingVal ? ( - setEditingCell({ ...editingCell, value: event.target.value })} - onBlur={handleEditInputBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { void handleEditInputKeyDown(event); }} - /> - ) : valText} - { - if (row.type !== 'note') return; - event.stopPropagation(); - startEditingCell(row.id, 'length', lengthText); - }} - > - {isEditingLength ? ( - setEditingCell({ ...editingCell, value: event.target.value })} - onBlur={handleEditInputBlur} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { void handleEditInputKeyDown(event); }} - /> - ) : lengthText} -
-
- - )} -
-
+ return ( + handleRowClick(row.id, index, event)} + onDoubleClick={(event) => { + event.stopPropagation(); + clearPendingSingleClickSelection(); + }} + > + { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}> + {isEditingPosition ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : positionText} + + {statusText} + { + if (row.type === 'pitch-bend') return; + event.stopPropagation(); + startEditingCell(row.id, 'num', numText); + }}> + {isEditingNum ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : numText} + + { + event.stopPropagation(); + startEditingCell(row.id, 'val', valText); + }}> + {isEditingVal ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : valText} + + { + if (row.type !== 'note') return; + event.stopPropagation(); + startEditingCell(row.id, 'length', lengthText); + }}> + {isEditingLength ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : lengthText} + + + ); + })} + + +
+ + )} + ); }; -export default ListEventPanel; +export default RegionEventListTab; diff --git a/src/components/event-list-panel/TrackEventListTab.tsx b/src/components/event-list-panel/TrackEventListTab.tsx new file mode 100644 index 0000000..c55a5a1 --- /dev/null +++ b/src/components/event-list-panel/TrackEventListTab.tsx @@ -0,0 +1,800 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { FaPlus, FaTrash } from 'react-icons/fa'; +import KGDropdown from '../common/KGDropdown'; +import { useProjectStore } from '../../stores/projectStore'; +import { KGCore } from '../../core/KGCore'; +import { KGAudioRegion } from '../../core/region/KGAudioRegion'; +import { KGRegion } from '../../core/region/KGRegion'; +import { KGTrackAutomationPoint, type TrackAutomationType } from '../../core/track/KGTrackAutomationPoint'; +import { KGMidiTrack } from '../../core/track/KGMidiTrack'; +import { KGAudioTrack } from '../../core/track/KGAudioTrack'; +import { + CreateRegionCommand, + CreateTrackAutomationPointsCommand, + DeleteMultipleRegionsCommand, + DeleteTrackAutomationPointsCommand, + MoveRegionCommand, + ResizeRegionCommand, + UpdateTrackAutomationPointsCommand, +} from '../../core/commands'; +import { + formatMidiEventLength, + formatMidiEventPosition, + MIDI_EVENT_TICKS_PER_BEAT, + parseMidiEventLengthDelta, + parseMidiEventLength, + parseMidiEventPositionDelta, + parseMidiEventPosition, +} from '../../util/midiUtil'; +import { isModifierKeyPressed } from '../../util/osUtil'; +import { showAlert } from '../../util/dialogUtil'; +import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants'; + +interface TrackEventListTabProps { + selectedTrack: KGMidiTrack | KGAudioTrack | null; +} + +type AddTrackItemType = 'midi-region' | 'volume' | 'pan'; +type TrackEditableColumn = 'position' | 'val' | 'length'; + +interface TrackRegionRowData { + id: string; + type: 'region'; + region: KGRegion; + absoluteStartBeat: number; + durationBeats: number; + statusLabel: 'MIDI' | 'Audio'; +} + +interface TrackAutomationRowData { + id: string; + type: 'automation'; + automationType: TrackAutomationType; + point: KGTrackAutomationPoint; + absoluteBeat: number; +} + +type TrackRowData = TrackRegionRowData | TrackAutomationRowData; + +interface TrackEditingCell { + rowId: string; + column: TrackEditableColumn; + value: string; +} + +const formatTrackAutomationValue = (automationType: TrackAutomationType, value: number): string => { + if (automationType === 'volume') { + if (value <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) { + return '−∞'; + } + + return `${value >= 0 ? '+' : ''}${value.toFixed(1)}dB`; + } + + const logicPanValue = value <= 0 ? Math.round(value * 64) : Math.round(value * 63); + return `${logicPanValue >= 0 ? '+' : ''}${logicPanValue}`; +}; + +const formatTrackAutomationInfo = (automationType: TrackAutomationType, value: number): string => { + return automationType === 'volume' ? `Raw ${value.toFixed(3)} dB` : `Raw ${value.toFixed(3)}`; +}; + +const parseTrackAutomationValueInput = ( + automationType: TrackAutomationType, + raw: string +): { value: number } | { error: string } => { + const trimmed = raw.trim().replace(/db$/i, ''); + + if (automationType === 'volume') { + if (trimmed === '−∞' || trimmed.toLowerCase() === '-inf' || trimmed.toLowerCase() === '-infinity') { + return { value: AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB }; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return { error: `Volume must be a number between ${AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB} and ${AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB}.` }; + } + if (parsed < AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB || parsed > AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB) { + return { error: `Volume must be between ${AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB} and ${AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB}.` }; + } + return { value: parsed }; + } + + if (!/^[+-]?\d+$/.test(trimmed)) { + return { error: 'Pan must be an integer between -64 and +63.' }; + } + + const parsed = parseInt(trimmed, 10); + if (parsed < -64 || parsed > 63) { + return { error: 'Pan must be between -64 and +63.' }; + } + + const normalized = parsed <= 0 ? parsed / 64 : parsed / 63; + return { value: Math.max(-1, Math.min(1, normalized)) }; +}; + +const findPreviousPanValue = (points: KGTrackAutomationPoint[], beat: number): number => { + const previousPoint = [...points].filter(point => point.getBeat() <= beat).sort((a, b) => b.getBeat() - a.getBeat())[0]; + return previousPoint?.getValue() ?? 0; +}; + +const TrackEventListTab: React.FC = ({ selectedTrack }) => { + const { + tracks, + playheadPosition, + timeSignature, + selectedRegionIds, + selectedTrackAutomationPointIds, + updateTrack, + refreshProjectState, + bumpTrackAutomationRedrawVersion, + } = useProjectStore(); + + const [showRegions, setShowRegions] = useState(true); + const [showVolume, setShowVolume] = useState(true); + const [showPan, setShowPan] = useState(true); + const [addTrackItemType, setAddTrackItemType] = useState('midi-region'); + const [editingCell, setEditingCell] = useState(null); + const rangeAnchorRowIdRef = useRef(null); + const editInputRef = useRef(null); + const suppressBlurCommitRef = useRef(false); + const pendingSingleClickSelectionRef = useRef(null); + + useEffect(() => { + if (selectedTrack instanceof KGAudioTrack && addTrackItemType === 'midi-region') { + setAddTrackItemType('volume'); + } + }, [selectedTrack, addTrackItemType]); + + useEffect(() => { + if (editingCell) { + editInputRef.current?.focus(); + editInputRef.current?.select(); + } + }, [editingCell?.rowId, editingCell?.column]); + + useEffect(() => { + return () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + } + }; + }, []); + + const availableAddOptions = useMemo(() => { + const options: Array<{ label: string; value: AddTrackItemType }> = []; + if (selectedTrack instanceof KGMidiTrack) { + options.push({ label: 'MIDI Region', value: 'midi-region' }); + } + options.push({ label: 'Volume', value: 'volume' }); + options.push({ label: 'Pan', value: 'pan' }); + return options; + }, [selectedTrack]); + + const liveSelectedTrack = useMemo(() => { + if (!selectedTrack) { + return null; + } + + const matchedTrack = tracks.find(track => track.getId() === selectedTrack.getId()) ?? null; + return matchedTrack instanceof KGMidiTrack || matchedTrack instanceof KGAudioTrack + ? matchedTrack + : selectedTrack; + }, [selectedTrack, tracks]); + + const trackRows: TrackRowData[] = useMemo(() => { + if (!liveSelectedTrack) return []; + + const regionRows: TrackRegionRowData[] = showRegions + ? liveSelectedTrack.getRegions().map(region => ({ + id: region.getId(), + type: 'region', + region, + absoluteStartBeat: region.getStartFromBeat(), + durationBeats: region.getLength(), + statusLabel: region instanceof KGAudioRegion ? 'Audio' : 'MIDI', + })) + : []; + + const volumeRows: TrackAutomationRowData[] = showVolume + ? liveSelectedTrack.getAutomationPoints('volume').map(point => ({ + id: point.getId(), + type: 'automation', + automationType: 'volume', + point, + absoluteBeat: point.getBeat(), + })) + : []; + + const panRows: TrackAutomationRowData[] = showPan + ? liveSelectedTrack.getAutomationPoints('pan').map(point => ({ + id: point.getId(), + type: 'automation', + automationType: 'pan', + point, + absoluteBeat: point.getBeat(), + })) + : []; + + return [...regionRows, ...volumeRows, ...panRows].sort((a, b) => { + const beatA = a.type === 'region' ? a.absoluteStartBeat : a.absoluteBeat; + const beatB = b.type === 'region' ? b.absoluteStartBeat : b.absoluteBeat; + if (beatA !== beatB) return beatA - beatB; + if (a.type !== b.type) return a.type === 'region' ? -1 : 1; + if (a.type === 'automation' && b.type === 'automation' && a.automationType !== b.automationType) { + return a.automationType.localeCompare(b.automationType); + } + return a.id.localeCompare(b.id); + }); + }, [liveSelectedTrack, showPan, showRegions, showVolume, tracks]); + + const selectedRowIdSet = new Set([...selectedRegionIds, ...selectedTrackAutomationPointIds]); + const visibleSelectedRows = trackRows.filter(row => selectedRowIdSet.has(row.id)); + + const clearPendingSingleClickSelection = () => { + if (pendingSingleClickSelectionRef.current !== null) { + window.clearTimeout(pendingSingleClickSelectionRef.current); + pendingSingleClickSelectionRef.current = null; + } + }; + + const commitTrackRegionSelection = (nextSelectedIds: Set) => { + if (!liveSelectedTrack) return; + + const core = KGCore.instance(); + const selectedRegions = liveSelectedTrack.getRegions().filter(region => nextSelectedIds.has(region.getId())); + const previouslySelectedRegions = core.getSelectedItems().filter(item => item instanceof KGRegion); + + liveSelectedTrack.getRegions().forEach(region => { + if (nextSelectedIds.has(region.getId())) region.select(); + else region.deselect(); + }); + + if (previouslySelectedRegions.length > 0) { + core.removeSelectedItems(previouslySelectedRegions); + } + if (selectedRegions.length > 0) { + core.addSelectedItems(selectedRegions); + } + + void updateTrack(liveSelectedTrack); + }; + + const commitTrackAutomationSelection = (nextSelectedIds: Set) => { + if (!liveSelectedTrack) return; + + const core = KGCore.instance(); + const points = [ + ...liveSelectedTrack.getAutomationPoints('volume'), + ...liveSelectedTrack.getAutomationPoints('pan'), + ]; + const selectedPoints = points.filter(point => nextSelectedIds.has(point.getId())); + const previouslySelectedPoints = core.getSelectedItems().filter(item => item instanceof KGTrackAutomationPoint); + + points.forEach(point => { + if (nextSelectedIds.has(point.getId())) point.select(); + else point.deselect(); + }); + + if (previouslySelectedPoints.length > 0) { + core.removeSelectedItems(previouslySelectedPoints); + } + if (selectedPoints.length > 0) { + core.addSelectedItems(selectedPoints); + } + + void updateTrack(liveSelectedTrack); + }; + + const commitSelection = (nextSelectedIds: Set) => { + commitTrackRegionSelection(nextSelectedIds); + commitTrackAutomationSelection(nextSelectedIds); + }; + + const startEditingCell = (rowId: string, column: TrackEditableColumn, value: string) => { + clearPendingSingleClickSelection(); + setEditingCell({ rowId, column, value }); + }; + + const cancelEditingCell = () => { + setEditingCell(null); + }; + + const commitEditingCell = async () => { + if (!editingCell || !liveSelectedTrack) return; + + const row = trackRows.find(candidate => candidate.id === editingCell.rowId); + if (!row) { + setEditingCell(null); + return; + } + + const trimmedValue = editingCell.value.trim(); + const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-'); + + if (row.type === 'region') { + const targetRows: TrackRegionRowData[] = selectedRowIdSet.has(row.id) && selectedRegionIds.length > 1 + ? trackRows.filter((candidate): candidate is TrackRegionRowData => candidate.type === 'region' && selectedRowIdSet.has(candidate.id)) + : [row]; + + if (editingCell.column === 'position') { + if (isDeltaEdit) { + const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.region.getStartFromBeat() + parsed.deltaBeats < 0) { + await showAlert('Position delta would move one or more regions before the start of the project.'); + return; + } + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new MoveRegionCommand( + targetRow.region.getId(), + targetRow.region.getStartFromBeat() + parsed.deltaBeats, + targetRow.region.getTrackId(), + targetRow.region.getTrackIndex() + )); + }); + } else { + const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + if (parsed.absoluteBeat < 0) { + await showAlert('Position cannot be earlier than the start of the project.'); + return; + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new MoveRegionCommand( + targetRow.region.getId(), + parsed.absoluteBeat, + targetRow.region.getTrackId(), + targetRow.region.getTrackIndex() + )); + }); + } + } + + if (editingCell.column === 'length') { + if (isDeltaEdit) { + const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetRow of targetRows) { + if (targetRow.region.getLength() + parsed.deltaBeats <= 0) { + await showAlert('Length delta would make one or more regions non-positive in duration.'); + return; + } + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new ResizeRegionCommand( + targetRow.region.getId(), + targetRow.region.getStartFromBeat(), + targetRow.region.getLength() + parsed.deltaBeats + )); + }); + } else { + const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + if (parsed.duration <= 0) { + await showAlert('Length must be positive.'); + return; + } + + targetRows.forEach(targetRow => { + KGCore.instance().executeCommand(new ResizeRegionCommand( + targetRow.region.getId(), + targetRow.region.getStartFromBeat(), + parsed.duration + )); + }); + } + } + } else { + const automationType = row.automationType; + const targetRows: TrackAutomationRowData[] = selectedRowIdSet.has(row.id) && selectedTrackAutomationPointIds.length > 1 + ? trackRows.filter((candidate): candidate is TrackAutomationRowData => ( + candidate.type === 'automation' + && candidate.automationType === automationType + && selectedRowIdSet.has(candidate.id) + )) + : [row]; + + const snapshots = targetRows.map(targetRow => ({ + pointId: targetRow.point.getId(), + beat: targetRow.point.getBeat(), + value: targetRow.point.getValue(), + })); + const updates: Array<{ pointId: string; beat?: number; value?: number }> = []; + + if (editingCell.column === 'position') { + if (isDeltaEdit) { + const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + + for (const targetRow of targetRows) { + const nextBeat = targetRow.point.getBeat() + parsed.deltaBeats; + if (nextBeat < 0) { + await showAlert('Position delta would move one or more automation points before the start of the project.'); + return; + } + updates.push({ pointId: targetRow.point.getId(), beat: nextBeat }); + } + } else { + const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + if (parsed.absoluteBeat < 0) { + await showAlert('Position cannot be earlier than the start of the project.'); + return; + } + + for (const targetRow of targetRows) { + updates.push({ pointId: targetRow.point.getId(), beat: parsed.absoluteBeat }); + } + } + } + + if (editingCell.column === 'val') { + const parsed = parseTrackAutomationValueInput(automationType, trimmedValue); + if ('error' in parsed) { + await showAlert(parsed.error); + return; + } + for (const targetRow of targetRows) { + updates.push({ pointId: targetRow.point.getId(), value: parsed.value }); + } + } + + if (updates.length > 0) { + KGCore.instance().executeCommand(new UpdateTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + automationType, + snapshots, + updates + )); + bumpTrackAutomationRedrawVersion(); + } + } + + await updateTrack(liveSelectedTrack); + refreshProjectState(); + setEditingCell(null); + }; + + const handleRowClick = (rowId: string, rowIndex: number, event: React.MouseEvent) => { + event.stopPropagation(); + if (editingCell) return; + + const isModifierPressed = isModifierKeyPressed(event); + const nextSelectedIds = new Set(selectedRowIdSet); + const isAlreadySelected = selectedRowIdSet.has(rowId); + const hasMultiSelection = selectedRowIdSet.size > 1; + + if (event.shiftKey) { + clearPendingSingleClickSelection(); + const anchorIndex = trackRows.findIndex(row => row.id === rangeAnchorRowIdRef.current); + const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex; + const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex; + + if (!isModifierPressed) { + nextSelectedIds.clear(); + } + + for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) { + nextSelectedIds.add(trackRows[index].id); + } + } else if (isModifierPressed) { + clearPendingSingleClickSelection(); + if (nextSelectedIds.has(rowId)) nextSelectedIds.delete(rowId); + else nextSelectedIds.add(rowId); + rangeAnchorRowIdRef.current = rowId; + } else { + if (isAlreadySelected && hasMultiSelection) { + clearPendingSingleClickSelection(); + pendingSingleClickSelectionRef.current = window.setTimeout(() => { + const delayedSelection = new Set([rowId]); + rangeAnchorRowIdRef.current = rowId; + commitSelection(delayedSelection); + pendingSingleClickSelectionRef.current = null; + }, 220); + return; + } + + clearPendingSingleClickSelection(); + nextSelectedIds.clear(); + nextSelectedIds.add(rowId); + rangeAnchorRowIdRef.current = rowId; + } + + if (event.shiftKey && rangeAnchorRowIdRef.current === null) { + rangeAnchorRowIdRef.current = rowId; + } + + commitSelection(nextSelectedIds); + }; + + const handleTableBackgroundMouseDown = (event: React.MouseEvent) => { + event.stopPropagation(); + if (event.target !== event.currentTarget) return; + clearPendingSingleClickSelection(); + rangeAnchorRowIdRef.current = null; + commitSelection(new Set()); + }; + + const handleEditInputKeyDown = async (event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.key === 'Enter') { + event.preventDefault(); + await commitEditingCell(); + } + + if (event.key === 'Escape') { + event.preventDefault(); + suppressBlurCommitRef.current = true; + cancelEditingCell(); + } + }; + + const handleEditInputBlur = () => { + if (suppressBlurCommitRef.current) { + suppressBlurCommitRef.current = false; + return; + } + + void commitEditingCell(); + }; + + const handleAddTrackItem = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (!liveSelectedTrack) return; + + if (addTrackItemType === 'midi-region') { + if (!(liveSelectedTrack instanceof KGMidiTrack)) return; + + const command = new CreateRegionCommand( + liveSelectedTrack.getId().toString(), + liveSelectedTrack.getTrackIndex(), + playheadPosition, + timeSignature.numerator + ); + KGCore.instance().executeCommand(command); + const createdRegion = command.getCreatedRegion(); + if (createdRegion) { + KGCore.instance().clearSelectedItems(); + createdRegion.select(); + KGCore.instance().addSelectedItem(createdRegion); + rangeAnchorRowIdRef.current = createdRegion.getId(); + } + } else { + const automationType: TrackAutomationType = addTrackItemType; + const value = automationType === 'volume' + ? liveSelectedTrack.getVolume() + : findPreviousPanValue(liveSelectedTrack.getPanAutomation(), playheadPosition); + + const command = new CreateTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + automationType, + [{ beat: playheadPosition, value }] + ); + KGCore.instance().executeCommand(command); + const createdPointId = command.getCreatedPointIds()[0]; + const createdPoint = createdPointId + ? liveSelectedTrack.getAutomationPoints(automationType).find(point => point.getId() === createdPointId) ?? null + : null; + if (createdPoint) { + KGCore.instance().clearSelectedItems(); + createdPoint.select(); + KGCore.instance().addSelectedItem(createdPoint); + rangeAnchorRowIdRef.current = createdPoint.getId(); + } + bumpTrackAutomationRedrawVersion(); + } + + await updateTrack(liveSelectedTrack); + refreshProjectState(); + }; + + const handleDeleteSelectedRows = async (event: React.MouseEvent) => { + event.stopPropagation(); + if (!liveSelectedTrack || visibleSelectedRows.length === 0) return; + + const regionIds = visibleSelectedRows + .filter((row): row is TrackRegionRowData => row.type === 'region') + .map(row => row.region.getId()); + const volumePointIds = visibleSelectedRows + .filter((row): row is TrackAutomationRowData => row.type === 'automation' && row.automationType === 'volume') + .map(row => row.point.getId()); + const panPointIds = visibleSelectedRows + .filter((row): row is TrackAutomationRowData => row.type === 'automation' && row.automationType === 'pan') + .map(row => row.point.getId()); + + if (regionIds.length > 0) { + KGCore.instance().executeCommand(new DeleteMultipleRegionsCommand( + regionIds + )); + } + if (volumePointIds.length > 0) { + KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + 'volume', + volumePointIds + )); + } + if (panPointIds.length > 0) { + KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand( + liveSelectedTrack.getId(), + 'pan', + panPointIds + )); + } + if (volumePointIds.length > 0 || panPointIds.length > 0) { + bumpTrackAutomationRedrawVersion(); + } + + rangeAnchorRowIdRef.current = null; + await updateTrack(liveSelectedTrack); + refreshProjectState(); + }; + + return ( + <> +
+ + + +
+ + {!liveSelectedTrack ? ( +
+ Please select a track to view regions and track automation. +
+ ) : ( + <> +
+
+ + setAddTrackItemType(value as AddTrackItemType)} + label="Add" + buttonClassName="event-list-type-button" + showValueAsLabel + /> +
+ +
+ +
+
+ +
+ + + + + + + + + + + {trackRows.map((row, index) => { + const positionText = formatMidiEventPosition(row.type === 'region' ? row.absoluteStartBeat : row.absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT); + const statusText = row.type === 'region' ? row.statusLabel : row.automationType === 'volume' ? 'Volume' : 'Pan'; + const valText = row.type === 'region' ? row.region.getName() : formatTrackAutomationValue(row.automationType, row.point.getValue()); + const infoText = row.type === 'region' ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) : formatTrackAutomationInfo(row.automationType, row.point.getValue()); + const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position'; + const isEditingVal = editingCell?.rowId === row.id && editingCell.column === 'val'; + const isEditingLength = editingCell?.rowId === row.id && editingCell.column === 'length'; + + return ( + handleRowClick(row.id, index, event)} + onDoubleClick={(event) => { + event.stopPropagation(); + clearPendingSingleClickSelection(); + }} + > + + + + + + ); + })} + +
PositionStatusValLength/Info
{ event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}> + {isEditingPosition ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : positionText} + {statusText} { + if (row.type === 'region') return; + event.stopPropagation(); + startEditingCell(row.id, 'val', valText); + }}> + {isEditingVal ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : valText} + { + if (row.type !== 'region') return; + event.stopPropagation(); + startEditingCell(row.id, 'length', infoText); + }}> + {isEditingLength ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={(event) => { void handleEditInputKeyDown(event); }} + /> + ) : infoText} +
+
+ + )} + + ); +}; + +export default TrackEventListTab; diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index ebf625f..7c45e52 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -47,8 +47,8 @@ const PianoRoll: React.FC = ({ }) => { const isSpectrogram = mode === 'spectrogram'; const isHybrid = mode === 'hybrid'; - const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion } = useProjectStore(); - + const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion } = useProjectStore(); + // Tool state for piano roll const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer'); @@ -62,7 +62,7 @@ const PianoRoll: React.FC = ({ const [pianoRollZoom, setPianoRollZoom] = useState(1); const [automationEnabled, setAutomationEnabled] = useState(false); const [automationType, setAutomationType] = useState('pitch-bend'); - + // Quantization state const [quantPosition, setQuantPosition] = useState('1/8'); const [quantLength, setQuantLength] = useState('1/8'); @@ -75,7 +75,7 @@ const PianoRoll: React.FC = ({ // Piano roll state with temporary initial values const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 }); - + // Blink effect state for toolbar button feedback const [blinkButton, setBlinkButton] = useState(null); const [size, setSize] = useState(initialSize || { width: 800, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT }); @@ -102,7 +102,7 @@ const PianoRoll: React.FC = ({ // Ref for storing the setNoteUpdateCounter function const triggerNoteUpdateRef = useRef> | null>(null); - + // Ref for storing the deleteSelectedNotes function const deleteSelectedNotesRef = useRef<(() => boolean) | null>(null); @@ -133,32 +133,32 @@ const PianoRoll: React.FC = ({ y: window.innerHeight - statusBarHeight - pianoRollHeight }; }; - + const calculateInitialSize = () => { const rootStyles = getComputedStyle(document.documentElement); const chatBoxWidthStr = rootStyles.getPropertyValue('--chat-box-width') || '350px'; const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px'; const chatBoxWidth = parseInt(chatBoxWidthStr, 10) || 350; const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300; - + let availableWidth = window.innerWidth; - if (showChatBox || showKGOnePanel || showListEventPanel) availableWidth -= chatBoxWidth; + if (showChatBox || showKGOnePanel || showEventListPanel) availableWidth -= chatBoxWidth; if (showInstrumentSelection) availableWidth -= instrumentPanelWidth; - + // Ensure a sensible minimum starting width const clampedWidth = Math.max(400, availableWidth); - + return { width: clampedWidth, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT }; }; - + // Set position and size only if not provided as props if (!initialPosition) { setPosition(calculateInitialPosition()); } - + if (!initialSize) { setSize(calculateInitialSize()); } @@ -195,17 +195,17 @@ const PianoRoll: React.FC = ({ // Sync local state with KGPianoRollState on mount useEffect(() => { const pianoRollState = KGPianoRollState.instance(); - + // Sync snapping state const currentSnap = pianoRollState.getCurrentSnap(); setSnapping(currentSnap); - + // Sync tool state const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil'; setActiveTool(currentTool); setAutomationEnabled(pianoRollState.getAutomationViewEnabled()); setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`); } @@ -257,10 +257,10 @@ const PianoRoll: React.FC = ({ onClose(); } }; - + // Add event listener window.addEventListener('keydown', handleKeyDown); - + // Remove event listener on cleanup return () => { window.removeEventListener('keydown', handleKeyDown); @@ -284,13 +284,13 @@ const PianoRoll: React.FC = ({ e.preventDefault(); } }; - + useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (isDragging) { // Set the flag to true as soon as any movement happens wasDraggingRef.current = true; - + setPosition({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y @@ -302,25 +302,25 @@ const PianoRoll: React.FC = ({ }); } }; - + const handleMouseUp = () => { setIsDragging(false); setIsResizing(false); // We keep wasDraggingRef.current as is - it will be used in handleTitleClick // and reset on the next mousedown }; - + if (isDragging || isResizing) { document.addEventListener('mousemove', handleMouseMove as unknown as EventListener); document.addEventListener('mouseup', handleMouseUp); } - + return () => { document.removeEventListener('mousemove', handleMouseMove as unknown as EventListener); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragging, isResizing, dragOffset, position]); - + // Handle title click to rename the region const handleTitleClick = async () => { // If we were just dragging, don't show the rename dialog @@ -330,28 +330,28 @@ const PianoRoll: React.FC = ({ } return; } - + if (!activeRegion) return; - + // Show a prompt to get the new name const newName = await showPrompt("Enter a new name for the region:", activeRegion.getName()); - + // If the user clicked Cancel or entered an empty string, do nothing if (!newName || newName.trim() === '' || newName === activeRegion.getName()) return; - + // Use command pattern to update the region name with undo support try { const command = new UpdateRegionCommand(activeRegion.getId(), { name: newName.trim() }); KGCore.instance().executeCommand(command); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Executed UpdateRegionCommand: renamed region ${activeRegion.getId()} to "${newName}" using command pattern`); } - + // Update the store to trigger re-render const updatedTracks = [...tracks]; useProjectStore.setState({ tracks: updatedTracks }); - + } catch (error) { console.error('Error renaming region:', error); await showAlert('Failed to rename region. Please try again.'); @@ -366,7 +366,7 @@ const PianoRoll: React.FC = ({ console.log(`Selected tool: ${tool}`); } }; - + // Handle snapping selection const handleSnappingSelect = useCallback((value: string) => { setSnapping(value); @@ -448,166 +448,166 @@ const PianoRoll: React.FC = ({ const handleSetDeleteNotesTrigger = (deleteFn: () => boolean) => { deleteSelectedNotesRef.current = deleteFn; }; - + // Quantize selected notes based on the selected quantization value const quantizeSelectedNotes = useCallback((quantValue: string) => { if (!activeRegion) return; - + // Get the KGCore instance const core = KGCore.instance(); - + // Get all selected notes const selectedItems = core.getSelectedItems(); - const selectedNotes = selectedItems.filter(item => - item instanceof KGMidiNote && + const selectedNotes = selectedItems.filter(item => + item instanceof KGMidiNote && activeRegion.getNotes().some(note => note.getId() === item.getId()) ) as KGMidiNote[]; - + if (selectedNotes.length === 0) { if (DEBUG_MODE.PIANO_ROLL) { console.log('No notes selected for quantization'); } return; } - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantizing ${selectedNotes.length} selected notes with value: ${quantValue}`); } - + // Parse the quantization value (e.g., "1/4", "1/8", "1/16", "1/32") const denominator = parseInt(quantValue.split('/')[1]); if (isNaN(denominator)) { console.error(`Invalid quantization value: ${quantValue}`); return; } - + // Calculate the quantization step in beats // In a 4/4 time signature, a quarter note (1/4) is 1 beat // In a 6/8 time signature, an eighth note (1/8) is 1 beat const { numerator, denominator: timeSigDenominator } = timeSignature; - + // Calculate beats per whole note based on time signature // In 4/4, a whole note is 4 beats // In 6/8, a whole note is 6 beats (because each beat is an eighth note) const beatsPerWholeNote = numerator * (4 / timeSigDenominator); - + // Calculate the quantization step in beats // quantizationStep should ALWAYS be 4 / denominator regardless of time signature const quantizationStep = 4 / denominator; - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Time signature: ${numerator}/${timeSigDenominator}`); console.log(`Beats per whole note: ${beatsPerWholeNote}`); console.log(`Quantization step: ${quantizationStep} beats`); } - + // Apply quantization to each selected note selectedNotes.forEach(note => { // Get the current start beat const currentStartBeat = note.getStartBeat(); - + // Calculate the quantized start beat const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep; - + // Calculate the duration of the note const duration = note.getEndBeat() - currentStartBeat; - + // Set the new start beat and maintain the duration note.setStartBeat(quantizedStartBeat); note.setEndBeat(quantizedStartBeat + duration); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantized note ${note.getId()}: ${currentStartBeat} -> ${quantizedStartBeat}`); } }); - + // Find the track that contains this region and update it const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); } - + // Trigger a re-render by incrementing the note update counter if (triggerNoteUpdateRef.current) { triggerNoteUpdateRef.current(prev => prev + 1); - + if (DEBUG_MODE.PIANO_ROLL) { console.log('Triggered note update to re-render quantized notes'); } } }, [activeRegion, timeSignature, updateTrack, tracks]); - + // Quantize selected notes length based on the selected quantization value const quantizeNoteLength = useCallback((quantValue: string) => { if (!activeRegion) return; - + // Get the KGCore instance const core = KGCore.instance(); - + // Get all selected notes const selectedItems = core.getSelectedItems(); - const selectedNotes = selectedItems.filter(item => - item instanceof KGMidiNote && + const selectedNotes = selectedItems.filter(item => + item instanceof KGMidiNote && activeRegion.getNotes().some(note => note.getId() === item.getId()) ) as KGMidiNote[]; - + if (selectedNotes.length === 0) { if (DEBUG_MODE.PIANO_ROLL) { console.log('No notes selected for length quantization'); } return; } - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantizing length of ${selectedNotes.length} selected notes with value: ${quantValue}`); } - + // Parse the quantization value (e.g., "1/1", "1/2", "1/4", "1/8", "1/16", "1/32") const denominator = parseInt(quantValue.split('/')[1]); if (isNaN(denominator)) { console.error(`Invalid quantization value: ${quantValue}`); return; } - + // Calculate the quantization step in beats // In a 4/4 time signature, a quarter note (1/4) is 1 beat // In a 6/8 time signature, an eighth note (1/8) is 1 beat const { numerator, denominator: timeSigDenominator } = timeSignature; - + // Calculate beats per whole note based on time signature // In 4/4, a whole note is 4 beats // In 6/8, a whole note is 6 beats (because each beat is an eighth note) const beatsPerWholeNote = numerator * (4 / timeSigDenominator); - + // Calculate the quantization step in beats // quantizationStep should ALWAYS be 4 / denominator regardless of time signature const quantizationStep = 4 / denominator; - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Time signature: ${numerator}/${timeSigDenominator}`); console.log(`Beats per whole note: ${beatsPerWholeNote}`); console.log(`Length quantization step: ${quantizationStep} beats`); } - + // Apply quantization to each selected note selectedNotes.forEach(note => { // Get the current start and end beats const startBeat = note.getStartBeat(); const currentEndBeat = note.getEndBeat(); - + // Calculate the current duration const currentDuration = currentEndBeat - startBeat; - + // Calculate the quantized duration // If the current duration is less than the quantization step, // extend it to match the quantization step exactly // Otherwise, round to the nearest multiple of quantizationStep let quantizedDuration; - + if (currentDuration < quantizationStep) { // For notes shorter than the quantization step, extend to exactly one step quantizedDuration = quantizationStep; - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Extending short note ${note.getId()} from ${currentDuration} to ${quantizedDuration}`); } @@ -615,28 +615,28 @@ const PianoRoll: React.FC = ({ // For longer notes, round to nearest multiple of quantizationStep quantizedDuration = Math.round(currentDuration / quantizationStep) * quantizationStep; } - + // Ensure minimum note length quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration); - + // Set the new end beat while maintaining the start beat note.setEndBeat(startBeat + quantizedDuration); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantized note length ${note.getId()}: ${currentDuration} -> ${quantizedDuration}`); } }); - + // Find the track that contains this region and update it const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId()); if (track) { updateTrack(track); } - + // Trigger a re-render by incrementing the note update counter if (triggerNoteUpdateRef.current) { triggerNoteUpdateRef.current(prev => prev + 1); - + if (DEBUG_MODE.PIANO_ROLL) { console.log('Triggered note update to re-render quantized note lengths'); } @@ -647,19 +647,19 @@ const PianoRoll: React.FC = ({ const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => { if (type === 'position') { setQuantPosition(value); - + // Apply quantization immediately when position quantization is changed quantizeSelectedNotes(value); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`quant-position selected: ${value}`); } } else { setQuantLength(value); - + // Apply length quantization immediately when length quantization is changed quantizeNoteLength(value); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`quant-length selected: ${value}`); } @@ -710,24 +710,24 @@ const PianoRoll: React.FC = ({ // We have 8 octaves (0-7), and C4 is in the middle // Each octave has 12 notes, each note is piano key height // C4 is in octave 4, and C is the first note in each octave - + // Calculate from the bottom: // - Octaves 0-3 = 4 octaves = 4 * 12 * piano key height // - Within octave 4, C is the first note (from bottom), so 0px additional const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20; const c4Position = 4 * 12 * keyHeight; // pixels from bottom - + // Total height of all notes (8 octaves * 12 notes * piano key height) const totalHeight = 8 * 12 * keyHeight; - + // Get the viewport height of the piano roll content const viewportHeight = pianoRollNoteScrollRef.current.clientHeight; - + // Calculate scroll position to center C4 // We need to scroll from the top, so we calculate: // (total height - C4 position) - (viewport height / 2) const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2); - + // Scroll to the calculated position pianoRollNoteScrollRef.current.scrollTop = Math.max(0, scrollPosition); } @@ -846,23 +846,23 @@ const PianoRoll: React.FC = ({ if (pianoRollNoteScrollRef.current && activeRegion) { // Get the starting beat of the region const startBeat = activeRegion.getStartFromBeat(); - + // Get the time signature to calculate beats per bar const beatsPerBar = timeSignature.numerator; - + // Calculate the bar number (0-indexed) const barNumber = Math.floor(startBeat / beatsPerBar); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Scrolling to region's starting bar: ${barNumber + 1} (startBeat: ${startBeat}, beatsPerBar: ${beatsPerBar})`); } - + // Calculate the pixel position (each bar is --region-grid-bar-width wide, which is 160px by default) const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-bar-width')) || 160; - + // Calculate the scroll position to scroll to the starting bar const scrollPosition = barNumber * barWidth; - + // Scroll to the calculated position pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition); } @@ -874,8 +874,8 @@ const PianoRoll: React.FC = ({ // Skip if user is typing in an input field (including ChatBox) const target = event.target as HTMLElement; if (target && ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.hasAttribute('data-chatbox-input') || target.closest('.chatbox-input') @@ -894,7 +894,7 @@ const PianoRoll: React.FC = ({ } return; } - + // Handle piano roll hotkeys const configManager = ConfigManager.instance(); if (configManager.getIsInitialized()) { @@ -916,21 +916,21 @@ const PianoRoll: React.FC = ({ const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string; const snap_1_8_key = configManager.get('hotkeys.piano_roll.snap_1_8') as string; const snap_1_16_key = configManager.get('hotkeys.piano_roll.snap_1_16') as string; - + // Quantize position hotkeys const qua_pos_1_4_key = configManager.get('hotkeys.piano_roll.qua_pos_1_4') as string; const qua_pos_1_8_key = configManager.get('hotkeys.piano_roll.qua_pos_1_8') as string; const qua_pos_1_16_key = configManager.get('hotkeys.piano_roll.qua_pos_1_16') as string; - + // Quantize length hotkeys const qua_len_1_4_key = configManager.get('hotkeys.piano_roll.qua_len_1_4') as string; const qua_len_1_8_key = configManager.get('hotkeys.piano_roll.qua_len_1_8') as string; const qua_len_1_16_key = configManager.get('hotkeys.piano_roll.qua_len_1_16') as string; - + let actionType: 'snap' | 'quantize' | null = null; let actionValue: string | null = null; let quantType: 'position' | 'length' | null = null; - + // Check snapping hotkeys if (event.key === snap_none_key) { actionType = 'snap'; @@ -973,21 +973,21 @@ const PianoRoll: React.FC = ({ actionValue = '1/16'; quantType = 'length'; } - + if (actionType && actionValue) { // Prevent default behavior event.preventDefault(); - + if (actionType === 'snap') { // Validate the snap value exists in snap options if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) { // Change snapping value handleSnappingSelect(actionValue); - + // Trigger blink effect for visual feedback setBlinkButton('snapping'); setTimeout(() => setBlinkButton(null), 200); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Snap hotkey triggered: ${event.key} → ${actionValue}`); } @@ -995,16 +995,16 @@ const PianoRoll: React.FC = ({ } else if (actionType === 'quantize' && quantType) { // Validate the quantValue exists in the appropriate options const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS; - + if (validOptions.includes(actionValue)) { // Apply quantization handleQuantSelect(quantType, actionValue); - + // Trigger blink effect for visual feedback const buttonName = quantType === 'length' ? 'quant-length' : 'quant-position'; setBlinkButton(buttonName); setTimeout(() => setBlinkButton(null), 200); - + if (DEBUG_MODE.PIANO_ROLL) { console.log(`Quantize ${quantType} hotkey triggered: ${event.key} → ${actionValue}`); } @@ -1013,10 +1013,10 @@ const PianoRoll: React.FC = ({ } } }; - + // Add event listener window.addEventListener('keydown', handlePianoRollKeyDown); - + // Remove event listener on cleanup return () => { window.removeEventListener('keydown', handlePianoRollKeyDown); @@ -1032,20 +1032,20 @@ const PianoRoll: React.FC = ({ return `${midiName} + ${audioName}`; } if (!activeRegion) return "EDIT NOTE CLIP"; - + // Calculate the bar and beat position of the region const startBeat = activeRegion.getStartFromBeat(); const { bar, beatInBar } = beatsToBar(startBeat, timeSignature); - + // Format as 1-indexed bar and beat (bar + 1, beatInBar + 1) const barNumber = bar + 1; const beatNumber = beatInBar + 1; - + return `${activeRegion.getName()} (at ${barNumber}:${beatNumber})`; }; return ( -
= ({ onTitleClick={handleTitleClick} onMouseDown={(e) => handleMouseDown(e, 'drag')} /> - + = ({ automationType={automationType} automationRedrawVersion={automationRedrawVersion} /> - -
handleMouseDown(e, 'resize')} > 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.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts index 798fae7..f002d31 100644 --- a/src/core/audio-interface/KGAudioInterface.test.ts +++ b/src/core/audio-interface/KGAudioInterface.test.ts @@ -24,6 +24,23 @@ import { ConfigManager } from '../config/ConfigManager'; import { KGAudioInterface } from './KGAudioInterface'; import { MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil'; +function createMockAudioBus() { + return { + resetLiveMidiPitchBend: vi.fn(), + setLiveMidiPitchBend: vi.fn(), + scheduleLiveMidiPitchBend: vi.fn(), + setLiveMidiExpression: vi.fn(), + scheduleLiveMidiExpression: vi.fn(), + setLiveMidiSustain: vi.fn(), + setAutomationVolume: vi.fn(), + setAutomationPan: vi.fn(), + scheduleAutomationPan: vi.fn(), + applyEffectiveVolume: vi.fn(), + getSolo: vi.fn().mockReturnValue(false), + shouldPlayWithSolo: vi.fn().mockReturnValue(true), + }; +} + describe('KGAudioInterface preroll playback', () => { beforeEach(() => { vi.useFakeTimers(); @@ -125,14 +142,7 @@ describe('KGAudioInterface preroll playback', () => { const track = createMockMidiTrack({ id: 1, regions: [region] }); const project = createMockProject({ tracks: [track] }); const audio = KGAudioInterface.instance(); - const audioBus = { - resetLiveMidiPitchBend: vi.fn(), - setLiveMidiPitchBend: vi.fn(), - scheduleLiveMidiPitchBend: vi.fn(), - setLiveMidiExpression: vi.fn(), - setLiveMidiSustain: vi.fn(), - shouldPlayWithSolo: vi.fn().mockReturnValue(true), - }; + const audioBus = createMockAudioBus(); ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 0); @@ -152,14 +162,7 @@ describe('KGAudioInterface preroll playback', () => { const track = createMockMidiTrack({ id: 1, regions: [region] }); const project = createMockProject({ tracks: [track] }); const audio = KGAudioInterface.instance(); - const audioBus = { - resetLiveMidiPitchBend: vi.fn(), - setLiveMidiPitchBend: vi.fn(), - scheduleLiveMidiPitchBend: vi.fn(), - setLiveMidiExpression: vi.fn(), - setLiveMidiSustain: vi.fn(), - shouldPlayWithSolo: vi.fn().mockReturnValue(true), - }; + const audioBus = createMockAudioBus(); ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 0); @@ -178,14 +181,7 @@ describe('KGAudioInterface preroll playback', () => { const track = createMockMidiTrack({ id: 1, regions: [region] }); const project = createMockProject({ tracks: [track] }); const audio = KGAudioInterface.instance(); - const audioBus = { - resetLiveMidiPitchBend: vi.fn(), - setLiveMidiPitchBend: vi.fn(), - scheduleLiveMidiPitchBend: vi.fn(), - setLiveMidiExpression: vi.fn(), - setLiveMidiSustain: vi.fn(), - shouldPlayWithSolo: vi.fn().mockReturnValue(true), - }; + const audioBus = createMockAudioBus(); ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 2); @@ -206,14 +202,7 @@ describe('KGAudioInterface preroll playback', () => { project.setLoopingRange([1, 1]); const audio = KGAudioInterface.instance(); - const audioBus = { - resetLiveMidiPitchBend: vi.fn(), - setLiveMidiPitchBend: vi.fn(), - scheduleLiveMidiPitchBend: vi.fn(), - setLiveMidiExpression: vi.fn(), - setLiveMidiSustain: vi.fn(), - shouldPlayWithSolo: vi.fn().mockReturnValue(true), - }; + const audioBus = createMockAudioBus(); ;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus); audio.preparePlayback(project, 5); 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..5457b24 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, @@ -27,9 +41,12 @@ const mockCore = { getRedoDescription: () => '', setOnCommandHistoryChanged: vi.fn(), executeCommand: vi.fn(), + undo: vi.fn(() => true), + redo: vi.fn(() => true), clearSelectedItems: vi.fn(), getStatus: () => 'Ready', getPlayheadPosition: () => 0, + setPlayheadPosition: vi.fn(), getIsPlaying: () => false, startPlaying: vi.fn().mockResolvedValue(undefined), stopPlaying: vi.fn().mockResolvedValue(undefined), @@ -41,22 +58,47 @@ 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.undo.mockReset(); + mockCore.undo.mockReturnValue(true); + mockCore.redo.mockReset(); + mockCore.redo.mockReturnValue(true); + 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 +195,59 @@ 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); + }); + + it('bumps track automation redraw version on undo and redo', async () => { + const { useProjectStore } = await import('./projectStore'); + + const initialVersion = useProjectStore.getState().trackAutomationRedrawVersion; + + act(() => { + useProjectStore.getState().undo(); + }); + + expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 1); + + act(() => { + useProjectStore.getState().redo(); + }); + + expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2); + }); }); diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts index c95f0d9..32f34cf 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 @@ -73,7 +74,7 @@ interface ProjectState { isPreparingPlayback: boolean; autoScrollEnabled: boolean; currentTime: string; // formatted time string - + // Selection state for UI reactivity selectedNoteIds: string[]; selectedPitchBendIds: string[]; @@ -81,7 +82,7 @@ interface ProjectState { selectedTrackAutomationPointIds: string[]; selectedRegionIds: string[]; selectedTrackId: string | null; - + // Piano roll state showPianoRoll: boolean; activeRegionId: string | null; @@ -91,20 +92,20 @@ interface ProjectState { activeTrackAutomationTrackId: string | null; activeTrackAutomationType: TrackAutomationType | null; trackAutomationRedrawVersion: number; - + // ChatBox state showChatBox: boolean; // K.G.One panel state showKGOnePanel: boolean; - // List event panel state - showListEventPanel: boolean; + // Event list panel state + showEventListPanel: boolean; // Instrument selection panel state showInstrumentSelection: boolean; // instrumentSelectionTrackId removed; panel now follows selectedTrackId - + // Audio import modal state showAudioImportModal: boolean; audioImportTargetTrackId: string | null; @@ -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; @@ -131,7 +140,7 @@ interface ProjectState { requestPianoRollScroll: (beatPosition: number) => void; mainContentScrollRequest: number | null; pianoRollScrollRequest: number | null; - + // Actions setProjectName: (name: string) => void; setSavedProjectName: (name: string) => void; @@ -168,7 +177,7 @@ interface ProjectState { clearAllSelections: () => void; setSelectedTrack: (trackId: string | null) => void; setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => void; - + // Piano roll actions setShowPianoRoll: (show: boolean) => void; setActiveRegionId: (regionId: string | null) => void; @@ -177,10 +186,10 @@ interface ProjectState { openHybridMode: (midiRegionId: string, audioRegionId: string) => void; bumpAutomationRedrawVersion: () => void; bumpTrackAutomationRedrawVersion: () => void; - + // Project state cleanup cleanupProjectState: () => void; - + // ChatBox actions setShowChatBox: (show: boolean) => void; toggleChatBox: () => void; @@ -188,27 +197,27 @@ interface ProjectState { // K.G.One panel actions toggleKGOnePanel: () => void; - // List event panel actions - toggleListEventPanel: () => void; + // Event List panel actions + toggleEventListPanel: () => void; // Instrument selection panel actions openInstrumentSelectionForTrack: () => void; toggleInstrumentSelectionForTrack: () => void; closeInstrumentSelection: () => void; - + // Settings actions setShowSettings: (show: boolean) => void; toggleSettings: () => void; - + // Copy/paste actions pasteRegionsAtTrack: (trackId: string, position: number) => void; pasteNotesToActiveRegion: (regionId: string, position: number) => void; - + // Undo/redo actions undo: () => void; redo: () => void; syncUndoRedoState: () => void; - + // Project state refresh actions refreshProjectState: () => void; @@ -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,30 +265,47 @@ 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(); - + // Initialize CSS variable for time signature on store creation updateTimeSignatureCSS(currentProject.getTimeSignature()); // Initialize CSS variable for max bars on store creation updateMaxBarsCSS(currentProject.getMaxBars()); // Initialize CSS variable for bar width multiplier on store creation updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier()); - + // Get initial ChatBox state from config const configManager = ConfigManager.instance(); - const initialChatBoxState = configManager.getIsInitialized() + const initialChatBoxState = configManager.getIsInitialized() ? (configManager.get('chatbox.default_open') as boolean) ?? false : false; - + // 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) @@ -288,7 +317,7 @@ export const useProjectStore = create((set, get) => { const syncSelectionFromCore = () => { const core = KGCore.instance(); const selectedItems = core.getSelectedItems(); - + const noteIds = selectedItems .filter(item => item instanceof KGMidiNote) .map(item => item.getId()); @@ -304,7 +333,7 @@ export const useProjectStore = create((set, get) => { const regionIds = selectedItems .filter(item => item instanceof KGRegion) .map(item => item.getId()); - + set({ selectedNoteIds: noteIds, selectedPitchBendIds: pitchBendIds, @@ -316,10 +345,10 @@ export const useProjectStore = create((set, get) => { // Register the sync callback with KGCore KGCore.instance().onSelectionChanged(syncSelectionFromCore); - + // Initial selection sync syncSelectionFromCore(); - + // Set up command history sync callback const syncUndoRedoState = () => { const core = KGCore.instance(); @@ -333,10 +362,10 @@ export const useProjectStore = create((set, get) => { // Register the undo/redo sync callback with KGCore KGCore.instance().setOnCommandHistoryChanged(syncUndoRedoState); - + // Initial undo/redo state sync syncUndoRedoState(); - + // Ensure a default track exists on initial app start // Also auto-select it and open instrument selection panel let initialSelectedTrackId: string | null = null; @@ -374,7 +403,7 @@ export const useProjectStore = create((set, get) => { isPreparingPlayback: false, autoScrollEnabled: true, currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()), - + // Initial selection state selectedNoteIds: [], selectedPitchBendIds: [], @@ -382,7 +411,7 @@ export const useProjectStore = create((set, get) => { selectedTrackAutomationPointIds: [], selectedRegionIds: [], selectedTrackId: initialSelectedTrackId, - + // Initial piano roll state showPianoRoll: false, activeRegionId: null, @@ -392,26 +421,26 @@ export const useProjectStore = create((set, get) => { activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, - + // Initial ChatBox state showChatBox: initialChatBoxState, // Initial K.G.One panel state showKGOnePanel: false, - // Initial List Event panel state - showListEventPanel: false, + // Initial Event List panel state + showEventListPanel: false, // Initial Instrument Selection panel state showInstrumentSelection: initialShowInstrumentSelection, - + // Initial audio import modal state showAudioImportModal: false, audioImportTargetTrackId: null, // Initial Settings state showSettings: false, - + // Initial undo/redo state canUndo: false, canRedo: false, @@ -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, @@ -456,18 +493,18 @@ export const useProjectStore = create((set, get) => { // Create and execute the add track command const command = new AddTrackCommand(); KGCore.instance().executeCommand(command); - + // Update the store state with a new array reference to trigger re-render const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); - + // Auto-select the newly created track and open instrument selection panel const newTrackId = command.getTrackId().toString(); set({ selectedTrackId: newTrackId, showInstrumentSelection: true, }); - + console.log(`Added track ${command.getTrackId()}`); } catch (error) { console.error('Error adding track:', error); @@ -593,23 +630,23 @@ export const useProjectStore = create((set, get) => { const deletedTrackIndex = currentTracks.findIndex(track => track.getId() === id); const { selectedTrackId } = get(); const isCurrentTrackSelected = selectedTrackId === id.toString(); - + // Create and execute the remove track command const command = new RemoveTrackCommand(id); KGCore.instance().executeCommand(command); - + // Update the store state with a new array reference to trigger re-render const project = KGCore.instance().getCurrentProject(); const remainingTracks = [...project.getTracks()] as KGTrack[]; set({ tracks: remainingTracks }); - + // Auto-select another track if any remain if (remainingTracks.length > 0) { // Prefer previous track, fallback to next track - const newSelectedIndex = deletedTrackIndex > 0 + const newSelectedIndex = deletedTrackIndex > 0 ? deletedTrackIndex - 1 // Select previous track : 0; // Select first remaining track (was next) - + const newSelectedTrack = remainingTracks[newSelectedIndex]; const newSelectedTrackId = newSelectedTrack.getId().toString(); @@ -625,7 +662,7 @@ export const useProjectStore = create((set, get) => { showInstrumentSelection: false, }); } - + console.log(`Removed track ${id}`); } catch (error) { console.error('Error removing track:', error); @@ -635,23 +672,23 @@ export const useProjectStore = create((set, get) => { updateTrack: async (updatedTrack: KGTrack) => { const { tracks } = get(); - + try { // Find the old track to compare instruments const oldTrack = tracks.find(track => track.getId() === updatedTrack.getId()); - + // Type guard to check if track is KGMidiTrack const isMidiTrack = (track: KGTrack): track is KGMidiTrack => { return track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track; }; - + // Check if instrument changed (only for MIDI tracks) - const shouldUpdateInstrument = - isMidiTrack(updatedTrack) && - oldTrack && + const shouldUpdateInstrument = + isMidiTrack(updatedTrack) && + oldTrack && isMidiTrack(oldTrack) && updatedTrack.getInstrument() !== oldTrack.getInstrument(); - + // Update instrument in audio interface if changed if (shouldUpdateInstrument && isMidiTrack(updatedTrack)) { const audioInterface = KGAudioInterface.instance(); @@ -659,18 +696,18 @@ export const useProjectStore = create((set, get) => { audioInterface.setTrackInstrument(updatedTrack.getId().toString(), newInstrument); console.log(`Updated track ${updatedTrack.getId()} instrument to ${newInstrument}`); } - + // Find and update the track - const updatedTracks = tracks.map(track => + const updatedTracks = tracks.map(track => track.getId() === updatedTrack.getId() ? updatedTrack : track ); - + // Update the core model KGCore.instance().getCurrentProject().setTracks(updatedTracks); - + // Update the store set({ tracks: updatedTracks }); - + console.log(`Updated track ${updatedTrack.getId()}`); } catch (error) { console.error('Error updating track:', error); @@ -687,7 +724,7 @@ export const useProjectStore = create((set, get) => { // Update the store state with the current project state const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); - + console.log(`Updated track ${trackId} properties`); } catch (error) { console.error('Error updating track properties:', error); @@ -699,14 +736,14 @@ export const useProjectStore = create((set, get) => { try { // Use the new updateTrackProperties method with command pattern await get().updateTrackProperties(trackId, { instrument }); - + console.log(`Set track ${trackId} instrument to ${instrument}`); } catch (error) { console.error('Error setting track instrument:', error); get().setStatus('Failed to change instrument'); } }, - + reorderTracks: (sourceIndex: number, destinationIndex: number) => { try { // Create and execute the reorder tracks command @@ -716,7 +753,7 @@ export const useProjectStore = create((set, get) => { // Update the store state with the current project state const project = KGCore.instance().getCurrentProject(); set({ tracks: [...project.getTracks()] as KGTrack[] }); - + console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`); } catch (error) { console.error('Error reordering tracks:', error); @@ -737,11 +774,11 @@ export const useProjectStore = create((set, get) => { refreshStatus: () => { set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' }); }, - + loadProject: async (project: KGProject | null = null, savedName?: string) => { try { const { setPlayheadPosition } = get(); - + // Upgrade incoming project data to latest structure version (only when provided explicitly) if (project) { project = upgradeProjectToLatest(project); @@ -749,12 +786,12 @@ export const useProjectStore = create((set, get) => { // Get project from KGCore if null const projectToLoad = project || KGCore.instance().getCurrentProject(); - + // Set the project in KGCore if one was provided if (project) { KGCore.instance().setCurrentProject(projectToLoad); } - + // Ensure a default "Melody" track exists for empty projects if (projectToLoad.getTracks().length === 0) { const addDefaultTrackCommand = new AddTrackCommand(undefined, 'Melody'); @@ -763,17 +800,17 @@ export const useProjectStore = create((set, get) => { // Reset playhead to 0 when loading a project setPlayheadPosition(0); - + // Get project properties const maxBars = projectToLoad.getMaxBars(); const timeSignature = projectToLoad.getTimeSignature(); const bpm = projectToLoad.getBpm(); const keySignature = projectToLoad.getKeySignature(); const tracks = projectToLoad.getTracks(); - + // Setup audio synths for all tracks const audioInterface = KGAudioInterface.instance(); - + // Clear any existing synths/buses first tracks.forEach(track => { const trackId = track.getId().toString(); @@ -820,7 +857,7 @@ export const useProjectStore = create((set, get) => { audioInterface.setTrackVolume(trackId, track.getVolume()); } } - + // Update CSS variables updateTimeSignatureCSS(timeSignature); updateMaxBarsCSS(maxBars); @@ -829,7 +866,7 @@ export const useProjectStore = create((set, get) => { // Log project loading info console.log(`Project max bars: ${maxBars}`); console.log(`Setup audio synths for ${tracks.length} tracks`); - + // Update the store state to reflect the loaded project // Force a new array reference for tracks to trigger React/Zustand re-render set({ @@ -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 }); @@ -863,7 +914,7 @@ export const useProjectStore = create((set, get) => { // Reset piano roll state for new/loaded project KGPianoRollState.instance().setLastEditedNoteLength(1); - + // Add a status message KGCore.instance().setStatus(`Project "${projectToLoad.getName()}" loaded with audio setup`); set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' }); @@ -876,7 +927,7 @@ export const useProjectStore = create((set, get) => { setPlayheadPosition: (position: number) => { const { bpm, timeSignature } = get(); KGCore.instance().setPlayheadPosition(position); - set({ + set({ playheadPosition: position, currentTime: beatsToTimeString(position, bpm, timeSignature) }); @@ -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(); @@ -1153,10 +1405,10 @@ export const useProjectStore = create((set, get) => { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ bpm }); KGCore.instance().executeCommand(command); - + // Update the store state set({ bpm }); - + console.log(`Set BPM to ${bpm}`); } catch (error) { console.error('Error setting BPM:', error); @@ -1169,12 +1421,12 @@ export const useProjectStore = create((set, get) => { // Create and execute the change project property command const command = new ChangeProjectPropertyCommand({ maxBars }); KGCore.instance().executeCommand(command); - + // Update the store state set({ maxBars }); // Sync CSS var so layout adjusts immediately updateMaxBarsCSS(maxBars); - + console.log(`Set max bars to ${maxBars}`); } catch (error) { console.error('Error setting max bars:', error); @@ -1240,7 +1492,7 @@ export const useProjectStore = create((set, get) => { // Selection actions syncSelectionFromCore, - + clearAllSelections: () => { KGCore.instance().clearSelectedItems(); // Note: syncSelectionFromCore will be called automatically via callback @@ -1274,7 +1526,7 @@ export const useProjectStore = create((set, get) => { selectedTrackId: trackId, }); }, - + // Piano roll actions setShowPianoRoll: (show: boolean) => { set({ showPianoRoll: show }); @@ -1301,7 +1553,7 @@ export const useProjectStore = create((set, get) => { bumpTrackAutomationRedrawVersion: () => { set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 })); }, - + // Project state cleanup - used when starting new/loading projects cleanupProjectState: () => { // Close piano roll if it's visible @@ -1315,37 +1567,39 @@ export const useProjectStore = create((set, get) => { activeTrackAutomationTrackId: null, activeTrackAutomationType: null, trackAutomationRedrawVersion: 0, + recordingAudioPreviewPeaks: [], + recordingAudioPreviewCurrentBeat: 0, }); - + // Clear any selected items KGCore.instance().clearSelectedItems(); // Note: syncSelectionFromCore will be called automatically via callback - + console.log("Cleaned up project state: closed piano roll, cleared active region, cleared selections"); }, - + // ChatBox action implementations setShowChatBox: (show: boolean) => { set({ showChatBox: show, showKGOnePanel: show ? false : get().showKGOnePanel, - showListEventPanel: show ? false : get().showListEventPanel + showEventListPanel: show ? false : get().showEventListPanel }); }, - + toggleChatBox: () => { const { showChatBox } = get(); - set({ showChatBox: !showChatBox, showKGOnePanel: false, showListEventPanel: false }); + set({ showChatBox: !showChatBox, showKGOnePanel: false, showEventListPanel: false }); }, toggleKGOnePanel: () => { const { showKGOnePanel } = get(); - set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showListEventPanel: false }); + set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showEventListPanel: false }); }, - toggleListEventPanel: () => { - const { showListEventPanel } = get(); - set({ showListEventPanel: !showListEventPanel, showChatBox: false, showKGOnePanel: false }); + toggleEventListPanel: () => { + const { showEventListPanel } = get(); + set({ showEventListPanel: !showEventListPanel, showChatBox: false, showKGOnePanel: false }); }, // Instrument selection panel actions @@ -1358,83 +1612,85 @@ export const useProjectStore = create((set, get) => { closeInstrumentSelection: () => { set({ showInstrumentSelection: false }); }, - + // Settings action implementations setShowSettings: (show: boolean) => { set({ showSettings: show }); }, - + toggleSettings: () => { const { showSettings } = get(); set({ showSettings: !showSettings }); }, - + // Copy/paste actions pasteRegionsAtTrack: (trackId: string, position: number) => { // Use command pattern for region pasting with undo support const command = PasteRegionsCommand.fromClipboard(trackId, position); - + if (!command) { console.log('No regions to paste'); return; } - + try { KGCore.instance().executeCommand(command); - + // Update the store to trigger re-render const { tracks } = get(); const updatedTracks = [...tracks]; set({ tracks: updatedTracks }); - + console.log(`Executed PasteRegionsCommand: pasted regions to track ${trackId} using command pattern`); } catch (error) { console.error('Error pasting regions:', error); } }, - + pasteNotesToActiveRegion: (regionId: string, position: number) => { // Use command pattern for note pasting with undo support const command = PasteNotesCommand.fromClipboard(regionId, position); - + if (!command) { console.log('No notes to paste'); return; } - + try { KGCore.instance().executeCommand(command); - + // Update the store to trigger re-render const { tracks } = get(); const updatedTracks = [...tracks]; set({ tracks: updatedTracks }); - + console.log(`Executed PasteNotesCommand: pasted notes to region ${regionId} using command pattern`); } catch (error) { console.error('Error pasting notes:', error); } }, - + // Undo/redo actions undo: () => { const core = KGCore.instance(); if (core.undo()) { // Use centralized refresh method get().refreshProjectState(); + get().bumpTrackAutomationRedrawVersion(); console.log('Undo completed'); } }, - + redo: () => { const core = KGCore.instance(); if (core.redo()) { // Use centralized refresh method get().refreshProjectState(); + get().bumpTrackAutomationRedrawVersion(); console.log('Redo completed'); } }, - + syncUndoRedoState: () => { const core = KGCore.instance(); set({ @@ -1444,13 +1700,13 @@ export const useProjectStore = create((set, get) => { redoDescription: core.getRedoDescription() }); }, - + // Centralized project state refresh method // Used by undo/redo and external operations (like XML tools) to sync UI with core model refreshProjectState: () => { const core = KGCore.instance(); const project = core.getCurrentProject(); - + // Force new array reference to trigger React re-renders set({ projectName: project.getName(), @@ -1473,7 +1729,7 @@ export const useProjectStore = create((set, get) => { actions.syncUndoRedoState(); actions.syncSelectionFromCore(); }, - + // Initialize store with configuration values initializeFromConfig: async () => { const configManager = ConfigManager.instance(); 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/test/mocks/tone.ts b/src/test/mocks/tone.ts index e29846a..0733dfa 100644 --- a/src/test/mocks/tone.ts +++ b/src/test/mocks/tone.ts @@ -102,6 +102,18 @@ export const MockGain = vi.fn().mockImplementation((initialValue: number = 1) => dispose: vi.fn() })); +// Mock Panner node +export const MockPanner = vi.fn().mockImplementation((initialValue: number = 0) => ({ + pan: { + value: initialValue, + setValueAtTime: vi.fn(), + }, + connect: vi.fn(), + disconnect: vi.fn(), + dispose: vi.fn(), + toDestination: vi.fn(), +})); + // Mock Meter export const MockMeter = vi.fn().mockImplementation(() => ({ getValue: vi.fn().mockReturnValue(-Infinity), @@ -120,6 +132,7 @@ export const ToneMock = { Destination: MockDestination, ToneAudioBuffer: MockToneAudioBuffer, Gain: MockGain, + Panner: MockPanner, Meter: MockMeter, // Context management 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; +}