feat: added recording MIDI device feature.

This commit is contained in:
Xiaohan-Tian
2026-05-01 10:59:36 -07:00
parent bd578cb01a
commit 77885c8e2e
8 changed files with 277 additions and 7 deletions
+57 -1
View File
@@ -12,9 +12,11 @@ import {
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
FaPlay, FaPause, FaComments, FaSync,
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
FaCog, FaMagnet, FaCut
FaCog, FaMagnet, FaCut, FaCircle
} from 'react-icons/fa';
import { KGProject, type KeySignature } from '../core/KGProject';
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { plainToInstance } from 'class-transformer';
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
import { KGMainContentState } from '../core/state/KGMainContentState';
@@ -45,6 +47,7 @@ const Toolbar: React.FC = () => {
isLooping, toggleLoop,
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
isRecording, startRecording, stopRecording,
// Piano roll state/actions
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
// Selection state
@@ -515,6 +518,11 @@ const Toolbar: React.FC = () => {
console.log("Pause button clicked");
}
try {
if (isRecording) {
await stopRecording();
setStatus("Recording stopped — notes committed");
return;
}
await stopPlaying();
} catch (error) {
console.error("Failed to stop playback:", error);
@@ -860,6 +868,46 @@ const Toolbar: React.FC = () => {
}
};
const handleRecordClick = async () => {
if (isRecording) {
await stopRecording();
setStatus("Recording stopped — notes committed");
return;
}
// Require an active or selected MIDI region
const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null);
if (!candidateId) {
await showAlert("Please open a MIDI region in the Piano Roll before starting recording.");
return;
}
const tracks = KGCore.instance().getCurrentProject().getTracks();
let isMidi = false;
for (const track of tracks) {
const region = track.getRegions().find(r => r.getId() === candidateId);
if (region) { isMidi = region instanceof KGMidiRegion; break; }
}
if (!isMidi) {
await showAlert("Please select a MIDI region before starting recording.");
return;
}
// Require at least one connected MIDI device
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
await showAlert("No MIDI device detected. Please connect a MIDI keyboard and try again.");
return;
}
// Ensure the piano roll is open showing the target region
if (!activeRegionId) {
setActiveRegionId(candidateId);
setShowPianoRoll(true);
}
await startRecording();
setStatus("Recording started...");
};
return (
<>
<div className="toolbar">
@@ -943,6 +991,14 @@ const Toolbar: React.FC = () => {
) : (
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
)}
<button
title={isRecording ? "Stop Recording" : "Record"}
className={`tool-button ${isRecording ? 'active' : ''}`}
style={isRecording ? { color: 'red' } : undefined}
onClick={handleRecordClick}
>
<FaCircle />
</button>
<button
title="Loop"
className={`tool-button ${isLooping ? 'active' : ''}`}
+8
View File
@@ -255,6 +255,14 @@
transition: opacity 0.1s ease;
}
.piano-grid-recording-note {
position: absolute;
background-color: rgba(255, 60, 60, 0.35);
border: 1px solid rgba(255, 60, 60, 0.85);
pointer-events: none;
z-index: 3;
}
.resize-handle {
position: absolute;
right: 5px;
+26 -2
View File
@@ -4,6 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
import { KGMidiNote } from '../../core/midi/KGMidiNote';
import { KGTrack } from '../../core/track/KGTrack';
import { KGCore } from '../../core/KGCore';
import { useProjectStore } from '../../stores/projectStore';
import PianoNote from './PianoNote';
import PianoKeys from './PianoKeys';
import PianoGridHeader from './PianoGridHeader';
@@ -43,7 +44,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
}) => {
// Get KGCore instance
const core = KGCore.instance();
// Recording state
const isRecording = useProjectStore(s => s.isRecording);
const recordingNotes = useProjectStore(s => s.recordingNotes);
// Use the note operations hook for resize and drag functionality
const {
resizingNoteId,
@@ -216,8 +221,26 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
});
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
const recordingNoteOverlays = useMemo(() => {
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
return recordingNotes.map((note, index) => (
<div
key={`recording-note-${index}`}
className="piano-grid-recording-note"
style={{
left: note.startBeat * beatWidth,
top: (107 - note.pitch) * noteHeight,
width: Math.max((note.endBeat - note.startBeat) * beatWidth, 4),
height: noteHeight,
}}
/>
));
}, [isRecording, recordingNotes, activeRegion]);
return (
<div
<div
className="piano-roll-content"
ref={contentRef}
>
@@ -239,6 +262,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
chordGuide={chordGuide}
>
{memoizedNotes}
{recordingNoteOverlays}
</PianoGrid>
</div>
</div>
@@ -7,9 +7,11 @@ const BehaviorSettings: React.FC = () => {
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50');
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
const [recordingOffset, setRecordingOffset] = useState<string>('0');
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
const [recordingOffsetValidationErrors, setRecordingOffsetValidationErrors] = useState<string[]>([]);
const configManager = ConfigManager.instance();
@@ -26,6 +28,8 @@ const BehaviorSettings: React.FC = () => {
setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0)));
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0)));
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
};
@@ -102,6 +106,28 @@ const BehaviorSettings: React.FC = () => {
}
};
const handleRecordingOffsetChange = async (value: string) => {
const numValueMs = value === '' ? 0 : parseFloat(value);
const numValueSeconds = numValueMs / 1000;
const errors: string[] = [];
if (isNaN(numValueMs)) {
errors.push('MIDI input latency must be a valid number');
} else if (numValueSeconds < 0) {
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
} else if (numValueSeconds > 0.5) {
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
}
setRecordingOffsetValidationErrors(errors);
if (errors.length === 0) {
setRecordingOffset(value);
await configManager.set('audio.recording_offset', numValueSeconds);
console.log(`MIDI input latency changed to: ${numValueSeconds}s (${numValueMs}ms)`);
}
};
const handleEnableAudioCaptureChange = async (value: string) => {
const boolValue = value === 'yes';
setEnableAudioCapture(boolValue);
@@ -212,6 +238,33 @@ const BehaviorSettings: React.FC = () => {
</div>
</div>
<div className="settings-item">
<label className="settings-label">
MIDI Input Latency (ms)
</label>
<input
type="number"
className="settings-select"
value={recordingOffset}
onChange={(e) => handleRecordingOffsetChange(e.target.value)}
min="0"
max="500"
step="1"
/>
{recordingOffsetValidationErrors.length > 0 && (
<div className="settings-validation-errors">
{recordingOffsetValidationErrors.map((error, index) => (
<div key={index} className="settings-validation-error">
{error}
</div>
))}
</div>
)}
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
Timing correction for MIDI recording (0-500ms). If recorded notes appear slightly late compared to where you intended to play them, increase this value to match your MIDI device's input latency. Each note's position is shifted back by this amount when committed.
</div>
</div>
<div className="settings-item">
<label className="settings-label">
Capture Audio for Screen Sharing