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
|
## 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">
|
<div align="center">
|
||||||
<img src="./public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
<img src="./public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
||||||
</div>
|
</div>
|
||||||
@@ -314,16 +316,25 @@ Split an existing audio region into individual stems (e.g. vocals, instruments,
|
|||||||
|
|
||||||
Feature priorities might change.
|
Feature priorities might change.
|
||||||
|
|
||||||
|
### 1.0
|
||||||
|
|
||||||
- [X] More instruments
|
- [X] More instruments
|
||||||
- [X] Automated testing (unit tests, integration tests, etc.)
|
- [X] Automated testing (unit tests, integration tests, etc.)
|
||||||
- [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D)
|
- [X] Intelligent Chord Assistant with functional harmony guidance (T/S/D)
|
||||||
- [X] Support track control automations (e.g. sustain, volume, pan, etc.)
|
- [X] Support track control automations (e.g. sustain, volume, pan, etc.)
|
||||||
- [X] Support MIDI control events (e.g. CC, pitch bend, etc.)
|
- [X] Support MIDI control events (e.g. CC, pitch bend, etc.)
|
||||||
- [X] Support WAV audio tracks
|
- [X] Support WAV audio tracks
|
||||||
- [ ] Filters and effects
|
- [X] Recording
|
||||||
- [ ] MCP Support
|
- [X] Event List
|
||||||
- [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`)
|
- [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
|
## Help Needed
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,10 @@
|
|||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"enable_audio_capture_for_screen_sharing": false,
|
"enable_audio_capture_for_screen_sharing": false,
|
||||||
|
"input_device_id": "default",
|
||||||
"lookahead_time": 0.05,
|
"lookahead_time": 0.05,
|
||||||
"midi_automation_interpolation_interval_ms": 10,
|
"midi_automation_interpolation_interval_ms": 10,
|
||||||
|
"output_device_id": "default",
|
||||||
"playback_delay": 0.2,
|
"playback_delay": 0.2,
|
||||||
"recording_offset": 0
|
"recording_offset": 0
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ vi.mock('./components/MainContent', () => ({ default: () => null }));
|
|||||||
vi.mock('./components/InstrumentSelection', () => ({ default: () => null }));
|
vi.mock('./components/InstrumentSelection', () => ({ default: () => null }));
|
||||||
vi.mock('./components/ChatBox', () => ({ default: () => null }));
|
vi.mock('./components/ChatBox', () => ({ default: () => null }));
|
||||||
vi.mock('./components/KGOnePanel', () => ({ 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('./components/settings', () => ({ SettingsPanel: () => null }));
|
||||||
vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({
|
vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({
|
||||||
KGToneBuffersPool: {
|
KGToneBuffersPool: {
|
||||||
|
|||||||
+4
-4
@@ -11,7 +11,7 @@ import ChatBox from './components/ChatBox';
|
|||||||
import { SettingsPanel } from './components/settings';
|
import { SettingsPanel } from './components/settings';
|
||||||
import LoadingOverlay from './components/common/LoadingOverlay';
|
import LoadingOverlay from './components/common/LoadingOverlay';
|
||||||
import KGOnePanel from './components/KGOnePanel';
|
import KGOnePanel from './components/KGOnePanel';
|
||||||
import ListEventPanel from './components/ListEventPanel';
|
import EventListPanel from './components/EventListPanel';
|
||||||
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
import { useEffect as useEffectReact, useState, useRef } from 'react';
|
||||||
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
import { KGToneBuffersPool } from './core/audio-interface/KGToneBuffersPool';
|
||||||
import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer';
|
import { KGOfflineRenderer } from './core/audio-interface/KGOfflineRenderer';
|
||||||
@@ -31,7 +31,7 @@ function App() {
|
|||||||
const {
|
const {
|
||||||
refreshStatus,
|
refreshStatus,
|
||||||
loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig,
|
loadProject, showChatBox, showSettings, setShowSettings, initializeFromConfig,
|
||||||
showInstrumentSelection, showKGOnePanel, showListEventPanel
|
showInstrumentSelection, showKGOnePanel, showEventListPanel
|
||||||
} = useProjectStore();
|
} = useProjectStore();
|
||||||
|
|
||||||
// Track if app has been initialized to prevent multiple initializations
|
// Track if app has been initialized to prevent multiple initializations
|
||||||
@@ -169,7 +169,7 @@ function App() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<KGOnePanel isVisible={showKGOnePanel && !showSettings} />
|
<KGOnePanel isVisible={showKGOnePanel && !showSettings} />
|
||||||
<ListEventPanel isVisible={showListEventPanel && !showSettings} />
|
<EventListPanel isVisible={showEventListPanel && !showSettings} />
|
||||||
<ChatBox isVisible={showChatBox && !showSettings} />
|
<ChatBox isVisible={showChatBox && !showSettings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ const MigrationOverlayContainer: React.FC = () => {
|
|||||||
useEffectReact(() => {
|
useEffectReact(() => {
|
||||||
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
|
KGCore.instance().setMigrationStateChangeCallback(setIsMigrating);
|
||||||
return () => {
|
return () => {
|
||||||
KGCore.instance().setMigrationStateChangeCallback(() => {});
|
KGCore.instance().setMigrationStateChangeCallback(() => { });
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.list-event-panel {
|
.event-list-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: var(--chat-box-width);
|
width: var(--chat-box-width);
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-panel.is-hidden {
|
.event-list-panel.is-hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-panel-header {
|
.event-list-panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-panel-header h3 {
|
.event-list-panel-header h3 {
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-panel-body {
|
.event-list-panel-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -39,13 +39,42 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-tabs {
|
.event-list-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
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;
|
flex: 1;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
color: #999;
|
color: #999;
|
||||||
@@ -55,20 +84,20 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-tab:hover {
|
.event-list-tab:hover {
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-tab.active {
|
.event-list-tab.active {
|
||||||
background-color: #5a9fd4;
|
background-color: #5a9fd4;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-empty-state {
|
.event-list-empty-state {
|
||||||
color: #888;
|
color: #888;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -78,7 +107,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-toolbar {
|
.event-list-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -86,23 +115,23 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-toolbar-group {
|
.event-list-toolbar-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-toolbar-group:first-child {
|
.event-list-toolbar-group:first-child {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-toolbar-group-right {
|
.event-list-toolbar-group-right {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-add-button {
|
.event-list-add-button {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -124,34 +153,34 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-add-button:hover {
|
.event-list-add-button:hover {
|
||||||
background-color: #3b3b3b;
|
background-color: #3b3b3b;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-dropdown-button,
|
.event-list-dropdown-button,
|
||||||
.list-event-quant-button,
|
.event-list-quant-button,
|
||||||
.list-event-type-button {
|
.event-list-type-button {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-dropdown-button {
|
.event-list-dropdown-button {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-quant-button {
|
.event-list-quant-button {
|
||||||
min-width: 78px;
|
min-width: 78px;
|
||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-type-button {
|
.event-list-type-button {
|
||||||
min-width: 88px;
|
min-width: 88px;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-delete-button {
|
.event-list-delete-button {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -168,17 +197,17 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-delete-button:hover:not(:disabled) {
|
.event-list-delete-button:hover:not(:disabled) {
|
||||||
background-color: #464646;
|
background-color: #464646;
|
||||||
border-color: #5a5a5a;
|
border-color: #5a5a5a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-delete-button:disabled {
|
.event-list-delete-button:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table-shell {
|
.event-list-table-shell {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -187,13 +216,13 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table {
|
.event-list-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table thead th {
|
.event-list-table thead th {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -209,25 +238,25 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table tbody tr {
|
.event-list-table tbody tr {
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table tbody tr:nth-child(odd) {
|
.event-list-table tbody tr:nth-child(odd) {
|
||||||
background-color: #282828;
|
background-color: #282828;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table tbody tr:nth-child(even) {
|
.event-list-table tbody tr:nth-child(even) {
|
||||||
background-color: #303030;
|
background-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table tbody tr.selected {
|
.event-list-table tbody tr.selected {
|
||||||
background-color: #5a9fd4;
|
background-color: #5a9fd4;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-table td {
|
.event-list-table td {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 2px 12px;
|
padding: 2px 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -237,7 +266,7 @@
|
|||||||
max-width: 0;
|
max-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-event-cell-input {
|
.event-list-cell-input {
|
||||||
width: calc(100% + 8px);
|
width: calc(100% + 8px);
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin: 0 -4px;
|
margin: 0 -4px;
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+215
-207
@@ -17,6 +17,7 @@ import {
|
|||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
|
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles, FaListUl } from 'react-icons/fa6';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
@@ -48,12 +49,12 @@ const Toolbar: React.FC = () => {
|
|||||||
barWidthMultiplier, setBarWidthMultiplier,
|
barWidthMultiplier, setBarWidthMultiplier,
|
||||||
isLooping, toggleLoop,
|
isLooping, toggleLoop,
|
||||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
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,
|
isRecording, startRecording, stopRecording,
|
||||||
// Piano roll state/actions
|
// Piano roll state/actions
|
||||||
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
||||||
// Selection state
|
// Selection state
|
||||||
selectedRegionIds,
|
selectedRegionIds, selectedTrackId,
|
||||||
// Playhead and refresh
|
// Playhead and refresh
|
||||||
playheadPosition, refreshProjectState,
|
playheadPosition, refreshProjectState,
|
||||||
requestMainContentScroll, requestPianoRollScroll
|
requestMainContentScroll, requestPianoRollScroll
|
||||||
@@ -939,11 +940,11 @@ const Toolbar: React.FC = () => {
|
|||||||
toggleKGOnePanel();
|
toggleKGOnePanel();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleListEventClick = () => {
|
const handleEventListClick = () => {
|
||||||
if (DEBUG_MODE.TOOLBAR) {
|
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
|
// 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 () => {
|
const handleRecordClick = async () => {
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
await stopRecording();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,218 +1047,218 @@ const Toolbar: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toolbar-center">
|
<div className="toolbar-center">
|
||||||
<button title="New" onClick={handleNewProject}><FaPlus /></button>
|
<button title="New" onClick={handleNewProject}><FaPlus /></button>
|
||||||
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button>
|
<button title="Load" onClick={handleLoadProject}><FaFolderOpen /></button>
|
||||||
<button title="Save" onClick={handleSaveProject}><FaSave /></button>
|
<button title="Save" onClick={handleSaveProject}><FaSave /></button>
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<button
|
<button
|
||||||
title="Export"
|
title="Export"
|
||||||
onClick={() => setShowExportDropdown(!showExportDropdown)}
|
onClick={() => setShowExportDropdown(!showExportDropdown)}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
|
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' }}
|
|
||||||
>
|
>
|
||||||
{barWidthMultiplier}x
|
<FaDownload />
|
||||||
</span>
|
</button>
|
||||||
{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 }}>
|
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10000 }}>
|
||||||
<KGDropdown
|
<KGDropdown
|
||||||
options={keySignatureOptions}
|
options={exportOptions}
|
||||||
value={keySignature}
|
value={exportOptions[0]}
|
||||||
onChange={handleKeySignatureChange}
|
onChange={handleExportProject}
|
||||||
label="Key Signature"
|
label="Export"
|
||||||
hideButton={true}
|
hideButton={true}
|
||||||
isOpen={showKeySignatureDropdown}
|
isOpen={showExportDropdown}
|
||||||
onToggle={setShowKeySignatureDropdown}
|
onToggle={setShowExportDropdown}
|
||||||
className="key-signature-dropdown"
|
className="export-dropdown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<FileImportModal
|
<FileImportModal
|
||||||
isVisible={showImportModal}
|
isVisible={showImportModal}
|
||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
onFileImport={handleFileImport}
|
onFileImport={handleFileImport}
|
||||||
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']}
|
||||||
title="Import Project"
|
title="Import Project"
|
||||||
description="Drag and drop your project file here"
|
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
<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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import './ListEventPanel.css';
|
|
||||||
import { FaPlus, FaTrash } from 'react-icons/fa';
|
import { FaPlus, FaTrash } from 'react-icons/fa';
|
||||||
import KGDropdown from './common/KGDropdown';
|
import KGDropdown from '../common/KGDropdown';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
import { KGMidiControllerEvent } from '../core/midi/KGMidiControllerEvent';
|
import { KGMidiControllerEvent } from '../../core/midi/KGMidiControllerEvent';
|
||||||
import { KGMidiNote } from '../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
import { KGMidiPitchBend } from '../../core/midi/KGMidiPitchBend';
|
||||||
import { KGPianoRollState } from '../core/state/KGPianoRollState';
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
|
import { KGPianoRollState } from '../../core/state/KGPianoRollState';
|
||||||
import {
|
import {
|
||||||
clampMidiControllerValue,
|
clampMidiControllerValue,
|
||||||
clampMidiPitchBendValue,
|
clampMidiPitchBendValue,
|
||||||
@@ -27,17 +27,18 @@ import {
|
|||||||
parseMidiEventPosition,
|
parseMidiEventPosition,
|
||||||
pitchToNoteNameString,
|
pitchToNoteNameString,
|
||||||
signedPitchBendToMidiValue
|
signedPitchBendToMidiValue
|
||||||
} from '../util/midiUtil';
|
} from '../../util/midiUtil';
|
||||||
import { isModifierKeyPressed } from '../util/osUtil';
|
import { isModifierKeyPressed } from '../../util/osUtil';
|
||||||
import { PIANO_ROLL_CONSTANTS } from '../constants';
|
import { PIANO_ROLL_CONSTANTS } from '../../constants';
|
||||||
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../core/commands';
|
import { CreateMidiEventsCommand, CreateNoteCommand, DeleteMidiEventsCommand } from '../../core/commands';
|
||||||
import { UpdateControllerEventPropertiesCommand } from '../core/commands/note/UpdateControllerEventPropertiesCommand';
|
import { UpdateControllerEventPropertiesCommand } from '../../core/commands/note/UpdateControllerEventPropertiesCommand';
|
||||||
import { UpdateNotePropertiesCommand } from '../core/commands/note/UpdateNotePropertiesCommand';
|
import { UpdateNotePropertiesCommand } from '../../core/commands/note/UpdateNotePropertiesCommand';
|
||||||
import { UpdatePitchBendPropertiesCommand } from '../core/commands/note/UpdatePitchBendPropertiesCommand';
|
import { UpdatePitchBendPropertiesCommand } from '../../core/commands/note/UpdatePitchBendPropertiesCommand';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../../util/dialogUtil';
|
||||||
|
|
||||||
interface ListEventPanelProps {
|
interface RegionEventListTabProps {
|
||||||
isVisible: boolean;
|
activeMidiRegion: KGMidiRegion | null;
|
||||||
|
parentTrack: KGMidiTrack | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NoteRowData {
|
interface NoteRowData {
|
||||||
@@ -65,6 +66,7 @@ interface ControllerRowData {
|
|||||||
|
|
||||||
type EventRowData = NoteRowData | PitchBendRowData | ControllerRowData;
|
type EventRowData = NoteRowData | PitchBendRowData | ControllerRowData;
|
||||||
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
type EditableColumn = 'position' | 'num' | 'val' | 'length';
|
||||||
|
type AddEventType = 'note' | 'pitch-bend' | 'controller';
|
||||||
|
|
||||||
interface EditingCell {
|
interface EditingCell {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
@@ -72,8 +74,6 @@ interface EditingCell {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddEventType = 'note' | 'pitch-bend' | 'controller';
|
|
||||||
|
|
||||||
const ADD_EVENT_TYPE_OPTIONS = [
|
const ADD_EVENT_TYPE_OPTIONS = [
|
||||||
{ label: 'Note', value: 'note' },
|
{ label: 'Note', value: 'note' },
|
||||||
{ label: 'Pitch Bend', value: 'pitch-bend' },
|
{ label: 'Pitch Bend', value: 'pitch-bend' },
|
||||||
@@ -181,15 +181,14 @@ const parseControllerValueDeltaInput = (raw: string): { delta: number } | { erro
|
|||||||
return { delta: parseInt(trimmed, 10) };
|
return { delta: parseInt(trimmed, 10) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
const RegionEventListTab: React.FC<RegionEventListTabProps> = ({ activeMidiRegion, parentTrack }) => {
|
||||||
const {
|
const {
|
||||||
tracks,
|
|
||||||
activeRegionId,
|
|
||||||
selectedRegionIds,
|
|
||||||
timeSignature,
|
|
||||||
selectedNoteIds,
|
selectedNoteIds,
|
||||||
selectedPitchBendIds,
|
selectedPitchBendIds,
|
||||||
selectedControllerEventIds,
|
selectedControllerEventIds,
|
||||||
|
selectedRegionIds,
|
||||||
|
activeRegionId,
|
||||||
|
timeSignature,
|
||||||
playheadPosition,
|
playheadPosition,
|
||||||
updateTrack,
|
updateTrack,
|
||||||
refreshProjectState,
|
refreshProjectState,
|
||||||
@@ -208,32 +207,12 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const suppressBlurCommitRef = useRef(false);
|
const suppressBlurCommitRef = useRef(false);
|
||||||
const pendingSingleClickSelectionRef = useRef<number | null>(null);
|
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
|
const noteRows: NoteRowData[] = activeMidiRegion
|
||||||
? activeMidiRegion.getNotes().map(note => ({
|
? activeMidiRegion.getNotes().map(note => ({
|
||||||
id: note.getId(),
|
id: note.getId(),
|
||||||
type: 'note',
|
type: 'note',
|
||||||
note,
|
note,
|
||||||
absoluteStartBeat: activeMidiRegion!.getStartFromBeat() + note.getStartBeat(),
|
absoluteStartBeat: activeMidiRegion.getStartFromBeat() + note.getStartBeat(),
|
||||||
durationBeats: note.getEndBeat() - note.getStartBeat(),
|
durationBeats: note.getEndBeat() - note.getStartBeat(),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
@@ -243,7 +222,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
id: pitchBend.getId(),
|
id: pitchBend.getId(),
|
||||||
type: 'pitch-bend',
|
type: 'pitch-bend',
|
||||||
pitchBend,
|
pitchBend,
|
||||||
absoluteBeat: activeMidiRegion!.getStartFromBeat() + pitchBend.getBeat(),
|
absoluteBeat: activeMidiRegion.getStartFromBeat() + pitchBend.getBeat(),
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -253,7 +232,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
type: 'controller',
|
type: 'controller',
|
||||||
controller,
|
controller,
|
||||||
controllerEvent: event,
|
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());
|
commitSelection(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTableShellClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleEditInputKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
@@ -808,10 +783,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const denominator = parseInt(quantValue.split('/')[1], 10);
|
const denominator = parseInt(quantValue.split('/')[1], 10);
|
||||||
if (Number.isNaN(denominator)) return;
|
if (Number.isNaN(denominator)) return;
|
||||||
|
|
||||||
const selectedNotes = activeMidiRegion
|
const selectedNotes = activeMidiRegion.getNotes().filter(note => selectedNoteIdSet.has(note.getId()));
|
||||||
.getNotes()
|
|
||||||
.filter(note => selectedNoteIdSet.has(note.getId()));
|
|
||||||
|
|
||||||
if (selectedNotes.length === 0) return;
|
if (selectedNotes.length === 0) return;
|
||||||
|
|
||||||
const quantizationStep = 4 / denominator;
|
const quantizationStep = 4 / denominator;
|
||||||
@@ -833,10 +805,7 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
const denominator = parseInt(quantValue.split('/')[1], 10);
|
const denominator = parseInt(quantValue.split('/')[1], 10);
|
||||||
if (Number.isNaN(denominator)) return;
|
if (Number.isNaN(denominator)) return;
|
||||||
|
|
||||||
const selectedNotes = activeMidiRegion
|
const selectedNotes = activeMidiRegion.getNotes().filter(note => selectedNoteIdSet.has(note.getId()));
|
||||||
.getNotes()
|
|
||||||
.filter(note => selectedNoteIdSet.has(note.getId()));
|
|
||||||
|
|
||||||
if (selectedNotes.length === 0) return;
|
if (selectedNotes.length === 0) return;
|
||||||
|
|
||||||
const quantizationStep = 4 / denominator;
|
const quantizationStep = 4 / denominator;
|
||||||
@@ -889,6 +858,10 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
if (createdNote) {
|
if (createdNote) {
|
||||||
createdNote.select();
|
createdNote.select();
|
||||||
KGCore.instance().clearSelectedItems();
|
KGCore.instance().clearSelectedItems();
|
||||||
|
if (selectedRegionIds.includes(activeRegionId ?? '')) {
|
||||||
|
activeMidiRegion.select();
|
||||||
|
KGCore.instance().addSelectedItem(activeMidiRegion);
|
||||||
|
}
|
||||||
KGCore.instance().addSelectedItem(createdNote);
|
KGCore.instance().addSelectedItem(createdNote);
|
||||||
rangeAnchorEventIdRef.current = createdNote.getId();
|
rangeAnchorEventIdRef.current = createdNote.getId();
|
||||||
}
|
}
|
||||||
@@ -905,6 +878,10 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
if (createdPitchBend) {
|
if (createdPitchBend) {
|
||||||
createdPitchBend.select();
|
createdPitchBend.select();
|
||||||
KGCore.instance().clearSelectedItems();
|
KGCore.instance().clearSelectedItems();
|
||||||
|
if (selectedRegionIds.includes(activeRegionId ?? '')) {
|
||||||
|
activeMidiRegion.select();
|
||||||
|
KGCore.instance().addSelectedItem(activeMidiRegion);
|
||||||
|
}
|
||||||
KGCore.instance().addSelectedItem(createdPitchBend);
|
KGCore.instance().addSelectedItem(createdPitchBend);
|
||||||
rangeAnchorEventIdRef.current = createdPitchBend.getId();
|
rangeAnchorEventIdRef.current = createdPitchBend.getId();
|
||||||
}
|
}
|
||||||
@@ -926,6 +903,10 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
if (createdControllerEvent) {
|
if (createdControllerEvent) {
|
||||||
createdControllerEvent.select();
|
createdControllerEvent.select();
|
||||||
KGCore.instance().clearSelectedItems();
|
KGCore.instance().clearSelectedItems();
|
||||||
|
if (selectedRegionIds.includes(activeRegionId ?? '')) {
|
||||||
|
activeMidiRegion.select();
|
||||||
|
KGCore.instance().addSelectedItem(activeMidiRegion);
|
||||||
|
}
|
||||||
KGCore.instance().addSelectedItem(createdControllerEvent);
|
KGCore.instance().addSelectedItem(createdControllerEvent);
|
||||||
rangeAnchorEventIdRef.current = createdControllerEvent.getId();
|
rangeAnchorEventIdRef.current = createdControllerEvent.getId();
|
||||||
}
|
}
|
||||||
@@ -959,249 +940,196 @@ const ListEventPanel: React.FC<ListEventPanelProps> = ({ isVisible }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`list-event-panel${isVisible ? '' : ' is-hidden'}`}>
|
<>
|
||||||
<div className="list-event-panel-header">
|
<div className="event-list-tabs" role="tablist" aria-label="Region event filters">
|
||||||
<h3>List Event</h3>
|
<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>
|
||||||
|
|
||||||
<div className="list-event-panel-body">
|
{!activeMidiRegion ? (
|
||||||
<div className="list-event-tabs" role="tablist" aria-label="Event types">
|
<div className="event-list-empty-state">
|
||||||
<button
|
Please select a MIDI region, or open one in the Piano Roll, to view its event list.
|
||||||
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>
|
|
||||||
</div>
|
</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="event-list-toolbar-group event-list-toolbar-group-right">
|
||||||
<div className="list-event-empty-state">
|
<KGDropdown
|
||||||
Please select a MIDI region, or open one in the Piano Roll, to view its event list.
|
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>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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">
|
<div className="event-list-table-shell" onMouseDown={handleTableBackgroundMouseDown}>
|
||||||
<KGDropdown
|
<table className="event-list-table">
|
||||||
options={KGPianoRollState.QUANT_POS_OPTIONS}
|
<thead>
|
||||||
value={quantPosition}
|
<tr>
|
||||||
onChange={(value) => {
|
<th>Position</th>
|
||||||
setQuantPosition(value);
|
<th>Status</th>
|
||||||
quantizeSelectedNotes(value);
|
<th>Num</th>
|
||||||
}}
|
<th>Val</th>
|
||||||
label="Qua. Pos."
|
<th>Length/Info</th>
|
||||||
buttonClassName="list-event-quant-button"
|
</tr>
|
||||||
/>
|
</thead>
|
||||||
<KGDropdown
|
<tbody>
|
||||||
options={KGPianoRollState.QUANT_LEN_OPTIONS}
|
{eventRows.map((row, index) => {
|
||||||
value={quantLength}
|
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
|
||||||
onChange={(value) => {
|
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
||||||
setQuantLength(value);
|
const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
|
||||||
quantizeSelectedNoteLengths(value);
|
const numText = row.type === 'note'
|
||||||
}}
|
? pitchToNoteNameString(row.note.getPitch())
|
||||||
label="Qua. Len."
|
: row.type === 'controller'
|
||||||
buttonClassName="list-event-quant-button"
|
? String(row.controller)
|
||||||
/>
|
: '';
|
||||||
<button
|
const valText = row.type === 'note'
|
||||||
className="list-event-delete-button"
|
? String(row.note.getVelocity())
|
||||||
title="Delete visible selected rows"
|
: row.type === 'pitch-bend'
|
||||||
type="button"
|
? String(midiPitchBendToSignedValue(row.pitchBend.getValue()))
|
||||||
onClick={handleDeleteSelectedRows}
|
: String(row.controllerEvent.getValue());
|
||||||
disabled={visibleSelectedRows.length === 0}
|
const lengthText = row.type === 'note'
|
||||||
>
|
? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
|
||||||
<FaTrash />
|
: row.type === 'pitch-bend'
|
||||||
</button>
|
? formatPitchBendInfo(row.pitchBend.getValue())
|
||||||
</div>
|
: `Raw ${row.controllerEvent.getValue()}`;
|
||||||
</div>
|
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
|
return (
|
||||||
className="list-event-table-shell"
|
<tr
|
||||||
onMouseDown={handleTableBackgroundMouseDown}
|
key={row.id}
|
||||||
onClick={handleTableShellClick}
|
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
|
||||||
onDoubleClick={handleTableShellClick}
|
onClick={(event) => handleRowClick(row.id, index, event)}
|
||||||
>
|
onDoubleClick={(event) => {
|
||||||
<table className="list-event-table">
|
event.stopPropagation();
|
||||||
<thead>
|
clearPendingSingleClickSelection();
|
||||||
<tr>
|
}}
|
||||||
<th>Position</th>
|
>
|
||||||
<th>Status</th>
|
<td title={positionText} onDoubleClick={(event) => { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
|
||||||
<th>Num</th>
|
{isEditingPosition ? (
|
||||||
<th>Val</th>
|
<input
|
||||||
<th>Length/Info</th>
|
ref={editInputRef}
|
||||||
</tr>
|
className="event-list-cell-input"
|
||||||
</thead>
|
value={editingCell.value}
|
||||||
<tbody>
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
{eventRows.map((row, index) => {
|
onBlur={handleEditInputBlur}
|
||||||
const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
|
onClick={(event) => event.stopPropagation()}
|
||||||
const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
const numText = row.type === 'note'
|
/>
|
||||||
? pitchToNoteNameString(row.note.getPitch())
|
) : positionText}
|
||||||
: row.type === 'controller'
|
</td>
|
||||||
? String(row.controller)
|
<td title={statusText}>{statusText}</td>
|
||||||
: '';
|
<td title={numText} onDoubleClick={(event) => {
|
||||||
const valText = row.type === 'note'
|
if (row.type === 'pitch-bend') return;
|
||||||
? String(row.note.getVelocity())
|
event.stopPropagation();
|
||||||
: row.type === 'pitch-bend'
|
startEditingCell(row.id, 'num', numText);
|
||||||
? String(midiPitchBendToSignedValue(row.pitchBend.getValue()))
|
}}>
|
||||||
: String(row.controllerEvent.getValue());
|
{isEditingNum ? (
|
||||||
const lengthText = row.type === 'note'
|
<input
|
||||||
? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
|
ref={editInputRef}
|
||||||
: row.type === 'pitch-bend'
|
className="event-list-cell-input"
|
||||||
? formatPitchBendInfo(row.pitchBend.getValue())
|
value={editingCell.value}
|
||||||
: `Raw ${row.controllerEvent.getValue()}`;
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
|
onBlur={handleEditInputBlur}
|
||||||
const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
|
onClick={(event) => event.stopPropagation()}
|
||||||
const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length';
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
|
/>
|
||||||
return (
|
) : numText}
|
||||||
<tr
|
</td>
|
||||||
key={row.id}
|
<td title={valText} onDoubleClick={(event) => {
|
||||||
className={selectedEventIdSet.has(row.id) ? 'selected' : ''}
|
event.stopPropagation();
|
||||||
onClick={(event) => handleRowClick(row.id, index, event)}
|
startEditingCell(row.id, 'val', valText);
|
||||||
onDoubleClick={(event) => {
|
}}>
|
||||||
event.stopPropagation();
|
{isEditingVal ? (
|
||||||
clearPendingSingleClickSelection();
|
<input
|
||||||
}}
|
ref={editInputRef}
|
||||||
>
|
className="event-list-cell-input"
|
||||||
<td
|
value={editingCell.value}
|
||||||
title={positionText}
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
onDoubleClick={(event) => {
|
onBlur={handleEditInputBlur}
|
||||||
event.stopPropagation();
|
onClick={(event) => event.stopPropagation()}
|
||||||
startEditingCell(row.id, 'position', positionText);
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
}}
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
>
|
/>
|
||||||
{isEditingPosition ? (
|
) : valText}
|
||||||
<input
|
</td>
|
||||||
ref={editInputRef}
|
<td title={lengthText} onDoubleClick={(event) => {
|
||||||
className="list-event-cell-input"
|
if (row.type !== 'note') return;
|
||||||
value={editingCell.value}
|
event.stopPropagation();
|
||||||
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
startEditingCell(row.id, 'length', lengthText);
|
||||||
onBlur={handleEditInputBlur}
|
}}>
|
||||||
onClick={(event) => event.stopPropagation()}
|
{isEditingLength ? (
|
||||||
onDoubleClick={(event) => event.stopPropagation()}
|
<input
|
||||||
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
ref={editInputRef}
|
||||||
/>
|
className="event-list-cell-input"
|
||||||
) : positionText}
|
value={editingCell.value}
|
||||||
</td>
|
onChange={(event) => setEditingCell({ ...editingCell, value: event.target.value })}
|
||||||
<td title={statusText}>{statusText}</td>
|
onBlur={handleEditInputBlur}
|
||||||
<td
|
onClick={(event) => event.stopPropagation()}
|
||||||
title={numText}
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
onDoubleClick={(event) => {
|
onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
|
||||||
if (row.type === 'pitch-bend') return;
|
/>
|
||||||
event.stopPropagation();
|
) : lengthText}
|
||||||
startEditingCell(row.id, 'num', numText);
|
</td>
|
||||||
}}
|
</tr>
|
||||||
>
|
);
|
||||||
{isEditingNum ? (
|
})}
|
||||||
<input
|
</tbody>
|
||||||
ref={editInputRef}
|
</table>
|
||||||
className="list-event-cell-input"
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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,7 +47,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const isSpectrogram = mode === 'spectrogram';
|
const isSpectrogram = mode === 'spectrogram';
|
||||||
const isHybrid = mode === 'hybrid';
|
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
|
// Tool state for piano roll
|
||||||
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
|
||||||
@@ -142,7 +142,7 @@ const PianoRoll: React.FC<PianoRollProps> = ({
|
|||||||
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
|
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
|
||||||
|
|
||||||
let availableWidth = window.innerWidth;
|
let availableWidth = window.innerWidth;
|
||||||
if (showChatBox || showKGOnePanel || showListEventPanel) availableWidth -= chatBoxWidth;
|
if (showChatBox || showKGOnePanel || showEventListPanel) availableWidth -= chatBoxWidth;
|
||||||
if (showInstrumentSelection) availableWidth -= instrumentPanelWidth;
|
if (showInstrumentSelection) availableWidth -= instrumentPanelWidth;
|
||||||
|
|
||||||
// Ensure a sensible minimum starting width
|
// Ensure a sensible minimum starting width
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import './Settings.css';
|
|||||||
import SettingsSidebar from './SettingsSidebar';
|
import SettingsSidebar from './SettingsSidebar';
|
||||||
import GeneralSettings from './sections/GeneralSettings';
|
import GeneralSettings from './sections/GeneralSettings';
|
||||||
import BehaviorSettings from './sections/BehaviorSettings';
|
import BehaviorSettings from './sections/BehaviorSettings';
|
||||||
|
import AudioIOSettings from './sections/AudioIOSettings';
|
||||||
import TemplatesSettings from './sections/TemplatesSettings';
|
import TemplatesSettings from './sections/TemplatesSettings';
|
||||||
import ChordGuideSettings from './sections/ChordGuideSettings';
|
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 {
|
interface SettingsPanelProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -21,6 +22,8 @@ const SettingsPanel: React.FC<SettingsPanelProps> = ({ onClose }) => {
|
|||||||
return <GeneralSettings />;
|
return <GeneralSettings />;
|
||||||
case 'behavior':
|
case 'behavior':
|
||||||
return <BehaviorSettings />;
|
return <BehaviorSettings />;
|
||||||
|
case 'audio_io':
|
||||||
|
return <AudioIOSettings />;
|
||||||
case 'templates':
|
case 'templates':
|
||||||
return <TemplatesSettings />;
|
return <TemplatesSettings />;
|
||||||
case 'chord_guide':
|
case 'chord_guide':
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const SettingsSidebar: React.FC<SettingsSidebarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const sections = [
|
const sections = [
|
||||||
{ id: 'general' as SettingsSection, label: 'General' },
|
{ id: 'general' as SettingsSection, label: 'General' },
|
||||||
|
{ id: 'audio_io' as SettingsSection, label: 'Audio I/O' },
|
||||||
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
|
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
|
||||||
{ id: 'templates' as SettingsSection, label: 'Templates' },
|
{ id: 'templates' as SettingsSection, label: 'Templates' },
|
||||||
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
|
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as SettingsPanel } from './SettingsPanel.tsx';
|
export { default as SettingsPanel } from './SettingsPanel.tsx';
|
||||||
export { default as SettingsSidebar } from './SettingsSidebar.tsx';
|
export { default as SettingsSidebar } from './SettingsSidebar.tsx';
|
||||||
export { default as GeneralSettings } from './sections/GeneralSettings.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 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);
|
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 {
|
.track-region.audio-region .region-header {
|
||||||
background-color: #4a8b5a;
|
background-color: #4a8b5a;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ describe('RegionItem', () => {
|
|||||||
value: vi.fn(() => ({
|
value: vi.fn(() => ({
|
||||||
clearRect: vi.fn(),
|
clearRect: vi.fn(),
|
||||||
fillRect: 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(onDrag).toHaveBeenCalledWith('midi-1', 10, 0);
|
||||||
expect(onDragEnd).toHaveBeenCalledWith('midi-1');
|
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 { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import { useProjectStore } from '../../stores/projectStore';
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
import { KGMainContentState } from '../../core/state/KGMainContentState';
|
||||||
|
import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
|
||||||
|
|
||||||
const DRAG_START_THRESHOLD_PX = 4;
|
const DRAG_START_THRESHOLD_PX = 4;
|
||||||
|
|
||||||
@@ -42,6 +43,8 @@ interface RegionItemProps {
|
|||||||
// Audio region data for rendering waveform
|
// Audio region data for rendering waveform
|
||||||
audioRegion?: KGAudioRegion;
|
audioRegion?: KGAudioRegion;
|
||||||
audioBuffer?: AudioBuffer;
|
audioBuffer?: AudioBuffer;
|
||||||
|
previewWaveformPeaks?: AudioRecordingPeak[];
|
||||||
|
isPreview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegionItem: React.FC<RegionItemProps> = ({
|
const RegionItem: React.FC<RegionItemProps> = ({
|
||||||
@@ -65,7 +68,9 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
onFineMoveEnd,
|
onFineMoveEnd,
|
||||||
midiRegion,
|
midiRegion,
|
||||||
audioRegion,
|
audioRegion,
|
||||||
audioBuffer
|
audioBuffer,
|
||||||
|
previewWaveformPeaks,
|
||||||
|
isPreview = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Get selection state and time signature from store
|
// Get selection state and time signature from store
|
||||||
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
|
||||||
@@ -303,6 +308,41 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
ctx.stroke();
|
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
|
// Create a stable reference to track note changes
|
||||||
const notesRef = useRef<string>('');
|
const notesRef = useRef<string>('');
|
||||||
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
|
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
|
||||||
@@ -325,19 +365,23 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
|
|
||||||
// Set up canvas when component mounts or updates
|
// Set up canvas when component mounts or updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRegion && audioBuffer) {
|
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
||||||
|
renderPreviewWaveformOnCanvas();
|
||||||
|
} else if (audioRegion && audioBuffer) {
|
||||||
renderWaveformOnCanvas();
|
renderWaveformOnCanvas();
|
||||||
} else {
|
} else {
|
||||||
renderNotesOnCanvas();
|
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
|
// Re-render canvas when region content size changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!regionContentRef.current) return;
|
if (!regionContentRef.current) return;
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (audioRegion && audioBuffer) {
|
if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
|
||||||
|
renderPreviewWaveformOnCanvas();
|
||||||
|
} else if (audioRegion && audioBuffer) {
|
||||||
renderWaveformOnCanvas();
|
renderWaveformOnCanvas();
|
||||||
} else {
|
} else {
|
||||||
renderNotesOnCanvas();
|
renderNotesOnCanvas();
|
||||||
@@ -351,12 +395,13 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
resizeObserver.unobserve(regionContentRef.current);
|
resizeObserver.unobserve(regionContentRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
|
}, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]);
|
||||||
|
|
||||||
// Handle mouse movement to detect edge proximity
|
// Handle mouse movement to detect edge proximity
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Skip if already resizing or dragging
|
// Skip if already resizing or dragging
|
||||||
if (isResizingRef.current || isDraggingRef.current) return;
|
if (isResizingRef.current || isDraggingRef.current) return;
|
||||||
|
if (isPreview) return;
|
||||||
|
|
||||||
// Disable move and resize when pencil tool is active
|
// Disable move and resize when pencil tool is active
|
||||||
const activeTool = KGMainContentState.instance().getActiveTool();
|
const activeTool = KGMainContentState.instance().getActiveTool();
|
||||||
@@ -402,6 +447,10 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
// Handle mouse down for resize or drag
|
// Handle mouse down for resize or drag
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Disable move and resize when pencil tool is active
|
// Disable move and resize when pencil tool is active
|
||||||
|
if (isPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeTool = KGMainContentState.instance().getActiveTool();
|
const activeTool = KGMainContentState.instance().getActiveTool();
|
||||||
if (activeTool === 'pencil') {
|
if (activeTool === 'pencil') {
|
||||||
// Still allow click events to pass through for region selection
|
// Still allow click events to pass through for region selection
|
||||||
@@ -604,11 +653,12 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? (isPrimarySelected ? 'selected' : 'selected-secondary') : ''} ${audioRegion ? 'audio-region' : ''}`}
|
className={`track-region ${isDragging ? 'dragging' : ''} ${isSelected ? (isPrimarySelected ? 'selected' : 'selected-secondary') : ''} ${audioRegion ? 'audio-region' : ''}`}
|
||||||
style={{ ...style, cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
|
style={{ ...style, cursor: isPreview ? 'default' : cursor, ...(isFineDragging ? { transform: `translateX(${fineTranslateX}px)`, zIndex: 100 } : {}) }}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={isPreview ? undefined : handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={isPreview ? undefined : handleMouseLeave}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={isPreview ? undefined : handleMouseDown}
|
||||||
data-region-id={id}
|
data-region-id={id}
|
||||||
|
data-preview-region={isPreview ? 'true' : 'false'}
|
||||||
data-resize-edge={resizeEdge}
|
data-resize-edge={resizeEdge}
|
||||||
data-is-resizing={isResizing}
|
data-is-resizing={isResizing}
|
||||||
data-is-dragging={isDragging}
|
data-is-dragging={isDragging}
|
||||||
@@ -617,7 +667,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
<div className={`region-content${audioRegion ? ' audio-region-content' : ''}`} ref={regionContentRef}>
|
||||||
<div className="region-left-buttons">
|
{!isPreview && <div className="region-left-buttons">
|
||||||
{!audioRegion && (
|
{!audioRegion && (
|
||||||
<button
|
<button
|
||||||
className="region-pencil-btn"
|
className="region-pencil-btn"
|
||||||
@@ -718,7 +768,7 @@ const RegionItem: React.FC<RegionItemProps> = ({
|
|||||||
<span className="region-fine-move-label">{fineDeltaDisplay}</span>
|
<span className="region-fine-move-label">{fineDeltaDisplay}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
<canvas ref={canvasRef} />
|
<canvas ref={canvasRef} />
|
||||||
</div>
|
</div>
|
||||||
</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 activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
|
||||||
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
|
||||||
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
|
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 [containerWidth, setContainerWidth] = useState(0);
|
||||||
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
const [resizingRegion, setResizingRegion] = useState<string | null>(null);
|
||||||
const [draggingRegion, setDraggingRegion] = 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
|
// Filter regions for this track
|
||||||
const trackRegions = regions.filter(region => region.trackIndex === index);
|
const trackRegions = regions.filter(region => region.trackIndex === index);
|
||||||
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
|
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 (
|
return (
|
||||||
<div
|
<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 && (
|
{isAutomationActive && activeTrackAutomationType && (
|
||||||
<TrackAutomationLane
|
<TrackAutomationLane
|
||||||
track={track}
|
track={track}
|
||||||
automationType={activeTrackAutomationType}
|
automationType={activeTrackAutomationType}
|
||||||
maxBars={maxBars}
|
maxBars={maxBars}
|
||||||
timeSignature={useProjectStore.getState().timeSignature}
|
timeSignature={storeTimeSignature}
|
||||||
redrawVersion={trackAutomationRedrawVersion}
|
redrawVersion={trackAutomationRedrawVersion}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export class KGCore {
|
|||||||
// Callback for external state updates (e.g., store)
|
// Callback for external state updates (e.g., store)
|
||||||
private playheadUpdateCallback: ((position: number) => void) | null = null;
|
private playheadUpdateCallback: ((position: number) => void) | null = null;
|
||||||
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
|
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
|
||||||
|
private loopBoundaryReachedCallback: ((loopEndBeat: number) => void) | null = null;
|
||||||
|
|
||||||
// Selection change callbacks for store synchronization
|
// Selection change callbacks for store synchronization
|
||||||
private selectionChangeCallbacks: (() => void)[] = [];
|
private selectionChangeCallbacks: (() => void)[] = [];
|
||||||
@@ -200,6 +201,10 @@ export class KGCore {
|
|||||||
this.playbackStateChangeCallback = callback;
|
this.playbackStateChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public setLoopBoundaryReachedCallback(callback: ((loopEndBeat: number) => void) | null): void {
|
||||||
|
this.loopBoundaryReachedCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
// Selection change callback management
|
// Selection change callback management
|
||||||
public onSelectionChanged(callback: () => void): void {
|
public onSelectionChanged(callback: () => void): void {
|
||||||
this.selectionChangeCallbacks.push(callback);
|
this.selectionChangeCallbacks.push(callback);
|
||||||
@@ -414,6 +419,14 @@ export class KGCore {
|
|||||||
|
|
||||||
// Wrap playhead position within loop range
|
// Wrap playhead position within loop range
|
||||||
if (newPosition >= loopEndBeats) {
|
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
|
// Calculate how far we've overshot and wrap back
|
||||||
const overshot = newPosition - loopEndBeats;
|
const overshot = newPosition - loopEndBeats;
|
||||||
newPosition = loopStartBeats + (overshot % loopLengthBeats);
|
newPosition = loopStartBeats + (overshot % loopLengthBeats);
|
||||||
|
|||||||
@@ -24,6 +24,23 @@ import { ConfigManager } from '../config/ConfigManager';
|
|||||||
import { KGAudioInterface } from './KGAudioInterface';
|
import { KGAudioInterface } from './KGAudioInterface';
|
||||||
import { MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil';
|
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', () => {
|
describe('KGAudioInterface preroll playback', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -125,14 +142,7 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
const project = createMockProject({ tracks: [track] });
|
const project = createMockProject({ tracks: [track] });
|
||||||
const audio = KGAudioInterface.instance();
|
const audio = KGAudioInterface.instance();
|
||||||
const audioBus = {
|
const audioBus = createMockAudioBus();
|
||||||
resetLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiExpression: vi.fn(),
|
|
||||||
setLiveMidiSustain: vi.fn(),
|
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 0);
|
audio.preparePlayback(project, 0);
|
||||||
@@ -152,14 +162,7 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
const project = createMockProject({ tracks: [track] });
|
const project = createMockProject({ tracks: [track] });
|
||||||
const audio = KGAudioInterface.instance();
|
const audio = KGAudioInterface.instance();
|
||||||
const audioBus = {
|
const audioBus = createMockAudioBus();
|
||||||
resetLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiExpression: vi.fn(),
|
|
||||||
setLiveMidiSustain: vi.fn(),
|
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 0);
|
audio.preparePlayback(project, 0);
|
||||||
@@ -178,14 +181,7 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
const track = createMockMidiTrack({ id: 1, regions: [region] });
|
||||||
const project = createMockProject({ tracks: [track] });
|
const project = createMockProject({ tracks: [track] });
|
||||||
const audio = KGAudioInterface.instance();
|
const audio = KGAudioInterface.instance();
|
||||||
const audioBus = {
|
const audioBus = createMockAudioBus();
|
||||||
resetLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiExpression: vi.fn(),
|
|
||||||
setLiveMidiSustain: vi.fn(),
|
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 2);
|
audio.preparePlayback(project, 2);
|
||||||
@@ -206,14 +202,7 @@ describe('KGAudioInterface preroll playback', () => {
|
|||||||
project.setLoopingRange([1, 1]);
|
project.setLoopingRange([1, 1]);
|
||||||
|
|
||||||
const audio = KGAudioInterface.instance();
|
const audio = KGAudioInterface.instance();
|
||||||
const audioBus = {
|
const audioBus = createMockAudioBus();
|
||||||
resetLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiPitchBend: vi.fn(),
|
|
||||||
scheduleLiveMidiPitchBend: vi.fn(),
|
|
||||||
setLiveMidiExpression: vi.fn(),
|
|
||||||
setLiveMidiSustain: vi.fn(),
|
|
||||||
shouldPlayWithSolo: vi.fn().mockReturnValue(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
;(audio as unknown as { trackAudioBuses: Map<string, unknown> }).trackAudioBuses.set('1', audioBus);
|
||||||
audio.preparePlayback(project, 5);
|
audio.preparePlayback(project, 5);
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ import {
|
|||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { KGAudioBus } from './KGAudioBus';
|
import { KGAudioBus } from './KGAudioBus';
|
||||||
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
|
||||||
|
import {
|
||||||
|
KGAudioRecorder,
|
||||||
|
type AudioRecordingPeak,
|
||||||
|
type AudioRecordingResult,
|
||||||
|
type AudioRecordingStartResult,
|
||||||
|
} from './KGAudioRecorder';
|
||||||
import type { InstrumentType } from '../track/KGMidiTrack';
|
import type { InstrumentType } from '../track/KGMidiTrack';
|
||||||
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
import type { KGAudioRegion } from '../region/KGAudioRegion';
|
||||||
import { KGCore } from '../KGCore';
|
import { KGCore } from '../KGCore';
|
||||||
@@ -80,6 +86,9 @@ export class KGAudioInterface {
|
|||||||
private captureDestination: MediaStreamAudioDestinationNode | null = null;
|
private captureDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
private captureStream: MediaStream | null = null;
|
private captureStream: MediaStream | null = null;
|
||||||
|
|
||||||
|
// Microphone recorder
|
||||||
|
private audioRecorder: KGAudioRecorder = new KGAudioRecorder();
|
||||||
|
|
||||||
// Private constructor to prevent direct instantiation
|
// Private constructor to prevent direct instantiation
|
||||||
private constructor() {
|
private constructor() {
|
||||||
console.log("KGAudioInterface initialized");
|
console.log("KGAudioInterface initialized");
|
||||||
@@ -195,6 +204,8 @@ export class KGAudioInterface {
|
|||||||
this.captureStream = null;
|
this.captureStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.audioRecorder.cancel();
|
||||||
|
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.isAudioContextStarted = 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
|
* 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: {
|
audio: {
|
||||||
enable_audio_capture_for_screen_sharing: boolean;
|
enable_audio_capture_for_screen_sharing: boolean;
|
||||||
|
input_device_id: string;
|
||||||
lookahead_time: number;
|
lookahead_time: number;
|
||||||
midi_automation_interpolation_interval_ms: number;
|
midi_automation_interpolation_interval_ms: number;
|
||||||
|
output_device_id: string;
|
||||||
playback_delay: number;
|
playback_delay: number;
|
||||||
recording_offset: number;
|
recording_offset: number;
|
||||||
};
|
};
|
||||||
@@ -253,8 +255,10 @@ export class ConfigManager {
|
|||||||
},
|
},
|
||||||
audio: {
|
audio: {
|
||||||
enable_audio_capture_for_screen_sharing: false,
|
enable_audio_capture_for_screen_sharing: false,
|
||||||
|
input_device_id: 'default',
|
||||||
lookahead_time: 0.05,
|
lookahead_time: 0.05,
|
||||||
midi_automation_interpolation_interval_ms: 10,
|
midi_automation_interpolation_interval_ms: 10,
|
||||||
|
output_device_id: 'default',
|
||||||
playback_delay: 0.2,
|
playback_delay: 0.2,
|
||||||
recording_offset: 0
|
recording_offset: 0
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
|
|||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { KGAudioTrack } from '../core/track/KGAudioTrack';
|
||||||
import { showAlert } from '../util/dialogUtil';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,7 +165,13 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
stopRecording();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const candidateId = activeRegionId ?? lastSelectedRegionId;
|
const candidateId = activeRegionId ?? lastSelectedRegionId;
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import App from './App.tsx';
|
|||||||
import DialogProvider from './components/common/DialogProvider';
|
import DialogProvider from './components/common/DialogProvider';
|
||||||
import { KGCore } from './core/KGCore';
|
import { KGCore } from './core/KGCore';
|
||||||
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
|
||||||
|
import { ConfigManager } from './core/config/ConfigManager';
|
||||||
import { KGMidiInput } from './core/midi-input/KGMidiInput';
|
import { KGMidiInput } from './core/midi-input/KGMidiInput';
|
||||||
import { KGDebugger } from './core/KGDebugger';
|
import { KGDebugger } from './core/KGDebugger';
|
||||||
|
import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
|
||||||
|
|
||||||
const root = createRoot(document.getElementById('root')!);
|
const root = createRoot(document.getElementById('root')!);
|
||||||
|
|
||||||
@@ -58,6 +60,37 @@ if (!window.isSecureContext) {
|
|||||||
// Initialize KGCore instance
|
// Initialize KGCore instance
|
||||||
await KGCore.instance().initialize();
|
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
|
// Initialize KGMidiInput instance
|
||||||
await KGMidiInput.instance().initialize();
|
await KGMidiInput.instance().initialize();
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { act } from '@testing-library/react';
|
import { act } from '@testing-library/react';
|
||||||
|
import { KGTrack } from '../core/track/KGTrack';
|
||||||
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
import { KGMidiTrack } from '../core/track/KGMidiTrack';
|
||||||
|
|
||||||
|
let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
|
||||||
getMaxBars: () => 32,
|
getMaxBars: () => 32,
|
||||||
getBarWidthMultiplier: () => 1,
|
getBarWidthMultiplier: () => 1,
|
||||||
getTracks: () => [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')],
|
getTracks: () => mockTracks,
|
||||||
getBpm: () => 120,
|
getBpm: () => 120,
|
||||||
getKeySignature: () => 'C major',
|
getKeySignature: () => 'C major',
|
||||||
getName: () => 'Test Project',
|
getName: () => 'Test Project',
|
||||||
getSelectedMode: () => 'major',
|
getSelectedMode: () => 'major',
|
||||||
getIsLooping: () => false,
|
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 = {
|
const mockCore = {
|
||||||
getCurrentProject: () => mockProject,
|
getCurrentProject: () => mockProject,
|
||||||
setPlayheadUpdateCallback: vi.fn(),
|
setPlayheadUpdateCallback: vi.fn(),
|
||||||
setPlaybackStateChangeCallback: vi.fn(),
|
setPlaybackStateChangeCallback: vi.fn(),
|
||||||
|
setLoopBoundaryReachedCallback: vi.fn(),
|
||||||
getSelectedItems: () => [],
|
getSelectedItems: () => [],
|
||||||
onSelectionChanged: vi.fn(),
|
onSelectionChanged: vi.fn(),
|
||||||
canUndo: () => false,
|
canUndo: () => false,
|
||||||
@@ -27,9 +41,12 @@ const mockCore = {
|
|||||||
getRedoDescription: () => '',
|
getRedoDescription: () => '',
|
||||||
setOnCommandHistoryChanged: vi.fn(),
|
setOnCommandHistoryChanged: vi.fn(),
|
||||||
executeCommand: vi.fn(),
|
executeCommand: vi.fn(),
|
||||||
|
undo: vi.fn(() => true),
|
||||||
|
redo: vi.fn(() => true),
|
||||||
clearSelectedItems: vi.fn(),
|
clearSelectedItems: vi.fn(),
|
||||||
getStatus: () => 'Ready',
|
getStatus: () => 'Ready',
|
||||||
getPlayheadPosition: () => 0,
|
getPlayheadPosition: () => 0,
|
||||||
|
setPlayheadPosition: vi.fn(),
|
||||||
getIsPlaying: () => false,
|
getIsPlaying: () => false,
|
||||||
startPlaying: vi.fn().mockResolvedValue(undefined),
|
startPlaying: vi.fn().mockResolvedValue(undefined),
|
||||||
stopPlaying: 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', () => ({
|
vi.mock('../core/config/ConfigManager', () => ({
|
||||||
ConfigManager: {
|
ConfigManager: {
|
||||||
instance: () => ({
|
instance: () => ({
|
||||||
getIsInitialized: () => true,
|
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', () => {
|
describe('projectStore piano roll state', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
|
||||||
mockCore.startPlaying.mockReset();
|
mockCore.startPlaying.mockReset();
|
||||||
mockCore.startPlaying.mockResolvedValue(undefined);
|
mockCore.startPlaying.mockResolvedValue(undefined);
|
||||||
mockCore.stopPlaying.mockReset();
|
mockCore.stopPlaying.mockReset();
|
||||||
mockCore.stopPlaying.mockResolvedValue(undefined);
|
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 () => {
|
it('clears hybrid state when opening a MIDI region', async () => {
|
||||||
@@ -153,4 +195,59 @@ describe('projectStore piano roll state', () => {
|
|||||||
await startPromise;
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+281
-25
@@ -25,6 +25,7 @@ import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
|
|||||||
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
|
||||||
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
|
||||||
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
|
||||||
|
import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* Update CSS custom property for time signature numerator
|
||||||
@@ -98,8 +99,8 @@ interface ProjectState {
|
|||||||
// K.G.One panel state
|
// K.G.One panel state
|
||||||
showKGOnePanel: boolean;
|
showKGOnePanel: boolean;
|
||||||
|
|
||||||
// List event panel state
|
// Event list panel state
|
||||||
showListEventPanel: boolean;
|
showEventListPanel: boolean;
|
||||||
|
|
||||||
// Instrument selection panel state
|
// Instrument selection panel state
|
||||||
showInstrumentSelection: boolean;
|
showInstrumentSelection: boolean;
|
||||||
@@ -114,11 +115,19 @@ interface ProjectState {
|
|||||||
|
|
||||||
// Recording state
|
// Recording state
|
||||||
isRecording: boolean;
|
isRecording: boolean;
|
||||||
|
recordingMode: 'midi' | 'audio' | null;
|
||||||
recordingTargetRegionId: string | null;
|
recordingTargetRegionId: string | null;
|
||||||
|
recordingTargetTrackId: string | null;
|
||||||
|
recordingTargetTrackIndex: number | null;
|
||||||
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
|
||||||
recordingPitchBends: Array<{ beat: number; value: number }>;
|
recordingPitchBends: Array<{ beat: number; value: number }>;
|
||||||
recordingControllerEventsByType: Array<Array<{ beat: number; value: number }>>;
|
recordingControllerEventsByType: Array<Array<{ beat: number; value: number }>>;
|
||||||
recordingOriginalPlayhead: number;
|
recordingOriginalPlayhead: number;
|
||||||
|
recordingStartBeatAbsolute: number;
|
||||||
|
recordingCommitStartBeatAbsolute: number;
|
||||||
|
recordingAudioPreviewPeaks: AudioRecordingPeak[];
|
||||||
|
recordingAudioPreviewCurrentBeat: number;
|
||||||
|
recordingAudioPreviewFileName: string | null;
|
||||||
|
|
||||||
// Undo/redo state
|
// Undo/redo state
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
@@ -188,8 +197,8 @@ interface ProjectState {
|
|||||||
// K.G.One panel actions
|
// K.G.One panel actions
|
||||||
toggleKGOnePanel: () => void;
|
toggleKGOnePanel: () => void;
|
||||||
|
|
||||||
// List event panel actions
|
// Event List panel actions
|
||||||
toggleListEventPanel: () => void;
|
toggleEventListPanel: () => void;
|
||||||
|
|
||||||
// Instrument selection panel actions
|
// Instrument selection panel actions
|
||||||
openInstrumentSelectionForTrack: () => void;
|
openInstrumentSelectionForTrack: () => void;
|
||||||
@@ -225,6 +234,9 @@ let _recordingActiveNotes: Map<number, { startBeat: number; velocity: number }>
|
|||||||
let _recordingRegionStartBeat: number = 0;
|
let _recordingRegionStartBeat: number = 0;
|
||||||
let _lastRecordedPitchBendValue: number | null = null;
|
let _lastRecordedPitchBendValue: number | null = null;
|
||||||
let _lastRecordedControllerValues: Map<number, number> = new Map();
|
let _lastRecordedControllerValues: Map<number, number> = new Map();
|
||||||
|
let _audioRecordingStartTimeoutId: number | null = null;
|
||||||
|
let _audioRecordingForcedStopBeatAbsolute: number | null = null;
|
||||||
|
let _audioRecordingHasStarted: boolean = false;
|
||||||
|
|
||||||
function createEmptyRecordedControllerBuckets(): Array<Array<{ beat: number; value: number }>> {
|
function createEmptyRecordedControllerBuckets(): Array<Array<{ beat: number; value: number }>> {
|
||||||
return Array.from({ length: 128 }, () => []);
|
return Array.from({ length: 128 }, () => []);
|
||||||
@@ -253,6 +265,20 @@ function finalizeRecordedNote(startBeat: number, candidateEndBeat: number): numb
|
|||||||
return candidateEndBeat;
|
return candidateEndBeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPendingAudioRecordingStart(): void {
|
||||||
|
if (_audioRecordingStartTimeoutId !== null) {
|
||||||
|
window.clearTimeout(_audioRecordingStartTimeoutId);
|
||||||
|
_audioRecordingStartTimeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAudioRecordingExtension(mimeType: string): string {
|
||||||
|
if (mimeType.includes('ogg')) return 'ogg';
|
||||||
|
if (mimeType.includes('mp4')) return 'm4a';
|
||||||
|
if (mimeType.includes('wav')) return 'wav';
|
||||||
|
return 'webm';
|
||||||
|
}
|
||||||
|
|
||||||
// Create the store
|
// Create the store
|
||||||
export const useProjectStore = create<ProjectState>((set, get) => {
|
export const useProjectStore = create<ProjectState>((set, get) => {
|
||||||
const currentProject = KGCore.instance().getCurrentProject();
|
const currentProject = KGCore.instance().getCurrentProject();
|
||||||
@@ -273,10 +299,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Set up playhead update callback to keep store in sync during playback
|
// Set up playhead update callback to keep store in sync during playback
|
||||||
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
|
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
|
||||||
const { bpm, timeSignature } = get();
|
const { bpm, timeSignature } = get();
|
||||||
set({
|
set(state => ({
|
||||||
playheadPosition: position,
|
playheadPosition: position,
|
||||||
currentTime: beatsToTimeString(position, bpm, timeSignature)
|
currentTime: beatsToTimeString(position, bpm, timeSignature),
|
||||||
});
|
recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio'
|
||||||
|
? Math.max(state.recordingCommitStartBeatAbsolute, position)
|
||||||
|
: state.recordingAudioPreviewCurrentBeat,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep store isPlaying in sync when core auto-stops (e.g., at maxBars)
|
// Keep store isPlaying in sync when core auto-stops (e.g., at maxBars)
|
||||||
@@ -399,8 +428,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
// Initial K.G.One panel state
|
// Initial K.G.One panel state
|
||||||
showKGOnePanel: false,
|
showKGOnePanel: false,
|
||||||
|
|
||||||
// Initial List Event panel state
|
// Initial Event List panel state
|
||||||
showListEventPanel: false,
|
showEventListPanel: false,
|
||||||
|
|
||||||
// Initial Instrument Selection panel state
|
// Initial Instrument Selection panel state
|
||||||
showInstrumentSelection: initialShowInstrumentSelection,
|
showInstrumentSelection: initialShowInstrumentSelection,
|
||||||
@@ -420,11 +449,19 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
// Initial recording state
|
// Initial recording state
|
||||||
isRecording: false,
|
isRecording: false,
|
||||||
|
recordingMode: null,
|
||||||
recordingTargetRegionId: null,
|
recordingTargetRegionId: null,
|
||||||
|
recordingTargetTrackId: null,
|
||||||
|
recordingTargetTrackIndex: null,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
recordingPitchBends: [],
|
recordingPitchBends: [],
|
||||||
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
recordingOriginalPlayhead: 0,
|
recordingOriginalPlayhead: 0,
|
||||||
|
recordingStartBeatAbsolute: 0,
|
||||||
|
recordingCommitStartBeatAbsolute: 0,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
|
recordingAudioPreviewFileName: null,
|
||||||
|
|
||||||
// Initial cross-component scroll request state
|
// Initial cross-component scroll request state
|
||||||
mainContentScrollRequest: null,
|
mainContentScrollRequest: null,
|
||||||
@@ -847,6 +884,20 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
trackAutomationRedrawVersion: 0,
|
trackAutomationRedrawVersion: 0,
|
||||||
|
isRecording: false,
|
||||||
|
recordingMode: null,
|
||||||
|
recordingTargetRegionId: null,
|
||||||
|
recordingTargetTrackId: null,
|
||||||
|
recordingTargetTrackIndex: null,
|
||||||
|
recordingNotes: [],
|
||||||
|
recordingPitchBends: [],
|
||||||
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
|
recordingOriginalPlayhead: 0,
|
||||||
|
recordingStartBeatAbsolute: 0,
|
||||||
|
recordingCommitStartBeatAbsolute: 0,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
|
recordingAudioPreviewFileName: null,
|
||||||
playheadPosition: 0, // Ensure store state is also updated
|
playheadPosition: 0, // Ensure store state is also updated
|
||||||
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
|
||||||
});
|
});
|
||||||
@@ -930,7 +981,99 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
startRecording: async () => {
|
startRecording: async () => {
|
||||||
const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
|
const {
|
||||||
|
activeRegionId,
|
||||||
|
timeSignature,
|
||||||
|
playheadPosition,
|
||||||
|
setPlayheadPosition,
|
||||||
|
selectedTrackId,
|
||||||
|
tracks,
|
||||||
|
} = get();
|
||||||
|
|
||||||
|
const selectedTrack = tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
|
||||||
|
if (selectedTrack instanceof KGAudioTrack) {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const beatsPerBar = timeSignature.numerator;
|
||||||
|
const projectLooping = project.getIsLooping();
|
||||||
|
const [loopStartBar] = project.getLoopingRange();
|
||||||
|
const loopStartBeat = loopStartBar * beatsPerBar;
|
||||||
|
const recordingCommitStartBeatAbsolute = projectLooping ? loopStartBeat : playheadPosition;
|
||||||
|
const recordingStartBeatAbsolute = recordingCommitStartBeatAbsolute - beatsPerBar;
|
||||||
|
const previewFileName = `Recording_${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
||||||
|
|
||||||
|
clearPendingAudioRecordingStart();
|
||||||
|
_audioRecordingForcedStopBeatAbsolute = null;
|
||||||
|
_audioRecordingHasStarted = false;
|
||||||
|
|
||||||
|
set({
|
||||||
|
isRecording: true,
|
||||||
|
recordingMode: 'audio',
|
||||||
|
recordingTargetRegionId: null,
|
||||||
|
recordingTargetTrackId: selectedTrack.getId().toString(),
|
||||||
|
recordingTargetTrackIndex: selectedTrack.getTrackIndex(),
|
||||||
|
recordingNotes: [],
|
||||||
|
recordingPitchBends: [],
|
||||||
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
|
recordingOriginalPlayhead: playheadPosition,
|
||||||
|
recordingStartBeatAbsolute,
|
||||||
|
recordingCommitStartBeatAbsolute,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: recordingCommitStartBeatAbsolute,
|
||||||
|
recordingAudioPreviewFileName: previewFileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
KGCore.instance().setLoopBoundaryReachedCallback(projectLooping
|
||||||
|
? (loopEndBeat: number) => {
|
||||||
|
_audioRecordingForcedStopBeatAbsolute = loopEndBeat;
|
||||||
|
void get().stopRecording();
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
|
||||||
|
setPlayheadPosition(recordingStartBeatAbsolute);
|
||||||
|
set({ isPreparingPlayback: true });
|
||||||
|
try {
|
||||||
|
await KGCore.instance().startPlaying({
|
||||||
|
preserveLoopPreroll: projectLooping,
|
||||||
|
});
|
||||||
|
set({ isPlaying: true, autoScrollEnabled: true });
|
||||||
|
|
||||||
|
const prerollMs = Math.max(0, ((recordingCommitStartBeatAbsolute - recordingStartBeatAbsolute) * (60 / project.getBpm())) * 1000);
|
||||||
|
_audioRecordingStartTimeoutId = window.setTimeout(() => {
|
||||||
|
_audioRecordingStartTimeoutId = null;
|
||||||
|
const inputDeviceId = (ConfigManager.instance().get('audio.input_device_id') as string | undefined) ?? 'default';
|
||||||
|
void KGAudioInterface.instance().startAudioRecording(inputDeviceId, (peaks) => {
|
||||||
|
set({ recordingAudioPreviewPeaks: peaks });
|
||||||
|
}).then((startResult) => {
|
||||||
|
_audioRecordingHasStarted = true;
|
||||||
|
if (startResult.fellBackToDefault) {
|
||||||
|
void ConfigManager.instance().set('audio.input_device_id', 'default');
|
||||||
|
get().setStatus('Previously selected audio input device is unavailable; using System Default.');
|
||||||
|
}
|
||||||
|
}).catch(async (error) => {
|
||||||
|
console.error('Failed to start audio recording:', error);
|
||||||
|
await KGAudioInterface.instance().cancelAudioRecording();
|
||||||
|
KGCore.instance().setLoopBoundaryReachedCallback(null);
|
||||||
|
await get().stopPlaying();
|
||||||
|
setPlayheadPosition(playheadPosition);
|
||||||
|
set({
|
||||||
|
isRecording: false,
|
||||||
|
recordingMode: null,
|
||||||
|
recordingTargetTrackId: null,
|
||||||
|
recordingTargetTrackIndex: null,
|
||||||
|
recordingStartBeatAbsolute: 0,
|
||||||
|
recordingCommitStartBeatAbsolute: 0,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
|
recordingAudioPreviewFileName: null,
|
||||||
|
});
|
||||||
|
get().setStatus(error instanceof Error ? error.message : 'Unable to start audio recording.');
|
||||||
|
});
|
||||||
|
}, prerollMs);
|
||||||
|
} finally {
|
||||||
|
set({ isPreparingPlayback: false });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const project = KGCore.instance().getCurrentProject();
|
const project = KGCore.instance().getCurrentProject();
|
||||||
let targetRegion: KGMidiRegion | null = null;
|
let targetRegion: KGMidiRegion | null = null;
|
||||||
@@ -945,13 +1088,28 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
_lastRecordedPitchBendValue = null;
|
_lastRecordedPitchBendValue = null;
|
||||||
_lastRecordedControllerValues = new Map();
|
_lastRecordedControllerValues = new Map();
|
||||||
|
|
||||||
|
const projectLooping = project.getIsLooping();
|
||||||
|
const [loopStartBar] = project.getLoopingRange();
|
||||||
|
const loopStartBeat = loopStartBar * timeSignature.numerator;
|
||||||
|
const recordingStartBeat = projectLooping
|
||||||
|
? loopStartBeat - timeSignature.numerator
|
||||||
|
: playheadPosition - timeSignature.numerator;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
isRecording: true,
|
isRecording: true,
|
||||||
|
recordingMode: 'midi',
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
recordingPitchBends: [],
|
recordingPitchBends: [],
|
||||||
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
recordingTargetRegionId: activeRegionId,
|
recordingTargetRegionId: activeRegionId,
|
||||||
|
recordingTargetTrackId: null,
|
||||||
|
recordingTargetTrackIndex: null,
|
||||||
recordingOriginalPlayhead: playheadPosition,
|
recordingOriginalPlayhead: playheadPosition,
|
||||||
|
recordingStartBeatAbsolute: recordingStartBeat,
|
||||||
|
recordingCommitStartBeatAbsolute: targetRegion.getStartFromBeat(),
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
|
recordingAudioPreviewFileName: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildCorrectedBeat = (): number => {
|
const buildCorrectedBeat = (): number => {
|
||||||
@@ -1009,13 +1167,6 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectLooping = project.getIsLooping();
|
|
||||||
const [loopStartBar] = project.getLoopingRange();
|
|
||||||
const loopStartBeat = loopStartBar * timeSignature.numerator;
|
|
||||||
const recordingStartBeat = projectLooping
|
|
||||||
? loopStartBeat - timeSignature.numerator
|
|
||||||
: playheadPosition - timeSignature.numerator;
|
|
||||||
|
|
||||||
setPlayheadPosition(recordingStartBeat);
|
setPlayheadPosition(recordingStartBeat);
|
||||||
set({ isPreparingPlayback: true });
|
set({ isPreparingPlayback: true });
|
||||||
try {
|
try {
|
||||||
@@ -1030,16 +1181,109 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
stopRecording: async () => {
|
stopRecording: async () => {
|
||||||
const {
|
const {
|
||||||
|
recordingMode,
|
||||||
recordingNotes,
|
recordingNotes,
|
||||||
recordingPitchBends,
|
recordingPitchBends,
|
||||||
recordingControllerEventsByType,
|
recordingControllerEventsByType,
|
||||||
recordingTargetRegionId,
|
recordingTargetRegionId,
|
||||||
|
recordingTargetTrackId,
|
||||||
|
recordingTargetTrackIndex,
|
||||||
recordingOriginalPlayhead,
|
recordingOriginalPlayhead,
|
||||||
|
recordingCommitStartBeatAbsolute,
|
||||||
|
recordingAudioPreviewFileName,
|
||||||
stopPlaying,
|
stopPlaying,
|
||||||
setPlayheadPosition,
|
setPlayheadPosition,
|
||||||
refreshProjectState
|
refreshProjectState,
|
||||||
|
projectName,
|
||||||
|
maxBars,
|
||||||
} = get();
|
} = get();
|
||||||
|
|
||||||
|
if (recordingMode === 'audio') {
|
||||||
|
clearPendingAudioRecordingStart();
|
||||||
|
KGCore.instance().setLoopBoundaryReachedCallback(null);
|
||||||
|
|
||||||
|
const stopBeatAbsolute = _audioRecordingForcedStopBeatAbsolute
|
||||||
|
?? Math.max(recordingCommitStartBeatAbsolute, KGAudioInterface.instance().getTransportPosition());
|
||||||
|
_audioRecordingForcedStopBeatAbsolute = null;
|
||||||
|
|
||||||
|
const recordingResult = _audioRecordingHasStarted
|
||||||
|
? await KGAudioInterface.instance().stopAudioRecording()
|
||||||
|
: (await KGAudioInterface.instance().cancelAudioRecording(), null);
|
||||||
|
_audioRecordingHasStarted = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
recordingResult &&
|
||||||
|
recordingTargetTrackId &&
|
||||||
|
recordingTargetTrackIndex !== null &&
|
||||||
|
stopBeatAbsolute > recordingCommitStartBeatAbsolute
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const extension = getAudioRecordingExtension(recordingResult.mimeType);
|
||||||
|
const fileName = `${recordingAudioPreviewFileName ?? 'Recording'}.${extension}`;
|
||||||
|
const audioFile = new File([recordingResult.blob], fileName, { type: recordingResult.mimeType });
|
||||||
|
const fileId = KGAudioFileStorage.generateAudioFileId(fileName);
|
||||||
|
const arrayBuffer = await audioFile.arrayBuffer();
|
||||||
|
const toneBuffer = new Tone.ToneAudioBuffer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const audioContext = Tone.getContext().rawContext as AudioContext;
|
||||||
|
audioContext.decodeAudioData(
|
||||||
|
arrayBuffer.slice(0),
|
||||||
|
(decoded) => { toneBuffer.set(decoded); resolve(); },
|
||||||
|
(err) => reject(err)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId().toString() === recordingTargetTrackId);
|
||||||
|
if (track) {
|
||||||
|
await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile);
|
||||||
|
KGAudioInterface.instance().loadAudioBufferForTrack(recordingTargetTrackId, fileId, toneBuffer);
|
||||||
|
|
||||||
|
const prevMaxBars = maxBars;
|
||||||
|
const durationInBeats = stopBeatAbsolute - recordingCommitStartBeatAbsolute;
|
||||||
|
const beatsPerBar = KGCore.instance().getCurrentProject().getTimeSignature().numerator;
|
||||||
|
const endBarNumber = Math.ceil((recordingCommitStartBeatAbsolute + durationInBeats) / beatsPerBar);
|
||||||
|
const newMaxBars = Math.max(prevMaxBars, endBarNumber);
|
||||||
|
|
||||||
|
const command = new ImportAudioCommand(
|
||||||
|
track.getId(),
|
||||||
|
recordingTargetTrackIndex,
|
||||||
|
fileId,
|
||||||
|
fileName,
|
||||||
|
toneBuffer.duration,
|
||||||
|
recordingCommitStartBeatAbsolute,
|
||||||
|
durationInBeats,
|
||||||
|
prevMaxBars,
|
||||||
|
newMaxBars
|
||||||
|
);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to finalize audio recording:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopPlaying();
|
||||||
|
setPlayheadPosition(recordingOriginalPlayhead);
|
||||||
|
set({
|
||||||
|
isRecording: false,
|
||||||
|
recordingMode: null,
|
||||||
|
isPreparingPlayback: false,
|
||||||
|
recordingTargetRegionId: null,
|
||||||
|
recordingTargetTrackId: null,
|
||||||
|
recordingTargetTrackIndex: null,
|
||||||
|
recordingNotes: [],
|
||||||
|
recordingPitchBends: [],
|
||||||
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
|
recordingStartBeatAbsolute: 0,
|
||||||
|
recordingCommitStartBeatAbsolute: 0,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
|
recordingAudioPreviewFileName: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize any held keys
|
// Finalize any held keys
|
||||||
const finalNotes = [...recordingNotes];
|
const finalNotes = [...recordingNotes];
|
||||||
const finalPitchBends = [...recordingPitchBends];
|
const finalPitchBends = [...recordingPitchBends];
|
||||||
@@ -1109,11 +1353,19 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
setPlayheadPosition(recordingOriginalPlayhead);
|
setPlayheadPosition(recordingOriginalPlayhead);
|
||||||
set({
|
set({
|
||||||
isRecording: false,
|
isRecording: false,
|
||||||
|
recordingMode: null,
|
||||||
isPreparingPlayback: false,
|
isPreparingPlayback: false,
|
||||||
recordingNotes: [],
|
recordingNotes: [],
|
||||||
recordingPitchBends: [],
|
recordingPitchBends: [],
|
||||||
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
|
||||||
recordingTargetRegionId: null
|
recordingTargetRegionId: null,
|
||||||
|
recordingTargetTrackId: null,
|
||||||
|
recordingTargetTrackIndex: null,
|
||||||
|
recordingStartBeatAbsolute: 0,
|
||||||
|
recordingCommitStartBeatAbsolute: 0,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
|
recordingAudioPreviewFileName: null,
|
||||||
});
|
});
|
||||||
_lastRecordedPitchBendValue = null;
|
_lastRecordedPitchBendValue = null;
|
||||||
_lastRecordedControllerValues = new Map();
|
_lastRecordedControllerValues = new Map();
|
||||||
@@ -1315,6 +1567,8 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
activeTrackAutomationTrackId: null,
|
activeTrackAutomationTrackId: null,
|
||||||
activeTrackAutomationType: null,
|
activeTrackAutomationType: null,
|
||||||
trackAutomationRedrawVersion: 0,
|
trackAutomationRedrawVersion: 0,
|
||||||
|
recordingAudioPreviewPeaks: [],
|
||||||
|
recordingAudioPreviewCurrentBeat: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear any selected items
|
// Clear any selected items
|
||||||
@@ -1329,23 +1583,23 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({
|
set({
|
||||||
showChatBox: show,
|
showChatBox: show,
|
||||||
showKGOnePanel: show ? false : get().showKGOnePanel,
|
showKGOnePanel: show ? false : get().showKGOnePanel,
|
||||||
showListEventPanel: show ? false : get().showListEventPanel
|
showEventListPanel: show ? false : get().showEventListPanel
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleChatBox: () => {
|
toggleChatBox: () => {
|
||||||
const { showChatBox } = get();
|
const { showChatBox } = get();
|
||||||
set({ showChatBox: !showChatBox, showKGOnePanel: false, showListEventPanel: false });
|
set({ showChatBox: !showChatBox, showKGOnePanel: false, showEventListPanel: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleKGOnePanel: () => {
|
toggleKGOnePanel: () => {
|
||||||
const { showKGOnePanel } = get();
|
const { showKGOnePanel } = get();
|
||||||
set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showListEventPanel: false });
|
set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showEventListPanel: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleListEventPanel: () => {
|
toggleEventListPanel: () => {
|
||||||
const { showListEventPanel } = get();
|
const { showEventListPanel } = get();
|
||||||
set({ showListEventPanel: !showListEventPanel, showChatBox: false, showKGOnePanel: false });
|
set({ showEventListPanel: !showEventListPanel, showChatBox: false, showKGOnePanel: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Instrument selection panel actions
|
// Instrument selection panel actions
|
||||||
@@ -1422,6 +1676,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
if (core.undo()) {
|
if (core.undo()) {
|
||||||
// Use centralized refresh method
|
// Use centralized refresh method
|
||||||
get().refreshProjectState();
|
get().refreshProjectState();
|
||||||
|
get().bumpTrackAutomationRedrawVersion();
|
||||||
console.log('Undo completed');
|
console.log('Undo completed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1431,6 +1686,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
if (core.redo()) {
|
if (core.redo()) {
|
||||||
// Use centralized refresh method
|
// Use centralized refresh method
|
||||||
get().refreshProjectState();
|
get().refreshProjectState();
|
||||||
|
get().bumpTrackAutomationRedrawVersion();
|
||||||
console.log('Redo completed');
|
console.log('Redo completed');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export const mockAudioInterface = {
|
|||||||
getCurrentBeat: vi.fn().mockReturnValue(0),
|
getCurrentBeat: vi.fn().mockReturnValue(0),
|
||||||
getTransportPosition: vi.fn().mockReturnValue(0),
|
getTransportPosition: vi.fn().mockReturnValue(0),
|
||||||
setBpm: vi.fn().mockReturnValue(undefined),
|
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
|
// Singleton pattern
|
||||||
getInstance: vi.fn().mockReturnThis(),
|
getInstance: vi.fn().mockReturnThis(),
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ export const MockGain = vi.fn().mockImplementation((initialValue: number = 1) =>
|
|||||||
dispose: vi.fn()
|
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
|
// Mock Meter
|
||||||
export const MockMeter = vi.fn().mockImplementation(() => ({
|
export const MockMeter = vi.fn().mockImplementation(() => ({
|
||||||
getValue: vi.fn().mockReturnValue(-Infinity),
|
getValue: vi.fn().mockReturnValue(-Infinity),
|
||||||
@@ -120,6 +132,7 @@ export const ToneMock = {
|
|||||||
Destination: MockDestination,
|
Destination: MockDestination,
|
||||||
ToneAudioBuffer: MockToneAudioBuffer,
|
ToneAudioBuffer: MockToneAudioBuffer,
|
||||||
Gain: MockGain,
|
Gain: MockGain,
|
||||||
|
Panner: MockPanner,
|
||||||
Meter: MockMeter,
|
Meter: MockMeter,
|
||||||
|
|
||||||
// Context management
|
// 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