Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/assets/change.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/assets/check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions src/components/piano/PracticeNoteBars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// 연습용 노트바 오버레이 — 누른 시간(지속시간)만큼 길이가 자란다.
// 디자인(그라데이션·건반별 폭·라운드)은 NoteBars와 동일. 다른 점은 길이가 고정이 아니라
// note-on ~ note-off 시간에 비례해 실시간으로 그려지는 것뿐이다. (requestAnimationFrame 기반)
import { useEffect, useRef, useState } from 'react';
import { noteCenterFraction, noteWidthFraction, type KeyCount } from '@/constants/piano';

export interface LiveNoteBar {
id: number;
midi: number;
startTime: number; // performance.now() at note-on
endTime: number | null; // performance.now() at note-off (null이면 아직 눌린 중)
}

interface PracticeNoteBarsProps {
bars: LiveNoteBar[];
keyCount: KeyCount;
pxPerMs: number; // 시간 → 길이(px) 환산 (BPM 기준)
onBarDone: (id: number) => void; // 다 올라간 노트바 제거
paused?: boolean; // 정지 중이면 그 자리에 얼림
frozenAt?: number; // 정지 시점 시각 (performance.now 기준) — 얼린 프레임 렌더 기준
}

// NoteBars와 동일한 디자인
const BAR_GRADIENT =
'linear-gradient(180deg, var(--color-primary-400) 0%, var(--color-primary-400) 50%, var(--color-gray-950) 100%)';

export default function PracticeNoteBars({
bars,
keyCount,
pxPerMs,
onBarDone,
paused = false,
frozenAt = 0,
}: PracticeNoteBarsProps) {
const [, setTick] = useState(0);
const rafRef = useRef<number | undefined>(undefined);

// 활성 노트바가 있고 재생 중일 때만 매 프레임 리렌더 (정지 중엔 루프 정지 = 얼림)
useEffect(() => {
if (bars.length === 0 || paused) return;
const loop = () => {
setTick((t) => (t + 1) % 1_000_000);
const now = performance.now();
// 뗀 뒤 헤더 위로 완전히 올라가(한 화면 높이 이상 상승) 화면 밖으로 나간 노트바 제거
for (const b of bars) {
if (b.endTime !== null && (now - b.endTime) * pxPerMs > window.innerHeight) onBarDone(b.id);
}
rafRef.current = requestAnimationFrame(loop);
};
rafRef.current = requestAnimationFrame(loop);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [bars, onBarDone, pxPerMs, paused]);

// 정지 중엔 정지 시점(frozenAt)으로 고정 렌더 → 그 자리에 얼림
const now = paused ? frozenAt : performance.now();

return (
<div className="pointer-events-none absolute bottom-full left-0 w-full">
{bars.map(({ id, midi, startTime, endTime }) => {
const frac = noteCenterFraction(midi, keyCount);
if (frac < 0) return null; // 범위 밖 음은 무시
const widthPct = noteWidthFraction(midi, keyCount) * 100;

const heldMs = (endTime ?? now) - startTime; // 누른 시간 (뗀 뒤엔 고정)
const height = Math.max(4, heldMs * pxPerMs); // 길이 = 누른 시간 × 환산
const released = endTime !== null;
const riseMs = released ? now - endTime : 0;
const translateY = -riseMs * pxPerMs; // 뗀 뒤 위로 올라감 (템포에 맞춘 속도)

return (
<span
key={id}
className="absolute bottom-0 rounded-t-[2px]"
style={{
left: `${frac * 100}%`,
width: `${widthPct}%`,
height: `${height}px`,
transform: `translateX(-50%) translateY(${translateY}px)`,
background: BAR_GRADIENT,
}}
/>
);
})}
</div>
);
}
18 changes: 14 additions & 4 deletions src/hooks/useActiveNotes.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
// src/hooks/useActiveNotes.ts
// 현재 눌려있는 건반 목록 관리 훅
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useMidi } from '@/hooks/useMidi';
import { useSettingStore } from '@/stores/settingsStore';

