+ Please select a MIDI region, or open one in the Piano Roll, to view its event list.
+
+
+
+
+ setAddEventType(value as AddEventType)}
+ label="Note"
+ buttonClassName="event-list-type-button"
+ showValueAsLabel
+ />
+
- {!activeMidiRegion ? (
-
- Please select a MIDI region, or open one in the Piano Roll, to view its event list.
+
+ {
+ setQuantPosition(value);
+ quantizeSelectedNotes(value);
+ }}
+ label="Qua. Pos."
+ buttonClassName="event-list-quant-button"
+ />
+ {
+ setQuantLength(value);
+ quantizeSelectedNoteLengths(value);
+ }}
+ label="Qua. Len."
+ buttonClassName="event-list-quant-button"
+ />
+
+
+
+
- ) : (
- <>
-
-
-
-
-
- setAddEventType(value as AddEventType)}
- label="Note"
- buttonClassName="list-event-type-button"
- showValueAsLabel
- />
-
-
- {
- setQuantPosition(value);
- quantizeSelectedNotes(value);
- }}
- label="Qua. Pos."
- buttonClassName="list-event-quant-button"
- />
- {
- setQuantLength(value);
- quantizeSelectedNoteLengths(value);
- }}
- label="Qua. Len."
- buttonClassName="list-event-quant-button"
- />
-
-
-
-
-
+
+
+
+
+ Position
+ Status
+ Num
+ Val
+ Length/Info
+
+
+
+ {eventRows.map((row, index) => {
+ const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
+ const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
+ const numText = row.type === 'note'
+ ? pitchToNoteNameString(row.note.getPitch())
+ : row.type === 'controller'
+ ? String(row.controller)
+ : '';
+ const valText = row.type === 'note'
+ ? String(row.note.getVelocity())
+ : row.type === 'pitch-bend'
+ ? String(midiPitchBendToSignedValue(row.pitchBend.getValue()))
+ : String(row.controllerEvent.getValue());
+ const lengthText = row.type === 'note'
+ ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
+ : row.type === 'pitch-bend'
+ ? formatPitchBendInfo(row.pitchBend.getValue())
+ : `Raw ${row.controllerEvent.getValue()}`;
+ const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
+ const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
+ const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
+ const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length';
-
-
-
-
- Position
- Status
- Num
- Val
- Length/Info
-
-
-
- {eventRows.map((row, index) => {
- const absoluteBeat = row.type === 'note' ? row.absoluteStartBeat : row.absoluteBeat;
- const positionText = formatMidiEventPosition(absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
- const statusText = row.type === 'note' ? 'Note' : row.type === 'pitch-bend' ? 'Pitch Bend' : 'Controller';
- const numText = row.type === 'note'
- ? pitchToNoteNameString(row.note.getPitch())
- : row.type === 'controller'
- ? String(row.controller)
- : '';
- const valText = row.type === 'note'
- ? String(row.note.getVelocity())
- : row.type === 'pitch-bend'
- ? String(midiPitchBendToSignedValue(row.pitchBend.getValue()))
- : String(row.controllerEvent.getValue());
- const lengthText = row.type === 'note'
- ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT)
- : row.type === 'pitch-bend'
- ? formatPitchBendInfo(row.pitchBend.getValue())
- : `Raw ${row.controllerEvent.getValue()}`;
- const isEditingPosition = editingCell?.eventId === row.id && editingCell.column === 'position';
- const isEditingNum = editingCell?.eventId === row.id && editingCell.column === 'num';
- const isEditingVal = editingCell?.eventId === row.id && editingCell.column === 'val';
- const isEditingLength = editingCell?.eventId === row.id && editingCell.column === 'length';
-
- return (
- handleRowClick(row.id, index, event)}
- onDoubleClick={(event) => {
- event.stopPropagation();
- clearPendingSingleClickSelection();
- }}
- >
- {
- event.stopPropagation();
- startEditingCell(row.id, 'position', positionText);
- }}
- >
- {isEditingPosition ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : positionText}
-
- {statusText}
- {
- if (row.type === 'pitch-bend') return;
- event.stopPropagation();
- startEditingCell(row.id, 'num', numText);
- }}
- >
- {isEditingNum ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : numText}
-
- {
- event.stopPropagation();
- startEditingCell(row.id, 'val', valText);
- }}
- >
- {isEditingVal ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : valText}
-
- {
- if (row.type !== 'note') return;
- event.stopPropagation();
- startEditingCell(row.id, 'length', lengthText);
- }}
- >
- {isEditingLength ? (
- setEditingCell({ ...editingCell, value: event.target.value })}
- onBlur={handleEditInputBlur}
- onClick={(event) => event.stopPropagation()}
- onDoubleClick={(event) => event.stopPropagation()}
- onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
- />
- ) : lengthText}
-
-
- );
- })}
-
-
-
- >
- )}
-
-
+ return (
+ handleRowClick(row.id, index, event)}
+ onDoubleClick={(event) => {
+ event.stopPropagation();
+ clearPendingSingleClickSelection();
+ }}
+ >
+ { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
+ {isEditingPosition ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : positionText}
+
+ {statusText}
+ {
+ if (row.type === 'pitch-bend') return;
+ event.stopPropagation();
+ startEditingCell(row.id, 'num', numText);
+ }}>
+ {isEditingNum ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : numText}
+
+ {
+ event.stopPropagation();
+ startEditingCell(row.id, 'val', valText);
+ }}>
+ {isEditingVal ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : valText}
+
+ {
+ if (row.type !== 'note') return;
+ event.stopPropagation();
+ startEditingCell(row.id, 'length', lengthText);
+ }}>
+ {isEditingLength ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : lengthText}
+
+
+ );
+ })}
+
+
+
+ >
+ )}
+ >
);
};
-export default ListEventPanel;
+export default RegionEventListTab;
diff --git a/src/components/event-list-panel/TrackEventListTab.tsx b/src/components/event-list-panel/TrackEventListTab.tsx
new file mode 100644
index 0000000..c55a5a1
--- /dev/null
+++ b/src/components/event-list-panel/TrackEventListTab.tsx
@@ -0,0 +1,800 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { FaPlus, FaTrash } from 'react-icons/fa';
+import KGDropdown from '../common/KGDropdown';
+import { useProjectStore } from '../../stores/projectStore';
+import { KGCore } from '../../core/KGCore';
+import { KGAudioRegion } from '../../core/region/KGAudioRegion';
+import { KGRegion } from '../../core/region/KGRegion';
+import { KGTrackAutomationPoint, type TrackAutomationType } from '../../core/track/KGTrackAutomationPoint';
+import { KGMidiTrack } from '../../core/track/KGMidiTrack';
+import { KGAudioTrack } from '../../core/track/KGAudioTrack';
+import {
+ CreateRegionCommand,
+ CreateTrackAutomationPointsCommand,
+ DeleteMultipleRegionsCommand,
+ DeleteTrackAutomationPointsCommand,
+ MoveRegionCommand,
+ ResizeRegionCommand,
+ UpdateTrackAutomationPointsCommand,
+} from '../../core/commands';
+import {
+ formatMidiEventLength,
+ formatMidiEventPosition,
+ MIDI_EVENT_TICKS_PER_BEAT,
+ parseMidiEventLengthDelta,
+ parseMidiEventLength,
+ parseMidiEventPositionDelta,
+ parseMidiEventPosition,
+} from '../../util/midiUtil';
+import { isModifierKeyPressed } from '../../util/osUtil';
+import { showAlert } from '../../util/dialogUtil';
+import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
+
+interface TrackEventListTabProps {
+ selectedTrack: KGMidiTrack | KGAudioTrack | null;
+}
+
+type AddTrackItemType = 'midi-region' | 'volume' | 'pan';
+type TrackEditableColumn = 'position' | 'val' | 'length';
+
+interface TrackRegionRowData {
+ id: string;
+ type: 'region';
+ region: KGRegion;
+ absoluteStartBeat: number;
+ durationBeats: number;
+ statusLabel: 'MIDI' | 'Audio';
+}
+
+interface TrackAutomationRowData {
+ id: string;
+ type: 'automation';
+ automationType: TrackAutomationType;
+ point: KGTrackAutomationPoint;
+ absoluteBeat: number;
+}
+
+type TrackRowData = TrackRegionRowData | TrackAutomationRowData;
+
+interface TrackEditingCell {
+ rowId: string;
+ column: TrackEditableColumn;
+ value: string;
+}
+
+const formatTrackAutomationValue = (automationType: TrackAutomationType, value: number): string => {
+ if (automationType === 'volume') {
+ if (value <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) {
+ return '−∞';
+ }
+
+ return `${value >= 0 ? '+' : ''}${value.toFixed(1)}dB`;
+ }
+
+ const logicPanValue = value <= 0 ? Math.round(value * 64) : Math.round(value * 63);
+ return `${logicPanValue >= 0 ? '+' : ''}${logicPanValue}`;
+};
+
+const formatTrackAutomationInfo = (automationType: TrackAutomationType, value: number): string => {
+ return automationType === 'volume' ? `Raw ${value.toFixed(3)} dB` : `Raw ${value.toFixed(3)}`;
+};
+
+const parseTrackAutomationValueInput = (
+ automationType: TrackAutomationType,
+ raw: string
+): { value: number } | { error: string } => {
+ const trimmed = raw.trim().replace(/db$/i, '');
+
+ if (automationType === 'volume') {
+ if (trimmed === '−∞' || trimmed.toLowerCase() === '-inf' || trimmed.toLowerCase() === '-infinity') {
+ return { value: AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB };
+ }
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) {
+ return { error: `Volume must be a number between ${AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB} and ${AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB}.` };
+ }
+ if (parsed < AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB || parsed > AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB) {
+ return { error: `Volume must be between ${AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB} and ${AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB}.` };
+ }
+ return { value: parsed };
+ }
+
+ if (!/^[+-]?\d+$/.test(trimmed)) {
+ return { error: 'Pan must be an integer between -64 and +63.' };
+ }
+
+ const parsed = parseInt(trimmed, 10);
+ if (parsed < -64 || parsed > 63) {
+ return { error: 'Pan must be between -64 and +63.' };
+ }
+
+ const normalized = parsed <= 0 ? parsed / 64 : parsed / 63;
+ return { value: Math.max(-1, Math.min(1, normalized)) };
+};
+
+const findPreviousPanValue = (points: KGTrackAutomationPoint[], beat: number): number => {
+ const previousPoint = [...points].filter(point => point.getBeat() <= beat).sort((a, b) => b.getBeat() - a.getBeat())[0];
+ return previousPoint?.getValue() ?? 0;
+};
+
+const TrackEventListTab: React.FC
= ({ selectedTrack }) => {
+ const {
+ tracks,
+ playheadPosition,
+ timeSignature,
+ selectedRegionIds,
+ selectedTrackAutomationPointIds,
+ updateTrack,
+ refreshProjectState,
+ bumpTrackAutomationRedrawVersion,
+ } = useProjectStore();
+
+ const [showRegions, setShowRegions] = useState(true);
+ const [showVolume, setShowVolume] = useState(true);
+ const [showPan, setShowPan] = useState(true);
+ const [addTrackItemType, setAddTrackItemType] = useState('midi-region');
+ const [editingCell, setEditingCell] = useState(null);
+ const rangeAnchorRowIdRef = useRef(null);
+ const editInputRef = useRef(null);
+ const suppressBlurCommitRef = useRef(false);
+ const pendingSingleClickSelectionRef = useRef(null);
+
+ useEffect(() => {
+ if (selectedTrack instanceof KGAudioTrack && addTrackItemType === 'midi-region') {
+ setAddTrackItemType('volume');
+ }
+ }, [selectedTrack, addTrackItemType]);
+
+ useEffect(() => {
+ if (editingCell) {
+ editInputRef.current?.focus();
+ editInputRef.current?.select();
+ }
+ }, [editingCell?.rowId, editingCell?.column]);
+
+ useEffect(() => {
+ return () => {
+ if (pendingSingleClickSelectionRef.current !== null) {
+ window.clearTimeout(pendingSingleClickSelectionRef.current);
+ }
+ };
+ }, []);
+
+ const availableAddOptions = useMemo(() => {
+ const options: Array<{ label: string; value: AddTrackItemType }> = [];
+ if (selectedTrack instanceof KGMidiTrack) {
+ options.push({ label: 'MIDI Region', value: 'midi-region' });
+ }
+ options.push({ label: 'Volume', value: 'volume' });
+ options.push({ label: 'Pan', value: 'pan' });
+ return options;
+ }, [selectedTrack]);
+
+ const liveSelectedTrack = useMemo(() => {
+ if (!selectedTrack) {
+ return null;
+ }
+
+ const matchedTrack = tracks.find(track => track.getId() === selectedTrack.getId()) ?? null;
+ return matchedTrack instanceof KGMidiTrack || matchedTrack instanceof KGAudioTrack
+ ? matchedTrack
+ : selectedTrack;
+ }, [selectedTrack, tracks]);
+
+ const trackRows: TrackRowData[] = useMemo(() => {
+ if (!liveSelectedTrack) return [];
+
+ const regionRows: TrackRegionRowData[] = showRegions
+ ? liveSelectedTrack.getRegions().map(region => ({
+ id: region.getId(),
+ type: 'region',
+ region,
+ absoluteStartBeat: region.getStartFromBeat(),
+ durationBeats: region.getLength(),
+ statusLabel: region instanceof KGAudioRegion ? 'Audio' : 'MIDI',
+ }))
+ : [];
+
+ const volumeRows: TrackAutomationRowData[] = showVolume
+ ? liveSelectedTrack.getAutomationPoints('volume').map(point => ({
+ id: point.getId(),
+ type: 'automation',
+ automationType: 'volume',
+ point,
+ absoluteBeat: point.getBeat(),
+ }))
+ : [];
+
+ const panRows: TrackAutomationRowData[] = showPan
+ ? liveSelectedTrack.getAutomationPoints('pan').map(point => ({
+ id: point.getId(),
+ type: 'automation',
+ automationType: 'pan',
+ point,
+ absoluteBeat: point.getBeat(),
+ }))
+ : [];
+
+ return [...regionRows, ...volumeRows, ...panRows].sort((a, b) => {
+ const beatA = a.type === 'region' ? a.absoluteStartBeat : a.absoluteBeat;
+ const beatB = b.type === 'region' ? b.absoluteStartBeat : b.absoluteBeat;
+ if (beatA !== beatB) return beatA - beatB;
+ if (a.type !== b.type) return a.type === 'region' ? -1 : 1;
+ if (a.type === 'automation' && b.type === 'automation' && a.automationType !== b.automationType) {
+ return a.automationType.localeCompare(b.automationType);
+ }
+ return a.id.localeCompare(b.id);
+ });
+ }, [liveSelectedTrack, showPan, showRegions, showVolume, tracks]);
+
+ const selectedRowIdSet = new Set([...selectedRegionIds, ...selectedTrackAutomationPointIds]);
+ const visibleSelectedRows = trackRows.filter(row => selectedRowIdSet.has(row.id));
+
+ const clearPendingSingleClickSelection = () => {
+ if (pendingSingleClickSelectionRef.current !== null) {
+ window.clearTimeout(pendingSingleClickSelectionRef.current);
+ pendingSingleClickSelectionRef.current = null;
+ }
+ };
+
+ const commitTrackRegionSelection = (nextSelectedIds: Set) => {
+ if (!liveSelectedTrack) return;
+
+ const core = KGCore.instance();
+ const selectedRegions = liveSelectedTrack.getRegions().filter(region => nextSelectedIds.has(region.getId()));
+ const previouslySelectedRegions = core.getSelectedItems().filter(item => item instanceof KGRegion);
+
+ liveSelectedTrack.getRegions().forEach(region => {
+ if (nextSelectedIds.has(region.getId())) region.select();
+ else region.deselect();
+ });
+
+ if (previouslySelectedRegions.length > 0) {
+ core.removeSelectedItems(previouslySelectedRegions);
+ }
+ if (selectedRegions.length > 0) {
+ core.addSelectedItems(selectedRegions);
+ }
+
+ void updateTrack(liveSelectedTrack);
+ };
+
+ const commitTrackAutomationSelection = (nextSelectedIds: Set) => {
+ if (!liveSelectedTrack) return;
+
+ const core = KGCore.instance();
+ const points = [
+ ...liveSelectedTrack.getAutomationPoints('volume'),
+ ...liveSelectedTrack.getAutomationPoints('pan'),
+ ];
+ const selectedPoints = points.filter(point => nextSelectedIds.has(point.getId()));
+ const previouslySelectedPoints = core.getSelectedItems().filter(item => item instanceof KGTrackAutomationPoint);
+
+ points.forEach(point => {
+ if (nextSelectedIds.has(point.getId())) point.select();
+ else point.deselect();
+ });
+
+ if (previouslySelectedPoints.length > 0) {
+ core.removeSelectedItems(previouslySelectedPoints);
+ }
+ if (selectedPoints.length > 0) {
+ core.addSelectedItems(selectedPoints);
+ }
+
+ void updateTrack(liveSelectedTrack);
+ };
+
+ const commitSelection = (nextSelectedIds: Set) => {
+ commitTrackRegionSelection(nextSelectedIds);
+ commitTrackAutomationSelection(nextSelectedIds);
+ };
+
+ const startEditingCell = (rowId: string, column: TrackEditableColumn, value: string) => {
+ clearPendingSingleClickSelection();
+ setEditingCell({ rowId, column, value });
+ };
+
+ const cancelEditingCell = () => {
+ setEditingCell(null);
+ };
+
+ const commitEditingCell = async () => {
+ if (!editingCell || !liveSelectedTrack) return;
+
+ const row = trackRows.find(candidate => candidate.id === editingCell.rowId);
+ if (!row) {
+ setEditingCell(null);
+ return;
+ }
+
+ const trimmedValue = editingCell.value.trim();
+ const isDeltaEdit = trimmedValue.startsWith('+') || trimmedValue.startsWith('-');
+
+ if (row.type === 'region') {
+ const targetRows: TrackRegionRowData[] = selectedRowIdSet.has(row.id) && selectedRegionIds.length > 1
+ ? trackRows.filter((candidate): candidate is TrackRegionRowData => candidate.type === 'region' && selectedRowIdSet.has(candidate.id))
+ : [row];
+
+ if (editingCell.column === 'position') {
+ if (isDeltaEdit) {
+ const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+
+ for (const targetRow of targetRows) {
+ if (targetRow.region.getStartFromBeat() + parsed.deltaBeats < 0) {
+ await showAlert('Position delta would move one or more regions before the start of the project.');
+ return;
+ }
+ }
+
+ targetRows.forEach(targetRow => {
+ KGCore.instance().executeCommand(new MoveRegionCommand(
+ targetRow.region.getId(),
+ targetRow.region.getStartFromBeat() + parsed.deltaBeats,
+ targetRow.region.getTrackId(),
+ targetRow.region.getTrackIndex()
+ ));
+ });
+ } else {
+ const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+ if (parsed.absoluteBeat < 0) {
+ await showAlert('Position cannot be earlier than the start of the project.');
+ return;
+ }
+
+ targetRows.forEach(targetRow => {
+ KGCore.instance().executeCommand(new MoveRegionCommand(
+ targetRow.region.getId(),
+ parsed.absoluteBeat,
+ targetRow.region.getTrackId(),
+ targetRow.region.getTrackIndex()
+ ));
+ });
+ }
+ }
+
+ if (editingCell.column === 'length') {
+ if (isDeltaEdit) {
+ const parsed = parseMidiEventLengthDelta(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+
+ for (const targetRow of targetRows) {
+ if (targetRow.region.getLength() + parsed.deltaBeats <= 0) {
+ await showAlert('Length delta would make one or more regions non-positive in duration.');
+ return;
+ }
+ }
+
+ targetRows.forEach(targetRow => {
+ KGCore.instance().executeCommand(new ResizeRegionCommand(
+ targetRow.region.getId(),
+ targetRow.region.getStartFromBeat(),
+ targetRow.region.getLength() + parsed.deltaBeats
+ ));
+ });
+ } else {
+ const parsed = parseMidiEventLength(trimmedValue, MIDI_EVENT_TICKS_PER_BEAT);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+ if (parsed.duration <= 0) {
+ await showAlert('Length must be positive.');
+ return;
+ }
+
+ targetRows.forEach(targetRow => {
+ KGCore.instance().executeCommand(new ResizeRegionCommand(
+ targetRow.region.getId(),
+ targetRow.region.getStartFromBeat(),
+ parsed.duration
+ ));
+ });
+ }
+ }
+ } else {
+ const automationType = row.automationType;
+ const targetRows: TrackAutomationRowData[] = selectedRowIdSet.has(row.id) && selectedTrackAutomationPointIds.length > 1
+ ? trackRows.filter((candidate): candidate is TrackAutomationRowData => (
+ candidate.type === 'automation'
+ && candidate.automationType === automationType
+ && selectedRowIdSet.has(candidate.id)
+ ))
+ : [row];
+
+ const snapshots = targetRows.map(targetRow => ({
+ pointId: targetRow.point.getId(),
+ beat: targetRow.point.getBeat(),
+ value: targetRow.point.getValue(),
+ }));
+ const updates: Array<{ pointId: string; beat?: number; value?: number }> = [];
+
+ if (editingCell.column === 'position') {
+ if (isDeltaEdit) {
+ const parsed = parseMidiEventPositionDelta(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+
+ for (const targetRow of targetRows) {
+ const nextBeat = targetRow.point.getBeat() + parsed.deltaBeats;
+ if (nextBeat < 0) {
+ await showAlert('Position delta would move one or more automation points before the start of the project.');
+ return;
+ }
+ updates.push({ pointId: targetRow.point.getId(), beat: nextBeat });
+ }
+ } else {
+ const parsed = parseMidiEventPosition(trimmedValue, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+ if (parsed.absoluteBeat < 0) {
+ await showAlert('Position cannot be earlier than the start of the project.');
+ return;
+ }
+
+ for (const targetRow of targetRows) {
+ updates.push({ pointId: targetRow.point.getId(), beat: parsed.absoluteBeat });
+ }
+ }
+ }
+
+ if (editingCell.column === 'val') {
+ const parsed = parseTrackAutomationValueInput(automationType, trimmedValue);
+ if ('error' in parsed) {
+ await showAlert(parsed.error);
+ return;
+ }
+ for (const targetRow of targetRows) {
+ updates.push({ pointId: targetRow.point.getId(), value: parsed.value });
+ }
+ }
+
+ if (updates.length > 0) {
+ KGCore.instance().executeCommand(new UpdateTrackAutomationPointsCommand(
+ liveSelectedTrack.getId(),
+ automationType,
+ snapshots,
+ updates
+ ));
+ bumpTrackAutomationRedrawVersion();
+ }
+ }
+
+ await updateTrack(liveSelectedTrack);
+ refreshProjectState();
+ setEditingCell(null);
+ };
+
+ const handleRowClick = (rowId: string, rowIndex: number, event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (editingCell) return;
+
+ const isModifierPressed = isModifierKeyPressed(event);
+ const nextSelectedIds = new Set(selectedRowIdSet);
+ const isAlreadySelected = selectedRowIdSet.has(rowId);
+ const hasMultiSelection = selectedRowIdSet.size > 1;
+
+ if (event.shiftKey) {
+ clearPendingSingleClickSelection();
+ const anchorIndex = trackRows.findIndex(row => row.id === rangeAnchorRowIdRef.current);
+ const rangeStartIndex = anchorIndex >= 0 ? Math.min(anchorIndex, rowIndex) : rowIndex;
+ const rangeEndIndex = anchorIndex >= 0 ? Math.max(anchorIndex, rowIndex) : rowIndex;
+
+ if (!isModifierPressed) {
+ nextSelectedIds.clear();
+ }
+
+ for (let index = rangeStartIndex; index <= rangeEndIndex; index += 1) {
+ nextSelectedIds.add(trackRows[index].id);
+ }
+ } else if (isModifierPressed) {
+ clearPendingSingleClickSelection();
+ if (nextSelectedIds.has(rowId)) nextSelectedIds.delete(rowId);
+ else nextSelectedIds.add(rowId);
+ rangeAnchorRowIdRef.current = rowId;
+ } else {
+ if (isAlreadySelected && hasMultiSelection) {
+ clearPendingSingleClickSelection();
+ pendingSingleClickSelectionRef.current = window.setTimeout(() => {
+ const delayedSelection = new Set([rowId]);
+ rangeAnchorRowIdRef.current = rowId;
+ commitSelection(delayedSelection);
+ pendingSingleClickSelectionRef.current = null;
+ }, 220);
+ return;
+ }
+
+ clearPendingSingleClickSelection();
+ nextSelectedIds.clear();
+ nextSelectedIds.add(rowId);
+ rangeAnchorRowIdRef.current = rowId;
+ }
+
+ if (event.shiftKey && rangeAnchorRowIdRef.current === null) {
+ rangeAnchorRowIdRef.current = rowId;
+ }
+
+ commitSelection(nextSelectedIds);
+ };
+
+ const handleTableBackgroundMouseDown = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (event.target !== event.currentTarget) return;
+ clearPendingSingleClickSelection();
+ rangeAnchorRowIdRef.current = null;
+ commitSelection(new Set());
+ };
+
+ const handleEditInputKeyDown = async (event: React.KeyboardEvent) => {
+ event.stopPropagation();
+
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ await commitEditingCell();
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ suppressBlurCommitRef.current = true;
+ cancelEditingCell();
+ }
+ };
+
+ const handleEditInputBlur = () => {
+ if (suppressBlurCommitRef.current) {
+ suppressBlurCommitRef.current = false;
+ return;
+ }
+
+ void commitEditingCell();
+ };
+
+ const handleAddTrackItem = async (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (!liveSelectedTrack) return;
+
+ if (addTrackItemType === 'midi-region') {
+ if (!(liveSelectedTrack instanceof KGMidiTrack)) return;
+
+ const command = new CreateRegionCommand(
+ liveSelectedTrack.getId().toString(),
+ liveSelectedTrack.getTrackIndex(),
+ playheadPosition,
+ timeSignature.numerator
+ );
+ KGCore.instance().executeCommand(command);
+ const createdRegion = command.getCreatedRegion();
+ if (createdRegion) {
+ KGCore.instance().clearSelectedItems();
+ createdRegion.select();
+ KGCore.instance().addSelectedItem(createdRegion);
+ rangeAnchorRowIdRef.current = createdRegion.getId();
+ }
+ } else {
+ const automationType: TrackAutomationType = addTrackItemType;
+ const value = automationType === 'volume'
+ ? liveSelectedTrack.getVolume()
+ : findPreviousPanValue(liveSelectedTrack.getPanAutomation(), playheadPosition);
+
+ const command = new CreateTrackAutomationPointsCommand(
+ liveSelectedTrack.getId(),
+ automationType,
+ [{ beat: playheadPosition, value }]
+ );
+ KGCore.instance().executeCommand(command);
+ const createdPointId = command.getCreatedPointIds()[0];
+ const createdPoint = createdPointId
+ ? liveSelectedTrack.getAutomationPoints(automationType).find(point => point.getId() === createdPointId) ?? null
+ : null;
+ if (createdPoint) {
+ KGCore.instance().clearSelectedItems();
+ createdPoint.select();
+ KGCore.instance().addSelectedItem(createdPoint);
+ rangeAnchorRowIdRef.current = createdPoint.getId();
+ }
+ bumpTrackAutomationRedrawVersion();
+ }
+
+ await updateTrack(liveSelectedTrack);
+ refreshProjectState();
+ };
+
+ const handleDeleteSelectedRows = async (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (!liveSelectedTrack || visibleSelectedRows.length === 0) return;
+
+ const regionIds = visibleSelectedRows
+ .filter((row): row is TrackRegionRowData => row.type === 'region')
+ .map(row => row.region.getId());
+ const volumePointIds = visibleSelectedRows
+ .filter((row): row is TrackAutomationRowData => row.type === 'automation' && row.automationType === 'volume')
+ .map(row => row.point.getId());
+ const panPointIds = visibleSelectedRows
+ .filter((row): row is TrackAutomationRowData => row.type === 'automation' && row.automationType === 'pan')
+ .map(row => row.point.getId());
+
+ if (regionIds.length > 0) {
+ KGCore.instance().executeCommand(new DeleteMultipleRegionsCommand(
+ regionIds
+ ));
+ }
+ if (volumePointIds.length > 0) {
+ KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand(
+ liveSelectedTrack.getId(),
+ 'volume',
+ volumePointIds
+ ));
+ }
+ if (panPointIds.length > 0) {
+ KGCore.instance().executeCommand(new DeleteTrackAutomationPointsCommand(
+ liveSelectedTrack.getId(),
+ 'pan',
+ panPointIds
+ ));
+ }
+ if (volumePointIds.length > 0 || panPointIds.length > 0) {
+ bumpTrackAutomationRedrawVersion();
+ }
+
+ rangeAnchorRowIdRef.current = null;
+ await updateTrack(liveSelectedTrack);
+ refreshProjectState();
+ };
+
+ return (
+ <>
+
+ setShowRegions(value => !value)}>Regions
+ setShowVolume(value => !value)}>Volume
+ setShowPan(value => !value)}>Pan
+
+
+ {!liveSelectedTrack ? (
+
+ Please select a track to view regions and track automation.
+
+ ) : (
+ <>
+
+
+
+
+
+ setAddTrackItemType(value as AddTrackItemType)}
+ label="Add"
+ buttonClassName="event-list-type-button"
+ showValueAsLabel
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Position
+ Status
+ Val
+ Length/Info
+
+
+
+ {trackRows.map((row, index) => {
+ const positionText = formatMidiEventPosition(row.type === 'region' ? row.absoluteStartBeat : row.absoluteBeat, timeSignature, MIDI_EVENT_TICKS_PER_BEAT);
+ const statusText = row.type === 'region' ? row.statusLabel : row.automationType === 'volume' ? 'Volume' : 'Pan';
+ const valText = row.type === 'region' ? row.region.getName() : formatTrackAutomationValue(row.automationType, row.point.getValue());
+ const infoText = row.type === 'region' ? formatMidiEventLength(row.durationBeats, MIDI_EVENT_TICKS_PER_BEAT) : formatTrackAutomationInfo(row.automationType, row.point.getValue());
+ const isEditingPosition = editingCell?.rowId === row.id && editingCell.column === 'position';
+ const isEditingVal = editingCell?.rowId === row.id && editingCell.column === 'val';
+ const isEditingLength = editingCell?.rowId === row.id && editingCell.column === 'length';
+
+ return (
+ handleRowClick(row.id, index, event)}
+ onDoubleClick={(event) => {
+ event.stopPropagation();
+ clearPendingSingleClickSelection();
+ }}
+ >
+ { event.stopPropagation(); startEditingCell(row.id, 'position', positionText); }}>
+ {isEditingPosition ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : positionText}
+
+ {statusText}
+ {
+ if (row.type === 'region') return;
+ event.stopPropagation();
+ startEditingCell(row.id, 'val', valText);
+ }}>
+ {isEditingVal ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : valText}
+
+ {
+ if (row.type !== 'region') return;
+ event.stopPropagation();
+ startEditingCell(row.id, 'length', infoText);
+ }}>
+ {isEditingLength ? (
+ setEditingCell({ ...editingCell, value: event.target.value })}
+ onBlur={handleEditInputBlur}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => { void handleEditInputKeyDown(event); }}
+ />
+ ) : infoText}
+
+
+ );
+ })}
+
+
+
+ >
+ )}
+ >
+ );
+};
+
+export default TrackEventListTab;
diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx
index ebf625f..7c45e52 100644
--- a/src/components/piano-roll/PianoRoll.tsx
+++ b/src/components/piano-roll/PianoRoll.tsx
@@ -47,8 +47,8 @@ const PianoRoll: React.FC = ({
}) => {
const isSpectrogram = mode === 'spectrogram';
const isHybrid = mode === 'hybrid';
- const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showListEventPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion } = useProjectStore();
-
+ const { maxBars, tracks, updateTrack, timeSignature, showChatBox, showKGOnePanel, showEventListPanel, showInstrumentSelection, keySignature, selectedMode, setSelectedMode, playheadPosition, isPlaying, autoScrollEnabled, bpm, pianoRollScrollRequest, selectedNoteIds, automationRedrawVersion } = useProjectStore();
+
// Tool state for piano roll
const [activeTool, setActiveTool] = useState<'pointer' | 'pencil'>('pointer');
@@ -62,7 +62,7 @@ const PianoRoll: React.FC = ({
const [pianoRollZoom, setPianoRollZoom] = useState(1);
const [automationEnabled, setAutomationEnabled] = useState(false);
const [automationType, setAutomationType] = useState('pitch-bend');
-
+
// Quantization state
const [quantPosition, setQuantPosition] = useState('1/8');
const [quantLength, setQuantLength] = useState('1/8');
@@ -75,7 +75,7 @@ const PianoRoll: React.FC = ({
// Piano roll state with temporary initial values
const [position, setPosition] = useState(initialPosition || { x: 0, y: 0 });
-
+
// Blink effect state for toolbar button feedback
const [blinkButton, setBlinkButton] = useState(null);
const [size, setSize] = useState(initialSize || { width: 800, height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT });
@@ -102,7 +102,7 @@ const PianoRoll: React.FC = ({
// Ref for storing the setNoteUpdateCounter function
const triggerNoteUpdateRef = useRef> | null>(null);
-
+
// Ref for storing the deleteSelectedNotes function
const deleteSelectedNotesRef = useRef<(() => boolean) | null>(null);
@@ -133,32 +133,32 @@ const PianoRoll: React.FC = ({
y: window.innerHeight - statusBarHeight - pianoRollHeight
};
};
-
+
const calculateInitialSize = () => {
const rootStyles = getComputedStyle(document.documentElement);
const chatBoxWidthStr = rootStyles.getPropertyValue('--chat-box-width') || '350px';
const instrumentPanelWidthStr = rootStyles.getPropertyValue('--instrument-selection-width') || '300px';
const chatBoxWidth = parseInt(chatBoxWidthStr, 10) || 350;
const instrumentPanelWidth = parseInt(instrumentPanelWidthStr, 10) || 300;
-
+
let availableWidth = window.innerWidth;
- if (showChatBox || showKGOnePanel || showListEventPanel) availableWidth -= chatBoxWidth;
+ if (showChatBox || showKGOnePanel || showEventListPanel) availableWidth -= chatBoxWidth;
if (showInstrumentSelection) availableWidth -= instrumentPanelWidth;
-
+
// Ensure a sensible minimum starting width
const clampedWidth = Math.max(400, availableWidth);
-
+
return {
width: clampedWidth,
height: PIANO_ROLL_CONSTANTS.PIANO_ROLL_HEIGHT
};
};
-
+
// Set position and size only if not provided as props
if (!initialPosition) {
setPosition(calculateInitialPosition());
}
-
+
if (!initialSize) {
setSize(calculateInitialSize());
}
@@ -195,17 +195,17 @@ const PianoRoll: React.FC = ({
// Sync local state with KGPianoRollState on mount
useEffect(() => {
const pianoRollState = KGPianoRollState.instance();
-
+
// Sync snapping state
const currentSnap = pianoRollState.getCurrentSnap();
setSnapping(currentSnap);
-
+
// Sync tool state
const currentTool = pianoRollState.getActiveTool() as 'pointer' | 'pencil';
setActiveTool(currentTool);
setAutomationEnabled(pianoRollState.getAutomationViewEnabled());
setAutomationType(pianoRollState.getCurrentAutomationType() as PianoRollAutomationType);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Synced piano roll state on mount - snap: ${currentSnap}, tool: ${currentTool}`);
}
@@ -257,10 +257,10 @@ const PianoRoll: React.FC = ({
onClose();
}
};
-
+
// Add event listener
window.addEventListener('keydown', handleKeyDown);
-
+
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handleKeyDown);
@@ -284,13 +284,13 @@ const PianoRoll: React.FC = ({
e.preventDefault();
}
};
-
+
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// Set the flag to true as soon as any movement happens
wasDraggingRef.current = true;
-
+
setPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y
@@ -302,25 +302,25 @@ const PianoRoll: React.FC = ({
});
}
};
-
+
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// We keep wasDraggingRef.current as is - it will be used in handleTitleClick
// and reset on the next mousedown
};
-
+
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove as unknown as EventListener);
document.addEventListener('mouseup', handleMouseUp);
}
-
+
return () => {
document.removeEventListener('mousemove', handleMouseMove as unknown as EventListener);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, isResizing, dragOffset, position]);
-
+
// Handle title click to rename the region
const handleTitleClick = async () => {
// If we were just dragging, don't show the rename dialog
@@ -330,28 +330,28 @@ const PianoRoll: React.FC = ({
}
return;
}
-
+
if (!activeRegion) return;
-
+
// Show a prompt to get the new name
const newName = await showPrompt("Enter a new name for the region:", activeRegion.getName());
-
+
// If the user clicked Cancel or entered an empty string, do nothing
if (!newName || newName.trim() === '' || newName === activeRegion.getName()) return;
-
+
// Use command pattern to update the region name with undo support
try {
const command = new UpdateRegionCommand(activeRegion.getId(), { name: newName.trim() });
KGCore.instance().executeCommand(command);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Executed UpdateRegionCommand: renamed region ${activeRegion.getId()} to "${newName}" using command pattern`);
}
-
+
// Update the store to trigger re-render
const updatedTracks = [...tracks];
useProjectStore.setState({ tracks: updatedTracks });
-
+
} catch (error) {
console.error('Error renaming region:', error);
await showAlert('Failed to rename region. Please try again.');
@@ -366,7 +366,7 @@ const PianoRoll: React.FC = ({
console.log(`Selected tool: ${tool}`);
}
};
-
+
// Handle snapping selection
const handleSnappingSelect = useCallback((value: string) => {
setSnapping(value);
@@ -448,166 +448,166 @@ const PianoRoll: React.FC = ({
const handleSetDeleteNotesTrigger = (deleteFn: () => boolean) => {
deleteSelectedNotesRef.current = deleteFn;
};
-
+
// Quantize selected notes based on the selected quantization value
const quantizeSelectedNotes = useCallback((quantValue: string) => {
if (!activeRegion) return;
-
+
// Get the KGCore instance
const core = KGCore.instance();
-
+
// Get all selected notes
const selectedItems = core.getSelectedItems();
- const selectedNotes = selectedItems.filter(item =>
- item instanceof KGMidiNote &&
+ const selectedNotes = selectedItems.filter(item =>
+ item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
-
+
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for quantization');
}
return;
}
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantizing ${selectedNotes.length} selected notes with value: ${quantValue}`);
}
-
+
// Parse the quantization value (e.g., "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(quantValue.split('/')[1]);
if (isNaN(denominator)) {
console.error(`Invalid quantization value: ${quantValue}`);
return;
}
-
+
// Calculate the quantization step in beats
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
const { numerator, denominator: timeSigDenominator } = timeSignature;
-
+
// Calculate beats per whole note based on time signature
// In 4/4, a whole note is 4 beats
// In 6/8, a whole note is 6 beats (because each beat is an eighth note)
const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
-
+
// Calculate the quantization step in beats
// quantizationStep should ALWAYS be 4 / denominator regardless of time signature
const quantizationStep = 4 / denominator;
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Time signature: ${numerator}/${timeSigDenominator}`);
console.log(`Beats per whole note: ${beatsPerWholeNote}`);
console.log(`Quantization step: ${quantizationStep} beats`);
}
-
+
// Apply quantization to each selected note
selectedNotes.forEach(note => {
// Get the current start beat
const currentStartBeat = note.getStartBeat();
-
+
// Calculate the quantized start beat
const quantizedStartBeat = Math.round(currentStartBeat / quantizationStep) * quantizationStep;
-
+
// Calculate the duration of the note
const duration = note.getEndBeat() - currentStartBeat;
-
+
// Set the new start beat and maintain the duration
note.setStartBeat(quantizedStartBeat);
note.setEndBeat(quantizedStartBeat + duration);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantized note ${note.getId()}: ${currentStartBeat} -> ${quantizedStartBeat}`);
}
});
-
+
// Find the track that contains this region and update it
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
-
+
// Trigger a re-render by incrementing the note update counter
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Triggered note update to re-render quantized notes');
}
}
}, [activeRegion, timeSignature, updateTrack, tracks]);
-
+
// Quantize selected notes length based on the selected quantization value
const quantizeNoteLength = useCallback((quantValue: string) => {
if (!activeRegion) return;
-
+
// Get the KGCore instance
const core = KGCore.instance();
-
+
// Get all selected notes
const selectedItems = core.getSelectedItems();
- const selectedNotes = selectedItems.filter(item =>
- item instanceof KGMidiNote &&
+ const selectedNotes = selectedItems.filter(item =>
+ item instanceof KGMidiNote &&
activeRegion.getNotes().some(note => note.getId() === item.getId())
) as KGMidiNote[];
-
+
if (selectedNotes.length === 0) {
if (DEBUG_MODE.PIANO_ROLL) {
console.log('No notes selected for length quantization');
}
return;
}
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantizing length of ${selectedNotes.length} selected notes with value: ${quantValue}`);
}
-
+
// Parse the quantization value (e.g., "1/1", "1/2", "1/4", "1/8", "1/16", "1/32")
const denominator = parseInt(quantValue.split('/')[1]);
if (isNaN(denominator)) {
console.error(`Invalid quantization value: ${quantValue}`);
return;
}
-
+
// Calculate the quantization step in beats
// In a 4/4 time signature, a quarter note (1/4) is 1 beat
// In a 6/8 time signature, an eighth note (1/8) is 1 beat
const { numerator, denominator: timeSigDenominator } = timeSignature;
-
+
// Calculate beats per whole note based on time signature
// In 4/4, a whole note is 4 beats
// In 6/8, a whole note is 6 beats (because each beat is an eighth note)
const beatsPerWholeNote = numerator * (4 / timeSigDenominator);
-
+
// Calculate the quantization step in beats
// quantizationStep should ALWAYS be 4 / denominator regardless of time signature
const quantizationStep = 4 / denominator;
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Time signature: ${numerator}/${timeSigDenominator}`);
console.log(`Beats per whole note: ${beatsPerWholeNote}`);
console.log(`Length quantization step: ${quantizationStep} beats`);
}
-
+
// Apply quantization to each selected note
selectedNotes.forEach(note => {
// Get the current start and end beats
const startBeat = note.getStartBeat();
const currentEndBeat = note.getEndBeat();
-
+
// Calculate the current duration
const currentDuration = currentEndBeat - startBeat;
-
+
// Calculate the quantized duration
// If the current duration is less than the quantization step,
// extend it to match the quantization step exactly
// Otherwise, round to the nearest multiple of quantizationStep
let quantizedDuration;
-
+
if (currentDuration < quantizationStep) {
// For notes shorter than the quantization step, extend to exactly one step
quantizedDuration = quantizationStep;
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Extending short note ${note.getId()} from ${currentDuration} to ${quantizedDuration}`);
}
@@ -615,28 +615,28 @@ const PianoRoll: React.FC = ({
// For longer notes, round to nearest multiple of quantizationStep
quantizedDuration = Math.round(currentDuration / quantizationStep) * quantizationStep;
}
-
+
// Ensure minimum note length
quantizedDuration = Math.max(PIANO_ROLL_CONSTANTS.MIN_NOTE_LENGTH, quantizedDuration);
-
+
// Set the new end beat while maintaining the start beat
note.setEndBeat(startBeat + quantizedDuration);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantized note length ${note.getId()}: ${currentDuration} -> ${quantizedDuration}`);
}
});
-
+
// Find the track that contains this region and update it
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
if (track) {
updateTrack(track);
}
-
+
// Trigger a re-render by incrementing the note update counter
if (triggerNoteUpdateRef.current) {
triggerNoteUpdateRef.current(prev => prev + 1);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log('Triggered note update to re-render quantized note lengths');
}
@@ -647,19 +647,19 @@ const PianoRoll: React.FC = ({
const handleQuantSelect = useCallback((type: 'position' | 'length', value: string) => {
if (type === 'position') {
setQuantPosition(value);
-
+
// Apply quantization immediately when position quantization is changed
quantizeSelectedNotes(value);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-position selected: ${value}`);
}
} else {
setQuantLength(value);
-
+
// Apply length quantization immediately when length quantization is changed
quantizeNoteLength(value);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`quant-length selected: ${value}`);
}
@@ -710,24 +710,24 @@ const PianoRoll: React.FC = ({
// We have 8 octaves (0-7), and C4 is in the middle
// Each octave has 12 notes, each note is piano key height
// C4 is in octave 4, and C is the first note in each octave
-
+
// Calculate from the bottom:
// - Octaves 0-3 = 4 octaves = 4 * 12 * piano key height
// - Within octave 4, C is the first note (from bottom), so 0px additional
const keyHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
const c4Position = 4 * 12 * keyHeight; // pixels from bottom
-
+
// Total height of all notes (8 octaves * 12 notes * piano key height)
const totalHeight = 8 * 12 * keyHeight;
-
+
// Get the viewport height of the piano roll content
const viewportHeight = pianoRollNoteScrollRef.current.clientHeight;
-
+
// Calculate scroll position to center C4
// We need to scroll from the top, so we calculate:
// (total height - C4 position) - (viewport height / 2)
const scrollPosition = (totalHeight - c4Position) - (viewportHeight / 2);
-
+
// Scroll to the calculated position
pianoRollNoteScrollRef.current.scrollTop = Math.max(0, scrollPosition);
}
@@ -846,23 +846,23 @@ const PianoRoll: React.FC = ({
if (pianoRollNoteScrollRef.current && activeRegion) {
// Get the starting beat of the region
const startBeat = activeRegion.getStartFromBeat();
-
+
// Get the time signature to calculate beats per bar
const beatsPerBar = timeSignature.numerator;
-
+
// Calculate the bar number (0-indexed)
const barNumber = Math.floor(startBeat / beatsPerBar);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Scrolling to region's starting bar: ${barNumber + 1} (startBeat: ${startBeat}, beatsPerBar: ${beatsPerBar})`);
}
-
+
// Calculate the pixel position (each bar is --region-grid-bar-width wide, which is 160px by default)
const barWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-bar-width')) || 160;
-
+
// Calculate the scroll position to scroll to the starting bar
const scrollPosition = barNumber * barWidth;
-
+
// Scroll to the calculated position
pianoRollNoteScrollRef.current.scrollLeft = Math.max(0, scrollPosition);
}
@@ -874,8 +874,8 @@ const PianoRoll: React.FC = ({
// Skip if user is typing in an input field (including ChatBox)
const target = event.target as HTMLElement;
if (target && (
- target.tagName === 'INPUT' ||
- target.tagName === 'TEXTAREA' ||
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
target.contentEditable === 'true' ||
target.hasAttribute('data-chatbox-input') ||
target.closest('.chatbox-input')
@@ -894,7 +894,7 @@ const PianoRoll: React.FC = ({
}
return;
}
-
+
// Handle piano roll hotkeys
const configManager = ConfigManager.instance();
if (configManager.getIsInitialized()) {
@@ -916,21 +916,21 @@ const PianoRoll: React.FC = ({
const snap_1_4_key = configManager.get('hotkeys.piano_roll.snap_1_4') as string;
const snap_1_8_key = configManager.get('hotkeys.piano_roll.snap_1_8') as string;
const snap_1_16_key = configManager.get('hotkeys.piano_roll.snap_1_16') as string;
-
+
// Quantize position hotkeys
const qua_pos_1_4_key = configManager.get('hotkeys.piano_roll.qua_pos_1_4') as string;
const qua_pos_1_8_key = configManager.get('hotkeys.piano_roll.qua_pos_1_8') as string;
const qua_pos_1_16_key = configManager.get('hotkeys.piano_roll.qua_pos_1_16') as string;
-
+
// Quantize length hotkeys
const qua_len_1_4_key = configManager.get('hotkeys.piano_roll.qua_len_1_4') as string;
const qua_len_1_8_key = configManager.get('hotkeys.piano_roll.qua_len_1_8') as string;
const qua_len_1_16_key = configManager.get('hotkeys.piano_roll.qua_len_1_16') as string;
-
+
let actionType: 'snap' | 'quantize' | null = null;
let actionValue: string | null = null;
let quantType: 'position' | 'length' | null = null;
-
+
// Check snapping hotkeys
if (event.key === snap_none_key) {
actionType = 'snap';
@@ -973,21 +973,21 @@ const PianoRoll: React.FC = ({
actionValue = '1/16';
quantType = 'length';
}
-
+
if (actionType && actionValue) {
// Prevent default behavior
event.preventDefault();
-
+
if (actionType === 'snap') {
// Validate the snap value exists in snap options
if (KGPianoRollState.SNAP_OPTIONS.includes(actionValue)) {
// Change snapping value
handleSnappingSelect(actionValue);
-
+
// Trigger blink effect for visual feedback
setBlinkButton('snapping');
setTimeout(() => setBlinkButton(null), 200);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Snap hotkey triggered: ${event.key} → ${actionValue}`);
}
@@ -995,16 +995,16 @@ const PianoRoll: React.FC = ({
} else if (actionType === 'quantize' && quantType) {
// Validate the quantValue exists in the appropriate options
const validOptions = quantType === 'length' ? KGPianoRollState.QUANT_LEN_OPTIONS : KGPianoRollState.QUANT_POS_OPTIONS;
-
+
if (validOptions.includes(actionValue)) {
// Apply quantization
handleQuantSelect(quantType, actionValue);
-
+
// Trigger blink effect for visual feedback
const buttonName = quantType === 'length' ? 'quant-length' : 'quant-position';
setBlinkButton(buttonName);
setTimeout(() => setBlinkButton(null), 200);
-
+
if (DEBUG_MODE.PIANO_ROLL) {
console.log(`Quantize ${quantType} hotkey triggered: ${event.key} → ${actionValue}`);
}
@@ -1013,10 +1013,10 @@ const PianoRoll: React.FC = ({
}
}
};
-
+
// Add event listener
window.addEventListener('keydown', handlePianoRollKeyDown);
-
+
// Remove event listener on cleanup
return () => {
window.removeEventListener('keydown', handlePianoRollKeyDown);
@@ -1032,20 +1032,20 @@ const PianoRoll: React.FC = ({
return `${midiName} + ${audioName}`;
}
if (!activeRegion) return "EDIT NOTE CLIP";
-
+
// Calculate the bar and beat position of the region
const startBeat = activeRegion.getStartFromBeat();
const { bar, beatInBar } = beatsToBar(startBeat, timeSignature);
-
+
// Format as 1-indexed bar and beat (bar + 1, beatInBar + 1)
const barNumber = bar + 1;
const beatNumber = beatInBar + 1;
-
+
return `${activeRegion.getName()} (at ${barNumber}:${beatNumber})`;
};
return (
- = ({
onTitleClick={handleTitleClick}
onMouseDown={(e) => handleMouseDown(e, 'drag')}
/>
-
+
= ({
automationType={automationType}
automationRedrawVersion={automationRedrawVersion}
/>
-
- handleMouseDown(e, 'resize')}
>
diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx
index b4e6c5a..8a4a1ab 100644
--- a/src/components/settings/SettingsPanel.tsx
+++ b/src/components/settings/SettingsPanel.tsx
@@ -3,10 +3,11 @@ import './Settings.css';
import SettingsSidebar from './SettingsSidebar';
import GeneralSettings from './sections/GeneralSettings';
import BehaviorSettings from './sections/BehaviorSettings';
+import AudioIOSettings from './sections/AudioIOSettings';
import TemplatesSettings from './sections/TemplatesSettings';
import ChordGuideSettings from './sections/ChordGuideSettings';
-export type SettingsSection = 'general' | 'behavior' | 'templates' | 'chord_guide';
+export type SettingsSection = 'general' | 'audio_io' | 'behavior' | 'templates' | 'chord_guide';
interface SettingsPanelProps {
onClose: () => void;
@@ -21,6 +22,8 @@ const SettingsPanel: React.FC
= ({ onClose }) => {
return ;
case 'behavior':
return ;
+ case 'audio_io':
+ return ;
case 'templates':
return ;
case 'chord_guide':
@@ -46,4 +49,4 @@ const SettingsPanel: React.FC = ({ onClose }) => {
);
};
-export default SettingsPanel;
\ No newline at end of file
+export default SettingsPanel;
diff --git a/src/components/settings/SettingsSidebar.tsx b/src/components/settings/SettingsSidebar.tsx
index d85f1c1..e192acd 100644
--- a/src/components/settings/SettingsSidebar.tsx
+++ b/src/components/settings/SettingsSidebar.tsx
@@ -15,6 +15,7 @@ const SettingsSidebar: React.FC = ({
}) => {
const sections = [
{ id: 'general' as SettingsSection, label: 'General' },
+ { id: 'audio_io' as SettingsSection, label: 'Audio I/O' },
{ id: 'behavior' as SettingsSection, label: 'Behavior' },
{ id: 'templates' as SettingsSection, label: 'Templates' },
{ id: 'chord_guide' as SettingsSection, label: 'Chord Guide' }
@@ -48,4 +49,4 @@ const SettingsSidebar: React.FC = ({
);
};
-export default SettingsSidebar;
\ No newline at end of file
+export default SettingsSidebar;
diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts
index 5da492f..277c806 100644
--- a/src/components/settings/index.ts
+++ b/src/components/settings/index.ts
@@ -1,5 +1,6 @@
export { default as SettingsPanel } from './SettingsPanel.tsx';
export { default as SettingsSidebar } from './SettingsSidebar.tsx';
export { default as GeneralSettings } from './sections/GeneralSettings.tsx';
+export { default as AudioIOSettings } from './sections/AudioIOSettings.tsx';
export { default as BehaviorSettings } from './sections/BehaviorSettings.tsx';
-export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
\ No newline at end of file
+export { default as TemplatesSettings } from './sections/TemplatesSettings.tsx';
diff --git a/src/components/settings/sections/AudioIOSettings.test.tsx b/src/components/settings/sections/AudioIOSettings.test.tsx
new file mode 100644
index 0000000..ac83bae
--- /dev/null
+++ b/src/components/settings/sections/AudioIOSettings.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import AudioIOSettings from './AudioIOSettings';
+
+const configState = new Map([
+ ['audio.input_device_id', 'default'],
+ ['audio.output_device_id', 'default'],
+]);
+
+const configManagerMock = {
+ getIsInitialized: vi.fn(() => true),
+ initialize: vi.fn().mockResolvedValue(undefined),
+ get: vi.fn((key: string) => configState.get(key)),
+ set: vi.fn(async (key: string, value: unknown) => {
+ configState.set(key, value);
+ }),
+};
+
+vi.mock('../../../core/config/ConfigManager', () => ({
+ ConfigManager: {
+ instance: () => configManagerMock,
+ },
+}));
+
+vi.mock('../../../util/audioDeviceUtil', () => ({
+ enumerateAudioDevices: vi.fn().mockResolvedValue({
+ inputs: [
+ { deviceId: 'default', label: 'System Default', kind: 'audioinput', isDefault: true },
+ { deviceId: 'mic-1', label: 'Studio Mic', kind: 'audioinput', isDefault: false },
+ ],
+ outputs: [
+ { deviceId: 'default', label: 'System Default', kind: 'audiooutput', isDefault: true },
+ { deviceId: 'speaker-1', label: 'Monitor Out', kind: 'audiooutput', isDefault: false },
+ ],
+ labelsAvailable: true,
+ canSelectOutput: true,
+ canWatchDeviceChanges: false,
+ }),
+ getDefaultAudioDeviceOption: vi.fn((kind: 'audioinput' | 'audiooutput') => ({
+ deviceId: 'default',
+ label: 'System Default',
+ kind,
+ isDefault: true,
+ })),
+ promptForAudioOutputDevice: vi.fn().mockResolvedValue({
+ deviceId: 'speaker-1',
+ label: 'Monitor Out',
+ kind: 'audiooutput',
+ isDefault: false,
+ }),
+ supportsAudioContextSinkSelection: vi.fn(() => false),
+}));
+
+describe('AudioIOSettings', () => {
+ beforeEach(() => {
+ configState.set('audio.input_device_id', 'default');
+ configState.set('audio.output_device_id', 'default');
+ configManagerMock.get.mockClear();
+ configManagerMock.set.mockClear();
+ });
+
+ it('renders input/output selectors and refresh action', async () => {
+ render( );
+
+ expect(await screen.findByText('Audio I/O')).toBeTruthy();
+ expect(screen.getByLabelText('Audio Input Device')).toBeTruthy();
+ expect(screen.getByLabelText('Audio Output Device')).toBeTruthy();
+ expect(screen.getByText('Refresh Device List')).toBeTruthy();
+ });
+
+ it('persists input device changes', async () => {
+ render( );
+
+ const select = await screen.findByLabelText('Audio Input Device');
+ fireEvent.change(select, { target: { value: 'mic-1' } });
+
+ await waitFor(() => {
+ expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'mic-1');
+ });
+ });
+
+ it('resolves missing saved devices to System Default when validation is conclusive', async () => {
+ configState.set('audio.input_device_id', 'missing-input');
+
+ render( );
+
+ await waitFor(() => {
+ expect(configManagerMock.set).toHaveBeenCalledWith('audio.input_device_id', 'default');
+ });
+ });
+});
diff --git a/src/components/settings/sections/AudioIOSettings.tsx b/src/components/settings/sections/AudioIOSettings.tsx
new file mode 100644
index 0000000..f5b67f4
--- /dev/null
+++ b/src/components/settings/sections/AudioIOSettings.tsx
@@ -0,0 +1,243 @@
+import React, { useEffect, useState } from 'react';
+import { ConfigManager } from '../../../core/config/ConfigManager';
+import {
+ enumerateAudioDevices,
+ getDefaultAudioDeviceOption,
+ promptForAudioOutputDevice,
+ supportsAudioContextSinkSelection,
+ type AudioDeviceOption,
+} from '../../../util/audioDeviceUtil';
+
+const AudioIOSettings: React.FC = () => {
+ const [inputDeviceId, setInputDeviceId] = useState('default');
+ const [outputDeviceId, setOutputDeviceId] = useState('default');
+ const [inputs, setInputs] = useState([getDefaultAudioDeviceOption('audioinput')]);
+ const [outputs, setOutputs] = useState([getDefaultAudioDeviceOption('audiooutput')]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [deviceStatus, setDeviceStatus] = useState('');
+ const [supportsOutputPrompt, setSupportsOutputPrompt] = useState(false);
+ const [supportsOutputSink, setSupportsOutputSink] = useState(false);
+
+ const configManager = ConfigManager.instance();
+
+ useEffect(() => {
+ const initialize = async () => {
+ if (!configManager.getIsInitialized()) {
+ await configManager.initialize();
+ }
+
+ setInputDeviceId((configManager.get('audio.input_device_id') as string | undefined) ?? 'default');
+ setOutputDeviceId((configManager.get('audio.output_device_id') as string | undefined) ?? 'default');
+ setSupportsOutputSink(supportsAudioContextSinkSelection());
+ await refreshDevices();
+ setLoading(false);
+ };
+
+ void initialize();
+
+ const mediaDevices = navigator.mediaDevices;
+ const handleDeviceChange = () => {
+ void refreshDevices(false);
+ };
+
+ if (mediaDevices && typeof mediaDevices.addEventListener === 'function') {
+ mediaDevices.addEventListener('devicechange', handleDeviceChange);
+ return () => {
+ mediaDevices.removeEventListener('devicechange', handleDeviceChange);
+ };
+ }
+
+ return undefined;
+ }, [configManager]);
+
+ const refreshDevices = async (showStatus: boolean = true) => {
+ setRefreshing(true);
+ try {
+ const snapshot = await enumerateAudioDevices();
+ setSupportsOutputPrompt(snapshot.canSelectOutput);
+
+ const configuredInputId = (configManager.get('audio.input_device_id') as string | undefined) ?? 'default';
+ const configuredOutputId = (configManager.get('audio.output_device_id') as string | undefined) ?? 'default';
+
+ const hasInput = snapshot.inputs.some(device => device.deviceId === configuredInputId);
+ const hasOutput = snapshot.outputs.some(device => device.deviceId === configuredOutputId);
+ const nextInputs = !hasInput && configuredInputId !== 'default' && !snapshot.labelsAvailable
+ ? [
+ ...snapshot.inputs,
+ {
+ deviceId: configuredInputId,
+ label: 'Previously Selected Input (permission required to verify)',
+ kind: 'audioinput' as const,
+ isDefault: false,
+ },
+ ]
+ : snapshot.inputs;
+ const nextOutputs = !hasOutput && configuredOutputId !== 'default' && !snapshot.labelsAvailable
+ ? [
+ ...snapshot.outputs,
+ {
+ deviceId: configuredOutputId,
+ label: 'Previously Selected Output (permission required to verify)',
+ kind: 'audiooutput' as const,
+ isDefault: false,
+ },
+ ]
+ : snapshot.outputs;
+
+ setInputs(nextInputs);
+ setOutputs(nextOutputs);
+
+ if (!hasInput && configuredInputId !== 'default' && snapshot.labelsAvailable) {
+ setInputDeviceId('default');
+ await configManager.set('audio.input_device_id', 'default');
+ setDeviceStatus('Previously selected audio input device is unavailable; using System Default.');
+ } else {
+ setInputDeviceId(hasInput || !snapshot.labelsAvailable ? configuredInputId : 'default');
+ }
+
+ if (!hasOutput && configuredOutputId !== 'default' && snapshot.labelsAvailable) {
+ setOutputDeviceId('default');
+ await configManager.set('audio.output_device_id', 'default');
+ setDeviceStatus('Previously selected audio output device is unavailable; using System Default.');
+ } else {
+ setOutputDeviceId(hasOutput || !snapshot.labelsAvailable ? configuredOutputId : 'default');
+ }
+
+ if (showStatus) {
+ setDeviceStatus(current => current || 'Audio device list refreshed.');
+ }
+ } catch (error) {
+ console.error('Unable to refresh audio devices:', error);
+ setDeviceStatus('Unable to read audio devices from the browser.');
+ } finally {
+ setRefreshing(false);
+ }
+ };
+
+ const handleInputDeviceChange = async (value: string) => {
+ setInputDeviceId(value);
+ await configManager.set('audio.input_device_id', value);
+ setDeviceStatus(value === 'default'
+ ? 'Audio input will use System Default on the next recording session.'
+ : 'Audio input will change on the next recording session.');
+ };
+
+ const handleOutputDeviceChange = async (value: string) => {
+ setOutputDeviceId(value);
+ await configManager.set('audio.output_device_id', value);
+ setDeviceStatus(value === 'default'
+ ? 'Audio output will use System Default after refresh.'
+ : 'Audio output device saved. Refresh the page to apply it in v1.');
+ };
+
+ const handleChooseOutputDevice = async () => {
+ try {
+ const selectedDevice = await promptForAudioOutputDevice();
+ if (!selectedDevice) {
+ setDeviceStatus('This browser does not support prompting for audio output devices.');
+ return;
+ }
+
+ setOutputDeviceId(selectedDevice.deviceId);
+ await configManager.set('audio.output_device_id', selectedDevice.deviceId);
+ await refreshDevices(false);
+ setDeviceStatus('Output device selected. Refresh the page to apply it in v1.');
+ } catch (error) {
+ console.error('Unable to choose audio output device:', error);
+ setDeviceStatus('The browser did not allow selecting a non-default output device.');
+ }
+ };
+
+ return (
+
+
+
Audio I/O
+
+
+
+
+
+
Device Routing
+ void refreshDevices()}
+ disabled={refreshing}
+ >
+ {refreshing ? 'Refreshing…' : 'Refresh Device List'}
+
+
+
+
+ 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.
+
+
+
+
+ Audio Input Device
+
+
void handleInputDeviceChange(e.target.value)}
+ disabled={loading}
+ >
+ {inputs.map(device => (
+
+ {device.label}
+
+ ))}
+
+
+ Changes apply to the next audio recording. If the selected device is removed, KGStudio will fall back to System Default.
+
+
+
+
+
+ Audio Output Device
+
+
void handleOutputDeviceChange(e.target.value)}
+ disabled={loading}
+ >
+ {outputs.map(device => (
+
+ {device.label}
+
+ ))}
+
+ {supportsOutputPrompt && (
+
+ void handleChooseOutputDevice()}>
+ Choose Output Device…
+
+
+ )}
+
+ Output-device changes require refresh in v1. Browser support for non-default output routing is limited, so KGStudio will continue on System Default when unsupported.
+
+ {!supportsOutputSink && (
+
+ This browser does not expose reliable live Web Audio sink switching. Non-default output selection is best-effort and may remain on System Default.
+
+ )}
+
+
+ {deviceStatus && (
+
+ {deviceStatus}
+
+ )}
+
+
+
+ );
+};
+
+export default AudioIOSettings;
diff --git a/src/components/track/Region.css b/src/components/track/Region.css
index 620911c..e112248 100644
--- a/src/components/track/Region.css
+++ b/src/components/track/Region.css
@@ -97,6 +97,11 @@
border-color: rgba(255, 255, 255, 0.7);
}
+.track-region[data-preview-region='true'] {
+ opacity: 0.55;
+ pointer-events: none;
+}
+
.track-region.audio-region .region-header {
background-color: #4a8b5a;
}
diff --git a/src/components/track/RegionItem.test.tsx b/src/components/track/RegionItem.test.tsx
index ee903b9..5076ff0 100644
--- a/src/components/track/RegionItem.test.tsx
+++ b/src/components/track/RegionItem.test.tsx
@@ -19,6 +19,10 @@ describe('RegionItem', () => {
value: vi.fn(() => ({
clearRect: vi.fn(),
fillRect: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ stroke: vi.fn(),
})),
});
@@ -107,4 +111,40 @@ describe('RegionItem', () => {
expect(onDrag).toHaveBeenCalledWith('midi-1', 10, 0);
expect(onDragEnd).toHaveBeenCalledWith('midi-1');
});
+
+ it('renders preview waveform peaks on the canvas for recording previews', () => {
+ const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext');
+ const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ x: 0,
+ y: 0,
+ left: 0,
+ top: 0,
+ right: 120,
+ bottom: 60,
+ width: 120,
+ height: 60,
+ toJSON: () => ({}),
+ });
+ const { container } = renderRegion({
+ audioRegion: undefined,
+ midiRegion: undefined,
+ previewWaveformPeaks: [
+ { min: -0.25, max: 0.5 },
+ { min: -0.5, max: 0.25 },
+ ],
+ isPreview: true,
+ });
+
+ const context = getContextSpy.mock.results[0]?.value as {
+ beginPath: ReturnType;
+ lineTo: ReturnType;
+ stroke: ReturnType;
+ };
+
+ expect(container.querySelector('[data-preview-region="true"]')).toBeTruthy();
+ expect(context.beginPath).toHaveBeenCalled();
+ expect(context.lineTo).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ rectSpy.mockRestore();
+ });
});
diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx
index 81072b5..9f92bd5 100644
--- a/src/components/track/RegionItem.tsx
+++ b/src/components/track/RegionItem.tsx
@@ -8,6 +8,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
import { useProjectStore } from '../../stores/projectStore';
import { KGMainContentState } from '../../core/state/KGMainContentState';
+import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder';
const DRAG_START_THRESHOLD_PX = 4;
@@ -42,6 +43,8 @@ interface RegionItemProps {
// Audio region data for rendering waveform
audioRegion?: KGAudioRegion;
audioBuffer?: AudioBuffer;
+ previewWaveformPeaks?: AudioRecordingPeak[];
+ isPreview?: boolean;
}
const RegionItem: React.FC = ({
@@ -65,7 +68,9 @@ const RegionItem: React.FC = ({
onFineMoveEnd,
midiRegion,
audioRegion,
- audioBuffer
+ audioBuffer,
+ previewWaveformPeaks,
+ isPreview = false,
}) => {
// Get selection state and time signature from store
const { selectedRegionIds, timeSignature, bpm } = useProjectStore();
@@ -303,6 +308,41 @@ const RegionItem: React.FC = ({
ctx.stroke();
};
+ const renderPreviewWaveformOnCanvas = () => {
+ if (!canvasRef.current || !regionContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return;
+
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const contentRect = regionContentRef.current.getBoundingClientRect();
+ const width = contentRect.width;
+ const height = contentRect.height;
+
+ canvas.width = width;
+ canvas.height = height;
+ ctx.clearRect(0, 0, width, height);
+
+ const centerY = height / 2;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+
+ for (let x = 0; x < width; x++) {
+ const peakIndex = Math.min(
+ previewWaveformPeaks.length - 1,
+ Math.floor((x / Math.max(1, width)) * previewWaveformPeaks.length)
+ );
+ const peak = previewWaveformPeaks[peakIndex];
+ const yMin = centerY - peak.max * centerY;
+ const yMax = centerY - peak.min * centerY;
+ ctx.moveTo(x, yMin);
+ ctx.lineTo(x, yMax);
+ }
+
+ ctx.stroke();
+ };
+
// Create a stable reference to track note changes
const notesRef = useRef('');
const [noteUpdateTrigger, setNoteUpdateTrigger] = useState(0);
@@ -325,19 +365,23 @@ const RegionItem: React.FC = ({
// Set up canvas when component mounts or updates
useEffect(() => {
- if (audioRegion && audioBuffer) {
+ if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
+ renderPreviewWaveformOnCanvas();
+ } else if (audioRegion && audioBuffer) {
renderWaveformOnCanvas();
} else {
renderNotesOnCanvas();
}
- }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
+ }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]);
// Re-render canvas when region content size changes
useEffect(() => {
if (!regionContentRef.current) return;
const resizeObserver = new ResizeObserver(() => {
- if (audioRegion && audioBuffer) {
+ if (previewWaveformPeaks && previewWaveformPeaks.length > 0) {
+ renderPreviewWaveformOnCanvas();
+ } else if (audioRegion && audioBuffer) {
renderWaveformOnCanvas();
} else {
renderNotesOnCanvas();
@@ -351,12 +395,13 @@ const RegionItem: React.FC = ({
resizeObserver.unobserve(regionContentRef.current);
}
};
- }, [midiRegion, audioRegion, audioBuffer, timeSignature, bpm]);
+ }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]);
// Handle mouse movement to detect edge proximity
const handleMouseMove = (e: React.MouseEvent) => {
// Skip if already resizing or dragging
if (isResizingRef.current || isDraggingRef.current) return;
+ if (isPreview) return;
// Disable move and resize when pencil tool is active
const activeTool = KGMainContentState.instance().getActiveTool();
@@ -402,6 +447,10 @@ const RegionItem: React.FC = ({
// Handle mouse down for resize or drag
const handleMouseDown = (e: React.MouseEvent) => {
// Disable move and resize when pencil tool is active
+ if (isPreview) {
+ return;
+ }
+
const activeTool = KGMainContentState.instance().getActiveTool();
if (activeTool === 'pencil') {
// Still allow click events to pass through for region selection
@@ -604,11 +653,12 @@ const RegionItem: React.FC = ({
= ({
{name}
-
+ {!isPreview &&
{!audioRegion && (
= ({
{fineDeltaDisplay}
)}
-
+
}
diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx
new file mode 100644
index 0000000..b92c91f
--- /dev/null
+++ b/src/components/track/TrackGridItem.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import TrackGridItem from './TrackGridItem';
+import { KGAudioTrack } from '../../core/track/KGAudioTrack';
+
+vi.mock('../../stores/projectStore', () => ({
+ useProjectStore: (selector?: (state: {
+ selectedRegionIds: string[];
+ activeTrackAutomationTrackId: string | null;
+ activeTrackAutomationType: null;
+ trackAutomationRedrawVersion: number;
+ recordingMode: 'audio' | 'midi' | null;
+ recordingTargetTrackIndex: number | null;
+ recordingCommitStartBeatAbsolute: number;
+ recordingAudioPreviewCurrentBeat: number;
+ recordingAudioPreviewPeaks: Array<{ min: number; max: number }>;
+ recordingAudioPreviewFileName: string | null;
+ timeSignature: { numerator: number; denominator: number };
+ }) => unknown) => {
+ const state = {
+ selectedRegionIds: [],
+ activeTrackAutomationTrackId: null,
+ activeTrackAutomationType: null,
+ trackAutomationRedrawVersion: 0,
+ recordingMode: 'audio' as const,
+ recordingTargetTrackIndex: 0,
+ recordingCommitStartBeatAbsolute: 4,
+ recordingAudioPreviewCurrentBeat: 8,
+ recordingAudioPreviewPeaks: [{ min: -0.5, max: 0.5 }],
+ recordingAudioPreviewFileName: 'Recording',
+ timeSignature: { numerator: 4, denominator: 4 },
+ };
+ return selector ? selector(state) : state;
+ },
+}));
+
+describe('TrackGridItem recording preview', () => {
+ beforeAll(() => {
+ Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
+ value: vi.fn(() => ({
+ clearRect: vi.fn(),
+ fillRect: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ stroke: vi.fn(),
+ })),
+ });
+
+ class ResizeObserverMock {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock);
+ });
+
+ it('renders a non-interactive preview region on the recording audio track', () => {
+ const track = new KGAudioTrack('Audio Track', 1);
+ track.setTrackIndex(0);
+
+ const view = render(
+
+ );
+
+ const previewRegion = view.container.querySelector('[data-preview-region="true"]');
+ expect(previewRegion).toBeTruthy();
+ });
+});
diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx
index 1b3742f..8345a8d 100644
--- a/src/components/track/TrackGridItem.tsx
+++ b/src/components/track/TrackGridItem.tsx
@@ -66,6 +66,13 @@ const TrackGridItem: React.FC = ({
const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId);
const activeTrackAutomationType = useProjectStore(state => state.activeTrackAutomationType);
const trackAutomationRedrawVersion = useProjectStore(state => state.trackAutomationRedrawVersion);
+ const recordingMode = useProjectStore(state => state.recordingMode);
+ const recordingTargetTrackIndex = useProjectStore(state => state.recordingTargetTrackIndex);
+ const recordingCommitStartBeatAbsolute = useProjectStore(state => state.recordingCommitStartBeatAbsolute);
+ const recordingAudioPreviewCurrentBeat = useProjectStore(state => state.recordingAudioPreviewCurrentBeat);
+ const recordingAudioPreviewPeaks = useProjectStore(state => state.recordingAudioPreviewPeaks);
+ const recordingAudioPreviewFileName = useProjectStore(state => state.recordingAudioPreviewFileName);
+ const storeTimeSignature = useProjectStore(state => state.timeSignature);
const [containerWidth, setContainerWidth] = useState(0);
const [resizingRegion, setResizingRegion] = useState(null);
const [draggingRegion, setDraggingRegion] = useState(null);
@@ -543,6 +550,16 @@ const TrackGridItem: React.FC = ({
// Filter regions for this track
const trackRegions = regions.filter(region => region.trackIndex === index);
const isAutomationActive = activeTrackAutomationTrackId === track.getId().toString() && activeTrackAutomationType !== null;
+ const shouldRenderRecordingPreview = recordingMode === 'audio'
+ && recordingTargetTrackIndex === index
+ && recordingAudioPreviewCurrentBeat >= recordingCommitStartBeatAbsolute;
+ const previewRegionStyle = shouldRenderRecordingPreview
+ ? {
+ left: `${(recordingCommitStartBeatAbsolute / storeTimeSignature.numerator) * (containerWidth / maxBars)}px`,
+ width: `${Math.max(0, ((recordingAudioPreviewCurrentBeat - recordingCommitStartBeatAbsolute) / storeTimeSignature.numerator) * (containerWidth / maxBars))}px`,
+ position: 'absolute' as const,
+ }
+ : null;
return (
= ({
/>
);
})}
+ {shouldRenderRecordingPreview && previewRegionStyle && (
+
+ )}
{isAutomationActive && activeTrackAutomationType && (
)}
diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts
index 6ee0b81..43eabf0 100644
--- a/src/core/KGCore.ts
+++ b/src/core/KGCore.ts
@@ -49,6 +49,7 @@ export class KGCore {
// Callback for external state updates (e.g., store)
private playheadUpdateCallback: ((position: number) => void) | null = null;
private playbackStateChangeCallback: ((isPlaying: boolean) => void) | null = null;
+ private loopBoundaryReachedCallback: ((loopEndBeat: number) => void) | null = null;
// Selection change callbacks for store synchronization
private selectionChangeCallbacks: (() => void)[] = [];
@@ -200,6 +201,10 @@ export class KGCore {
this.playbackStateChangeCallback = callback;
}
+ public setLoopBoundaryReachedCallback(callback: ((loopEndBeat: number) => void) | null): void {
+ this.loopBoundaryReachedCallback = callback;
+ }
+
// Selection change callback management
public onSelectionChanged(callback: () => void): void {
this.selectionChangeCallbacks.push(callback);
@@ -414,6 +419,14 @@ export class KGCore {
// Wrap playhead position within loop range
if (newPosition >= loopEndBeats) {
+ if (this.loopBoundaryReachedCallback) {
+ const callback = this.loopBoundaryReachedCallback;
+ this.loopBoundaryReachedCallback = null;
+ this.setPlayheadPosition(loopEndBeats);
+ callback(loopEndBeats);
+ return;
+ }
+
// Calculate how far we've overshot and wrap back
const overshot = newPosition - loopEndBeats;
newPosition = loopStartBeats + (overshot % loopLengthBeats);
diff --git a/src/core/audio-interface/KGAudioInterface.test.ts b/src/core/audio-interface/KGAudioInterface.test.ts
index 798fae7..f002d31 100644
--- a/src/core/audio-interface/KGAudioInterface.test.ts
+++ b/src/core/audio-interface/KGAudioInterface.test.ts
@@ -24,6 +24,23 @@ import { ConfigManager } from '../config/ConfigManager';
import { KGAudioInterface } from './KGAudioInterface';
import { MIDI_PITCH_BEND_CENTER, midiPitchBendToNormalized } from '../../util/midiUtil';
+function createMockAudioBus() {
+ return {
+ resetLiveMidiPitchBend: vi.fn(),
+ setLiveMidiPitchBend: vi.fn(),
+ scheduleLiveMidiPitchBend: vi.fn(),
+ setLiveMidiExpression: vi.fn(),
+ scheduleLiveMidiExpression: vi.fn(),
+ setLiveMidiSustain: vi.fn(),
+ setAutomationVolume: vi.fn(),
+ setAutomationPan: vi.fn(),
+ scheduleAutomationPan: vi.fn(),
+ applyEffectiveVolume: vi.fn(),
+ getSolo: vi.fn().mockReturnValue(false),
+ shouldPlayWithSolo: vi.fn().mockReturnValue(true),
+ };
+}
+
describe('KGAudioInterface preroll playback', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -125,14 +142,7 @@ describe('KGAudioInterface preroll playback', () => {
const track = createMockMidiTrack({ id: 1, regions: [region] });
const project = createMockProject({ tracks: [track] });
const audio = KGAudioInterface.instance();
- const audioBus = {
- resetLiveMidiPitchBend: vi.fn(),
- setLiveMidiPitchBend: vi.fn(),
- scheduleLiveMidiPitchBend: vi.fn(),
- setLiveMidiExpression: vi.fn(),
- setLiveMidiSustain: vi.fn(),
- shouldPlayWithSolo: vi.fn().mockReturnValue(true),
- };
+ const audioBus = createMockAudioBus();
;(audio as unknown as { trackAudioBuses: Map
}).trackAudioBuses.set('1', audioBus);
audio.preparePlayback(project, 0);
@@ -152,14 +162,7 @@ describe('KGAudioInterface preroll playback', () => {
const track = createMockMidiTrack({ id: 1, regions: [region] });
const project = createMockProject({ tracks: [track] });
const audio = KGAudioInterface.instance();
- const audioBus = {
- resetLiveMidiPitchBend: vi.fn(),
- setLiveMidiPitchBend: vi.fn(),
- scheduleLiveMidiPitchBend: vi.fn(),
- setLiveMidiExpression: vi.fn(),
- setLiveMidiSustain: vi.fn(),
- shouldPlayWithSolo: vi.fn().mockReturnValue(true),
- };
+ const audioBus = createMockAudioBus();
;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus);
audio.preparePlayback(project, 0);
@@ -178,14 +181,7 @@ describe('KGAudioInterface preroll playback', () => {
const track = createMockMidiTrack({ id: 1, regions: [region] });
const project = createMockProject({ tracks: [track] });
const audio = KGAudioInterface.instance();
- const audioBus = {
- resetLiveMidiPitchBend: vi.fn(),
- setLiveMidiPitchBend: vi.fn(),
- scheduleLiveMidiPitchBend: vi.fn(),
- setLiveMidiExpression: vi.fn(),
- setLiveMidiSustain: vi.fn(),
- shouldPlayWithSolo: vi.fn().mockReturnValue(true),
- };
+ const audioBus = createMockAudioBus();
;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus);
audio.preparePlayback(project, 2);
@@ -206,14 +202,7 @@ describe('KGAudioInterface preroll playback', () => {
project.setLoopingRange([1, 1]);
const audio = KGAudioInterface.instance();
- const audioBus = {
- resetLiveMidiPitchBend: vi.fn(),
- setLiveMidiPitchBend: vi.fn(),
- scheduleLiveMidiPitchBend: vi.fn(),
- setLiveMidiExpression: vi.fn(),
- setLiveMidiSustain: vi.fn(),
- shouldPlayWithSolo: vi.fn().mockReturnValue(true),
- };
+ const audioBus = createMockAudioBus();
;(audio as unknown as { trackAudioBuses: Map }).trackAudioBuses.set('1', audioBus);
audio.preparePlayback(project, 5);
diff --git a/src/core/audio-interface/KGAudioInterface.ts b/src/core/audio-interface/KGAudioInterface.ts
index ba86350..bf785ea 100644
--- a/src/core/audio-interface/KGAudioInterface.ts
+++ b/src/core/audio-interface/KGAudioInterface.ts
@@ -24,6 +24,12 @@ import {
import * as Tone from 'tone';
import { KGAudioBus } from './KGAudioBus';
import { KGAudioPlayerBus } from './KGAudioPlayerBus';
+import {
+ KGAudioRecorder,
+ type AudioRecordingPeak,
+ type AudioRecordingResult,
+ type AudioRecordingStartResult,
+} from './KGAudioRecorder';
import type { InstrumentType } from '../track/KGMidiTrack';
import type { KGAudioRegion } from '../region/KGAudioRegion';
import { KGCore } from '../KGCore';
@@ -80,6 +86,9 @@ export class KGAudioInterface {
private captureDestination: MediaStreamAudioDestinationNode | null = null;
private captureStream: MediaStream | null = null;
+ // Microphone recorder
+ private audioRecorder: KGAudioRecorder = new KGAudioRecorder();
+
// Private constructor to prevent direct instantiation
private constructor() {
console.log("KGAudioInterface initialized");
@@ -194,6 +203,8 @@ export class KGAudioInterface {
this.captureDestination = null;
this.captureStream = null;
}
+
+ await this.audioRecorder.cancel();
this.isInitialized = false;
this.isAudioContextStarted = false;
@@ -1071,6 +1082,45 @@ export class KGAudioInterface {
}
}
+ public async startAudioRecording(
+ inputDeviceId: string = 'default',
+ onPeaks?: (peaks: AudioRecordingPeak[]) => void
+ ): Promise {
+ return await this.audioRecorder.start(inputDeviceId, onPeaks);
+ }
+
+ public async stopAudioRecording(): Promise {
+ return await this.audioRecorder.stop();
+ }
+
+ public async cancelAudioRecording(): Promise {
+ await this.audioRecorder.cancel();
+ }
+
+ public async applyConfiguredOutputDevice(outputDeviceId: string): Promise {
+ if (outputDeviceId === 'default') {
+ return false;
+ }
+
+ const rawContext = Tone.getContext().rawContext as AudioContext & {
+ setSinkId?: (sinkId: string) => Promise;
+ sinkId?: string;
+ };
+
+ if (typeof rawContext.setSinkId !== 'function') {
+ return false;
+ }
+
+ try {
+ await rawContext.setSinkId(outputDeviceId);
+ console.log(`Applied audio output device ${outputDeviceId}`);
+ return true;
+ } catch (error) {
+ console.warn(`Unable to apply audio output device ${outputDeviceId}:`, error);
+ return false;
+ }
+ }
+
/**
* Clear all scheduled events
*/
diff --git a/src/core/audio-interface/KGAudioRecorder.ts b/src/core/audio-interface/KGAudioRecorder.ts
new file mode 100644
index 0000000..2bdf24a
--- /dev/null
+++ b/src/core/audio-interface/KGAudioRecorder.ts
@@ -0,0 +1,289 @@
+import * as Tone from 'tone';
+
+export interface AudioRecordingPeak {
+ min: number;
+ max: number;
+}
+
+export interface AudioRecordingResult {
+ blob: Blob;
+ mimeType: string;
+ durationSeconds: number;
+ peaks: AudioRecordingPeak[];
+}
+
+export interface AudioRecordingStartResult {
+ usedDeviceId: string;
+ fellBackToDefault: boolean;
+}
+
+export class KGAudioRecorder {
+ private static readonly DEBUG_LOG_PREFIX = '[KGAudioRecorder]';
+ private static readonly PEAK_LOG_EVERY_FRAMES = 20;
+
+ private mediaStream: MediaStream | null = null;
+ private mediaRecorder: MediaRecorder | null = null;
+ private audioSource: MediaStreamAudioSourceNode | null = null;
+ private analyserNode: AnalyserNode | null = null;
+ private peakPollIntervalId: number | null = null;
+ private chunks: Blob[] = [];
+ private peaks: AudioRecordingPeak[] = [];
+ private recordingStartedAtMs: number | null = null;
+ private onPeaks: ((peaks: AudioRecordingPeak[]) => void) | null = null;
+ private peakFrameCount: number = 0;
+
+ public async start(
+ inputDeviceId: string = 'default',
+ onPeaks?: (peaks: AudioRecordingPeak[]) => void
+ ): Promise {
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+ throw new Error('Audio recording is already in progress.');
+ }
+
+ if (!navigator.mediaDevices?.getUserMedia) {
+ throw new Error('This browser does not support microphone recording.');
+ }
+
+ if (typeof MediaRecorder === 'undefined') {
+ throw new Error('This browser does not support MediaRecorder.');
+ }
+
+ this.cleanup(false);
+
+ this.onPeaks = onPeaks ?? null;
+ this.chunks = [];
+ this.peaks = [];
+ this.peakFrameCount = 0;
+
+ let stream: MediaStream;
+ let usedDeviceId = inputDeviceId;
+ let fellBackToDefault = false;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: inputDeviceId === 'default'
+ ? true
+ : { deviceId: { exact: inputDeviceId } },
+ });
+ } catch (error) {
+ if (inputDeviceId === 'default') {
+ throw error;
+ }
+
+ console.warn(`${KGAudioRecorder.DEBUG_LOG_PREFIX} selected input unavailable, retrying default input`, {
+ inputDeviceId,
+ error,
+ });
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ usedDeviceId = 'default';
+ fellBackToDefault = true;
+ }
+
+ this.mediaStream = stream;
+ this.logInputTracks(stream);
+
+ const audioContext = Tone.getContext().rawContext as AudioContext;
+ this.audioSource = audioContext.createMediaStreamSource(stream);
+ this.analyserNode = audioContext.createAnalyser();
+ this.analyserNode.fftSize = 2048;
+ this.audioSource.connect(this.analyserNode);
+
+ const mimeType = this.getPreferredMimeType();
+ this.mediaRecorder = mimeType
+ ? new MediaRecorder(stream, { mimeType })
+ : new MediaRecorder(stream);
+ console.info(
+ `${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder created`,
+ {
+ mimeType: this.mediaRecorder.mimeType || mimeType || 'browser-default',
+ audioContextState: audioContext.state,
+ sampleRate: audioContext.sampleRate,
+ }
+ );
+ this.mediaRecorder.ondataavailable = (event: BlobEvent) => {
+ if (event.data.size > 0) {
+ this.chunks.push(event.data);
+ console.info(
+ `${KGAudioRecorder.DEBUG_LOG_PREFIX} dataavailable`,
+ { chunkBytes: event.data.size, totalChunks: this.chunks.length }
+ );
+ }
+ };
+ this.mediaRecorder.onerror = (event) => {
+ console.error(`${KGAudioRecorder.DEBUG_LOG_PREFIX} MediaRecorder error`, event);
+ };
+
+ this.mediaRecorder.start(250);
+ this.recordingStartedAtMs = performance.now();
+ console.info(`${KGAudioRecorder.DEBUG_LOG_PREFIX} recording started`);
+ this.startPeakPolling();
+ return {
+ usedDeviceId,
+ fellBackToDefault,
+ };
+ }
+
+ public async stop(): Promise {
+ const recorder = this.mediaRecorder;
+ if (!recorder) {
+ return null;
+ }
+
+ if (recorder.state === 'inactive') {
+ const blob = this.chunks.length > 0
+ ? new Blob(this.chunks, { type: recorder.mimeType || 'audio/webm' })
+ : null;
+ const durationSeconds = this.recordingStartedAtMs === null
+ ? 0
+ : Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
+ this.cleanup(false);
+ console.info(
+ `${KGAudioRecorder.DEBUG_LOG_PREFIX} stop requested on inactive recorder`,
+ { blobBytes: blob?.size ?? 0, peakFrames: this.peaks.length, durationSeconds }
+ );
+ return blob
+ ? { blob, mimeType: blob.type || recorder.mimeType || 'audio/webm', durationSeconds, peaks: [...this.peaks] }
+ : null;
+ }
+
+ return await new Promise((resolve) => {
+ const handleStop = () => {
+ recorder.removeEventListener('stop', handleStop);
+ this.capturePeakFrame();
+ const mimeType = recorder.mimeType || 'audio/webm';
+ const blob = new Blob(this.chunks, { type: mimeType });
+ const durationSeconds = this.recordingStartedAtMs === null
+ ? 0
+ : Math.max(0, (performance.now() - this.recordingStartedAtMs) / 1000);
+ const peaks = [...this.peaks];
+ console.info(
+ `${KGAudioRecorder.DEBUG_LOG_PREFIX} recording stopped`,
+ {
+ mimeType,
+ blobBytes: blob.size,
+ peakFrames: peaks.length,
+ durationSeconds,
+ }
+ );
+ this.cleanup(false);
+ resolve(blob.size > 0 ? { blob, mimeType: blob.type || mimeType, durationSeconds, peaks } : null);
+ };
+
+ recorder.addEventListener('stop', handleStop, { once: true });
+ recorder.stop();
+ });
+ }
+
+ public async cancel(): Promise {
+ const recorder = this.mediaRecorder;
+ if (recorder && recorder.state !== 'inactive') {
+ await new Promise((resolve) => {
+ recorder.addEventListener('stop', () => resolve(), { once: true });
+ recorder.stop();
+ });
+ }
+
+ this.cleanup(false);
+ }
+
+ private startPeakPolling(): void {
+ this.stopPeakPolling();
+ this.peakPollIntervalId = window.setInterval(() => {
+ this.capturePeakFrame();
+ }, 50);
+ }
+
+ private stopPeakPolling(): void {
+ if (this.peakPollIntervalId !== null) {
+ window.clearInterval(this.peakPollIntervalId);
+ this.peakPollIntervalId = null;
+ }
+ }
+
+ private capturePeakFrame(): void {
+ if (!this.analyserNode) {
+ return;
+ }
+
+ const buffer = new Float32Array(this.analyserNode.fftSize);
+ this.analyserNode.getFloatTimeDomainData(buffer);
+
+ let min = 1;
+ let max = -1;
+ for (const sample of buffer) {
+ if (sample < min) min = sample;
+ if (sample > max) max = sample;
+ }
+
+ this.peaks.push({ min, max });
+ this.peakFrameCount += 1;
+
+ if (this.peakFrameCount === 1 || this.peakFrameCount % KGAudioRecorder.PEAK_LOG_EVERY_FRAMES === 0) {
+ console.info(
+ `${KGAudioRecorder.DEBUG_LOG_PREFIX} analyser peak`,
+ {
+ frame: this.peakFrameCount,
+ min: Number(min.toFixed(4)),
+ max: Number(max.toFixed(4)),
+ span: Number((max - min).toFixed(4)),
+ }
+ );
+ }
+
+ this.onPeaks?.([...this.peaks]);
+ }
+
+ private getPreferredMimeType(): string | undefined {
+ const candidates = [
+ 'audio/webm;codecs=opus',
+ 'audio/ogg;codecs=opus',
+ 'audio/mp4',
+ 'audio/webm',
+ ];
+
+ return candidates.find(type => typeof MediaRecorder.isTypeSupported === 'function' && MediaRecorder.isTypeSupported(type));
+ }
+
+ private cleanup(resetPeaks: boolean): void {
+ this.stopPeakPolling();
+
+ if (this.audioSource) {
+ this.audioSource.disconnect();
+ this.audioSource = null;
+ }
+
+ if (this.analyserNode) {
+ this.analyserNode.disconnect();
+ this.analyserNode = null;
+ }
+
+ if (this.mediaStream) {
+ this.mediaStream.getTracks().forEach(track => track.stop());
+ this.mediaStream = null;
+ }
+
+ this.mediaRecorder = null;
+ this.recordingStartedAtMs = null;
+ this.onPeaks = null;
+ this.peakFrameCount = 0;
+
+ if (resetPeaks) {
+ this.peaks = [];
+ this.chunks = [];
+ }
+ }
+
+ private logInputTracks(stream: MediaStream): void {
+ const tracks = stream.getAudioTracks();
+ console.info(
+ `${KGAudioRecorder.DEBUG_LOG_PREFIX} acquired audio stream`,
+ tracks.map(track => ({
+ id: track.id,
+ label: track.label,
+ enabled: track.enabled,
+ muted: track.muted,
+ readyState: track.readyState,
+ settings: typeof track.getSettings === 'function' ? track.getSettings() : {},
+ }))
+ );
+ }
+}
diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts
index 4527fb6..b38884f 100644
--- a/src/core/config/ConfigManager.ts
+++ b/src/core/config/ConfigManager.ts
@@ -78,8 +78,10 @@ interface AppConfig {
};
audio: {
enable_audio_capture_for_screen_sharing: boolean;
+ input_device_id: string;
lookahead_time: number;
midi_automation_interpolation_interval_ms: number;
+ output_device_id: string;
playback_delay: number;
recording_offset: number;
};
@@ -253,8 +255,10 @@ export class ConfigManager {
},
audio: {
enable_audio_capture_for_screen_sharing: false,
+ input_device_id: 'default',
lookahead_time: 0.05,
midi_automation_interpolation_interval_ms: 10,
+ output_device_id: 'default',
playback_delay: 0.2,
recording_offset: 0
},
diff --git a/src/hooks/useGlobalKeyboardHandler.ts b/src/hooks/useGlobalKeyboardHandler.ts
index f2d7c90..2e005c9 100644
--- a/src/hooks/useGlobalKeyboardHandler.ts
+++ b/src/hooks/useGlobalKeyboardHandler.ts
@@ -8,6 +8,7 @@ import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
import { KGCore } from '../core/KGCore';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
+import { KGAudioTrack } from '../core/track/KGAudioTrack';
import { showAlert } from '../util/dialogUtil';
/**
@@ -164,7 +165,13 @@ export const useGlobalKeyboardHandler = () => {
event.preventDefault();
if (isRecording) {
stopRecording();
- setStatus('Recording stopped — notes committed');
+ setStatus('Recording stopped');
+ return;
+ }
+ const selectedTrack = useProjectStore.getState().tracks.find(track => track.getId().toString() === useProjectStore.getState().selectedTrackId) ?? null;
+ if (selectedTrack instanceof KGAudioTrack) {
+ startRecording();
+ setStatus('Audio recording started...');
return;
}
const candidateId = activeRegionId ?? lastSelectedRegionId;
diff --git a/src/main.tsx b/src/main.tsx
index 7ef77d8..1740c22 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -7,8 +7,10 @@ import App from './App.tsx';
import DialogProvider from './components/common/DialogProvider';
import { KGCore } from './core/KGCore';
import { KGAudioInterface } from './core/audio-interface/KGAudioInterface';
+import { ConfigManager } from './core/config/ConfigManager';
import { KGMidiInput } from './core/midi-input/KGMidiInput';
import { KGDebugger } from './core/KGDebugger';
+import { enumerateAudioDevices, validateConfiguredAudioDevices } from './util/audioDeviceUtil';
const root = createRoot(document.getElementById('root')!);
@@ -58,6 +60,37 @@ if (!window.isSecureContext) {
// Initialize KGCore instance
await KGCore.instance().initialize();
+const configManager = ConfigManager.instance();
+try {
+ const deviceSnapshot = await enumerateAudioDevices();
+ const validatedDevices = validateConfiguredAudioDevices(
+ configManager.get('audio.input_device_id') as string | undefined,
+ configManager.get('audio.output_device_id') as string | undefined,
+ deviceSnapshot
+ );
+
+ const updates: Array> = [];
+ const statusMessages: string[] = [];
+
+ if (validatedDevices.inputFellBackToDefault) {
+ updates.push(configManager.set('audio.input_device_id', 'default'));
+ statusMessages.push('Previously selected audio input device is unavailable; using System Default.');
+ }
+ if (validatedDevices.outputFellBackToDefault) {
+ updates.push(configManager.set('audio.output_device_id', 'default'));
+ statusMessages.push('Previously selected audio output device is unavailable; using System Default.');
+ }
+
+ if (updates.length > 0) {
+ await Promise.all(updates);
+ KGCore.instance().setStatus(statusMessages.join(' '));
+ }
+
+ await KGAudioInterface.instance().applyConfiguredOutputDevice(validatedDevices.outputDeviceId);
+} catch (error) {
+ console.warn('Audio device validation skipped:', error);
+}
+
// Initialize KGMidiInput instance
await KGMidiInput.instance().initialize();
diff --git a/src/stores/projectStore.test.ts b/src/stores/projectStore.test.ts
index d3f2109..5457b24 100644
--- a/src/stores/projectStore.test.ts
+++ b/src/stores/projectStore.test.ts
@@ -1,24 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
+import { KGTrack } from '../core/track/KGTrack';
import { KGMidiTrack } from '../core/track/KGMidiTrack';
+let mockTracks: KGTrack[] = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
const mockProject = {
getTimeSignature: () => ({ numerator: 4, denominator: 4 }),
getMaxBars: () => 32,
getBarWidthMultiplier: () => 1,
- getTracks: () => [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')],
+ getTracks: () => mockTracks,
getBpm: () => 120,
getKeySignature: () => 'C major',
getName: () => 'Test Project',
getSelectedMode: () => 'major',
getIsLooping: () => false,
- getLoopingRange: () => null,
+ getLoopingRange: () => [0, 0] as [number, number],
};
+const mockAudioInterface = {
+ getTransportPosition: vi.fn().mockReturnValue(8),
+ startAudioRecording: vi.fn().mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false }),
+ stopAudioRecording: vi.fn().mockResolvedValue(null),
+ cancelAudioRecording: vi.fn().mockResolvedValue(undefined),
+};
+
+const configValues = new Map([
+ ['audio.input_device_id', 'default'],
+]);
+
const mockCore = {
getCurrentProject: () => mockProject,
setPlayheadUpdateCallback: vi.fn(),
setPlaybackStateChangeCallback: vi.fn(),
+ setLoopBoundaryReachedCallback: vi.fn(),
getSelectedItems: () => [],
onSelectionChanged: vi.fn(),
canUndo: () => false,
@@ -27,9 +41,12 @@ const mockCore = {
getRedoDescription: () => '',
setOnCommandHistoryChanged: vi.fn(),
executeCommand: vi.fn(),
+ undo: vi.fn(() => true),
+ redo: vi.fn(() => true),
clearSelectedItems: vi.fn(),
getStatus: () => 'Ready',
getPlayheadPosition: () => 0,
+ setPlayheadPosition: vi.fn(),
getIsPlaying: () => false,
startPlaying: vi.fn().mockResolvedValue(undefined),
stopPlaying: vi.fn().mockResolvedValue(undefined),
@@ -41,22 +58,47 @@ vi.mock('../core/KGCore', () => ({
},
}));
+vi.mock('../core/audio-interface/KGAudioInterface', () => ({
+ KGAudioInterface: {
+ instance: () => mockAudioInterface,
+ },
+}));
+
vi.mock('../core/config/ConfigManager', () => ({
ConfigManager: {
instance: () => ({
getIsInitialized: () => true,
- get: () => false,
+ get: (key: string) => configValues.get(key),
+ set: vi.fn(async (key: string, value: unknown) => {
+ configValues.set(key, value);
+ }),
}),
},
}));
describe('projectStore piano roll state', () => {
beforeEach(() => {
+ vi.useFakeTimers();
vi.resetModules();
+ mockTracks = [new KGMidiTrack('Track 1', 0, 'acoustic_grand_piano')];
mockCore.startPlaying.mockReset();
mockCore.startPlaying.mockResolvedValue(undefined);
mockCore.stopPlaying.mockReset();
mockCore.stopPlaying.mockResolvedValue(undefined);
+ mockCore.undo.mockReset();
+ mockCore.undo.mockReturnValue(true);
+ mockCore.redo.mockReset();
+ mockCore.redo.mockReturnValue(true);
+ mockCore.setLoopBoundaryReachedCallback.mockReset();
+ mockAudioInterface.startAudioRecording.mockReset();
+ mockAudioInterface.startAudioRecording.mockResolvedValue({ usedDeviceId: 'default', fellBackToDefault: false });
+ mockAudioInterface.stopAudioRecording.mockReset();
+ mockAudioInterface.stopAudioRecording.mockResolvedValue(null);
+ mockAudioInterface.cancelAudioRecording.mockReset();
+ mockAudioInterface.cancelAudioRecording.mockResolvedValue(undefined);
+ mockAudioInterface.getTransportPosition.mockReset();
+ mockAudioInterface.getTransportPosition.mockReturnValue(8);
+ configValues.set('audio.input_device_id', 'default');
});
it('clears hybrid state when opening a MIDI region', async () => {
@@ -153,4 +195,59 @@ describe('projectStore piano roll state', () => {
await startPromise;
});
});
+
+ it('starts and stops audio-track recording without requiring a selected region', async () => {
+ const { KGAudioTrack } = await import('../core/track/KGAudioTrack');
+ const audioTrack = new KGAudioTrack('Audio 1', 1);
+ audioTrack.setTrackIndex(0);
+ mockTracks = [audioTrack];
+
+ const { useProjectStore } = await import('./projectStore');
+
+ act(() => {
+ useProjectStore.getState().setSelectedTrack('1');
+ useProjectStore.getState().setPlayheadPosition(8);
+ });
+
+ await act(async () => {
+ await useProjectStore.getState().startRecording();
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+
+ expect(mockCore.startPlaying).toHaveBeenCalledWith({ preserveLoopPreroll: false });
+ expect(mockAudioInterface.startAudioRecording).toHaveBeenCalled();
+ expect(useProjectStore.getState().recordingMode).toBe('audio');
+ expect(useProjectStore.getState().recordingCommitStartBeatAbsolute).toBe(8);
+
+ await act(async () => {
+ await useProjectStore.getState().stopTransport();
+ });
+
+ expect(mockAudioInterface.stopAudioRecording).toHaveBeenCalled();
+ expect(useProjectStore.getState().isRecording).toBe(false);
+ expect(useProjectStore.getState().recordingMode).toBeNull();
+ expect(useProjectStore.getState().playheadPosition).toBe(8);
+ });
+
+ it('bumps track automation redraw version on undo and redo', async () => {
+ const { useProjectStore } = await import('./projectStore');
+
+ const initialVersion = useProjectStore.getState().trackAutomationRedrawVersion;
+
+ act(() => {
+ useProjectStore.getState().undo();
+ });
+
+ expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 1);
+
+ act(() => {
+ useProjectStore.getState().redo();
+ });
+
+ expect(useProjectStore.getState().trackAutomationRedrawVersion).toBe(initialVersion + 2);
+ });
});
diff --git a/src/stores/projectStore.ts b/src/stores/projectStore.ts
index c95f0d9..32f34cf 100644
--- a/src/stores/projectStore.ts
+++ b/src/stores/projectStore.ts
@@ -25,6 +25,7 @@ import { KGMidiPitchBend } from '../core/midi/KGMidiPitchBend';
import { CreateMidiEventsCommand, type NoteCreationData, type PitchBendCreationData, type ControllerEventCreationData } from '../core/commands/note/CreateMidiEventsCommand';
import { MIDI_PITCH_BEND_CENTER } from '../util/midiUtil';
import { KGTrackAutomationPoint, type TrackAutomationType } from '../core/track/KGTrackAutomationPoint';
+import type { AudioRecordingPeak } from '../core/audio-interface/KGAudioRecorder';
/**
* Update CSS custom property for time signature numerator
@@ -73,7 +74,7 @@ interface ProjectState {
isPreparingPlayback: boolean;
autoScrollEnabled: boolean;
currentTime: string; // formatted time string
-
+
// Selection state for UI reactivity
selectedNoteIds: string[];
selectedPitchBendIds: string[];
@@ -81,7 +82,7 @@ interface ProjectState {
selectedTrackAutomationPointIds: string[];
selectedRegionIds: string[];
selectedTrackId: string | null;
-
+
// Piano roll state
showPianoRoll: boolean;
activeRegionId: string | null;
@@ -91,20 +92,20 @@ interface ProjectState {
activeTrackAutomationTrackId: string | null;
activeTrackAutomationType: TrackAutomationType | null;
trackAutomationRedrawVersion: number;
-
+
// ChatBox state
showChatBox: boolean;
// K.G.One panel state
showKGOnePanel: boolean;
- // List event panel state
- showListEventPanel: boolean;
+ // Event list panel state
+ showEventListPanel: boolean;
// Instrument selection panel state
showInstrumentSelection: boolean;
// instrumentSelectionTrackId removed; panel now follows selectedTrackId
-
+
// Audio import modal state
showAudioImportModal: boolean;
audioImportTargetTrackId: string | null;
@@ -114,11 +115,19 @@ interface ProjectState {
// Recording state
isRecording: boolean;
+ recordingMode: 'midi' | 'audio' | null;
recordingTargetRegionId: string | null;
+ recordingTargetTrackId: string | null;
+ recordingTargetTrackIndex: number | null;
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number; velocity: number }>;
recordingPitchBends: Array<{ beat: number; value: number }>;
recordingControllerEventsByType: Array>;
recordingOriginalPlayhead: number;
+ recordingStartBeatAbsolute: number;
+ recordingCommitStartBeatAbsolute: number;
+ recordingAudioPreviewPeaks: AudioRecordingPeak[];
+ recordingAudioPreviewCurrentBeat: number;
+ recordingAudioPreviewFileName: string | null;
// Undo/redo state
canUndo: boolean;
@@ -131,7 +140,7 @@ interface ProjectState {
requestPianoRollScroll: (beatPosition: number) => void;
mainContentScrollRequest: number | null;
pianoRollScrollRequest: number | null;
-
+
// Actions
setProjectName: (name: string) => void;
setSavedProjectName: (name: string) => void;
@@ -168,7 +177,7 @@ interface ProjectState {
clearAllSelections: () => void;
setSelectedTrack: (trackId: string | null) => void;
setTrackAutomationView: (trackId: string | null, automationType: TrackAutomationType | null) => void;
-
+
// Piano roll actions
setShowPianoRoll: (show: boolean) => void;
setActiveRegionId: (regionId: string | null) => void;
@@ -177,10 +186,10 @@ interface ProjectState {
openHybridMode: (midiRegionId: string, audioRegionId: string) => void;
bumpAutomationRedrawVersion: () => void;
bumpTrackAutomationRedrawVersion: () => void;
-
+
// Project state cleanup
cleanupProjectState: () => void;
-
+
// ChatBox actions
setShowChatBox: (show: boolean) => void;
toggleChatBox: () => void;
@@ -188,27 +197,27 @@ interface ProjectState {
// K.G.One panel actions
toggleKGOnePanel: () => void;
- // List event panel actions
- toggleListEventPanel: () => void;
+ // Event List panel actions
+ toggleEventListPanel: () => void;
// Instrument selection panel actions
openInstrumentSelectionForTrack: () => void;
toggleInstrumentSelectionForTrack: () => void;
closeInstrumentSelection: () => void;
-
+
// Settings actions
setShowSettings: (show: boolean) => void;
toggleSettings: () => void;
-
+
// Copy/paste actions
pasteRegionsAtTrack: (trackId: string, position: number) => void;
pasteNotesToActiveRegion: (regionId: string, position: number) => void;
-
+
// Undo/redo actions
undo: () => void;
redo: () => void;
syncUndoRedoState: () => void;
-
+
// Project state refresh actions
refreshProjectState: () => void;
@@ -225,6 +234,9 @@ let _recordingActiveNotes: Map
let _recordingRegionStartBeat: number = 0;
let _lastRecordedPitchBendValue: number | null = null;
let _lastRecordedControllerValues: Map = new Map();
+let _audioRecordingStartTimeoutId: number | null = null;
+let _audioRecordingForcedStopBeatAbsolute: number | null = null;
+let _audioRecordingHasStarted: boolean = false;
function createEmptyRecordedControllerBuckets(): Array> {
return Array.from({ length: 128 }, () => []);
@@ -253,30 +265,47 @@ function finalizeRecordedNote(startBeat: number, candidateEndBeat: number): numb
return candidateEndBeat;
}
+function clearPendingAudioRecordingStart(): void {
+ if (_audioRecordingStartTimeoutId !== null) {
+ window.clearTimeout(_audioRecordingStartTimeoutId);
+ _audioRecordingStartTimeoutId = null;
+ }
+}
+
+function getAudioRecordingExtension(mimeType: string): string {
+ if (mimeType.includes('ogg')) return 'ogg';
+ if (mimeType.includes('mp4')) return 'm4a';
+ if (mimeType.includes('wav')) return 'wav';
+ return 'webm';
+}
+
// Create the store
export const useProjectStore = create((set, get) => {
const currentProject = KGCore.instance().getCurrentProject();
-
+
// Initialize CSS variable for time signature on store creation
updateTimeSignatureCSS(currentProject.getTimeSignature());
// Initialize CSS variable for max bars on store creation
updateMaxBarsCSS(currentProject.getMaxBars());
// Initialize CSS variable for bar width multiplier on store creation
updateBarWidthMultiplierCSS(currentProject.getBarWidthMultiplier());
-
+
// Get initial ChatBox state from config
const configManager = ConfigManager.instance();
- const initialChatBoxState = configManager.getIsInitialized()
+ const initialChatBoxState = configManager.getIsInitialized()
? (configManager.get('chatbox.default_open') as boolean) ?? false
: false;
-
+
// Set up playhead update callback to keep store in sync during playback
KGCore.instance().setPlayheadUpdateCallback((position: number) => {
const { bpm, timeSignature } = get();
- set({
+ set(state => ({
playheadPosition: position,
- currentTime: beatsToTimeString(position, bpm, timeSignature)
- });
+ currentTime: beatsToTimeString(position, bpm, timeSignature),
+ recordingAudioPreviewCurrentBeat: state.recordingMode === 'audio'
+ ? Math.max(state.recordingCommitStartBeatAbsolute, position)
+ : state.recordingAudioPreviewCurrentBeat,
+ }));
});
// Keep store isPlaying in sync when core auto-stops (e.g., at maxBars)
@@ -288,7 +317,7 @@ export const useProjectStore = create((set, get) => {
const syncSelectionFromCore = () => {
const core = KGCore.instance();
const selectedItems = core.getSelectedItems();
-
+
const noteIds = selectedItems
.filter(item => item instanceof KGMidiNote)
.map(item => item.getId());
@@ -304,7 +333,7 @@ export const useProjectStore = create((set, get) => {
const regionIds = selectedItems
.filter(item => item instanceof KGRegion)
.map(item => item.getId());
-
+
set({
selectedNoteIds: noteIds,
selectedPitchBendIds: pitchBendIds,
@@ -316,10 +345,10 @@ export const useProjectStore = create((set, get) => {
// Register the sync callback with KGCore
KGCore.instance().onSelectionChanged(syncSelectionFromCore);
-
+
// Initial selection sync
syncSelectionFromCore();
-
+
// Set up command history sync callback
const syncUndoRedoState = () => {
const core = KGCore.instance();
@@ -333,10 +362,10 @@ export const useProjectStore = create((set, get) => {
// Register the undo/redo sync callback with KGCore
KGCore.instance().setOnCommandHistoryChanged(syncUndoRedoState);
-
+
// Initial undo/redo state sync
syncUndoRedoState();
-
+
// Ensure a default track exists on initial app start
// Also auto-select it and open instrument selection panel
let initialSelectedTrackId: string | null = null;
@@ -374,7 +403,7 @@ export const useProjectStore = create((set, get) => {
isPreparingPlayback: false,
autoScrollEnabled: true,
currentTime: beatsToTimeString(KGCore.instance().getPlayheadPosition(), currentProject.getBpm(), currentProject.getTimeSignature()),
-
+
// Initial selection state
selectedNoteIds: [],
selectedPitchBendIds: [],
@@ -382,7 +411,7 @@ export const useProjectStore = create((set, get) => {
selectedTrackAutomationPointIds: [],
selectedRegionIds: [],
selectedTrackId: initialSelectedTrackId,
-
+
// Initial piano roll state
showPianoRoll: false,
activeRegionId: null,
@@ -392,26 +421,26 @@ export const useProjectStore = create((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
-
+
// Initial ChatBox state
showChatBox: initialChatBoxState,
// Initial K.G.One panel state
showKGOnePanel: false,
- // Initial List Event panel state
- showListEventPanel: false,
+ // Initial Event List panel state
+ showEventListPanel: false,
// Initial Instrument Selection panel state
showInstrumentSelection: initialShowInstrumentSelection,
-
+
// Initial audio import modal state
showAudioImportModal: false,
audioImportTargetTrackId: null,
// Initial Settings state
showSettings: false,
-
+
// Initial undo/redo state
canUndo: false,
canRedo: false,
@@ -420,11 +449,19 @@ export const useProjectStore = create((set, get) => {
// Initial recording state
isRecording: false,
+ recordingMode: null,
recordingTargetRegionId: null,
+ recordingTargetTrackId: null,
+ recordingTargetTrackIndex: null,
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingOriginalPlayhead: 0,
+ recordingStartBeatAbsolute: 0,
+ recordingCommitStartBeatAbsolute: 0,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
+ recordingAudioPreviewFileName: null,
// Initial cross-component scroll request state
mainContentScrollRequest: null,
@@ -456,18 +493,18 @@ export const useProjectStore = create((set, get) => {
// Create and execute the add track command
const command = new AddTrackCommand();
KGCore.instance().executeCommand(command);
-
+
// Update the store state with a new array reference to trigger re-render
const project = KGCore.instance().getCurrentProject();
set({ tracks: [...project.getTracks()] as KGTrack[] });
-
+
// Auto-select the newly created track and open instrument selection panel
const newTrackId = command.getTrackId().toString();
set({
selectedTrackId: newTrackId,
showInstrumentSelection: true,
});
-
+
console.log(`Added track ${command.getTrackId()}`);
} catch (error) {
console.error('Error adding track:', error);
@@ -593,23 +630,23 @@ export const useProjectStore = create((set, get) => {
const deletedTrackIndex = currentTracks.findIndex(track => track.getId() === id);
const { selectedTrackId } = get();
const isCurrentTrackSelected = selectedTrackId === id.toString();
-
+
// Create and execute the remove track command
const command = new RemoveTrackCommand(id);
KGCore.instance().executeCommand(command);
-
+
// Update the store state with a new array reference to trigger re-render
const project = KGCore.instance().getCurrentProject();
const remainingTracks = [...project.getTracks()] as KGTrack[];
set({ tracks: remainingTracks });
-
+
// Auto-select another track if any remain
if (remainingTracks.length > 0) {
// Prefer previous track, fallback to next track
- const newSelectedIndex = deletedTrackIndex > 0
+ const newSelectedIndex = deletedTrackIndex > 0
? deletedTrackIndex - 1 // Select previous track
: 0; // Select first remaining track (was next)
-
+
const newSelectedTrack = remainingTracks[newSelectedIndex];
const newSelectedTrackId = newSelectedTrack.getId().toString();
@@ -625,7 +662,7 @@ export const useProjectStore = create((set, get) => {
showInstrumentSelection: false,
});
}
-
+
console.log(`Removed track ${id}`);
} catch (error) {
console.error('Error removing track:', error);
@@ -635,23 +672,23 @@ export const useProjectStore = create((set, get) => {
updateTrack: async (updatedTrack: KGTrack) => {
const { tracks } = get();
-
+
try {
// Find the old track to compare instruments
const oldTrack = tracks.find(track => track.getId() === updatedTrack.getId());
-
+
// Type guard to check if track is KGMidiTrack
const isMidiTrack = (track: KGTrack): track is KGMidiTrack => {
return track.getCurrentType() === 'KGMidiTrack' && 'getInstrument' in track;
};
-
+
// Check if instrument changed (only for MIDI tracks)
- const shouldUpdateInstrument =
- isMidiTrack(updatedTrack) &&
- oldTrack &&
+ const shouldUpdateInstrument =
+ isMidiTrack(updatedTrack) &&
+ oldTrack &&
isMidiTrack(oldTrack) &&
updatedTrack.getInstrument() !== oldTrack.getInstrument();
-
+
// Update instrument in audio interface if changed
if (shouldUpdateInstrument && isMidiTrack(updatedTrack)) {
const audioInterface = KGAudioInterface.instance();
@@ -659,18 +696,18 @@ export const useProjectStore = create((set, get) => {
audioInterface.setTrackInstrument(updatedTrack.getId().toString(), newInstrument);
console.log(`Updated track ${updatedTrack.getId()} instrument to ${newInstrument}`);
}
-
+
// Find and update the track
- const updatedTracks = tracks.map(track =>
+ const updatedTracks = tracks.map(track =>
track.getId() === updatedTrack.getId() ? updatedTrack : track
);
-
+
// Update the core model
KGCore.instance().getCurrentProject().setTracks(updatedTracks);
-
+
// Update the store
set({ tracks: updatedTracks });
-
+
console.log(`Updated track ${updatedTrack.getId()}`);
} catch (error) {
console.error('Error updating track:', error);
@@ -687,7 +724,7 @@ export const useProjectStore = create((set, get) => {
// Update the store state with the current project state
const project = KGCore.instance().getCurrentProject();
set({ tracks: [...project.getTracks()] as KGTrack[] });
-
+
console.log(`Updated track ${trackId} properties`);
} catch (error) {
console.error('Error updating track properties:', error);
@@ -699,14 +736,14 @@ export const useProjectStore = create((set, get) => {
try {
// Use the new updateTrackProperties method with command pattern
await get().updateTrackProperties(trackId, { instrument });
-
+
console.log(`Set track ${trackId} instrument to ${instrument}`);
} catch (error) {
console.error('Error setting track instrument:', error);
get().setStatus('Failed to change instrument');
}
},
-
+
reorderTracks: (sourceIndex: number, destinationIndex: number) => {
try {
// Create and execute the reorder tracks command
@@ -716,7 +753,7 @@ export const useProjectStore = create((set, get) => {
// Update the store state with the current project state
const project = KGCore.instance().getCurrentProject();
set({ tracks: [...project.getTracks()] as KGTrack[] });
-
+
console.log(`Reordered track from index ${sourceIndex} to ${destinationIndex}`);
} catch (error) {
console.error('Error reordering tracks:', error);
@@ -737,11 +774,11 @@ export const useProjectStore = create((set, get) => {
refreshStatus: () => {
set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' });
},
-
+
loadProject: async (project: KGProject | null = null, savedName?: string) => {
try {
const { setPlayheadPosition } = get();
-
+
// Upgrade incoming project data to latest structure version (only when provided explicitly)
if (project) {
project = upgradeProjectToLatest(project);
@@ -749,12 +786,12 @@ export const useProjectStore = create((set, get) => {
// Get project from KGCore if null
const projectToLoad = project || KGCore.instance().getCurrentProject();
-
+
// Set the project in KGCore if one was provided
if (project) {
KGCore.instance().setCurrentProject(projectToLoad);
}
-
+
// Ensure a default "Melody" track exists for empty projects
if (projectToLoad.getTracks().length === 0) {
const addDefaultTrackCommand = new AddTrackCommand(undefined, 'Melody');
@@ -763,17 +800,17 @@ export const useProjectStore = create((set, get) => {
// Reset playhead to 0 when loading a project
setPlayheadPosition(0);
-
+
// Get project properties
const maxBars = projectToLoad.getMaxBars();
const timeSignature = projectToLoad.getTimeSignature();
const bpm = projectToLoad.getBpm();
const keySignature = projectToLoad.getKeySignature();
const tracks = projectToLoad.getTracks();
-
+
// Setup audio synths for all tracks
const audioInterface = KGAudioInterface.instance();
-
+
// Clear any existing synths/buses first
tracks.forEach(track => {
const trackId = track.getId().toString();
@@ -820,7 +857,7 @@ export const useProjectStore = create((set, get) => {
audioInterface.setTrackVolume(trackId, track.getVolume());
}
}
-
+
// Update CSS variables
updateTimeSignatureCSS(timeSignature);
updateMaxBarsCSS(maxBars);
@@ -829,7 +866,7 @@ export const useProjectStore = create((set, get) => {
// Log project loading info
console.log(`Project max bars: ${maxBars}`);
console.log(`Setup audio synths for ${tracks.length} tracks`);
-
+
// Update the store state to reflect the loaded project
// Force a new array reference for tracks to trigger React/Zustand re-render
set({
@@ -847,6 +884,20 @@ export const useProjectStore = create((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
+ isRecording: false,
+ recordingMode: null,
+ recordingTargetRegionId: null,
+ recordingTargetTrackId: null,
+ recordingTargetTrackIndex: null,
+ recordingNotes: [],
+ recordingPitchBends: [],
+ recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
+ recordingOriginalPlayhead: 0,
+ recordingStartBeatAbsolute: 0,
+ recordingCommitStartBeatAbsolute: 0,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
+ recordingAudioPreviewFileName: null,
playheadPosition: 0, // Ensure store state is also updated
currentTime: beatsToTimeString(0, bpm, timeSignature) // Reset time display
});
@@ -863,7 +914,7 @@ export const useProjectStore = create((set, get) => {
// Reset piano roll state for new/loaded project
KGPianoRollState.instance().setLastEditedNoteLength(1);
-
+
// Add a status message
KGCore.instance().setStatus(`Project "${projectToLoad.getName()}" loaded with audio setup`);
set({ currentStatus: KGCore.instance().getStatus() || 'Unknown' });
@@ -876,7 +927,7 @@ export const useProjectStore = create((set, get) => {
setPlayheadPosition: (position: number) => {
const { bpm, timeSignature } = get();
KGCore.instance().setPlayheadPosition(position);
- set({
+ set({
playheadPosition: position,
currentTime: beatsToTimeString(position, bpm, timeSignature)
});
@@ -930,7 +981,99 @@ export const useProjectStore = create((set, get) => {
},
startRecording: async () => {
- const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
+ const {
+ activeRegionId,
+ timeSignature,
+ playheadPosition,
+ setPlayheadPosition,
+ selectedTrackId,
+ tracks,
+ } = get();
+
+ const selectedTrack = tracks.find(track => track.getId().toString() === selectedTrackId) ?? null;
+ if (selectedTrack instanceof KGAudioTrack) {
+ const project = KGCore.instance().getCurrentProject();
+ const beatsPerBar = timeSignature.numerator;
+ const projectLooping = project.getIsLooping();
+ const [loopStartBar] = project.getLoopingRange();
+ const loopStartBeat = loopStartBar * beatsPerBar;
+ const recordingCommitStartBeatAbsolute = projectLooping ? loopStartBeat : playheadPosition;
+ const recordingStartBeatAbsolute = recordingCommitStartBeatAbsolute - beatsPerBar;
+ const previewFileName = `Recording_${new Date().toISOString().replace(/[:.]/g, '-')}`;
+
+ clearPendingAudioRecordingStart();
+ _audioRecordingForcedStopBeatAbsolute = null;
+ _audioRecordingHasStarted = false;
+
+ set({
+ isRecording: true,
+ recordingMode: 'audio',
+ recordingTargetRegionId: null,
+ recordingTargetTrackId: selectedTrack.getId().toString(),
+ recordingTargetTrackIndex: selectedTrack.getTrackIndex(),
+ recordingNotes: [],
+ recordingPitchBends: [],
+ recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
+ recordingOriginalPlayhead: playheadPosition,
+ recordingStartBeatAbsolute,
+ recordingCommitStartBeatAbsolute,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: recordingCommitStartBeatAbsolute,
+ recordingAudioPreviewFileName: previewFileName,
+ });
+
+ KGCore.instance().setLoopBoundaryReachedCallback(projectLooping
+ ? (loopEndBeat: number) => {
+ _audioRecordingForcedStopBeatAbsolute = loopEndBeat;
+ void get().stopRecording();
+ }
+ : null);
+
+ setPlayheadPosition(recordingStartBeatAbsolute);
+ set({ isPreparingPlayback: true });
+ try {
+ await KGCore.instance().startPlaying({
+ preserveLoopPreroll: projectLooping,
+ });
+ set({ isPlaying: true, autoScrollEnabled: true });
+
+ const prerollMs = Math.max(0, ((recordingCommitStartBeatAbsolute - recordingStartBeatAbsolute) * (60 / project.getBpm())) * 1000);
+ _audioRecordingStartTimeoutId = window.setTimeout(() => {
+ _audioRecordingStartTimeoutId = null;
+ const inputDeviceId = (ConfigManager.instance().get('audio.input_device_id') as string | undefined) ?? 'default';
+ void KGAudioInterface.instance().startAudioRecording(inputDeviceId, (peaks) => {
+ set({ recordingAudioPreviewPeaks: peaks });
+ }).then((startResult) => {
+ _audioRecordingHasStarted = true;
+ if (startResult.fellBackToDefault) {
+ void ConfigManager.instance().set('audio.input_device_id', 'default');
+ get().setStatus('Previously selected audio input device is unavailable; using System Default.');
+ }
+ }).catch(async (error) => {
+ console.error('Failed to start audio recording:', error);
+ await KGAudioInterface.instance().cancelAudioRecording();
+ KGCore.instance().setLoopBoundaryReachedCallback(null);
+ await get().stopPlaying();
+ setPlayheadPosition(playheadPosition);
+ set({
+ isRecording: false,
+ recordingMode: null,
+ recordingTargetTrackId: null,
+ recordingTargetTrackIndex: null,
+ recordingStartBeatAbsolute: 0,
+ recordingCommitStartBeatAbsolute: 0,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
+ recordingAudioPreviewFileName: null,
+ });
+ get().setStatus(error instanceof Error ? error.message : 'Unable to start audio recording.');
+ });
+ }, prerollMs);
+ } finally {
+ set({ isPreparingPlayback: false });
+ }
+ return;
+ }
const project = KGCore.instance().getCurrentProject();
let targetRegion: KGMidiRegion | null = null;
@@ -945,13 +1088,28 @@ export const useProjectStore = create((set, get) => {
_lastRecordedPitchBendValue = null;
_lastRecordedControllerValues = new Map();
+ const projectLooping = project.getIsLooping();
+ const [loopStartBar] = project.getLoopingRange();
+ const loopStartBeat = loopStartBar * timeSignature.numerator;
+ const recordingStartBeat = projectLooping
+ ? loopStartBeat - timeSignature.numerator
+ : playheadPosition - timeSignature.numerator;
+
set({
isRecording: true,
+ recordingMode: 'midi',
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
recordingTargetRegionId: activeRegionId,
+ recordingTargetTrackId: null,
+ recordingTargetTrackIndex: null,
recordingOriginalPlayhead: playheadPosition,
+ recordingStartBeatAbsolute: recordingStartBeat,
+ recordingCommitStartBeatAbsolute: targetRegion.getStartFromBeat(),
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
+ recordingAudioPreviewFileName: null,
});
const buildCorrectedBeat = (): number => {
@@ -1009,13 +1167,6 @@ export const useProjectStore = create((set, get) => {
}
);
- const projectLooping = project.getIsLooping();
- const [loopStartBar] = project.getLoopingRange();
- const loopStartBeat = loopStartBar * timeSignature.numerator;
- const recordingStartBeat = projectLooping
- ? loopStartBeat - timeSignature.numerator
- : playheadPosition - timeSignature.numerator;
-
setPlayheadPosition(recordingStartBeat);
set({ isPreparingPlayback: true });
try {
@@ -1030,16 +1181,109 @@ export const useProjectStore = create((set, get) => {
stopRecording: async () => {
const {
+ recordingMode,
recordingNotes,
recordingPitchBends,
recordingControllerEventsByType,
recordingTargetRegionId,
+ recordingTargetTrackId,
+ recordingTargetTrackIndex,
recordingOriginalPlayhead,
+ recordingCommitStartBeatAbsolute,
+ recordingAudioPreviewFileName,
stopPlaying,
setPlayheadPosition,
- refreshProjectState
+ refreshProjectState,
+ projectName,
+ maxBars,
} = get();
+ if (recordingMode === 'audio') {
+ clearPendingAudioRecordingStart();
+ KGCore.instance().setLoopBoundaryReachedCallback(null);
+
+ const stopBeatAbsolute = _audioRecordingForcedStopBeatAbsolute
+ ?? Math.max(recordingCommitStartBeatAbsolute, KGAudioInterface.instance().getTransportPosition());
+ _audioRecordingForcedStopBeatAbsolute = null;
+
+ const recordingResult = _audioRecordingHasStarted
+ ? await KGAudioInterface.instance().stopAudioRecording()
+ : (await KGAudioInterface.instance().cancelAudioRecording(), null);
+ _audioRecordingHasStarted = false;
+
+ if (
+ recordingResult &&
+ recordingTargetTrackId &&
+ recordingTargetTrackIndex !== null &&
+ stopBeatAbsolute > recordingCommitStartBeatAbsolute
+ ) {
+ try {
+ const extension = getAudioRecordingExtension(recordingResult.mimeType);
+ const fileName = `${recordingAudioPreviewFileName ?? 'Recording'}.${extension}`;
+ const audioFile = new File([recordingResult.blob], fileName, { type: recordingResult.mimeType });
+ const fileId = KGAudioFileStorage.generateAudioFileId(fileName);
+ const arrayBuffer = await audioFile.arrayBuffer();
+ const toneBuffer = new Tone.ToneAudioBuffer();
+ await new Promise((resolve, reject) => {
+ const audioContext = Tone.getContext().rawContext as AudioContext;
+ audioContext.decodeAudioData(
+ arrayBuffer.slice(0),
+ (decoded) => { toneBuffer.set(decoded); resolve(); },
+ (err) => reject(err)
+ );
+ });
+
+ const track = KGCore.instance().getCurrentProject().getTracks().find(candidate => candidate.getId().toString() === recordingTargetTrackId);
+ if (track) {
+ await KGAudioFileStorage.storeAudioFile(projectName, fileId, audioFile);
+ KGAudioInterface.instance().loadAudioBufferForTrack(recordingTargetTrackId, fileId, toneBuffer);
+
+ const prevMaxBars = maxBars;
+ const durationInBeats = stopBeatAbsolute - recordingCommitStartBeatAbsolute;
+ const beatsPerBar = KGCore.instance().getCurrentProject().getTimeSignature().numerator;
+ const endBarNumber = Math.ceil((recordingCommitStartBeatAbsolute + durationInBeats) / beatsPerBar);
+ const newMaxBars = Math.max(prevMaxBars, endBarNumber);
+
+ const command = new ImportAudioCommand(
+ track.getId(),
+ recordingTargetTrackIndex,
+ fileId,
+ fileName,
+ toneBuffer.duration,
+ recordingCommitStartBeatAbsolute,
+ durationInBeats,
+ prevMaxBars,
+ newMaxBars
+ );
+ KGCore.instance().executeCommand(command);
+ refreshProjectState();
+ }
+ } catch (error) {
+ console.error('Failed to finalize audio recording:', error);
+ }
+ }
+
+ await stopPlaying();
+ setPlayheadPosition(recordingOriginalPlayhead);
+ set({
+ isRecording: false,
+ recordingMode: null,
+ isPreparingPlayback: false,
+ recordingTargetRegionId: null,
+ recordingTargetTrackId: null,
+ recordingTargetTrackIndex: null,
+ recordingNotes: [],
+ recordingPitchBends: [],
+ recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
+ recordingStartBeatAbsolute: 0,
+ recordingCommitStartBeatAbsolute: 0,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
+ recordingAudioPreviewFileName: null,
+ });
+ return;
+ }
+
// Finalize any held keys
const finalNotes = [...recordingNotes];
const finalPitchBends = [...recordingPitchBends];
@@ -1109,11 +1353,19 @@ export const useProjectStore = create((set, get) => {
setPlayheadPosition(recordingOriginalPlayhead);
set({
isRecording: false,
+ recordingMode: null,
isPreparingPlayback: false,
recordingNotes: [],
recordingPitchBends: [],
recordingControllerEventsByType: createEmptyRecordedControllerBuckets(),
- recordingTargetRegionId: null
+ recordingTargetRegionId: null,
+ recordingTargetTrackId: null,
+ recordingTargetTrackIndex: null,
+ recordingStartBeatAbsolute: 0,
+ recordingCommitStartBeatAbsolute: 0,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
+ recordingAudioPreviewFileName: null,
});
_lastRecordedPitchBendValue = null;
_lastRecordedControllerValues = new Map();
@@ -1153,10 +1405,10 @@ export const useProjectStore = create((set, get) => {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ bpm });
KGCore.instance().executeCommand(command);
-
+
// Update the store state
set({ bpm });
-
+
console.log(`Set BPM to ${bpm}`);
} catch (error) {
console.error('Error setting BPM:', error);
@@ -1169,12 +1421,12 @@ export const useProjectStore = create((set, get) => {
// Create and execute the change project property command
const command = new ChangeProjectPropertyCommand({ maxBars });
KGCore.instance().executeCommand(command);
-
+
// Update the store state
set({ maxBars });
// Sync CSS var so layout adjusts immediately
updateMaxBarsCSS(maxBars);
-
+
console.log(`Set max bars to ${maxBars}`);
} catch (error) {
console.error('Error setting max bars:', error);
@@ -1240,7 +1492,7 @@ export const useProjectStore = create((set, get) => {
// Selection actions
syncSelectionFromCore,
-
+
clearAllSelections: () => {
KGCore.instance().clearSelectedItems();
// Note: syncSelectionFromCore will be called automatically via callback
@@ -1274,7 +1526,7 @@ export const useProjectStore = create((set, get) => {
selectedTrackId: trackId,
});
},
-
+
// Piano roll actions
setShowPianoRoll: (show: boolean) => {
set({ showPianoRoll: show });
@@ -1301,7 +1553,7 @@ export const useProjectStore = create((set, get) => {
bumpTrackAutomationRedrawVersion: () => {
set(state => ({ trackAutomationRedrawVersion: state.trackAutomationRedrawVersion + 1 }));
},
-
+
// Project state cleanup - used when starting new/loading projects
cleanupProjectState: () => {
// Close piano roll if it's visible
@@ -1315,37 +1567,39 @@ export const useProjectStore = create((set, get) => {
activeTrackAutomationTrackId: null,
activeTrackAutomationType: null,
trackAutomationRedrawVersion: 0,
+ recordingAudioPreviewPeaks: [],
+ recordingAudioPreviewCurrentBeat: 0,
});
-
+
// Clear any selected items
KGCore.instance().clearSelectedItems();
// Note: syncSelectionFromCore will be called automatically via callback
-
+
console.log("Cleaned up project state: closed piano roll, cleared active region, cleared selections");
},
-
+
// ChatBox action implementations
setShowChatBox: (show: boolean) => {
set({
showChatBox: show,
showKGOnePanel: show ? false : get().showKGOnePanel,
- showListEventPanel: show ? false : get().showListEventPanel
+ showEventListPanel: show ? false : get().showEventListPanel
});
},
-
+
toggleChatBox: () => {
const { showChatBox } = get();
- set({ showChatBox: !showChatBox, showKGOnePanel: false, showListEventPanel: false });
+ set({ showChatBox: !showChatBox, showKGOnePanel: false, showEventListPanel: false });
},
toggleKGOnePanel: () => {
const { showKGOnePanel } = get();
- set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showListEventPanel: false });
+ set({ showKGOnePanel: !showKGOnePanel, showChatBox: false, showEventListPanel: false });
},
- toggleListEventPanel: () => {
- const { showListEventPanel } = get();
- set({ showListEventPanel: !showListEventPanel, showChatBox: false, showKGOnePanel: false });
+ toggleEventListPanel: () => {
+ const { showEventListPanel } = get();
+ set({ showEventListPanel: !showEventListPanel, showChatBox: false, showKGOnePanel: false });
},
// Instrument selection panel actions
@@ -1358,83 +1612,85 @@ export const useProjectStore = create((set, get) => {
closeInstrumentSelection: () => {
set({ showInstrumentSelection: false });
},
-
+
// Settings action implementations
setShowSettings: (show: boolean) => {
set({ showSettings: show });
},
-
+
toggleSettings: () => {
const { showSettings } = get();
set({ showSettings: !showSettings });
},
-
+
// Copy/paste actions
pasteRegionsAtTrack: (trackId: string, position: number) => {
// Use command pattern for region pasting with undo support
const command = PasteRegionsCommand.fromClipboard(trackId, position);
-
+
if (!command) {
console.log('No regions to paste');
return;
}
-
+
try {
KGCore.instance().executeCommand(command);
-
+
// Update the store to trigger re-render
const { tracks } = get();
const updatedTracks = [...tracks];
set({ tracks: updatedTracks });
-
+
console.log(`Executed PasteRegionsCommand: pasted regions to track ${trackId} using command pattern`);
} catch (error) {
console.error('Error pasting regions:', error);
}
},
-
+
pasteNotesToActiveRegion: (regionId: string, position: number) => {
// Use command pattern for note pasting with undo support
const command = PasteNotesCommand.fromClipboard(regionId, position);
-
+
if (!command) {
console.log('No notes to paste');
return;
}
-
+
try {
KGCore.instance().executeCommand(command);
-
+
// Update the store to trigger re-render
const { tracks } = get();
const updatedTracks = [...tracks];
set({ tracks: updatedTracks });
-
+
console.log(`Executed PasteNotesCommand: pasted notes to region ${regionId} using command pattern`);
} catch (error) {
console.error('Error pasting notes:', error);
}
},
-
+
// Undo/redo actions
undo: () => {
const core = KGCore.instance();
if (core.undo()) {
// Use centralized refresh method
get().refreshProjectState();
+ get().bumpTrackAutomationRedrawVersion();
console.log('Undo completed');
}
},
-
+
redo: () => {
const core = KGCore.instance();
if (core.redo()) {
// Use centralized refresh method
get().refreshProjectState();
+ get().bumpTrackAutomationRedrawVersion();
console.log('Redo completed');
}
},
-
+
syncUndoRedoState: () => {
const core = KGCore.instance();
set({
@@ -1444,13 +1700,13 @@ export const useProjectStore = create((set, get) => {
redoDescription: core.getRedoDescription()
});
},
-
+
// Centralized project state refresh method
// Used by undo/redo and external operations (like XML tools) to sync UI with core model
refreshProjectState: () => {
const core = KGCore.instance();
const project = core.getCurrentProject();
-
+
// Force new array reference to trigger React re-renders
set({
projectName: project.getName(),
@@ -1473,7 +1729,7 @@ export const useProjectStore = create((set, get) => {
actions.syncUndoRedoState();
actions.syncSelectionFromCore();
},
-
+
// Initialize store with configuration values
initializeFromConfig: async () => {
const configManager = ConfigManager.instance();
diff --git a/src/test/mocks/audio-interface.ts b/src/test/mocks/audio-interface.ts
index 94fb438..9febee7 100644
--- a/src/test/mocks/audio-interface.ts
+++ b/src/test/mocks/audio-interface.ts
@@ -35,6 +35,10 @@ export const mockAudioInterface = {
getCurrentBeat: vi.fn().mockReturnValue(0),
getTransportPosition: vi.fn().mockReturnValue(0),
setBpm: vi.fn().mockReturnValue(undefined),
+ startAudioRecording: vi.fn().mockResolvedValue(undefined),
+ stopAudioRecording: vi.fn().mockResolvedValue(null),
+ cancelAudioRecording: vi.fn().mockResolvedValue(undefined),
+ applyConfiguredOutputDevice: vi.fn().mockResolvedValue(false),
// Singleton pattern
getInstance: vi.fn().mockReturnThis(),
diff --git a/src/test/mocks/tone.ts b/src/test/mocks/tone.ts
index e29846a..0733dfa 100644
--- a/src/test/mocks/tone.ts
+++ b/src/test/mocks/tone.ts
@@ -102,6 +102,18 @@ export const MockGain = vi.fn().mockImplementation((initialValue: number = 1) =>
dispose: vi.fn()
}));
+// Mock Panner node
+export const MockPanner = vi.fn().mockImplementation((initialValue: number = 0) => ({
+ pan: {
+ value: initialValue,
+ setValueAtTime: vi.fn(),
+ },
+ connect: vi.fn(),
+ disconnect: vi.fn(),
+ dispose: vi.fn(),
+ toDestination: vi.fn(),
+}));
+
// Mock Meter
export const MockMeter = vi.fn().mockImplementation(() => ({
getValue: vi.fn().mockReturnValue(-Infinity),
@@ -120,6 +132,7 @@ export const ToneMock = {
Destination: MockDestination,
ToneAudioBuffer: MockToneAudioBuffer,
Gain: MockGain,
+ Panner: MockPanner,
Meter: MockMeter,
// Context management
diff --git a/src/util/audioDeviceUtil.test.ts b/src/util/audioDeviceUtil.test.ts
new file mode 100644
index 0000000..6dc293b
--- /dev/null
+++ b/src/util/audioDeviceUtil.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import {
+ getDefaultAudioDeviceOption,
+ validateConfiguredAudioDevices,
+ type AudioDeviceSnapshot,
+} from './audioDeviceUtil';
+
+describe('audioDeviceUtil', () => {
+ it('falls back to System Default when saved devices are missing and labels are available', () => {
+ const snapshot: AudioDeviceSnapshot = {
+ inputs: [
+ getDefaultAudioDeviceOption('audioinput'),
+ { deviceId: 'mic-1', label: 'Mic 1', kind: 'audioinput', isDefault: false },
+ ],
+ outputs: [
+ getDefaultAudioDeviceOption('audiooutput'),
+ { deviceId: 'speaker-1', label: 'Speaker 1', kind: 'audiooutput', isDefault: false },
+ ],
+ labelsAvailable: true,
+ canSelectOutput: true,
+ canWatchDeviceChanges: true,
+ };
+
+ const result = validateConfiguredAudioDevices('missing-input', 'missing-output', snapshot);
+
+ expect(result.inputDeviceId).toBe('default');
+ expect(result.outputDeviceId).toBe('default');
+ expect(result.inputFellBackToDefault).toBe(true);
+ expect(result.outputFellBackToDefault).toBe(true);
+ });
+
+ it('preserves saved devices when labels are unavailable and validation is inconclusive', () => {
+ const snapshot: AudioDeviceSnapshot = {
+ inputs: [getDefaultAudioDeviceOption('audioinput')],
+ outputs: [getDefaultAudioDeviceOption('audiooutput')],
+ labelsAvailable: false,
+ canSelectOutput: false,
+ canWatchDeviceChanges: false,
+ };
+
+ const result = validateConfiguredAudioDevices('saved-input', 'saved-output', snapshot);
+
+ expect(result.inputDeviceId).toBe('saved-input');
+ expect(result.outputDeviceId).toBe('saved-output');
+ expect(result.inputFellBackToDefault).toBe(false);
+ expect(result.outputFellBackToDefault).toBe(false);
+ });
+});
diff --git a/src/util/audioDeviceUtil.ts b/src/util/audioDeviceUtil.ts
new file mode 100644
index 0000000..6cefff7
--- /dev/null
+++ b/src/util/audioDeviceUtil.ts
@@ -0,0 +1,160 @@
+export type AudioDeviceKind = 'audioinput' | 'audiooutput';
+
+export interface AudioDeviceOption {
+ deviceId: string;
+ label: string;
+ kind: AudioDeviceKind;
+ isDefault: boolean;
+}
+
+export interface AudioDeviceSnapshot {
+ inputs: AudioDeviceOption[];
+ outputs: AudioDeviceOption[];
+ labelsAvailable: boolean;
+ canSelectOutput: boolean;
+ canWatchDeviceChanges: boolean;
+}
+
+export interface AudioDeviceValidationResult {
+ inputDeviceId: string;
+ outputDeviceId: string;
+ inputFellBackToDefault: boolean;
+ outputFellBackToDefault: boolean;
+}
+
+const DEFAULT_DEVICE_ID = 'default';
+const COMMUNICATIONS_DEVICE_ID = 'communications';
+
+export function getDefaultAudioDeviceOption(kind: AudioDeviceKind): AudioDeviceOption {
+ return {
+ deviceId: DEFAULT_DEVICE_ID,
+ label: 'System Default',
+ kind,
+ isDefault: true,
+ };
+}
+
+export function supportsDeviceEnumeration(): boolean {
+ return typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof navigator.mediaDevices.enumerateDevices === 'function';
+}
+
+export function supportsDeviceChangeEvents(): boolean {
+ return typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof navigator.mediaDevices.addEventListener === 'function';
+}
+
+export function supportsAudioOutputSelection(): boolean {
+ return typeof navigator !== 'undefined' &&
+ !!navigator.mediaDevices &&
+ typeof (navigator.mediaDevices as MediaDevices & {
+ selectAudioOutput?: () => Promise;
+ }).selectAudioOutput === 'function';
+}
+
+export function supportsAudioContextSinkSelection(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ return typeof (window.AudioContext?.prototype as AudioContext & {
+ setSinkId?: (sinkId: string) => Promise;
+ } | undefined)?.setSinkId === 'function';
+}
+
+export async function enumerateAudioDevices(): Promise {
+ const fallback: AudioDeviceSnapshot = {
+ inputs: [getDefaultAudioDeviceOption('audioinput')],
+ outputs: [getDefaultAudioDeviceOption('audiooutput')],
+ labelsAvailable: false,
+ canSelectOutput: supportsAudioOutputSelection(),
+ canWatchDeviceChanges: supportsDeviceChangeEvents(),
+ };
+
+ if (!supportsDeviceEnumeration()) {
+ return fallback;
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ const labelsAvailable = devices.some(device => device.label.trim().length > 0);
+
+ const inputs = normalizeAudioDevices(devices, 'audioinput');
+ const outputs = normalizeAudioDevices(devices, 'audiooutput');
+
+ return {
+ inputs,
+ outputs,
+ labelsAvailable,
+ canSelectOutput: supportsAudioOutputSelection(),
+ canWatchDeviceChanges: supportsDeviceChangeEvents(),
+ };
+}
+
+export function validateConfiguredAudioDevices(
+ inputDeviceId: string | undefined,
+ outputDeviceId: string | undefined,
+ snapshot: AudioDeviceSnapshot
+): AudioDeviceValidationResult {
+ const normalizedInputId = inputDeviceId ?? DEFAULT_DEVICE_ID;
+ const normalizedOutputId = outputDeviceId ?? DEFAULT_DEVICE_ID;
+
+ const canValidateInput = snapshot.labelsAvailable || snapshot.inputs.some(device => device.deviceId === normalizedInputId);
+ const canValidateOutput = snapshot.labelsAvailable || snapshot.outputs.some(device => device.deviceId === normalizedOutputId);
+
+ const validInput = normalizedInputId === DEFAULT_DEVICE_ID ||
+ !canValidateInput ||
+ snapshot.inputs.some(device => device.deviceId === normalizedInputId);
+ const validOutput = normalizedOutputId === DEFAULT_DEVICE_ID ||
+ !canValidateOutput ||
+ snapshot.outputs.some(device => device.deviceId === normalizedOutputId);
+
+ return {
+ inputDeviceId: validInput ? normalizedInputId : DEFAULT_DEVICE_ID,
+ outputDeviceId: validOutput ? normalizedOutputId : DEFAULT_DEVICE_ID,
+ inputFellBackToDefault: !validInput,
+ outputFellBackToDefault: !validOutput,
+ };
+}
+
+export async function promptForAudioOutputDevice(): Promise {
+ if (!supportsAudioOutputSelection()) {
+ return null;
+ }
+
+ const selected = await (navigator.mediaDevices as MediaDevices & {
+ selectAudioOutput: () => Promise;
+ }).selectAudioOutput();
+ return {
+ deviceId: selected.deviceId,
+ label: selected.label || 'Selected Output Device',
+ kind: 'audiooutput',
+ isDefault: false,
+ };
+}
+
+function normalizeAudioDevices(
+ devices: MediaDeviceInfo[],
+ kind: AudioDeviceKind
+): AudioDeviceOption[] {
+ const normalized: AudioDeviceOption[] = [getDefaultAudioDeviceOption(kind)];
+ let unnamedCount = 1;
+
+ devices
+ .filter(device => device.kind === kind)
+ .forEach(device => {
+ if (device.deviceId === DEFAULT_DEVICE_ID || device.deviceId === COMMUNICATIONS_DEVICE_ID) {
+ return;
+ }
+
+ normalized.push({
+ deviceId: device.deviceId,
+ label: device.label || `${kind === 'audioinput' ? 'Input' : 'Output'} Device ${unnamedCount++}`,
+ kind,
+ isDefault: false,
+ });
+ });
+
+ return normalized;
+}