247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
|
import { noteNameToPitch, midiPercussionKeyMap } from '../../util/midiUtil';
|
|
import { useProjectStore } from '../../stores/projectStore';
|
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
|
import { KGMidiInput, type LiveMidiNoteActivityEvent } from '../../core/midi-input/KGMidiInput';
|
|
|
|
interface PianoKeysProps {
|
|
activeRegion: KGMidiRegion | null;
|
|
}
|
|
|
|
function incrementPitchCount(source: Map<number, number>, pitch: number): Map<number, number> {
|
|
const next = new Map(source);
|
|
next.set(pitch, (next.get(pitch) ?? 0) + 1);
|
|
return next;
|
|
}
|
|
|
|
function decrementPitchCount(source: Map<number, number>, pitch: number): Map<number, number> {
|
|
const next = new Map(source);
|
|
const current = next.get(pitch) ?? 0;
|
|
|
|
if (current <= 1) {
|
|
next.delete(pitch);
|
|
} else {
|
|
next.set(pitch, current - 1);
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
|
const [mouseActivePitches, setMouseActivePitches] = useState<Map<number, number>>(new Map());
|
|
const [midiActivePitches, setMidiActivePitches] = useState<Map<number, number>>(new Map());
|
|
const mouseActivePitchesRef = useRef<Map<number, number>>(new Map());
|
|
const tracks = useProjectStore(state => state.tracks);
|
|
const playheadPosition = useProjectStore(state => state.playheadPosition);
|
|
const isPlaying = useProjectStore(state => state.isPlaying);
|
|
|
|
// Check if current active region belongs to a drum track
|
|
const isDrumTrack = useMemo(() => {
|
|
if (!activeRegion) return false;
|
|
const track = tracks.find(t => t.getId().toString() === activeRegion.getTrackId());
|
|
return track instanceof KGMidiTrack && track.getInstrument() === 'standard';
|
|
}, [activeRegion, tracks]);
|
|
|
|
const playbackActivePitches = useMemo(() => {
|
|
if (!activeRegion || !isPlaying) {
|
|
return new Set<number>();
|
|
}
|
|
|
|
const activePitches = new Set<number>();
|
|
const absolutePlayhead = playheadPosition;
|
|
const regionStartBeat = activeRegion.getStartFromBeat();
|
|
|
|
activeRegion.getNotes().forEach(note => {
|
|
const startBeat = regionStartBeat + note.getStartBeat();
|
|
const endBeat = regionStartBeat + note.getEndBeat();
|
|
|
|
if (absolutePlayhead >= startBeat && absolutePlayhead < endBeat) {
|
|
activePitches.add(note.getPitch());
|
|
}
|
|
});
|
|
|
|
return activePitches;
|
|
}, [activeRegion, isPlaying, playheadPosition]);
|
|
|
|
useEffect(() => {
|
|
const midiInput = KGMidiInput.instance();
|
|
|
|
const handleLiveNoteActivity = (event: LiveMidiNoteActivityEvent) => {
|
|
setMidiActivePitches(current => (
|
|
event.isNoteOn
|
|
? incrementPitchCount(current, event.pitch)
|
|
: decrementPitchCount(current, event.pitch)
|
|
));
|
|
};
|
|
|
|
midiInput.addLiveNoteActivityListener(handleLiveNoteActivity);
|
|
|
|
return () => {
|
|
midiInput.removeLiveNoteActivityListener(handleLiveNoteActivity);
|
|
};
|
|
}, []);
|
|
|
|
// Handle mouse down on piano key
|
|
const handleKeyMouseDown = (keyId: string) => {
|
|
const pitch = noteNameToPitch(keyId);
|
|
|
|
// Prevent double pressing the same key
|
|
if ((mouseActivePitchesRef.current.get(pitch) ?? 0) > 0) {
|
|
return;
|
|
}
|
|
|
|
// Get the track ID from active region
|
|
if (!activeRegion) {
|
|
console.warn('No active region, cannot play piano key');
|
|
return;
|
|
}
|
|
|
|
const trackId = activeRegion.getTrackId();
|
|
|
|
try {
|
|
// Get audio interface and start playing the note
|
|
const audioInterface = KGAudioInterface.instance();
|
|
if (audioInterface.getIsInitialized()) {
|
|
// Try to start audio context if not started yet
|
|
if (!audioInterface.getIsAudioContextStarted()) {
|
|
audioInterface.startAudioContext().catch(() => {
|
|
// Silently fail if still not allowed - browser policy
|
|
});
|
|
}
|
|
|
|
// Trigger note attack if audio context is ready
|
|
if (audioInterface.getIsAudioContextStarted()) {
|
|
audioInterface.triggerNoteAttack(trackId, pitch, 127);
|
|
|
|
const nextMouseActivePitches = incrementPitchCount(mouseActivePitchesRef.current, pitch);
|
|
mouseActivePitchesRef.current = nextMouseActivePitches;
|
|
setMouseActivePitches(nextMouseActivePitches);
|
|
|
|
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error playing piano key ${keyId}:`, error);
|
|
}
|
|
};
|
|
|
|
// Handle mouse up on piano key
|
|
const handleKeyMouseUp = (keyId: string) => {
|
|
const pitch = noteNameToPitch(keyId);
|
|
|
|
// Only release if key was actually pressed
|
|
if ((mouseActivePitchesRef.current.get(pitch) ?? 0) === 0) {
|
|
return;
|
|
}
|
|
|
|
// Get the track ID from active region
|
|
if (!activeRegion) {
|
|
return;
|
|
}
|
|
|
|
const trackId = activeRegion.getTrackId();
|
|
|
|
try {
|
|
// Get audio interface and stop playing the note
|
|
const audioInterface = KGAudioInterface.instance();
|
|
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
|
audioInterface.releaseNote(trackId, pitch);
|
|
|
|
const nextMouseActivePitches = decrementPitchCount(mouseActivePitchesRef.current, pitch);
|
|
mouseActivePitchesRef.current = nextMouseActivePitches;
|
|
setMouseActivePitches(nextMouseActivePitches);
|
|
|
|
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error releasing piano key ${keyId}:`, error);
|
|
}
|
|
};
|
|
|
|
// Handle mouse leave to ensure keys are released
|
|
const handleKeyMouseLeave = (keyId: string) => {
|
|
handleKeyMouseUp(keyId);
|
|
};
|
|
|
|
// Generate piano keys (C0 to C7)
|
|
const generatePianoKeys = () => {
|
|
const octaves = [];
|
|
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
|
|
// Generate octaves from C0 to C7
|
|
for (let octave = 0; octave <= 7; octave++) {
|
|
const octaveKeys = [];
|
|
|
|
// Add keys in reverse order (B to C) for each octave
|
|
for (let i = notes.length - 1; i >= 0; i--) {
|
|
const note = notes[i];
|
|
const isSharp = note.includes('#');
|
|
const keyId = `${note}${octave}`;
|
|
const pitch = noteNameToPitch(keyId);
|
|
const isMouseActive = (mouseActivePitches.get(pitch) ?? 0) > 0;
|
|
const isMidiActive = (midiActivePitches.get(pitch) ?? 0) > 0;
|
|
const isPlaybackActive = playbackActivePitches.has(pitch);
|
|
const showIndicator = isMouseActive || isMidiActive;
|
|
const hasBackgroundFeedback = isMouseActive || isPlaybackActive;
|
|
const keyClass = [
|
|
'piano-key',
|
|
isSharp ? 'sharp' : 'natural',
|
|
isMouseActive ? 'mouse-active' : '',
|
|
isMidiActive ? 'midi-active' : '',
|
|
isPlaybackActive ? 'playback-active' : '',
|
|
hasBackgroundFeedback ? 'visual-active' : '',
|
|
].filter(Boolean).join(' ');
|
|
const isC = note === 'C';
|
|
|
|
// For drum tracks, show drum labels when available
|
|
let labelContent = null;
|
|
if (isDrumTrack) {
|
|
const drumInfo = midiPercussionKeyMap[pitch];
|
|
if (drumInfo) {
|
|
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
|
|
}
|
|
} else if (isC) {
|
|
labelContent = <span className="key-label">C{octave}</span>;
|
|
}
|
|
|
|
octaveKeys.push(
|
|
<div
|
|
key={keyId}
|
|
className={keyClass}
|
|
data-note={keyId}
|
|
onMouseDown={() => handleKeyMouseDown(keyId)}
|
|
onMouseUp={() => handleKeyMouseUp(keyId)}
|
|
onMouseLeave={() => handleKeyMouseLeave(keyId)}
|
|
style={{
|
|
cursor: 'pointer',
|
|
userSelect: 'none' // Prevent text selection
|
|
}}
|
|
>
|
|
{labelContent}
|
|
{showIndicator ? <span className="piano-key-activity-dot" data-testid={`piano-key-dot-${keyId}`} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Add each octave to the beginning of the array
|
|
octaves.unshift(
|
|
<div key={`octave-${octave}`} className="piano-octave">
|
|
{octaveKeys}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return octaves;
|
|
};
|
|
|
|
return (
|
|
<div className="piano-keys-container">
|
|
{generatePianoKeys()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PianoKeys;
|