export function useActiveNotes(onNoteOn?: (note: number, velocity: number, time: number) => void) {
export function useActiveNotes(
onNoteOn?: (note: number, velocity: number, time: number) => void,
onNoteOff?: (note: number) => void, // 뗀 순간 (지속시간 측정 등)
enabled: boolean = true, // false면 입력 무시 (하이라이트도 안 함)
) {
const [activeNotes, setActiveNotes] = useState<Set<number>>(new Set());
const { inputId } = useSettingStore();
const enabledRef = useRef(enabled); // MIDI 콜백에서 최신 값 참조
enabledRef.current = enabled;

const { inputs, error } = useMidi(
(note, velocity, time) => {
if (!enabledRef.current) return; // 비활성(정지) 중엔 입력 무시
setActiveNotes((prev) => new Set(prev).add(note));
onNoteOn?.(note, velocity, time);
},
(note) =>
(note) => {
// note-off는 항상 처리 (눌려있던 음 해제 — stuck 방지)
setActiveNotes((prev) => {
const next = new Set(prev);
next.delete(note);
return next;
}),
});
onNoteOff?.(note);
Comment on lines +23 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

비활성 상태의 note-off를 연주 결과로 기록하지 마세요.

enabled=false여도 onNoteOff가 실행됩니다. PracticePlayPage.tsx에서는 pause 중 key release가 노트바 종료 시각을 기록하고, 재개 시 시작/종료 시각을 함께 이동시키므로 노트바 길이에 일시정지 시간이 포함됩니다. 활성 노트 표시 해제와 연주 이벤트 종료를 분리하고, pause/stop 시점에 열린 노트를 명시적으로 확정·해제하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useActiveNotes.ts` around lines 23 - 30, useActiveNotes의 note-off
처리에서 활성 노트 표시 해제와 연주 이벤트 종료를 분리하세요. enabled=false인 비활성 상태에서는 onNoteOff를 호출하지 말고
Set 정리만 수행하며, PracticePlayPage의 pause/stop 경로에서 열린 노트를 명시적으로 확정·해제해 일시정지 시간이 노트
길이에 포함되지 않도록 하세요.

},
inputId, // ← 선택된 기기만 입력받도록 전달
);

Expand Down
4 changes: 3 additions & 1 deletion src/hooks/useMetronome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ export function useMetronome() {
[getMetronome],
);
const stop = useCallback(() => ref.current?.stop(), []);
return { start, stop };
const pause = useCallback(() => ref.current?.pause(), []);
const resume = useCallback(() => ref.current?.resume(), []);
return { start, stop, pause, resume };
}
4 changes: 2 additions & 2 deletions src/hooks/useMidi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ export function useMidi(

for (const input of list) {
input.onmidimessage = (e) => {
// 기기를 선택한 상태면, 그 기기의 입력만 처리
// input에서 선택한 기기만 처리 (미선택이면 아무 입력도 받지 않음)
const active = handlers.current.activeInputId;
if (active && input.id !== active) return;
if (input.id !== active) return;

const [status, note, velocity] = e.data!;
const cmd = status & 0xf0;
Expand Down
40 changes: 40 additions & 0 deletions src/hooks/usePianoSound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 건반 입력 → 신디 사운드 재생 훅
// Tone.immediate()로 lookAhead(기본 0.1s)를 우회해, 누른 시점과 소리 시점 차이를 최소화한다.
// (메트로놈 Transport의 lookAhead는 건드리지 않으므로 반주 스케줄링은 영향 없음)
import { useEffect, useRef } from 'react';
import * as Tone from 'tone';

export function usePianoSound() {
const synthRef = useRef<Tone.PolySynth | null>(null);

// 신디는 한 번만 생성 (지연 초기화)
const getSynth = () => {
if (!synthRef.current) {
synthRef.current = new Tone.PolySynth(Tone.Synth).toDestination();
synthRef.current.volume.value = -4; // 화음 시 클리핑 방지
}
return synthRef.current;
};

useEffect(() => {
return () => {
synthRef.current?.releaseAll();
synthRef.current?.dispose();
synthRef.current = null;
};
}, []);

// 누름: 즉시 소리 시작 (velocity 0~127 → 0~1)
const noteOn = (midi: number, velocity = 100) => {
const note = Tone.Frequency(midi, 'midi').toNote();
getSynth().triggerAttack(note, Tone.immediate(), velocity / 127);
};

// 뗌: 즉시 소리 끝 (홀드 길이 반영)
const noteOff = (midi: number) => {
const note = Tone.Frequency(midi, 'midi').toNote();
getSynth().triggerRelease(note, Tone.immediate());
};

return { noteOn, noteOff };
}
2 changes: 1 addition & 1 deletion src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
font-size: 32px;
font-weight: 600;
line-height: 44px;
letter-spacing: -1px;
letter-spacing: -0.64px;
}

.heading-medium-m {
Expand Down
8 changes: 5 additions & 3 deletions src/pages/latency/LatencyCheckPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { noteCenterFraction } from '@/constants/piano';
import Metronome from '@/components/metronome/MetronomeDots';
import { useActiveNotes } from '@/hooks/useActiveNotes';
import { useMetronome } from '@/hooks/useMetronome';
import { usePianoSound } from '@/hooks/usePianoSound';
import { useSettingStore } from '@/stores/settingsStore';
import RefreshIcon from '@/assets/restart.svg?react';
import * as Tone from 'tone';
Expand All @@ -25,6 +26,7 @@ function LatencyCheckPage() {
const navigate = useNavigate();
const { keyCount, inputId, bpm, setLatency } = useSettingStore();
const { start, stop } = useMetronome();
const { noteOn: playNote, noteOff: stopNote } = usePianoSound();

const [phase, setPhase] = useState<Phase>('intro');
const [beatInBar, setBeatInBar] = useState(-1);
Expand Down Expand Up @@ -53,7 +55,8 @@ function LatencyCheckPage() {
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// 측정용 MIDI 입력 (건반 하이라이트와 같은 useMidi 인스턴스를 공유 — measuring일 때만 기록)
const { activeNotes } = useActiveNotes((note, _velocity, time) => {
const { activeNotes } = useActiveNotes((note, velocity, time) => {
playNote(note, velocity); // 탭 피드백 소리
// 친 음 위치에서 노트바 생성 (박자 유효 여부와 무관하게 시각 피드백) — measuring 단계에서만
// 선택된 keyCount 범위 밖 노트는 NoteBars가 렌더링하지 않아 onAnimationEnd가 발동하지 않으므로 애초에 쌓지 않는다
if (phaseRef.current === 'measuring' && noteCenterFraction(note, keyCount) >= 0) {
Expand All @@ -77,7 +80,7 @@ function LatencyCheckPage() {
usedBeatsRef.current.add(nearest);
samplesRef.current.push(offset);
}
});
}, stopNote); // 뗄 때 소리 끝

// 애니메이션 종료된 노트바 제거
const handleBarDone = (id: number) => setNoteBars((prev) => prev.filter((b) => b.id !== id));
Expand Down Expand Up @@ -233,7 +236,6 @@ function LatencyCheckPage() {
{countdown}
</span>
)}
{/* phase === 'measuring' → 노트바 (나중에) */}
</div>

{/* 건반 영역 (+ 친 음에서 솟아오르는 노트바) */}
Expand Down
Loading