feat: added visual feedback when pressing piano keys
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import PianoKeys from './PianoKeys';
|
||||
import { createMockMidiNote, createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data';
|
||||
|
||||
type TestLiveNoteActivityListener = (...args: [{ pitch: number; isNoteOn: boolean }]) => void;
|
||||
|
||||
const storeState = {
|
||||
tracks: [createMockMidiTrack({ id: 1 })],
|
||||
playheadPosition: 0,
|
||||
isPlaying: false,
|
||||
};
|
||||
|
||||
const audioInterfaceMock = {
|
||||
getIsInitialized: vi.fn(),
|
||||
getIsAudioContextStarted: vi.fn(),
|
||||
startAudioContext: vi.fn(),
|
||||
triggerNoteAttack: vi.fn(),
|
||||
releaseNote: vi.fn(),
|
||||
};
|
||||
|
||||
let liveNoteActivityListener: TestLiveNoteActivityListener | null = null;
|
||||
const midiInputMock = {
|
||||
addLiveNoteActivityListener: vi.fn((listener: TestLiveNoteActivityListener) => {
|
||||
liveNoteActivityListener = listener;
|
||||
}),
|
||||
removeLiveNoteActivityListener: vi.fn((listener: TestLiveNoteActivityListener) => {
|
||||
if (liveNoteActivityListener === listener) {
|
||||
liveNoteActivityListener = null;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: (selector: (...args: [typeof storeState]) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
vi.mock('../../core/audio-interface/KGAudioInterface', () => ({
|
||||
KGAudioInterface: {
|
||||
instance: () => audioInterfaceMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../core/midi-input/KGMidiInput', () => ({
|
||||
KGMidiInput: {
|
||||
instance: () => midiInputMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('PianoKeys', () => {
|
||||
const activeRegion = createMockMidiRegion({
|
||||
trackId: '1',
|
||||
notes: [createMockMidiNote({ id: 'note-c4', pitch: 60, startBeat: 0, endBeat: 2 })],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
storeState.tracks = [createMockMidiTrack({ id: 1 })];
|
||||
storeState.playheadPosition = 0;
|
||||
storeState.isPlaying = false;
|
||||
liveNoteActivityListener = null;
|
||||
vi.clearAllMocks();
|
||||
audioInterfaceMock.getIsInitialized.mockReturnValue(true);
|
||||
audioInterfaceMock.getIsAudioContextStarted.mockReturnValue(true);
|
||||
audioInterfaceMock.startAudioContext.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('shows dot and background feedback for mouse preview while held', () => {
|
||||
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
|
||||
fireEvent.mouseDown(key);
|
||||
|
||||
expect(key.className).toContain('visual-active');
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseUp(key);
|
||||
|
||||
expect(key.className).not.toContain('visual-active');
|
||||
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows MIDI activity dot without background feedback', () => {
|
||||
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
expect(midiInputMock.addLiveNoteActivityListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
liveNoteActivityListener?.({ pitch: 60, isNoteOn: true });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
expect(key.className).not.toContain('visual-active');
|
||||
|
||||
act(() => {
|
||||
liveNoteActivityListener?.({ pitch: 60, isNoteOn: false });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows playback background feedback without dot for sounding notes in the active region', () => {
|
||||
storeState.isPlaying = true;
|
||||
storeState.playheadPosition = 1;
|
||||
|
||||
const { container } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
|
||||
expect(key.className).toContain('playback-active');
|
||||
expect(key.className).toContain('visual-active');
|
||||
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves source-specific feedback while mouse, MIDI, and playback overlap', () => {
|
||||
storeState.isPlaying = true;
|
||||
storeState.playheadPosition = 1;
|
||||
|
||||
const { container, rerender } = render(<PianoKeys activeRegion={activeRegion} />);
|
||||
const key = container.querySelector('[data-note="C4"]') as HTMLElement;
|
||||
|
||||
fireEvent.mouseDown(key);
|
||||
act(() => {
|
||||
liveNoteActivityListener?.({ pitch: 60, isNoteOn: true });
|
||||
});
|
||||
|
||||
expect(key.className).toContain('visual-active');
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
|
||||
storeState.isPlaying = false;
|
||||
rerender(<PianoKeys activeRegion={activeRegion} />);
|
||||
|
||||
expect(key.className).toContain('visual-active');
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseUp(key);
|
||||
|
||||
expect(key.className).not.toContain('visual-active');
|
||||
expect(screen.getByTestId('piano-key-dot-C4')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
liveNoteActivityListener?.({ pitch: 60, isNoteOn: false });
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('piano-key-dot-C4')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,94 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { noteNameToPitch, midiPercussionKeyMap, pitchToNoteNameString } from '../../util/midiUtil';
|
||||
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 [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set());
|
||||
const pressedKeysRef = useRef<Set<string>>(new Set());
|
||||
const { tracks } = useProjectStore();
|
||||
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 = React.useMemo(() => {
|
||||
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 (pressedKeysRef.current.has(keyId)) {
|
||||
if ((mouseActivePitchesRef.current.get(pitch) ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,9 +101,6 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
const trackId = activeRegion.getTrackId();
|
||||
|
||||
try {
|
||||
// Convert note name to pitch (keyId is always a note name like "C4")
|
||||
const pitch = noteNameToPitch(keyId);
|
||||
|
||||
// Get audio interface and start playing the note
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized()) {
|
||||
@@ -54,11 +115,9 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
if (audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.triggerNoteAttack(trackId, pitch, 127);
|
||||
|
||||
// Update pressed keys state
|
||||
const newPressedKeys = new Set(pressedKeysRef.current);
|
||||
newPressedKeys.add(keyId);
|
||||
pressedKeysRef.current = newPressedKeys;
|
||||
setPressedKeys(newPressedKeys);
|
||||
const nextMouseActivePitches = incrementPitchCount(mouseActivePitchesRef.current, pitch);
|
||||
mouseActivePitchesRef.current = nextMouseActivePitches;
|
||||
setMouseActivePitches(nextMouseActivePitches);
|
||||
|
||||
console.log(`Started playing piano key: ${keyId} (pitch ${pitch})`);
|
||||
}
|
||||
@@ -70,8 +129,10 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
|
||||
// Handle mouse up on piano key
|
||||
const handleKeyMouseUp = (keyId: string) => {
|
||||
const pitch = noteNameToPitch(keyId);
|
||||
|
||||
// Only release if key was actually pressed
|
||||
if (!pressedKeysRef.current.has(keyId)) {
|
||||
if ((mouseActivePitchesRef.current.get(pitch) ?? 0) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,19 +144,14 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
const trackId = activeRegion.getTrackId();
|
||||
|
||||
try {
|
||||
// Convert note name to pitch (keyId is always a note name like "C4")
|
||||
const pitch = noteNameToPitch(keyId);
|
||||
|
||||
// Get audio interface and stop playing the note
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
if (audioInterface.getIsInitialized() && audioInterface.getIsAudioContextStarted()) {
|
||||
audioInterface.releaseNote(trackId, pitch);
|
||||
|
||||
// Update pressed keys state
|
||||
const newPressedKeys = new Set(pressedKeysRef.current);
|
||||
newPressedKeys.delete(keyId);
|
||||
pressedKeysRef.current = newPressedKeys;
|
||||
setPressedKeys(newPressedKeys);
|
||||
const nextMouseActivePitches = decrementPitchCount(mouseActivePitchesRef.current, pitch);
|
||||
mouseActivePitchesRef.current = nextMouseActivePitches;
|
||||
setMouseActivePitches(nextMouseActivePitches);
|
||||
|
||||
console.log(`Stopped playing piano key: ${keyId} (pitch ${pitch})`);
|
||||
}
|
||||
@@ -123,14 +179,25 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
const note = notes[i];
|
||||
const isSharp = note.includes('#');
|
||||
const keyId = `${note}${octave}`;
|
||||
const isPressed = pressedKeys.has(keyId);
|
||||
const keyClass = `piano-key ${isSharp ? 'sharp' : 'natural'} ${isPressed ? 'pressed' : ''}`;
|
||||
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 pitch = noteNameToPitch(keyId);
|
||||
const drumInfo = midiPercussionKeyMap[pitch];
|
||||
if (drumInfo) {
|
||||
labelContent = <span className="key-label">{drumInfo.shortName}</span>;
|
||||
@@ -153,6 +220,7 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
}}
|
||||
>
|
||||
{labelContent}
|
||||
{showIndicator ? <span className="piano-key-activity-dot" data-testid={`piano-key-dot-${keyId}`} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,4 +243,4 @@ const PianoKeys: React.FC<PianoKeysProps> = ({ activeRegion }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PianoKeys;
|
||||
export default PianoKeys;
|
||||
|
||||
@@ -572,6 +572,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
position: relative;
|
||||
transition: background-color 0.08s ease;
|
||||
}
|
||||
|
||||
.piano-key.natural {
|
||||
@@ -584,9 +586,37 @@
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.piano-key.natural.visual-active {
|
||||
background-color: #b8b8b8;
|
||||
}
|
||||
|
||||
.piano-key.sharp.visual-active {
|
||||
background-color: #5a5a5a;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
font-size: 10px;
|
||||
padding-left: 5px;
|
||||
padding-right: 18px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.piano-key-activity-dot {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
border-radius: 999px;
|
||||
background-color: #000;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.piano-key.sharp .piano-key-activity-dot {
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.piano-grid {
|
||||
|
||||
Reference in New Issue
Block a user