Merge pull request #39 from KGAudioLab/feat/2026-05-09-waveform-recording
Feat/2026 05 09 waveform recording
This commit is contained in:
@@ -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**.
|
||||
<div align="center">
|
||||
<img src="./public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
+1
-1
@@ -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: {
|
||||
|
||||
+7
-7
@@ -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() {
|
||||
</>
|
||||
)}
|
||||
<KGOnePanel isVisible={showKGOnePanel && !showSettings} />
|
||||
<ListEventPanel isVisible={showListEventPanel && !showSettings} />
|
||||
<EventListPanel isVisible={showEventListPanel && !showSettings} />
|
||||
<ChatBox isVisible={showChatBox && !showSettings} />
|
||||
</div>
|
||||
|
||||
@@ -264,7 +264,7 @@ const MigrationOverlayContainer: React.FC = () => {
|
||||
useEffectReact(() => {
|
||||
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
|
||||
return () => {
|
||||
KGCore.instance().setMigrationStateChangeCallback(() => {});
|
||||
KGCore.instance().setMigrationStateChangeCallback(() => { });
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof midiTrack | typeof audioTrack>;
|
||||
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<typeof vi.fn>;
|
||||
refreshProjectState: ReturnType<typeof vi.fn>;
|
||||
bumpAutomationRedrawVersion: ReturnType<typeof vi.fn>;
|
||||
bumpTrackAutomationRedrawVersion: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<KGRegion | KGMidiNote | KGMidiPitchBend | KGMidiControllerEvent | KGTrackAutomationPoint> = [];
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||
fireEvent.click(screen.getByText('Second Region').closest('tr')!);
|
||||
rerender(<EventListPanel isVisible={true} />);
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||
fireEvent.click(screen.getByText('-6.0dB').closest('tr')!);
|
||||
rerender(<EventListPanel isVisible={true} />);
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||
fireEvent.click(screen.getByText('-32').closest('tr')!);
|
||||
rerender(<EventListPanel isVisible={true} />);
|
||||
|
||||
expect(storeState.selectedTrackAutomationPointIds).toEqual(['pan-1']);
|
||||
});
|
||||
|
||||
it('hides MIDI Region add option for audio tracks', () => {
|
||||
storeState.selectedTrackId = '2';
|
||||
|
||||
render(<EventListPanel isVisible={true} />);
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Track' }));
|
||||
fireEvent.click(screen.getByText('-6.0dB').closest('tr')!);
|
||||
rerender(<EventListPanel isVisible={true} />);
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
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(<EventListPanel isVisible={true} />);
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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<EventListPanelProps> = ({ isVisible }) => {
|
||||
const { tracks, activeRegionId, selectedRegionIds, selectedTrackId } = useProjectStore();
|
||||
const [scopeTab, setScopeTab] = useState<ScopeTab>('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 (
|
||||
<div className={`event-list-panel${isVisible ? '' : ' is-hidden'}`}>
|
||||
<div className="event-list-panel-header">
|
||||
<h3>Event List</h3>
|
||||
</div>
|
||||
|
||||
<div className="event-list-scope-tabs" role="tablist" aria-label="Event list scopes">
|
||||
<button
|
||||
className={`event-list-scope-tab${scopeTab === 'region' ? ' active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setScopeTab('region')}
|
||||
>
|
||||
Region
|
||||
</button>
|
||||
<button
|
||||
className={`event-list-scope-tab${scopeTab === 'track' ? ' active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setScopeTab('track')}
|
||||
>
|
||||
Track
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="event-list-panel-body">
|
||||
{scopeTab === 'region' ? (
|
||||
<RegionEventListTab activeMidiRegion={activeMidiRegion} parentTrack={parentTrack} />
|
||||
) : (
|
||||
<TrackEventListTab selectedTrack={selectedTrack} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventListPanel;
|
||||
@@ -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<typeof vi.fn>;
|
||||
refreshProjectState: ReturnType<typeof vi.fn>;
|
||||
bumpAutomationRedrawVersion: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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<KGRegion | KGMidiNote | KGMidiPitchBend | KGMidiControllerEvent> = [];
|
||||
|
||||
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(<ListEventPanel isVisible={true} />);
|
||||
|
||||
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(<ListEventPanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('C4').closest('tr')!);
|
||||
rerender(<ListEventPanel isVisible={true} />);
|
||||
|
||||
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(<ListEventPanel isVisible={true} />);
|
||||
|
||||
fireEvent.click(screen.getByText('C4').closest('tr')!);
|
||||
rerender(<ListEventPanel isVisible={true} />);
|
||||
fireEvent.click(screen.getByText('Pitch Bend').closest('tr')!, { shiftKey: true });
|
||||
rerender(<ListEventPanel isVisible={true} />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
+267
-259
@@ -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 = () => {
|
||||
<div className="logo-container">
|
||||
<img src={`${import.meta.env.BASE_URL}logo.png`} alt="DAW Logo" className="logo" />
|
||||
</div>
|
||||
<div
|
||||
className="project-name"
|
||||
<div
|
||||
className="project-name"
|
||||
onClick={handleProjectNameClick}
|
||||
>
|
||||
{projectName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-center">
|
||||
<button title="New" onClick={handleNewProject}><FaPlus /></button>
|
||||
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button>
|
||||
<button title="Save" onClick={handleSaveProject}><FaSave /></button>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
title="Export"
|
||||
onClick={() => setShowExportDropdown(!showExportDropdown)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||
>
|
||||
<FaDownload />
|
||||
</button>
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={exportOptions}
|
||||
value={exportOptions[0]}
|
||||
onChange={handleExportProject}
|
||||
label="Export"
|
||||
hideButton={true}
|
||||
isOpen={showExportDropdown}
|
||||
onToggle={setShowExportDropdown}
|
||||
className="export-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button title="Import" onClick={handleImportProject}><FaUpload /></button>
|
||||
<button
|
||||
title="Settings"
|
||||
className={`tool-button ${showSettings ? 'active' : ''}`}
|
||||
onClick={handleSettingsClick}
|
||||
>
|
||||
<FaCog />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button title="Undo" onClick={handleUndoClick}><FaUndo /></button>
|
||||
<button title="Redo" onClick={handleRedoClick}><FaRedo /></button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button
|
||||
title="Select"
|
||||
className={`tool-button ${activeMainTool === 'pointer' ? 'active' : ''}`}
|
||||
onClick={() => handleMainToolSelect('pointer')}
|
||||
>
|
||||
<FaMousePointer />
|
||||
</button>
|
||||
<button
|
||||
title="Pencil"
|
||||
className={`tool-button ${activeMainTool === 'pencil' ? 'active' : ''}`}
|
||||
onClick={() => handleMainToolSelect('pencil')}
|
||||
>
|
||||
<FaPencil />
|
||||
</button>
|
||||
<button
|
||||
title="Split Region at Playhead"
|
||||
onClick={handleSplitClick}
|
||||
>
|
||||
<FaCut />
|
||||
</button>
|
||||
<button
|
||||
title="Merge Selected MIDI Regions"
|
||||
onClick={handleMergeClick}
|
||||
>
|
||||
<FaCompress />
|
||||
</button>
|
||||
<button
|
||||
title="Snap to Grid"
|
||||
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||
onClick={handleSnappingToggle}
|
||||
>
|
||||
<FaMagnet />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
|
||||
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
|
||||
<button title="Delete" onClick={handleDeleteClick}><FaTrash /></button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button title="Back to beginning" className="button-back-to-beginning" onClick={handleBackToBeginningClick}><FaStepBackward /></button>
|
||||
{!isPlaying ? (
|
||||
<button title="Play" className="button-play" onClick={handlePlayClick} disabled={isPreparingPlayback}><FaPlay /></button>
|
||||
) : (
|
||||
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
|
||||
)}
|
||||
<button
|
||||
title={isRecording ? "Stop Recording" : "Record"}
|
||||
className={`tool-button record-button ${isRecording ? 'active' : ''}`}
|
||||
onClick={handleRecordClick}
|
||||
>
|
||||
<FaCircle />
|
||||
</button>
|
||||
<button
|
||||
title="Loop"
|
||||
className={`tool-button ${isLooping ? 'active' : ''}`}
|
||||
onClick={handleLoopToggle}
|
||||
>
|
||||
<FaSync />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button
|
||||
title="Metronome"
|
||||
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
|
||||
onClick={handleMetronomeToggle}
|
||||
>
|
||||
<MetronomeIcon />
|
||||
</button>
|
||||
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
|
||||
{/* <button title="Record"><FaCircle className="record-btn" /></button>
|
||||
<button title="Metronome">🎵</button> */}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-right">
|
||||
<div className="transport-control">
|
||||
<div className="transport-item" style={{ position: 'relative' }} ref={zoomSliderRef}>
|
||||
<span
|
||||
className='current-zoom'
|
||||
onClick={() => setShowZoomSlider(!showZoomSlider)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
|
||||
<div className="toolbar-center">
|
||||
<button title="New" onClick={handleNewProject}><FaPlus /></button>
|
||||
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button>
|
||||
<button title="Save" onClick={handleSaveProject}><FaSave /></button>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
title="Export"
|
||||
onClick={() => setShowExportDropdown(!showExportDropdown)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
||||
>
|
||||
{barWidthMultiplier}x
|
||||
</span>
|
||||
{showZoomSlider && (
|
||||
<div className="zoom-slider-popup">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="8"
|
||||
step="1"
|
||||
value={barWidthMultiplier}
|
||||
onChange={(e) => setBarWidthMultiplier(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="zoom-slider-label">{barWidthMultiplier}x</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{bpm}</span>
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
|
||||
</div>
|
||||
<div className="transport-item" style={{ position: 'relative' }}>
|
||||
<span
|
||||
className='current-key-signature'
|
||||
onClick={() => setShowKeySignatureDropdown(!showKeySignatureDropdown)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{keySignature}
|
||||
</span>
|
||||
<FaDownload />
|
||||
</button>
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={keySignatureOptions}
|
||||
value={keySignature}
|
||||
onChange={handleKeySignatureChange}
|
||||
label="Key Signature"
|
||||
options={exportOptions}
|
||||
value={exportOptions[0]}
|
||||
onChange={handleExportProject}
|
||||
label="Export"
|
||||
hideButton={true}
|
||||
isOpen={showKeySignatureDropdown}
|
||||
onToggle={setShowKeySignatureDropdown}
|
||||
className="key-signature-dropdown"
|
||||
isOpen={showExportDropdown}
|
||||
onToggle={setShowExportDropdown}
|
||||
className="export-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button title="Import" onClick={handleImportProject}><FaUpload /></button>
|
||||
<button
|
||||
title="Settings"
|
||||
className={`tool-button ${showSettings ? 'active' : ''}`}
|
||||
onClick={handleSettingsClick}
|
||||
>
|
||||
<FaCog />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button title="Undo" onClick={handleUndoClick}><FaUndo /></button>
|
||||
<button title="Redo" onClick={handleRedoClick}><FaRedo /></button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button
|
||||
title="Select"
|
||||
className={`tool-button ${activeMainTool === 'pointer' ? 'active' : ''}`}
|
||||
onClick={() => handleMainToolSelect('pointer')}
|
||||
>
|
||||
<FaMousePointer />
|
||||
</button>
|
||||
<button
|
||||
title="Pencil"
|
||||
className={`tool-button ${activeMainTool === 'pencil' ? 'active' : ''}`}
|
||||
onClick={() => handleMainToolSelect('pencil')}
|
||||
>
|
||||
<FaPencil />
|
||||
</button>
|
||||
<button
|
||||
title="Split Region at Playhead"
|
||||
onClick={handleSplitClick}
|
||||
>
|
||||
<FaCut />
|
||||
</button>
|
||||
<button
|
||||
title="Merge Selected MIDI Regions"
|
||||
onClick={handleMergeClick}
|
||||
>
|
||||
<FaCompress />
|
||||
</button>
|
||||
<button
|
||||
title="Snap to Grid"
|
||||
className={`tool-button ${isSnapping ? 'active' : ''}`}
|
||||
onClick={handleSnappingToggle}
|
||||
>
|
||||
<FaMagnet />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button title="Copy" onClick={handleCopyClick}><FaCopy /></button>
|
||||
<button title="Paste" onClick={handlePasteClick}><FaPaste /></button>
|
||||
<button title="Delete" onClick={handleDeleteClick}><FaTrash /></button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button title="Back to beginning" className="button-back-to-beginning" onClick={handleBackToBeginningClick}><FaStepBackward /></button>
|
||||
{!isPlaying ? (
|
||||
<button title="Play" className="button-play" onClick={handlePlayClick} disabled={isPreparingPlayback}><FaPlay /></button>
|
||||
) : (
|
||||
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
|
||||
)}
|
||||
<button
|
||||
title={isRecording ? "Stop Recording" : "Record"}
|
||||
className={`tool-button record-button ${isRecording ? 'active' : ''}`}
|
||||
onClick={handleRecordClick}
|
||||
>
|
||||
<FaCircle />
|
||||
</button>
|
||||
<button
|
||||
title="Loop"
|
||||
className={`tool-button ${isLooping ? 'active' : ''}`}
|
||||
onClick={handleLoopToggle}
|
||||
>
|
||||
<FaSync />
|
||||
</button>
|
||||
<div className="toolbar-separator"></div>
|
||||
<button
|
||||
title="Metronome"
|
||||
className={`tool-button ${isMetronomeEnabled ? 'active' : ''}`}
|
||||
onClick={handleMetronomeToggle}
|
||||
>
|
||||
<MetronomeIcon />
|
||||
</button>
|
||||
<button title="Piano" onClick={handlePianoButtonClick}><PianoIcon /></button>
|
||||
{/* <button title="Record"><FaCircle className="record-btn" /></button>
|
||||
<button title="Metronome">🎵</button> */}
|
||||
</div>
|
||||
|
||||
<div className="toolbar-right">
|
||||
<div className="transport-control">
|
||||
<div className="transport-item" style={{ position: 'relative' }} ref={zoomSliderRef}>
|
||||
<span
|
||||
className='current-zoom'
|
||||
onClick={() => setShowZoomSlider(!showZoomSlider)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{barWidthMultiplier}x
|
||||
</span>
|
||||
{showZoomSlider && (
|
||||
<div className="zoom-slider-popup">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="8"
|
||||
step="1"
|
||||
value={barWidthMultiplier}
|
||||
onChange={(e) => setBarWidthMultiplier(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="zoom-slider-label">{barWidthMultiplier}x</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-time' onClick={handleCurrentTimeClick} style={{ cursor: 'pointer' }}>{currentTime}</span>
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-bpm' onClick={handleBpmClick} style={{ cursor: 'pointer' }}>{bpm}</span>
|
||||
</div>
|
||||
<div className="transport-item">
|
||||
<span className='current-time-signature' onClick={handleTimeSignatureClick} style={{ cursor: 'pointer' }}>{timeSignature.numerator + "/" + timeSignature.denominator}</span>
|
||||
</div>
|
||||
<div className="transport-item" style={{ position: 'relative' }}>
|
||||
<span
|
||||
className='current-key-signature'
|
||||
onClick={() => setShowKeySignatureDropdown(!showKeySignatureDropdown)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{keySignature}
|
||||
</span>
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
||||
<KGDropdown
|
||||
options={keySignatureOptions}
|
||||
value={keySignature}
|
||||
onChange={handleKeySignatureChange}
|
||||
label="Key Signature"
|
||||
hideButton={true}
|
||||
isOpen={showKeySignatureDropdown}
|
||||
onToggle={setShowKeySignatureDropdown}
|
||||
className="key-signature-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
title={isKGOneEnabled ? 'K.G.One Music Generator' : 'K.G.One integration is disabled — enable it in Settings'}
|
||||
onClick={handleKGOneClick}
|
||||
disabled={!isKGOneEnabled}
|
||||
className={showKGOnePanel ? 'active' : ''}
|
||||
style={!isKGOneEnabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<FaWandMagicSparkles />
|
||||
</button>
|
||||
<button
|
||||
title="Chat"
|
||||
onClick={handleChatClick}
|
||||
className={showChatBox ? 'active' : ''}
|
||||
>
|
||||
<FaComments />
|
||||
</button>
|
||||
<button
|
||||
title="Event List Editor"
|
||||
onClick={handleEventListClick}
|
||||
className={showEventListPanel ? 'active' : ''}
|
||||
>
|
||||
<FaListUl />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
title={isKGOneEnabled ? 'K.G.One Music Generator' : 'K.G.One integration is disabled — enable it in Settings'}
|
||||
onClick={handleKGOneClick}
|
||||
disabled={!isKGOneEnabled}
|
||||
className={showKGOnePanel ? 'active' : ''}
|
||||
style={!isKGOneEnabled ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
>
|
||||
<FaWandMagicSparkles />
|
||||
</button>
|
||||
<button
|
||||
title="Chat"
|
||||
onClick={handleChatClick}
|
||||
className={showChatBox ? 'active' : ''}
|
||||
>
|
||||
<FaComments />
|
||||
</button>
|
||||
<button
|
||||
title="List Event Editor"
|
||||
onClick={handleListEventClick}
|
||||
className={showListEventPanel ? 'active' : ''}
|
||||
>
|
||||
<FaListUl />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileImportModal
|
||||
isVisible={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onFileImport={handleFileImport}
|
||||
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
||||
title="Import Project"
|
||||
description="Drag and drop your project file here"
|
||||
/>
|
||||
|
||||
<LoadingOverlay
|
||||
visible={isOpeningProject}
|
||||
message="Opening project..."
|
||||
/>
|
||||
|
||||
{showOpenProject && (
|
||||
<OpenProjectModal
|
||||
onClose={() => setShowOpenProject(false)}
|
||||
onConfirmOpenProject={handleConfirmOpenProject}
|
||||
onOpenProject={handleOpenProjectSelect}
|
||||
currentProjectName={savedProjectName}
|
||||
onCreateNewProject={createNewProject}
|
||||
<FileImportModal
|
||||
isVisible={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onFileImport={handleFileImport}
|
||||
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
||||
title="Import Project"
|
||||
description="Drag and drop your project file here"
|
||||
/>
|
||||
)}
|
||||
|
||||
<LoadingOverlay
|
||||
visible={isOpeningProject}
|
||||
message="Opening project..."
|
||||
/>
|
||||
|
||||
{showOpenProject && (
|
||||
<OpenProjectModal
|
||||
onClose={() => setShowOpenProject(false)}
|
||||
onConfirmOpenProject={handleConfirmOpenProject}
|
||||
onOpenProject={handleOpenProjectSelect}
|
||||
currentProjectName={savedProjectName}
|
||||
onCreateNewProject={createNewProject}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+225
-297
@@ -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<ListEventPanelProps> = ({ isVisible }) => {
|
||||
const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegion, parentTrack }) => {
|
||||
const {
|
||||
tracks,
|
||||
activeRegionId,
|
||||
selectedRegionIds,
|
||||
timeSignature,
|
||||
selectedNoteIds,
|
||||
selectedPitchBendIds,
|
||||
selectedControllerEventIds,
|
||||
selectedRegionIds,
|
||||
activeRegionId,
|
||||
timeSignature,
|
||||
playheadPosition,
|
||||
updateTrack,
|
||||
refreshProjectState,
|
||||
@@ -208,32 +207,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
const suppressBlurCommitRef = useRef(false);
|
||||
const pendingSingleClickSelectionRef = useRef<number | null>(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<ListEventPanelProps> = ({ 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<ListEventPanelProps> = ({ isVisible }) => {
|
||||
type: 'controller',
|
||||
controller,
|
||||
controllerEvent: event,
|
||||
absoluteBeat: activeMidiRegion!.getStartFromBeat() + event.getBeat(),
|
||||
absoluteBeat: activeMidiRegion.getStartFromBeat() + event.getBeat(),
|
||||
}))
|
||||
: [];
|
||||
|
||||
@@ -774,10 +753,6 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
||||
commitSelection(new Set());
|
||||
};
|
||||
|
||||
const handleTableShellClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -808,10 +783,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ 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<ListEventPanelProps> = ({ 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<ListEventPanelProps> = ({ 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<ListEventPanelProps> = ({ 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<ListEventPanelProps> = ({ 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<ListEventPanelProps> = ({ isVisible }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`list-event-panel${isVisible ? '' : ' is-hidden'}`}>
|
||||
<div className="list-event-panel-header">
|
||||
<h3>List Event</h3>
|
||||
<>
|
||||
<div className="event-list-tabs" role="tablist" aria-label="Region event filters">
|
||||
<button className={`event-list-tab${showNotes ? ' active' : ''}`} type="button" onClick={() => setShowNotes(value => !value)}>Notes</button>
|
||||
<button className={`event-list-tab${showPitchBends ? ' active' : ''}`} type="button" onClick={() => setShowPitchBends(value => !value)}>Pitch Bends</button>
|
||||
<button className={`event-list-tab${showControllers ? ' active' : ''}`} type="button" onClick={() => setShowControllers(value => !value)}>Controller</button>
|
||||
</div>
|
||||
|
||||
<div className="list-event-panel-body">
|
||||
<div className="list-event-tabs" role="tablist" aria-label="Event types">
|
||||
<button
|
||||
className={`list-event-tab${showNotes ? ' active' : ''}`}
|
||||
aria-pressed={showNotes}
|
||||
type="button"
|
||||
onClick={() => setShowNotes(value => !value)}
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
className={`list-event-tab${showPitchBends ? ' active' : ''}`}
|
||||
aria-pressed={showPitchBends}
|
||||
type="button"
|
||||
onClick={() => setShowPitchBends(value => !value)}
|
||||
>
|
||||
Pitch Bends
|
||||
</button>
|
||||
<button
|
||||
className={`list-event-tab${showControllers ? ' active' : ''}`}
|
||||
aria-pressed={showControllers}
|
||||
type="button"
|
||||
onClick={() => setShowControllers(value => !value)}
|
||||
>
|
||||
Controller
|
||||
</button>
|
||||
{!activeMidiRegion ? (
|
||||
<div className="event-list-empty-state">
|
||||
Please select a MIDI region, or open one in the Piano Roll, to view its event list.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="event-list-toolbar">
|
||||
<div className="event-list-toolbar-group">
|
||||
<button
|
||||
className="event-list-add-button"
|
||||
title={addEventType === 'note' ? 'Add note at playhead' : addEventType === 'pitch-bend' ? 'Add pitch bend at playhead' : 'Add controller event at playhead'}
|
||||
type="button"
|
||||
onClick={handleAddEvent}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<KGDropdown
|
||||
options={[...ADD_EVENT_TYPE_OPTIONS]}
|
||||
value={addEventType}
|
||||
onChange={(value) => setAddEventType(value as AddEventType)}
|
||||
label="Note"
|
||||
buttonClassName="event-list-type-button"
|
||||
showValueAsLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!activeMidiRegion ? (
|
||||
<div className="list-event-empty-state">
|
||||
Please select a MIDI region, or open one in the Piano Roll, to view its event list.
|
||||
<div className="event-list-toolbar-group event-list-toolbar-group-right">
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_POS_OPTIONS}
|
||||
value={quantPosition}
|
||||
onChange={(value) => {
|
||||
setQuantPosition(value);
|
||||
quantizeSelectedNotes(value);
|
||||
}}
|
||||
label="Qua. Pos."
|
||||
buttonClassName="event-list-quant-button"
|
||||
/>
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_LEN_OPTIONS}
|
||||
value={quantLength}
|
||||
onChange={(value) => {
|
||||
setQuantLength(value);
|
||||
quantizeSelectedNoteLengths(value);
|
||||
}}
|
||||
label="Qua. Len."
|
||||
buttonClassName="event-list-quant-button"
|
||||
/>
|
||||
<button
|
||||
className="event-list-delete-button"
|
||||
title="Delete visible selected rows"
|
||||
type="button"
|
||||
onClick={handleDeleteSelectedRows}
|
||||
disabled={visibleSelectedRows.length === 0}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="list-event-toolbar">
|
||||
<div className="list-event-toolbar-group">
|
||||
<button
|
||||
className="list-event-add-button"
|
||||
title={
|
||||
addEventType === 'note'
|
||||
? 'Add note at playhead'
|
||||
: addEventType === 'pitch-bend'
|
||||
? 'Add pitch bend at playhead'
|
||||
: 'Add controller event at playhead'
|
||||
}
|
||||
type="button"
|
||||
onClick={handleAddEvent}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<KGDropdown
|
||||
options={[...ADD_EVENT_TYPE_OPTIONS]}
|
||||
value={addEventType}
|
||||
onChange={(value) => setAddEventType(value as AddEventType)}
|
||||
label="Note"
|
||||
buttonClassName="list-event-type-button"
|
||||
showValueAsLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="list-event-toolbar-group list-event-toolbar-group-right">
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_POS_OPTIONS}
|
||||
value={quantPosition}
|
||||
onChange={(value) => {
|
||||
setQuantPosition(value);
|
||||
quantizeSelectedNotes(value);
|
||||
}}
|
||||
label="Qua. Pos."
|
||||
buttonClassName="list-event-quant-button"
|
||||
/>
|
||||
<KGDropdown
|
||||
options={KGPianoRollState.QUANT_LEN_OPTIONS}
|
||||
value={quantLength}
|
||||
onChange={(value) => {
|
||||
setQuantLength(value);
|
||||
quantizeSelectedNoteLengths(value);
|
||||
}}
|
||||
label="Qua. Len."
|
||||
buttonClassName="list-event-quant-button"
|
||||
/>
|
||||
<button
|
||||
className="list-event-delete-button"
|
||||
title="Delete visible selected rows"
|
||||
type="button"
|
||||
onClick={handleDeleteSelectedRows}
|
||||
disabled={visibleSelectedRows.length === 0}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="event-list-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
|
||||
<table className="event-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Position</th>
|
||||
<th>Status</th>
|
||||
<th>Num</th>
|
||||
<th>Val</th>
|
||||
<th>Length/Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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';
|
||||
|
||||
<div
|
||||
className="list-event-table-shell"
|
||||
onMouseDown={handleTableBackgroundMouseDown}
|
||||
onClick={handleTableShellClick}
|
||||
onDoubleClick={handleTableShellClick}
|
||||
>
|
||||
<table className="list-event-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Position</th>
|
||||
<th>Status</th>
|
||||
<th>Num</th>
|
||||
<th>Val</th>
|
||||
<th>Length/Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
|
||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearPendingSingleClickSelection();
|
||||
}}
|
||||
>
|
||||
<td
|
||||
title={positionText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'position', positionText);
|
||||
}}
|
||||
>
|
||||
{isEditingPosition ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : positionText}
|
||||
</td>
|
||||
<td title={statusText}>{statusText}</td>
|
||||
<td
|
||||
title={numText}
|
||||
onDoubleClick={(event) => {
|
||||
if (row.type === 'pitch-bend') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'num', numText);
|
||||
}}
|
||||
>
|
||||
{isEditingNum ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : numText}
|
||||
</td>
|
||||
<td
|
||||
title={valText}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'val', valText);
|
||||
}}
|
||||
>
|
||||
{isEditingVal ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : valText}
|
||||
</td>
|
||||
<td
|
||||
title={lengthText}
|
||||
onDoubleClick={(event) => {
|
||||
if (row.type !== 'note') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'length', lengthText);
|
||||
}}
|
||||
>
|
||||
{isEditingLength ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="list-event-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : lengthText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
|
||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearPendingSingleClickSelection();
|
||||
}}
|
||||
>
|
||||
<td title={positionText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
|
||||
{isEditingPosition ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : positionText}
|
||||
</td>
|
||||
<td title={statusText}>{statusText}</td>
|
||||
<td title={numText} onDoubleClick={(event) => {
|
||||
if (row.type === 'pitch-bend') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'num', numText);
|
||||
}}>
|
||||
{isEditingNum ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : numText}
|
||||
</td>
|
||||
<td title={valText} onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'val', valText);
|
||||
}}>
|
||||
{isEditingVal ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : valText}
|
||||
</td>
|
||||
<td title={lengthText} onDoubleClick={(event) => {
|
||||
if (row.type !== 'note') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'length', lengthText);
|
||||
}}>
|
||||
{isEditingLength ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : lengthText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListEventPanel;
|
||||
export default RegionEventListTab;
|
||||
@@ -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<TrackEventListTabProps> = ({ 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<AddTrackItemType>('midi-region');
|
||||
const [editingCell, setEditingCell] = useState<TrackEditingCell | null>(null);
|
||||
const rangeAnchorRowIdRef = useRef<string | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const suppressBlurCommitRef = useRef(false);
|
||||
const pendingSingleClickSelectionRef = useRef<number | null>(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<string>) => {
|
||||
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<string>) => {
|
||||
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<string>) => {
|
||||
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<HTMLTableRowElement>) => {
|
||||
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<string>([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<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (event.target !== event.currentTarget) return;
|
||||
clearPendingSingleClickSelection();
|
||||
rangeAnchorRowIdRef.current = null;
|
||||
commitSelection(new Set());
|
||||
};
|
||||
|
||||
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="event-list-tabs" role="tablist" aria-label="Track list modes">
|
||||
<button className={`event-list-tab${showRegions ? ' active' : ''}`} aria-pressed={showRegions} type="button" onClick={() => setShowRegions(value => !value)}>Regions</button>
|
||||
<button className={`event-list-tab${showVolume ? ' active' : ''}`} aria-pressed={showVolume} type="button" onClick={() => setShowVolume(value => !value)}>Volume</button>
|
||||
<button className={`event-list-tab${showPan ? ' active' : ''}`} aria-pressed={showPan} type="button" onClick={() => setShowPan(value => !value)}>Pan</button>
|
||||
</div>
|
||||
|
||||
{!liveSelectedTrack ? (
|
||||
<div className="event-list-empty-state">
|
||||
Please select a track to view regions and track automation.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="event-list-toolbar">
|
||||
<div className="event-list-toolbar-group">
|
||||
<button
|
||||
className="event-list-add-button"
|
||||
title={addTrackItemType === 'midi-region' ? 'Add MIDI region at playhead' : addTrackItemType === 'volume' ? 'Add volume automation point at playhead' : 'Add pan automation point at playhead'}
|
||||
type="button"
|
||||
onClick={handleAddTrackItem}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
<KGDropdown
|
||||
options={availableAddOptions}
|
||||
value={addTrackItemType}
|
||||
onChange={(value) => setAddTrackItemType(value as AddTrackItemType)}
|
||||
label="Add"
|
||||
buttonClassName="event-list-type-button"
|
||||
showValueAsLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="event-list-toolbar-group event-list-toolbar-group-right">
|
||||
<button
|
||||
className="event-list-delete-button"
|
||||
title="Delete visible selected rows"
|
||||
type="button"
|
||||
onClick={handleDeleteSelectedRows}
|
||||
disabled={visibleSelectedRows.length === 0}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="event-list-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
|
||||
<table className="event-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Position</th>
|
||||
<th>Status</th>
|
||||
<th>Val</th>
|
||||
<th>Length/Info</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={selectedRowIdSet.has(row.id) ? 'selected' : ''}
|
||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearPendingSingleClickSelection();
|
||||
}}
|
||||
>
|
||||
<td title={positionText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
|
||||
{isEditingPosition ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : positionText}
|
||||
</td>
|
||||
<td title={statusText}>{statusText}</td>
|
||||
<td title={valText} onDoubleClick={(event) => {
|
||||
if (row.type === 'region') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'val', valText);
|
||||
}}>
|
||||
{isEditingVal ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : valText}
|
||||
</td>
|
||||
<td title={infoText} onDoubleClick={(event) => {
|
||||
if (row.type !== 'region') return;
|
||||
event.stopPropagation();
|
||||
startEditingCell(row.id, 'length', infoText);
|
||||
}}>
|
||||
{isEditingLength ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="event-list-cell-input"
|
||||
value={editingCell.value}
|
||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||
onBlur={handleEditInputBlur}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||
/>
|
||||
) : infoText}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackEventListTab;
|
||||
@@ -47,8 +47,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
}) => {
|
||||
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<PianoRollProps> = ({
|
||||
const [pianoRollZoom, setPianoRollZoom] = useState<number>(1);
|
||||
const [automationEnabled, setAutomationEnabled] = useState(false);
|
||||
const [automationType, setAutomationType] = useState<PianoRollAutomationType>('pitch-bend');
|
||||
|
||||
|
||||
// Quantization state
|
||||
const [quantPosition, setQuantPosition] = useState<string>('1/8');
|
||||
const [quantLength, setQuantLength] = useState<string>('1/8');
|
||||
@@ -75,7 +75,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
// 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<string | null>(null);
|
||||
const [size, setSize] = useState(initialSize || { width: 800, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT });
|
||||
@@ -102,7 +102,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
|
||||
// Ref for storing the setNoteUpdateCounter function
|
||||
const triggerNoteUpdateRef = useRef<React.Dispatch<React.SetStateAction<number>> | null>(null);
|
||||
|
||||
|
||||
// Ref for storing the deleteSelectedNotes function
|
||||
const deleteSelectedNotesRef = useRef<(() => boolean) | null>(null);
|
||||
|
||||
@@ -133,32 +133,32 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
// 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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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<PianoRollProps> = ({
|
||||
}
|
||||
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<PianoRollProps> = ({
|
||||
console.log(`Selected tool: ${tool}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Handle snapping selection
|
||||
const handleSnappingSelect = useCallback((value: string) => {
|
||||
setSnapping(value);
|
||||
@@ -448,166 +448,166 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
// 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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
// 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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
// 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<PianoRollProps> = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Handle piano roll hotkeys
|
||||
const configManager = ConfigManager.instance();
|
||||
if (configManager.getIsInitialized()) {
|
||||
@@ -916,21 +916,21 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
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<PianoRollProps> = ({
|
||||
} 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<PianoRollProps> = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 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<PianoRollProps> = ({
|
||||
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 (
|
||||
<div
|
||||
<div
|
||||
className="piano-roll-panel"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
@@ -1063,7 +1063,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
onTitleClick={handleTitleClick}
|
||||
onMouseDown={(e) => handleMouseDown(e, 'drag')}
|
||||
/>
|
||||
|
||||
|
||||
<PianoRollToolbar
|
||||
activeTool={activeTool}
|
||||
onToolSelect={handleToolSelect}
|
||||
@@ -1120,8 +1120,8 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
||||
automationType={automationType}
|
||||
automationRedrawVersion={automationRedrawVersion}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={(e) => handleMouseDown(e, 'resize')}
|
||||
>
|
||||
|
||||
@@ -3,10 +3,11 @@ import './Settings.css';
|
||||
import SettingsSidebar from './SettingsSidebar';
|
||||
import GeneralSettings from './sections/GeneralSettings';
|
||||
import BehaviorSettings from './sections/BehaviorSettings';
|
||||
import AudioIOSettings from './sections/AudioIOSettings';
|
||||
import TemplatesSettings from './sections/TemplatesSettings';
|
||||
import ChordGuideSettings from './sections/ChordGuideSettings';
|
||||
|
||||
export type SettingsSection = 'general' | 'behavior' | 'templates' | 'chord_guide';
|
||||
export type SettingsSection = 'general' | 'audio_io' | 'behavior' | 'templates' | 'chord_guide';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -21,6 +22,8 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
|
||||
return <GeneralSettings />;
|
||||
case 'behavior':
|
||||
return <BehaviorSettings />;
|
||||
case 'audio_io':
|
||||
return <AudioIOSettings />;
|
||||
case 'templates':
|
||||
return <TemplatesSettings />;
|
||||
case 'chord_guide':
|
||||
@@ -46,4 +49,4 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPanel;
|
||||
export default SettingsPanel;
|
||||
|
||||
@@ -15,6 +15,7 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
|
||||
}) => {
|
||||
const sections = [
|
||||
{ id: 'general' as SettingsSection, label: 'General' },
|
||||
{ id: 'audio_io' as SettingsSection, label: 'Audio I/O' },
|
||||
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
|
||||
{ id: 'templates' as SettingsSection, label: 'Templates' },
|
||||
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
|
||||
@@ -48,4 +49,4 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSidebar;
|
||||
export default SettingsSidebar;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as SettingsPanel } from './SettingsPanel.tsx';
|
||||
export { default as SettingsSidebar } from './SettingsSidebar.tsx';
|
||||
export { default as GeneralSettings } from './sections/GeneralSettings.tsx';
|
||||
export { default as AudioIOSettings } from './sections/AudioIOSettings.tsx';
|
||||
export { default as BehaviorSettings } from './sections/BehaviorSettings.tsx';
|
||||
export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
|
||||
export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import AudioIOSettings from './AudioIOSettings';
|
||||
|
||||
const configState = new Map<string, unknown>([
|
||||
['audio.input_device_id', 'default'],
|
||||
['audio.output_device_id', 'default'],
|
||||
]);
|
||||
|
||||
const configManagerMock = {
|
||||
getIsInitialized: vi.fn(() => true),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn((key: string) => configState.get(key)),
|
||||
set: vi.fn(async (key: string, value: unknown) => {
|
||||
configState.set(key, value);
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('../../../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => configManagerMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../util/audioDeviceUtil', () => ({
|
||||
enumerateAudioDevices: vi.fn().mockResolvedValue({
|
||||
inputs: [
|
||||
{ deviceId: 'default', label: 'System Default', kind: 'audioinput', isDefault: true },
|
||||
{ deviceId: 'mic-1', label: 'Studio Mic', kind: 'audioinput', isDefault: false },
|
||||
],
|
||||
outputs: [
|
||||
{ deviceId: 'default', label: 'System Default', kind: 'audiooutput', isDefault: true },
|
||||
{ deviceId: 'speaker-1', label: 'Monitor Out', kind: 'audiooutput', isDefault: false },
|
||||
],
|
||||
labelsAvailable: true,
|
||||
canSelectOutput: true,
|
||||
canWatchDeviceChanges: false,
|
||||
}),
|
||||
getDefaultAudioDeviceOption: vi.fn((kind: 'audioinput' | 'audiooutput') => ({
|
||||
deviceId: 'default',
|
||||
label: 'System Default',
|
||||
kind,
|
||||
isDefault: true,
|
||||
})),
|
||||
promptForAudioOutputDevice: vi.fn().mockResolvedValue({
|
||||
deviceId: 'speaker-1',
|
||||
label: 'Monitor Out',
|
||||
kind: 'audiooutput',
|
||||
isDefault: false,
|
||||
}),
|
||||
supportsAudioContextSinkSelection: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('AudioIOSettings', () => {
|
||||
beforeEach(() => {
|
||||
configState.set('audio.input_device_id', 'default');
|
||||
configState.set('audio.output_device_id', 'default');
|
||||
configManagerMock.get.mockClear();
|
||||
configManagerMock.set.mockClear();
|
||||
});
|
||||
|
||||
it('renders input/output selectors and refresh action', async () => {
|
||||
render(<AudioIOSettings />);
|
||||
|
||||
expect(await screen.findByText('Audio I/O')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Audio Input Device')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Audio Output Device')).toBeTruthy();
|
||||
expect(screen.getByText('Refresh Device List')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('persists input device changes', async () => {
|
||||
render(<AudioIOSettings />);
|
||||
|
||||
const select = await screen.findByLabelText('Audio Input Device');
|
||||
fireEvent.change(select, { target: { value: 'mic-1' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'mic-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves missing saved devices to System Default when validation is conclusive', async () => {
|
||||
configState.set('audio.input_device_id', 'missing-input');
|
||||
|
||||
render(<AudioIOSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'default');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import {
|
||||
enumerateAudioDevices,
|
||||
getDefaultAudioDeviceOption,
|
||||
promptForAudioOutputDevice,
|
||||
supportsAudioContextSinkSelection,
|
||||
type AudioDeviceOption,
|
||||
} from '../../../util/audioDeviceUtil';
|
||||
|
||||
const AudioIOSettings: React.FC = () => {
|
||||
const [inputDeviceId, setInputDeviceId] = useState<string>('default');
|
||||
const [outputDeviceId, setOutputDeviceId] = useState<string>('default');
|
||||
const [inputs, setInputs] = useState<AudioDeviceOption[]>([getDefaultAudioDeviceOption('audioinput')]);
|
||||
const [outputs, setOutputs] = useState<AudioDeviceOption[]>([getDefaultAudioDeviceOption('audiooutput')]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||
const [deviceStatus, setDeviceStatus] = useState<string>('');
|
||||
const [supportsOutputPrompt, setSupportsOutputPrompt] = useState<boolean>(false);
|
||||
const [supportsOutputSink, setSupportsOutputSink] = useState<boolean>(false);
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
setInputDeviceId((configManager.get('audio.input_device_id') as string | undefined) ?? 'default');
|
||||
setOutputDeviceId((configManager.get('audio.output_device_id') as string | undefined) ?? 'default');
|
||||
setSupportsOutputSink(supportsAudioContextSinkSelection());
|
||||
await refreshDevices();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
void initialize();
|
||||
|
||||
const mediaDevices = navigator.mediaDevices;
|
||||
const handleDeviceChange = () => {
|
||||
void refreshDevices(false);
|
||||
};
|
||||
|
||||
if (mediaDevices && typeof mediaDevices.addEventListener === 'function') {
|
||||
mediaDevices.addEventListener('devicechange', handleDeviceChange);
|
||||
return () => {
|
||||
mediaDevices.removeEventListener('devicechange', handleDeviceChange);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [configManager]);
|
||||
|
||||
const refreshDevices = async (showStatus: boolean = true) => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const snapshot = await enumerateAudioDevices();
|
||||
setSupportsOutputPrompt(snapshot.canSelectOutput);
|
||||
|
||||
const configuredInputId = (configManager.get('audio.input_device_id') as string | undefined) ?? 'default';
|
||||
const configuredOutputId = (configManager.get('audio.output_device_id') as string | undefined) ?? 'default';
|
||||
|
||||
const hasInput = snapshot.inputs.some(device => device.deviceId === configuredInputId);
|
||||
const hasOutput = snapshot.outputs.some(device => device.deviceId === configuredOutputId);
|
||||
const nextInputs = !hasInput && configuredInputId !== 'default' && !snapshot.labelsAvailable
|
||||
? [
|
||||
...snapshot.inputs,
|
||||
{
|
||||
deviceId: configuredInputId,
|
||||
label: 'Previously Selected Input (permission required to verify)',
|
||||
kind: 'audioinput' as const,
|
||||
isDefault: false,
|
||||
},
|
||||
]
|
||||
: snapshot.inputs;
|
||||
const nextOutputs = !hasOutput && configuredOutputId !== 'default' && !snapshot.labelsAvailable
|
||||
? [
|
||||
...snapshot.outputs,
|
||||
{
|
||||
deviceId: configuredOutputId,
|
||||
label: 'Previously Selected Output (permission required to verify)',
|
||||
kind: 'audiooutput' as const,
|
||||
isDefault: false,
|
||||
},
|
||||
]
|
||||
: snapshot.outputs;
|
||||
|
||||
setInputs(nextInputs);
|
||||
setOutputs(nextOutputs);
|
||||
|
||||
if (!hasInput && configuredInputId !== 'default' && snapshot.labelsAvailable) {
|
||||
setInputDeviceId('default');
|
||||
await configManager.set('audio.input_device_id', 'default');
|
||||
setDeviceStatus('Previously selected audio input device is unavailable; using System Default.');
|
||||
} else {
|
||||
setInputDeviceId(hasInput || !snapshot.labelsAvailable ? configuredInputId : 'default');
|
||||
}
|
||||
|
||||
if (!hasOutput && configuredOutputId !== 'default' && snapshot.labelsAvailable) {
|
||||
setOutputDeviceId('default');
|
||||
await configManager.set('audio.output_device_id', 'default');
|
||||
setDeviceStatus('Previously selected audio output device is unavailable; using System Default.');
|
||||
} else {
|
||||
setOutputDeviceId(hasOutput || !snapshot.labelsAvailable ? configuredOutputId : 'default');
|
||||
}
|
||||
|
||||
if (showStatus) {
|
||||
setDeviceStatus(current => current || 'Audio device list refreshed.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unable to refresh audio devices:', error);
|
||||
setDeviceStatus('Unable to read audio devices from the browser.');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputDeviceChange = async (value: string) => {
|
||||
setInputDeviceId(value);
|
||||
await configManager.set('audio.input_device_id', value);
|
||||
setDeviceStatus(value === 'default'
|
||||
? 'Audio input will use System Default on the next recording session.'
|
||||
: 'Audio input will change on the next recording session.');
|
||||
};
|
||||
|
||||
const handleOutputDeviceChange = async (value: string) => {
|
||||
setOutputDeviceId(value);
|
||||
await configManager.set('audio.output_device_id', value);
|
||||
setDeviceStatus(value === 'default'
|
||||
? 'Audio output will use System Default after refresh.'
|
||||
: 'Audio output device saved. Refresh the page to apply it in v1.');
|
||||
};
|
||||
|
||||
const handleChooseOutputDevice = async () => {
|
||||
try {
|
||||
const selectedDevice = await promptForAudioOutputDevice();
|
||||
if (!selectedDevice) {
|
||||
setDeviceStatus('This browser does not support prompting for audio output devices.');
|
||||
return;
|
||||
}
|
||||
|
||||
setOutputDeviceId(selectedDevice.deviceId);
|
||||
await configManager.set('audio.output_device_id', selectedDevice.deviceId);
|
||||
await refreshDevices(false);
|
||||
setDeviceStatus('Output device selected. Refresh the page to apply it in v1.');
|
||||
} catch (error) {
|
||||
console.error('Unable to choose audio output device:', error);
|
||||
setDeviceStatus('The browser did not allow selecting a non-default output device.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Audio I/O</h3>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-content">
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-header">
|
||||
<h4>Device Routing</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn"
|
||||
onClick={() => void refreshDevices()}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh Device List'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-description">
|
||||
Choose the devices KGStudio should use for recording and playback. Input changes apply to the next recording session. Output changes require a page refresh in v1.
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="audio-input-device-select">
|
||||
Audio Input Device
|
||||
</label>
|
||||
<select
|
||||
id="audio-input-device-select"
|
||||
className="settings-select"
|
||||
value={inputDeviceId}
|
||||
onChange={(e) => void handleInputDeviceChange(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{inputs.map(device => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="audio-output-device-select">
|
||||
Audio Output Device
|
||||
</label>
|
||||
<select
|
||||
id="audio-output-device-select"
|
||||
className="settings-select"
|
||||
value={outputDeviceId}
|
||||
onChange={(e) => void handleOutputDeviceChange(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{outputs.map(device => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{supportsOutputPrompt && (
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<button type="button" className="settings-btn" onClick={() => void handleChooseOutputDevice()}>
|
||||
Choose Output Device…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Output-device changes require refresh in v1. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported.
|
||||
</div>
|
||||
{!supportsOutputSink && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#d0a56b', marginTop: '6px' }}>
|
||||
This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviceStatus && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#9bc17c', marginTop: '8px' }}>
|
||||
{deviceStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioIOSettings;
|
||||
@@ -97,6 +97,11 @@
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.track-region[data-preview-region='true'] {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track-region.audio-region .region-header {
|
||||
background-color: #4a8b5a;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ describe('RegionItem', () => {
|
||||
value: vi.fn(() => ({
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -107,4 +111,40 @@ describe('RegionItem', () => {
|
||||
expect(onDrag).toHaveBeenCalledWith('midi-1', 10, 0);
|
||||
expect(onDragEnd).toHaveBeenCalledWith('midi-1');
|
||||
});
|
||||
|
||||
it('renders preview waveform peaks on the canvas for recording previews', () => {
|
||||
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext');
|
||||
const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 120,
|
||||
bottom: 60,
|
||||
width: 120,
|
||||
height: 60,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const { container } = renderRegion({
|
||||
audioRegion: undefined,
|
||||
midiRegion: undefined,
|
||||
previewWaveformPeaks: [
|
||||
{ min: -0.25, max: 0.5 },
|
||||
{ min: -0.5, max: 0.25 },
|
||||
],
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
const context = getContextSpy.mock.results[0]?.value as {
|
||||
beginPath: ReturnType<typeof vi.fn>;
|
||||
lineTo: ReturnType<typeof vi.fn>;
|
||||
stroke: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
expect(container.querySelector('[data-preview-region="true"]')).toBeTruthy();
|
||||
expect(context.beginPath).toHaveBeenCalled();
|
||||
expect(context.lineTo).toHaveBeenCalled();
|
||||
expect(context.stroke).toHaveBeenCalled();
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
||||
|
||||
const DRAG_START_THRESHOLD_PX = 4;
|
||||
|
||||
@@ -42,6 +43,8 @@ interface RegionItemProps {
|
||||
// Audio region data for rendering waveform
|
||||
audioRegion?: KGAudioRegion;
|
||||
audioBuffer?: AudioBuffer;
|
||||
previewWaveformPeaks?: AudioRecordingPeak[];
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
const RegionItem: React.FC<RegionItemProps> = ({
|
||||
@@ -65,7 +68,9 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
onFineMoveEnd,
|
||||
midiRegion,
|
||||
audioRegion,
|
||||
audioBuffer
|
||||
audioBuffer,
|
||||
previewWaveformPeaks,
|
||||
isPreview = false,
|
||||
}) => {
|
||||
// Get selection state and time signature from store
|
||||
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||
@@ -303,6 +308,41 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
const renderPreviewWaveformOnCanvas = () => {
|
||||
if (!canvasRef.current || !regionContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const contentRect = regionContentRef.current.getBoundingClientRect();
|
||||
const width = contentRect.width;
|
||||
const height = contentRect.height;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const centerY = height / 2;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const peakIndex = Math.min(
|
||||
previewWaveformPeaks.length - 1,
|
||||
Math.floor((x / Math.max(1, width)) * previewWaveformPeaks.length)
|
||||
);
|
||||
const peak = previewWaveformPeaks[peakIndex];
|
||||
const yMin = centerY - peak.max * centerY;
|
||||
const yMax = centerY - peak.min * centerY;
|
||||
ctx.moveTo(x, yMin);
|
||||
ctx.lineTo(x, yMax);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
// Create a stable reference to track note changes
|
||||
const notesRef = useRef<string>('');
|
||||
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
|
||||
@@ -325,19 +365,23 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
|
||||
// Set up canvas when component mounts or updates
|
||||
useEffect(() => {
|
||||
if (audioRegion && audioBuffer) {
|
||||
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
||||
renderPreviewWaveformOnCanvas();
|
||||
} else if (audioRegion && audioBuffer) {
|
||||
renderWaveformOnCanvas();
|
||||
} else {
|
||||
renderNotesOnCanvas();
|
||||
}
|
||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
|
||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
|
||||
|
||||
// Re-render canvas when region content size changes
|
||||
useEffect(() => {
|
||||
if (!regionContentRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (audioRegion && audioBuffer) {
|
||||
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
||||
renderPreviewWaveformOnCanvas();
|
||||
} else if (audioRegion && audioBuffer) {
|
||||
renderWaveformOnCanvas();
|
||||
} else {
|
||||
renderNotesOnCanvas();
|
||||
@@ -351,12 +395,13 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
resizeObserver.unobserve(regionContentRef.current);
|
||||
}
|
||||
};
|
||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
|
||||
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]);
|
||||
|
||||
// Handle mouse movement to detect edge proximity
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Skip if already resizing or dragging
|
||||
if (isResizingRef.current || isDraggingRef.current) return;
|
||||
if (isPreview) return;
|
||||
|
||||
// Disable move and resize when pencil tool is active
|
||||
const activeTool = KGMainContentState.instance().getActiveTool();
|
||||
@@ -402,6 +447,10 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
// Handle mouse down for resize or drag
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Disable move and resize when pencil tool is active
|
||||
if (isPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTool = KGMainContentState.instance().getActiveTool();
|
||||
if (activeTool === 'pencil') {
|
||||
// Still allow click events to pass through for region selection
|
||||
@@ -604,11 +653,12 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
<div
|
||||
key={id}
|
||||
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? (isPrimarySelected ? 'selected' : 'selected-secondary') : ''} ${audioRegion ? 'audio-region' : ''}`}
|
||||
style={{ ...style, cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ ...style, cursor: isPreview ? 'default' : cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
|
||||
onMouseMove={isPreview ? undefined : handleMouseMove}
|
||||
onMouseLeave={isPreview ? undefined : handleMouseLeave}
|
||||
onMouseDown={isPreview ? undefined : handleMouseDown}
|
||||
data-region-id={id}
|
||||
data-preview-region={isPreview ? 'true' : 'false'}
|
||||
data-resize-edge={resizeEdge}
|
||||
data-is-resizing={isResizing}
|
||||
data-is-dragging={isDragging}
|
||||
@@ -617,7 +667,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
{name}
|
||||
</div>
|
||||
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
||||
<div className="region-left-buttons">
|
||||
{!isPreview && <div className="region-left-buttons">
|
||||
{!audioRegion && (
|
||||
<button
|
||||
className="region-pencil-btn"
|
||||
@@ -718,7 +768,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
||||
<span className="region-fine-move-label">{fineDeltaDisplay}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import TrackGridItem from './TrackGridItem';
|
||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: (selector?: (state: {
|
||||
selectedRegionIds: string[];
|
||||
activeTrackAutomationTrackId: string | null;
|
||||
activeTrackAutomationType: null;
|
||||
trackAutomationRedrawVersion: number;
|
||||
recordingMode: 'audio' | 'midi' | null;
|
||||
recordingTargetTrackIndex: number | null;
|
||||
recordingCommitStartBeatAbsolute: number;
|
||||
recordingAudioPreviewCurrentBeat: number;
|
||||
recordingAudioPreviewPeaks: Array<{ min: number; max: number }>;
|
||||
recordingAudioPreviewFileName: string | null;
|
||||
timeSignature: { numerator: number; denominator: number };
|
||||
}) => unknown) => {
|
||||
const state = {
|
||||
selectedRegionIds: [],
|
||||
activeTrackAutomationTrackId: null,
|
||||
activeTrackAutomationType: null,
|
||||
trackAutomationRedrawVersion: 0,
|
||||
recordingMode: 'audio' as const,
|
||||
recordingTargetTrackIndex: 0,
|
||||
recordingCommitStartBeatAbsolute: 4,
|
||||
recordingAudioPreviewCurrentBeat: 8,
|
||||
recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }],
|
||||
recordingAudioPreviewFileName: 'Recording',
|
||||
timeSignature: { numerator: 4, denominator: 4 },
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('TrackGridItem recording preview', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
value: vi.fn(() => ({
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
});
|
||||
|
||||
it('renders a non-interactive preview region on the recording audio track', () => {
|
||||
const track = new KGAudioTrack('Audio Track', 1);
|
||||
track.setTrackIndex(0);
|
||||
|
||||
const view = render(
|
||||
<TrackGridItem
|
||||
track={track}
|
||||
index={0}
|
||||
isDragging={false}
|
||||
isDragOver={false}
|
||||
regions={[]}
|
||||
maxBars={8}
|
||||
selectedRegionId={null}
|
||||
gridContainerRef={{ current: document.createElement('div') }}
|
||||
onDoubleClick={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const previewRegion = view.container.querySelector('[data-preview-region="true"]');
|
||||
expect(previewRegion).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,13 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
||||
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
||||
const recordingMode = useProjectStore(state => state.recordingMode);
|
||||
const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
|
||||
const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
|
||||
const recordingAudioPreviewCurrentBeat = useProjectStore(state => state.recordingAudioPreviewCurrentBeat);
|
||||
const recordingAudioPreviewPeaks = useProjectStore(state => state.recordingAudioPreviewPeaks);
|
||||
const recordingAudioPreviewFileName = useProjectStore(state => state.recordingAudioPreviewFileName);
|
||||
const storeTimeSignature = useProjectStore(state => state.timeSignature);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||
const [draggingRegion, setDraggingRegion] = useState<string | null>(null);
|
||||
@@ -543,6 +550,16 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
// Filter regions for this track
|
||||
const trackRegions = regions.filter(region => region.trackIndex === index);
|
||||
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
|
||||
const shouldRenderRecordingPreview = recordingMode === 'audio'
|
||||
&& recordingTargetTrackIndex === index
|
||||
&& recordingAudioPreviewCurrentBeat >= recordingCommitStartBeatAbsolute;
|
||||
const previewRegionStyle = shouldRenderRecordingPreview
|
||||
? {
|
||||
left: `${(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) * (containerWidth / maxBars)}px`,
|
||||
width: `${Math.max(0, ((recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator) * (containerWidth / maxBars))}px`,
|
||||
position: 'absolute' as const,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -626,12 +643,25 @@ const TrackGridItem: React.FC<TrackGridItemProps> = ({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{shouldRenderRecordingPreview && previewRegionStyle && (
|
||||
<RegionItem
|
||||
key="audio-recording-preview"
|
||||
id="audio-recording-preview"
|
||||
name={recordingAudioPreviewFileName ?? 'Recording'}
|
||||
style={previewRegionStyle}
|
||||
barNumber={(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) + 1}
|
||||
length={(recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator}
|
||||
trackIndex={index}
|
||||
previewWaveformPeaks={recordingAudioPreviewPeaks}
|
||||
isPreview
|
||||
/>
|
||||
)}
|
||||
{isAutomationActive && activeTrackAutomationType && (
|
||||
<TrackAutomationLane
|
||||
track={track}
|
||||
automationType={activeTrackAutomationType}
|
||||
maxBars={maxBars}
|
||||
timeSignature={useProjectStore.getState().timeSignature}
|
||||
timeSignature={storeTimeSignature}
|
||||
redrawVersion={trackAutomationRedrawVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,7 @@ export class KGCore {
|
||||
// Callback for external state updates (e.g., store)
|
||||
private playheadUpdateCallback: ((position: number) => void) | null = null;
|
||||
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
|
||||
private loopBoundaryReachedCallback: ((loopEndBeat: number) => void) | null = null;
|
||||
|
||||
// Selection change callbacks for store synchronization
|
||||
private selectionChangeCallbacks: (() => void)[] = [];
|
||||
@@ -200,6 +201,10 @@ export class KGCore {
|
||||
this.playbackStateChangeCallback = callback;
|
||||
}
|
||||
|
||||
public setLoopBoundaryReachedCallback(callback: ((loopEndBeat: number) => void) | null): void {
|
||||
this.loopBoundaryReachedCallback = callback;
|
||||
}
|
||||
|
||||
// Selection change callback management
|
||||
public onSelectionChanged(callback: () => void): void {
|
||||
this.selectionChangeCallbacks.push(callback);
|
||||
@@ -414,6 +419,14 @@ export class KGCore {
|
||||
|
||||
// Wrap playhead position within loop range
|
||||
if (newPosition >= loopEndBeats) {
|
||||
if (this.loopBoundaryReachedCallback) {
|
||||
const callback = this.loopBoundaryReachedCallback;
|
||||
this.loopBoundaryReachedCallback = null;
|
||||
this.setPlayheadPosition(loopEndBeats);
|
||||
callback(loopEndBeats);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how far we've overshot and wrap back
|
||||
const overshot = newPosition - loopEndBeats;
|
||||
newPosition = loopStartBeats + (overshot % loopLengthBeats);
|
||||
|
||||
@@ -24,6 +24,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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||
audio.preparePlayback(project, 5);
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
import * as Tone from 'tone';
|
||||
import { KGAudioBus } from './KGAudioBus';
|
||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||
import {
|
||||
KGAudioRecorder,
|
||||
type AudioRecordingPeak,
|
||||
type AudioRecordingResult,
|
||||
type AudioRecordingStartResult,
|
||||
} from './KGAudioRecorder';
|
||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
||||
import { KGCore } from '../KGCore';
|
||||
@@ -80,6 +86,9 @@ export class KGAudioInterface {
|
||||
private captureDestination: MediaStreamAudioDestinationNode | null = null;
|
||||
private captureStream: MediaStream | null = null;
|
||||
|
||||
// Microphone recorder
|
||||
private audioRecorder: KGAudioRecorder = new KGAudioRecorder();
|
||||
|
||||
// Private constructor to prevent direct instantiation
|
||||
private constructor() {
|
||||
console.log("KGAudioInterface initialized");
|
||||
@@ -194,6 +203,8 @@ export class KGAudioInterface {
|
||||
this.captureDestination = null;
|
||||
this.captureStream = null;
|
||||
}
|
||||
|
||||
await this.audioRecorder.cancel();
|
||||
|
||||
this.isInitialized = false;
|
||||
this.isAudioContextStarted = false;
|
||||
@@ -1071,6 +1082,45 @@ export class KGAudioInterface {
|
||||
}
|
||||
}
|
||||
|
||||
public async startAudioRecording(
|
||||
inputDeviceId: string = 'default',
|
||||
onPeaks?: (peaks: AudioRecordingPeak[]) => void
|
||||
): Promise<AudioRecordingStartResult> {
|
||||
return await this.audioRecorder.start(inputDeviceId, onPeaks);
|
||||
}
|
||||
|
||||
public async stopAudioRecording(): Promise<AudioRecordingResult | null> {
|
||||
return await this.audioRecorder.stop();
|
||||
}
|
||||
|
||||
public async cancelAudioRecording(): Promise<void> {
|
||||
await this.audioRecorder.cancel();
|
||||
}
|
||||
|
||||
public async applyConfiguredOutputDevice(outputDeviceId: string): Promise<boolean> {
|
||||
if (outputDeviceId === 'default') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawContext = Tone.getContext().rawContext as AudioContext & {
|
||||
setSinkId?: (sinkId: string) => Promise<void>;
|
||||
sinkId?: string;
|
||||
};
|
||||
|
||||
if (typeof rawContext.setSinkId !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await rawContext.setSinkId(outputDeviceId);
|
||||
console.log(`Applied audio output device ${outputDeviceId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`Unable to apply audio output device ${outputDeviceId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all scheduled events
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import * as Tone from 'tone';
|
||||
|
||||
export interface AudioRecordingPeak {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface AudioRecordingResult {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
durationSeconds: number;
|
||||
peaks: AudioRecordingPeak[];
|
||||
}
|
||||
|
||||
export interface AudioRecordingStartResult {
|
||||
usedDeviceId: string;
|
||||
fellBackToDefault: boolean;
|
||||
}
|
||||
|
||||
export class KGAudioRecorder {
|
||||
private static readonly DEBUG_LOG_PREFIX = '[KGAudioRecorder]';
|
||||
private static readonly PEAK_LOG_EVERY_FRAMES = 20;
|
||||
|
||||
private mediaStream: MediaStream | null = null;
|
||||
private mediaRecorder: MediaRecorder | null = null;
|
||||
private audioSource: MediaStreamAudioSourceNode | null = null;
|
||||
private analyserNode: AnalyserNode | null = null;
|
||||
private peakPollIntervalId: number | null = null;
|
||||
private chunks: Blob[] = [];
|
||||
private peaks: AudioRecordingPeak[] = [];
|
||||
private recordingStartedAtMs: number | null = null;
|
||||
private onPeaks: ((peaks: AudioRecordingPeak[]) => void) | null = null;
|
||||
private peakFrameCount: number = 0;
|
||||
|
||||
public async start(
|
||||
inputDeviceId: string = 'default',
|
||||
onPeaks?: (peaks: AudioRecordingPeak[]) => void
|
||||
): Promise<AudioRecordingStartResult> {
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
|
||||
throw new Error('Audio recording is already in progress.');
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('This browser does not support microphone recording.');
|
||||
}
|
||||
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
throw new Error('This browser does not support MediaRecorder.');
|
||||
}
|
||||
|
||||
this.cleanup(false);
|
||||
|
||||
this.onPeaks = onPeaks ?? null;
|
||||
this.chunks = [];
|
||||
this.peaks = [];
|
||||
this.peakFrameCount = 0;
|
||||
|
||||
let stream: MediaStream;
|
||||
let usedDeviceId = inputDeviceId;
|
||||
let fellBackToDefault = false;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: inputDeviceId === 'default'
|
||||
? true
|
||||
: { deviceId: { exact: inputDeviceId } },
|
||||
});
|
||||
} catch (error) {
|
||||
if (inputDeviceId === 'default') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn(`${KGAudioRecorder.DEBUG_LOG_PREFIX} selected input unavailable, retrying default input`, {
|
||||
inputDeviceId,
|
||||
error,
|
||||
});
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
usedDeviceId = 'default';
|
||||
fellBackToDefault = true;
|
||||
}
|
||||
|
||||
this.mediaStream = stream;
|
||||
this.logInputTracks(stream);
|
||||
|
||||
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||
this.audioSource = audioContext.createMediaStreamSource(stream);
|
||||
this.analyserNode = audioContext.createAnalyser();
|
||||
this.analyserNode.fftSize = 2048;
|
||||
this.audioSource.connect(this.analyserNode);
|
||||
|
||||
const mimeType = this.getPreferredMimeType();
|
||||
this.mediaRecorder = mimeType
|
||||
? new MediaRecorder(stream, { mimeType })
|
||||
: new MediaRecorder(stream);
|
||||
console.info(
|
||||
`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder created`,
|
||||
{
|
||||
mimeType: this.mediaRecorder.mimeType || mimeType || 'browser-default',
|
||||
audioContextState: audioContext.state,
|
||||
sampleRate: audioContext.sampleRate,
|
||||
}
|
||||
);
|
||||
this.mediaRecorder.ondataavailable = (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
console.info(
|
||||
`${KGAudioRecorder.DEBUG_LOG_PREFIX} dataavailable`,
|
||||
{ chunkBytes: event.data.size, totalChunks: this.chunks.length }
|
||||
);
|
||||
}
|
||||
};
|
||||
this.mediaRecorder.onerror = (event) => {
|
||||
console.error(`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder error`, event);
|
||||
};
|
||||
|
||||
this.mediaRecorder.start(250);
|
||||
this.recordingStartedAtMs = performance.now();
|
||||
console.info(`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording started`);
|
||||
this.startPeakPolling();
|
||||
return {
|
||||
usedDeviceId,
|
||||
fellBackToDefault,
|
||||
};
|
||||
}
|
||||
|
||||
public async stop(): Promise<AudioRecordingResult | null> {
|
||||
const recorder = this.mediaRecorder;
|
||||
if (!recorder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recorder.state === 'inactive') {
|
||||
const blob = this.chunks.length > 0
|
||||
? new Blob(this.chunks, { type: recorder.mimeType || 'audio/webm' })
|
||||
: null;
|
||||
const durationSeconds = this.recordingStartedAtMs === null
|
||||
? 0
|
||||
: Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
|
||||
this.cleanup(false);
|
||||
console.info(
|
||||
`${KGAudioRecorder.DEBUG_LOG_PREFIX} stop requested on inactive recorder`,
|
||||
{ blobBytes: blob?.size ?? 0, peakFrames: this.peaks.length, durationSeconds }
|
||||
);
|
||||
return blob
|
||||
? { blob, mimeType: blob.type || recorder.mimeType || 'audio/webm', durationSeconds, peaks: [...this.peaks] }
|
||||
: null;
|
||||
}
|
||||
|
||||
return await new Promise<AudioRecordingResult | null>((resolve) => {
|
||||
const handleStop = () => {
|
||||
recorder.removeEventListener('stop', handleStop);
|
||||
this.capturePeakFrame();
|
||||
const mimeType = recorder.mimeType || 'audio/webm';
|
||||
const blob = new Blob(this.chunks, { type: mimeType });
|
||||
const durationSeconds = this.recordingStartedAtMs === null
|
||||
? 0
|
||||
: Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
|
||||
const peaks = [...this.peaks];
|
||||
console.info(
|
||||
`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording stopped`,
|
||||
{
|
||||
mimeType,
|
||||
blobBytes: blob.size,
|
||||
peakFrames: peaks.length,
|
||||
durationSeconds,
|
||||
}
|
||||
);
|
||||
this.cleanup(false);
|
||||
resolve(blob.size > 0 ? { blob, mimeType: blob.type || mimeType, durationSeconds, peaks } : null);
|
||||
};
|
||||
|
||||
recorder.addEventListener('stop', handleStop, { once: true });
|
||||
recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
public async cancel(): Promise<void> {
|
||||
const recorder = this.mediaRecorder;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
await new Promise<void>((resolve) => {
|
||||
recorder.addEventListener('stop', () => resolve(), { once: true });
|
||||
recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
this.cleanup(false);
|
||||
}
|
||||
|
||||
private startPeakPolling(): void {
|
||||
this.stopPeakPolling();
|
||||
this.peakPollIntervalId = window.setInterval(() => {
|
||||
this.capturePeakFrame();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private stopPeakPolling(): void {
|
||||
if (this.peakPollIntervalId !== null) {
|
||||
window.clearInterval(this.peakPollIntervalId);
|
||||
this.peakPollIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private capturePeakFrame(): void {
|
||||
if (!this.analyserNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = new Float32Array(this.analyserNode.fftSize);
|
||||
this.analyserNode.getFloatTimeDomainData(buffer);
|
||||
|
||||
let min = 1;
|
||||
let max = -1;
|
||||
for (const sample of buffer) {
|
||||
if (sample < min) min = sample;
|
||||
if (sample > max) max = sample;
|
||||
}
|
||||
|
||||
this.peaks.push({ min, max });
|
||||
this.peakFrameCount += 1;
|
||||
|
||||
if (this.peakFrameCount === 1 || this.peakFrameCount % KGAudioRecorder.PEAK_LOG_EVERY_FRAMES === 0) {
|
||||
console.info(
|
||||
`${KGAudioRecorder.DEBUG_LOG_PREFIX} analyser peak`,
|
||||
{
|
||||
frame: this.peakFrameCount,
|
||||
min: Number(min.toFixed(4)),
|
||||
max: Number(max.toFixed(4)),
|
||||
span: Number((max - min).toFixed(4)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.onPeaks?.([...this.peaks]);
|
||||
}
|
||||
|
||||
private getPreferredMimeType(): string | undefined {
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
'audio/webm',
|
||||
];
|
||||
|
||||
return candidates.find(type => typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(type));
|
||||
}
|
||||
|
||||
private cleanup(resetPeaks: boolean): void {
|
||||
this.stopPeakPolling();
|
||||
|
||||
if (this.audioSource) {
|
||||
this.audioSource.disconnect();
|
||||
this.audioSource = null;
|
||||
}
|
||||
|
||||
if (this.analyserNode) {
|
||||
this.analyserNode.disconnect();
|
||||
this.analyserNode = null;
|
||||
}
|
||||
|
||||
if (this.mediaStream) {
|
||||
this.mediaStream.getTracks().forEach(track => track.stop());
|
||||
this.mediaStream = null;
|
||||
}
|
||||
|
||||
this.mediaRecorder = null;
|
||||
this.recordingStartedAtMs = null;
|
||||
this.onPeaks = null;
|
||||
this.peakFrameCount = 0;
|
||||
|
||||
if (resetPeaks) {
|
||||
this.peaks = [];
|
||||
this.chunks = [];
|
||||
}
|
||||
}
|
||||
|
||||
private logInputTracks(stream: MediaStream): void {
|
||||
const tracks = stream.getAudioTracks();
|
||||
console.info(
|
||||
`${KGAudioRecorder.DEBUG_LOG_PREFIX} acquired audio stream`,
|
||||
tracks.map(track => ({
|
||||
id: track.id,
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
muted: track.muted,
|
||||
readyState: track.readyState,
|
||||
settings: typeof track.getSettings === 'function' ? track.getSettings() : {},
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -78,8 +78,10 @@ interface AppConfig {
|
||||
};
|
||||
audio: {
|
||||
enable_audio_capture_for_screen_sharing: boolean;
|
||||
input_device_id: string;
|
||||
lookahead_time: number;
|
||||
midi_automation_interpolation_interval_ms: number;
|
||||
output_device_id: string;
|
||||
playback_delay: number;
|
||||
recording_offset: number;
|
||||
};
|
||||
@@ -253,8 +255,10 @@ export class ConfigManager {
|
||||
},
|
||||
audio: {
|
||||
enable_audio_capture_for_screen_sharing: false,
|
||||
input_device_id: 'default',
|
||||
lookahead_time: 0.05,
|
||||
midi_automation_interpolation_interval_ms: 10,
|
||||
output_device_id: 'default',
|
||||
playback_delay: 0.2,
|
||||
recording_offset: 0
|
||||
},
|
||||
|
||||
@@ -8,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;
|
||||
|
||||
@@ -7,8 +7,10 @@ import App from './App.tsx';
|
||||
import DialogProvider from './components/common/DialogProvider';
|
||||
import { KGCore } from './core/KGCore';
|
||||
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
|
||||
import { ConfigManager } from './core/config/ConfigManager';
|
||||
import { KGMidiInput } from './core/midi-input/KGMidiInput';
|
||||
import { KGDebugger } from './core/KGDebugger';
|
||||
import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
|
||||
@@ -58,6 +60,37 @@ if (!window.isSecureContext) {
|
||||
// Initialize KGCore instance
|
||||
await KGCore.instance().initialize();
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
try {
|
||||
const deviceSnapshot = await enumerateAudioDevices();
|
||||
const validatedDevices = validateConfiguredAudioDevices(
|
||||
configManager.get('audio.input_device_id') as string | undefined,
|
||||
configManager.get('audio.output_device_id') as string | undefined,
|
||||
deviceSnapshot
|
||||
);
|
||||
|
||||
const updates: Array<Promise<void>> = [];
|
||||
const statusMessages: string[] = [];
|
||||
|
||||
if (validatedDevices.inputFellBackToDefault) {
|
||||
updates.push(configManager.set('audio.input_device_id', 'default'));
|
||||
statusMessages.push('Previously selected audio input device is unavailable; using System Default.');
|
||||
}
|
||||
if (validatedDevices.outputFellBackToDefault) {
|
||||
updates.push(configManager.set('audio.output_device_id', 'default'));
|
||||
statusMessages.push('Previously selected audio output device is unavailable; using System Default.');
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await Promise.all(updates);
|
||||
KGCore.instance().setStatus(statusMessages.join(' '));
|
||||
}
|
||||
|
||||
await KGAudioInterface.instance().applyConfiguredOutputDevice(validatedDevices.outputDeviceId);
|
||||
} catch (error) {
|
||||
console.warn('Audio device validation skipped:', error);
|
||||
}
|
||||
|
||||
// Initialize KGMidiInput instance
|
||||
await KGMidiInput.instance().initialize();
|
||||
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { KGTrack } from '../core/track/KGTrack';
|
||||
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||
|
||||
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||
const mockProject = {
|
||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||
getMaxBars: () => 32,
|
||||
getBarWidthMultiplier: () => 1,
|
||||
getTracks: () => [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')],
|
||||
getTracks: () => mockTracks,
|
||||
getBpm: () => 120,
|
||||
getKeySignature: () => 'C major',
|
||||
getName: () => 'Test Project',
|
||||
getSelectedMode: () => 'major',
|
||||
getIsLooping: () => false,
|
||||
getLoopingRange: () => null,
|
||||
getLoopingRange: () => [0, 0] as [number, number],
|
||||
};
|
||||
|
||||
const mockAudioInterface = {
|
||||
getTransportPosition: vi.fn().mockReturnValue(8),
|
||||
startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }),
|
||||
stopAudioRecording: vi.fn().mockResolvedValue(null),
|
||||
cancelAudioRecording: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const configValues = new Map<string, unknown>([
|
||||
['audio.input_device_id', 'default'],
|
||||
]);
|
||||
|
||||
const mockCore = {
|
||||
getCurrentProject: () => mockProject,
|
||||
setPlayheadUpdateCallback: vi.fn(),
|
||||
setPlaybackStateChangeCallback: vi.fn(),
|
||||
setLoopBoundaryReachedCallback: vi.fn(),
|
||||
getSelectedItems: () => [],
|
||||
onSelectionChanged: vi.fn(),
|
||||
canUndo: () => false,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+373
-117
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
export type AudioDeviceKind = 'audioinput' | 'audiooutput';
|
||||
|
||||
export interface AudioDeviceOption {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
kind: AudioDeviceKind;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface AudioDeviceSnapshot {
|
||||
inputs: AudioDeviceOption[];
|
||||
outputs: AudioDeviceOption[];
|
||||
labelsAvailable: boolean;
|
||||
canSelectOutput: boolean;
|
||||
canWatchDeviceChanges: boolean;
|
||||
}
|
||||
|
||||
export interface AudioDeviceValidationResult {
|
||||
inputDeviceId: string;
|
||||
outputDeviceId: string;
|
||||
inputFellBackToDefault: boolean;
|
||||
outputFellBackToDefault: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_DEVICE_ID = 'default';
|
||||
const COMMUNICATIONS_DEVICE_ID = 'communications';
|
||||
|
||||
export function getDefaultAudioDeviceOption(kind: AudioDeviceKind): AudioDeviceOption {
|
||||
return {
|
||||
deviceId: DEFAULT_DEVICE_ID,
|
||||
label: 'System Default',
|
||||
kind,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function supportsDeviceEnumeration(): boolean {
|
||||
return typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.enumerateDevices === 'function';
|
||||
}
|
||||
|
||||
export function supportsDeviceChangeEvents(): boolean {
|
||||
return typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.addEventListener === 'function';
|
||||
}
|
||||
|
||||
export function supportsAudioOutputSelection(): boolean {
|
||||
return typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof (navigator.mediaDevices as MediaDevices & {
|
||||
selectAudioOutput?: () => Promise<MediaDeviceInfo>;
|
||||
}).selectAudioOutput === 'function';
|
||||
}
|
||||
|
||||
export function supportsAudioContextSinkSelection(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof (window.AudioContext?.prototype as AudioContext & {
|
||||
setSinkId?: (sinkId: string) => Promise<void>;
|
||||
} | undefined)?.setSinkId === 'function';
|
||||
}
|
||||
|
||||
export async function enumerateAudioDevices(): Promise<AudioDeviceSnapshot> {
|
||||
const fallback: AudioDeviceSnapshot = {
|
||||
inputs: [getDefaultAudioDeviceOption('audioinput')],
|
||||
outputs: [getDefaultAudioDeviceOption('audiooutput')],
|
||||
labelsAvailable: false,
|
||||
canSelectOutput: supportsAudioOutputSelection(),
|
||||
canWatchDeviceChanges: supportsDeviceChangeEvents(),
|
||||
};
|
||||
|
||||
if (!supportsDeviceEnumeration()) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const labelsAvailable = devices.some(device => device.label.trim().length > 0);
|
||||
|
||||
const inputs = normalizeAudioDevices(devices, 'audioinput');
|
||||
const outputs = normalizeAudioDevices(devices, 'audiooutput');
|
||||
|
||||
return {
|
||||
inputs,
|
||||
outputs,
|
||||
labelsAvailable,
|
||||
canSelectOutput: supportsAudioOutputSelection(),
|
||||
canWatchDeviceChanges: supportsDeviceChangeEvents(),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConfiguredAudioDevices(
|
||||
inputDeviceId: string | undefined,
|
||||
outputDeviceId: string | undefined,
|
||||
snapshot: AudioDeviceSnapshot
|
||||
): AudioDeviceValidationResult {
|
||||
const normalizedInputId = inputDeviceId ?? DEFAULT_DEVICE_ID;
|
||||
const normalizedOutputId = outputDeviceId ?? DEFAULT_DEVICE_ID;
|
||||
|
||||
const canValidateInput = snapshot.labelsAvailable || snapshot.inputs.some(device => device.deviceId === normalizedInputId);
|
||||
const canValidateOutput = snapshot.labelsAvailable || snapshot.outputs.some(device => device.deviceId === normalizedOutputId);
|
||||
|
||||
const validInput = normalizedInputId === DEFAULT_DEVICE_ID ||
|
||||
!canValidateInput ||
|
||||
snapshot.inputs.some(device => device.deviceId === normalizedInputId);
|
||||
const validOutput = normalizedOutputId === DEFAULT_DEVICE_ID ||
|
||||
!canValidateOutput ||
|
||||
snapshot.outputs.some(device => device.deviceId === normalizedOutputId);
|
||||
|
||||
return {
|
||||
inputDeviceId: validInput ? normalizedInputId : DEFAULT_DEVICE_ID,
|
||||
outputDeviceId: validOutput ? normalizedOutputId : DEFAULT_DEVICE_ID,
|
||||
inputFellBackToDefault: !validInput,
|
||||
outputFellBackToDefault: !validOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptForAudioOutputDevice(): Promise<AudioDeviceOption | null> {
|
||||
if (!supportsAudioOutputSelection()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = await (navigator.mediaDevices as MediaDevices & {
|
||||
selectAudioOutput: () => Promise<MediaDeviceInfo>;
|
||||
}).selectAudioOutput();
|
||||
return {
|
||||
deviceId: selected.deviceId,
|
||||
label: selected.label || 'Selected Output Device',
|
||||
kind: 'audiooutput',
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAudioDevices(
|
||||
devices: MediaDeviceInfo[],
|
||||
kind: AudioDeviceKind
|
||||
): AudioDeviceOption[] {
|
||||
const normalized: AudioDeviceOption[] = [getDefaultAudioDeviceOption(kind)];
|
||||
let unnamedCount = 1;
|
||||
|
||||
devices
|
||||
.filter(device => device.kind === kind)
|
||||
.forEach(device => {
|
||||
if (device.deviceId === DEFAULT_DEVICE_ID || device.deviceId === COMMUNICATIONS_DEVICE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalized.push({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `${kind === 'audioinput' ? 'Input' : 'Output'} Device ${unnamedCount++}`,
|
||||
kind,
|
||||
isDefault: false,
|
||||
});
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
Reference in New Issue
Block a user