From 5c632f530a86bb46d145b6e01ce306afd434c8a0 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Wed, 22 Jul 2026 17:02:28 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=EC=97=B0=EC=A3=BC=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94,=20=EB=B0=B1=ED=82=B9=ED=8A=B8=EB=9E=99&?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A1=9C=EB=A1=AC=20UI=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/change.svg | 3 + src/assets/check.svg | 2 +- src/index.css | 2 +- src/pages/practice/PracticePlayPage.tsx | 131 +++++++++++++++++- .../practice/components/BackingTrack.tsx | 48 +++++++ 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/assets/change.svg create mode 100644 src/pages/practice/components/BackingTrack.tsx diff --git a/src/assets/change.svg b/src/assets/change.svg new file mode 100644 index 0000000..fa81a96 --- /dev/null +++ b/src/assets/change.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/check.svg b/src/assets/check.svg index 934f3da..d563144 100644 --- a/src/assets/check.svg +++ b/src/assets/check.svg @@ -1,3 +1,3 @@ - + diff --git a/src/index.css b/src/index.css index 4c34059..75a6724 100644 --- a/src/index.css +++ b/src/index.css @@ -135,7 +135,7 @@ font-size: 32px; font-weight: 600; line-height: 44px; - letter-spacing: -1px; + letter-spacing: -0.64px; } .heading-medium-m { diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 2c7df31..2c9235a 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -1,20 +1,139 @@ // 연습 플레이 페이지 - 61건반/88건반 +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import * as Tone from 'tone'; import Piano from '@/components/piano/Piano'; +import MetronomeDots from '@/components/metronome/MetronomeDots'; +import BackingTrack from '@/pages/practice/components/BackingTrack'; import { useActiveNotes } from '@/hooks/useActiveNotes'; +import { useMetronome } from '@/hooks/useMetronome'; import { useSettingStore } from '@/stores/settingsStore'; +import { ALL_TRACKS, RECOMMENDED_TRACKS } from '@/pages/practice/mockTracks'; +import { buildFallbackProgression, MODE_LABEL } from '@/pages/practice/trackDisplay'; +import PlayIcon from '@/assets/practice/play.svg?react'; +import StopIcon from '@/assets/practice/stop.svg?react'; +import RefreshIcon from '@/assets/restart.svg?react'; +import CheckIcon from '@/assets/check.svg?react'; +import ChangeIcon from '@/assets/change.svg?react'; function PracticePlayPage() { - const { activeNotes } = useActiveNotes(); + const navigate = useNavigate(); + const { practiceId } = useParams(); const { keyCount } = useSettingStore(); + const { activeNotes } = useActiveNotes(); + const { start, stop } = useMetronome(); + + const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId) ?? RECOMMENDED_TRACKS[0]; + const beatsPerBar = Number(track.timeSignature.split('/')[0]); // '4/4' → 4 + const measures = track.chordProgression ?? buildFallbackProgression(track.chords, beatsPerBar); + const totalCells = measures.length * beatsPerBar; + + const [isPlaying, setIsPlaying] = useState(false); + const [beatInBar, setBeatInBar] = useState(-1); // 진행점 (마디 내 0-based) + const [currentBeat, setCurrentBeat] = useState(-1); // 백킹트랙 전체 진행 박 + const totalBeatRef = useRef(0); + + const stopPlayback = () => { + stop(); + Tone.getDraw().cancel(); + setIsPlaying(false); + setBeatInBar(-1); + setCurrentBeat(-1); + }; + + const startPlayback = async () => { + await Tone.start(); // 오디오 잠금 해제 (클릭 핸들러 안에서만 가능) + totalBeatRef.current = 0; + setIsPlaying(true); + start(track.bpm, beatsPerBar, (time, bib) => { + const beat = totalBeatRef.current % totalCells; + Tone.getDraw().schedule(() => { + setBeatInBar(bib); + setCurrentBeat(beat); + }, time); + totalBeatRef.current += 1; + }); + }; + + const handlePlayToggle = () => (isPlaying ? stopPlayback() : startPlayback()); + const handleRestart = () => { + stopPlayback(); + startPlayback(); + }; + + // 언마운트 시 정리 + useEffect(() => { + return () => { + stop(); + Tone.getDraw().cancel(); + }; + }, [stop]); return ( -
-

연습 시작

+
+ {/* 헤더 */} +
+ {/* Track Title: 재생 버튼(좌) + 제목/칩 컬럼 — 칩은 제목 아래 */} +
+ +
+
{track.title}
+
+ {track.genre.toUpperCase()} + + {track.key} {MODE_LABEL[track.mode]} + + {track.bpm}BPM +
+
+
+ +
+ + + +
+
+ + {/* 본문 */} +
+ {/* 백킹트랙 + 진행점 */} +
+ +
+ +
+
- {/* 나중에 여기에 떨어지는 노트바(canvas) 영역이 들어감 */} -
+ {/* 노트바 / 코드명 영역 (다음 단계) */} +
- + +
); } diff --git a/src/pages/practice/components/BackingTrack.tsx b/src/pages/practice/components/BackingTrack.tsx new file mode 100644 index 0000000..65416c3 --- /dev/null +++ b/src/pages/practice/components/BackingTrack.tsx @@ -0,0 +1,48 @@ +// 백킹트랙(코드 진행) 표시 전용 컴포넌트 +// 한 화면에 마디 PAGE_SIZE(4)개를 테두리 영역 안에 표시하고, 재생 중인 박(currentBeat)을 +// 보라색으로 강조한다. 현재 페이지의 마디를 모두 지나가면 다음 4개 마디로 넘어간다. +import type { ChordMeasure } from '@/types/track'; + +interface BackingTrackProps { + measures: ChordMeasure[]; + currentBeat: number; // 전체 진행 기준 현재 박(0-based), 미재생이면 -1 + beatsPerBar: number; // 4/4 → 4 + className?: string; +} + +const PAGE_SIZE = 4; // 한 화면에 보이는 마디 수 + +export default function BackingTrack({ measures, currentBeat, beatsPerBar, className = '' }: BackingTrackProps) { + // 현재 마디 → 현재 페이지(마디 4개 묶음) 계산 + const currentMeasure = currentBeat < 0 ? 0 : Math.floor(currentBeat / beatsPerBar); + const page = Math.floor(currentMeasure / PAGE_SIZE); + const startMeasure = page * PAGE_SIZE; + const visibleMeasures = measures.slice(startMeasure, startMeasure + PAGE_SIZE); + + return ( + // 컨테이너: width 1510px, padding 12px, justify-between, radius 6px, bg gray-900 +
+ {visibleMeasures.map((measure, localIndex) => { + const measureIndex = startMeasure + localIndex; // 전체 진행 기준 마디 인덱스 + return ( +
+ {measure.map((chord, cellIndex) => { + const flatIndex = measureIndex * beatsPerBar + cellIndex; + const isCurrent = flatIndex === currentBeat; + return ( + // 셀: 80×30, radius 4px, bg gray-600 (재생 중인 박은 보라색) +
+ {chord ?? '-'} +
+ ); + })} +
+ ); + })} +
+ ); +} From 74d130367d3b0801b7bda5f48a3831b9807bd8fe Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Wed, 22 Jul 2026 23:25:20 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=EC=8B=9C=EC=9E=91=EC=A0=84=20STA?= =?UTF-8?q?RT=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/practice/PracticePlayPage.tsx | 19 ++++++++++++++++--- .../practice/components/BackingTrack.tsx | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 2c9235a..cf52dda 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -31,6 +31,7 @@ function PracticePlayPage() { const [isPlaying, setIsPlaying] = useState(false); const [beatInBar, setBeatInBar] = useState(-1); // 진행점 (마디 내 0-based) const [currentBeat, setCurrentBeat] = useState(-1); // 백킹트랙 전체 진행 박 + const [showStart, setShowStart] = useState(true); // 진입 시 START 안내 (1초) const totalBeatRef = useRef(0); const stopPlayback = () => { @@ -69,6 +70,16 @@ function PracticePlayPage() { }; }, [stop]); + // 진입 시 START 1초 표시 후 자동 재생 + useEffect(() => { + const timer = setTimeout(() => { + setShowStart(false); + startPlayback(); + }, 1000); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return (
{/* 헤더 */} @@ -120,7 +131,7 @@ function PracticePlayPage() { {/* 본문 */} -
+
{/* 백킹트랙 + 진행점 */}
@@ -129,8 +140,10 @@ function PracticePlayPage() {
- {/* 노트바 / 코드명 영역 (다음 단계) */} -
+ {/* 중앙 영역: 진입 시 START, 이후 노트바/코드명 (다음 단계) */} +
+ {showStart && START} +
diff --git a/src/pages/practice/components/BackingTrack.tsx b/src/pages/practice/components/BackingTrack.tsx index 65416c3..d3a43f8 100644 --- a/src/pages/practice/components/BackingTrack.tsx +++ b/src/pages/practice/components/BackingTrack.tsx @@ -34,7 +34,7 @@ export default function BackingTrack({ measures, currentBeat, beatsPerBar, class
{chord ?? '-'}
From cac85c4b688a95b1cff8f8a2f35bcb21f18c2d02 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Wed, 22 Jul 2026 23:45:01 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=EA=B1=B4=EB=B0=98=20=EB=88=8C?= =?UTF-8?q?=EB=A0=80=EC=9D=84=20=EB=95=8C=20=EB=85=B8=ED=8A=B8=EB=B0=94=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/piano/PracticeNoteBars.tsx | 82 +++++++++++++++++++++++ src/hooks/useActiveNotes.ts | 11 ++- src/pages/practice/PracticePlayPage.tsx | 36 ++++++++-- 3 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 src/components/piano/PracticeNoteBars.tsx diff --git a/src/components/piano/PracticeNoteBars.tsx b/src/components/piano/PracticeNoteBars.tsx new file mode 100644 index 0000000..5f0a63b --- /dev/null +++ b/src/components/piano/PracticeNoteBars.tsx @@ -0,0 +1,82 @@ +// 연습용 노트바 오버레이 — 누른 시간(지속시간)만큼 길이가 자란다. +// 디자인(그라데이션·건반별 폭·라운드)은 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; // 다 올라간 노트바 제거 +} + +// NoteBars와 동일한 디자인 +const BAR_GRADIENT = + 'linear-gradient(180deg, var(--color-primary-400) 0%, var(--color-primary-400) 50%, var(--color-gray-950) 100%)'; +const RISE_SPEED = 0.35; // px/ms — 손을 뗀 뒤 위로 스크롤되는 속도 +const RISE_FADE = 900; // ms — 뗀 뒤 사라지기까지 + +export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: PracticeNoteBarsProps) { + const [, setTick] = useState(0); + const rafRef = useRef(undefined); + + // 활성 노트바가 있는 동안 매 프레임 리렌더 (길이 성장 / 상승 애니메이션) + useEffect(() => { + if (bars.length === 0) return; + const loop = () => { + setTick((t) => (t + 1) % 1_000_000); + const now = performance.now(); + // 뗀 뒤 RISE_FADE 지난 노트바 제거 + for (const b of bars) { + if (b.endTime !== null && now - b.endTime > RISE_FADE) onBarDone(b.id); + } + rafRef.current = requestAnimationFrame(loop); + }; + rafRef.current = requestAnimationFrame(loop); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [bars, onBarDone]); + + const now = performance.now(); + + return ( +
+ {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 * RISE_SPEED; // 뗀 뒤 위로 올라감 + const opacity = released ? Math.max(0, 1 - riseMs / RISE_FADE) : 1; + + return ( + + ); + })} +
+ ); +} diff --git a/src/hooks/useActiveNotes.ts b/src/hooks/useActiveNotes.ts index 4cffc83..5fe474d 100644 --- a/src/hooks/useActiveNotes.ts +++ b/src/hooks/useActiveNotes.ts @@ -4,7 +4,10 @@ import { 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, // 뗀 순간 (지속시간 측정 등) +) { const [activeNotes, setActiveNotes] = useState>(new Set()); const { inputId } = useSettingStore(); @@ -13,12 +16,14 @@ export function useActiveNotes(onNoteOn?: (note: number, velocity: number, time: setActiveNotes((prev) => new Set(prev).add(note)); onNoteOn?.(note, velocity, time); }, - (note) => + (note) => { setActiveNotes((prev) => { const next = new Set(prev); next.delete(note); return next; - }), + }); + onNoteOff?.(note); + }, inputId, // ← 선택된 기기만 입력받도록 전달 ); diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index cf52dda..fbf327a 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -1,8 +1,10 @@ // 연습 플레이 페이지 - 61건반/88건반 -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import * as Tone from 'tone'; import Piano from '@/components/piano/Piano'; +import PracticeNoteBars, { type LiveNoteBar } from '@/components/piano/PracticeNoteBars'; +import { noteCenterFraction } from '@/constants/piano'; import MetronomeDots from '@/components/metronome/MetronomeDots'; import BackingTrack from '@/pages/practice/components/BackingTrack'; import { useActiveNotes } from '@/hooks/useActiveNotes'; @@ -16,23 +18,45 @@ import RefreshIcon from '@/assets/restart.svg?react'; import CheckIcon from '@/assets/check.svg?react'; import ChangeIcon from '@/assets/change.svg?react'; +const PX_PER_BEAT = 120; // 노트바 길이 환산: 1박 = 120px + function PracticePlayPage() { const navigate = useNavigate(); const { practiceId } = useParams(); const { keyCount } = useSettingStore(); - const { activeNotes } = useActiveNotes(); const { start, stop } = useMetronome(); const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId) ?? RECOMMENDED_TRACKS[0]; const beatsPerBar = Number(track.timeSignature.split('/')[0]); // '4/4' → 4 const measures = track.chordProgression ?? buildFallbackProgression(track.chords, beatsPerBar); const totalCells = measures.length * beatsPerBar; + const pxPerMs = (PX_PER_BEAT * track.bpm) / 60000; // 노트바 길이: 1박 = PX_PER_BEAT px const [isPlaying, setIsPlaying] = useState(false); const [beatInBar, setBeatInBar] = useState(-1); // 진행점 (마디 내 0-based) const [currentBeat, setCurrentBeat] = useState(-1); // 백킹트랙 전체 진행 박 const [showStart, setShowStart] = useState(true); // 진입 시 START 안내 (1초) + const [noteBars, setNoteBars] = useState([]); // 연습 노트바 (가변 길이) const totalBeatRef = useRef(0); + const barIdRef = useRef(0); + const heldRef = useRef>(new Map()); // midi → 진행 중 노트바 id + + // 친 음: 노트바 생성(성장 시작) → 뗄 때 길이 확정 후 위로 사라짐 + const handleNoteOn = (note: number) => { + if (noteCenterFraction(note, keyCount) < 0) return; // 범위 밖 음은 무시 + const id = barIdRef.current++; + heldRef.current.set(note, id); + setNoteBars((prev) => [...prev, { id, midi: note, startTime: performance.now(), endTime: null }]); + }; + const handleNoteOff = (note: number) => { + const id = heldRef.current.get(note); + if (id === undefined) return; + heldRef.current.delete(note); + setNoteBars((prev) => prev.map((b) => (b.id === id ? { ...b, endTime: performance.now() } : b))); + }; + const handleBarDone = useCallback((id: number) => setNoteBars((prev) => prev.filter((b) => b.id !== id)), []); + + const { activeNotes } = useActiveNotes(handleNoteOn, handleNoteOff); const stopPlayback = () => { stop(); @@ -135,7 +159,7 @@ function PracticePlayPage() { {/* 백킹트랙 + 진행점 */}
-
+
@@ -145,7 +169,11 @@ function PracticePlayPage() { {showStart && START}
- + {/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */} +
+ + +
); From af041ac5576bbef5f03f88661812c5a1f28faaf6 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Wed, 22 Jul 2026 23:55:49 +0900 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20=EB=85=B8=ED=8A=B8=EB=B0=94=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=EB=A5=BC=20BPM=20=ED=85=9C=ED=8F=AC=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=94=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/piano/PracticeNoteBars.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/piano/PracticeNoteBars.tsx b/src/components/piano/PracticeNoteBars.tsx index 5f0a63b..fdd5665 100644 --- a/src/components/piano/PracticeNoteBars.tsx +++ b/src/components/piano/PracticeNoteBars.tsx @@ -21,8 +21,7 @@ interface PracticeNoteBarsProps { // NoteBars와 동일한 디자인 const BAR_GRADIENT = 'linear-gradient(180deg, var(--color-primary-400) 0%, var(--color-primary-400) 50%, var(--color-gray-950) 100%)'; -const RISE_SPEED = 0.35; // px/ms — 손을 뗀 뒤 위로 스크롤되는 속도 -const RISE_FADE = 900; // ms — 뗀 뒤 사라지기까지 +const RISE_FADE = 900; // ms — 뗀 뒤 사라지기까지 (상승 속도는 pxPerMs = 템포에 연동) export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: PracticeNoteBarsProps) { const [, setTick] = useState(0); @@ -59,7 +58,7 @@ export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: const height = Math.max(4, heldMs * pxPerMs); // 길이 = 누른 시간 × 환산 const released = endTime !== null; const riseMs = released ? now - endTime : 0; - const translateY = -riseMs * RISE_SPEED; // 뗀 뒤 위로 올라감 + const translateY = -riseMs * pxPerMs; // 뗀 뒤 위로 올라감 (템포에 맞춘 속도) const opacity = released ? Math.max(0, 1 - riseMs / RISE_FADE) : 1; return ( From 326e13d5657176f5632def4456652cdfcc239563 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 00:10:18 +0900 Subject: [PATCH 05/15] =?UTF-8?q?fix:=20=EB=85=B8=ED=8A=B8=EB=B0=94=20?= =?UTF-8?q?=EB=B0=B1=ED=82=B9=ED=8A=B8=EB=9E=99=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/piano/PracticeNoteBars.tsx | 9 +++------ src/pages/latency/LatencyCheckPage.tsx | 1 - src/pages/practice/PracticePlayPage.tsx | 23 ++++++++++++++--------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/components/piano/PracticeNoteBars.tsx b/src/components/piano/PracticeNoteBars.tsx index fdd5665..95a5ec4 100644 --- a/src/components/piano/PracticeNoteBars.tsx +++ b/src/components/piano/PracticeNoteBars.tsx @@ -21,7 +21,6 @@ interface PracticeNoteBarsProps { // NoteBars와 동일한 디자인 const BAR_GRADIENT = 'linear-gradient(180deg, var(--color-primary-400) 0%, var(--color-primary-400) 50%, var(--color-gray-950) 100%)'; -const RISE_FADE = 900; // ms — 뗀 뒤 사라지기까지 (상승 속도는 pxPerMs = 템포에 연동) export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: PracticeNoteBarsProps) { const [, setTick] = useState(0); @@ -33,9 +32,9 @@ export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: const loop = () => { setTick((t) => (t + 1) % 1_000_000); const now = performance.now(); - // 뗀 뒤 RISE_FADE 지난 노트바 제거 + // 뗀 뒤 헤더 위로 완전히 올라가(한 화면 높이 이상 상승) 화면 밖으로 나간 노트바 제거 for (const b of bars) { - if (b.endTime !== null && now - b.endTime > RISE_FADE) onBarDone(b.id); + if (b.endTime !== null && (now - b.endTime) * pxPerMs > window.innerHeight) onBarDone(b.id); } rafRef.current = requestAnimationFrame(loop); }; @@ -43,7 +42,7 @@ export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; - }, [bars, onBarDone]); + }, [bars, onBarDone, pxPerMs]); const now = performance.now(); @@ -59,7 +58,6 @@ export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: const released = endTime !== null; const riseMs = released ? now - endTime : 0; const translateY = -riseMs * pxPerMs; // 뗀 뒤 위로 올라감 (템포에 맞춘 속도) - const opacity = released ? Math.max(0, 1 - riseMs / RISE_FADE) : 1; return ( diff --git a/src/pages/latency/LatencyCheckPage.tsx b/src/pages/latency/LatencyCheckPage.tsx index aa31fa2..b7bb6df 100644 --- a/src/pages/latency/LatencyCheckPage.tsx +++ b/src/pages/latency/LatencyCheckPage.tsx @@ -233,7 +233,6 @@ function LatencyCheckPage() { {countdown} )} - {/* phase === 'measuring' → 노트바 (나중에) */}
{/* 건반 영역 (+ 친 음에서 솟아오르는 노트바) */} diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index fbf327a..701effa 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -164,15 +164,20 @@ function PracticePlayPage() {
- {/* 중앙 영역: 진입 시 START, 이후 노트바/코드명 (다음 단계) */} -
- {showStart && START} -
- - {/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */} -
- - + {/* 진입 시 START (본문 기준 고정) */} + {showStart && ( + + START + + )} + + {/* 노트바 클리핑 영역: 백킹트랙 아래 ~ 건반. 노트바가 상단(백킹트랙 밑)에서 잘려 사라진다 */} +
+ {/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */} +
+ + +
From ed2235773a273007716f1cfb389b0cf9b6fd88e2 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 00:23:35 +0900 Subject: [PATCH 06/15] =?UTF-8?q?feat:=EA=B1=B4=EB=B0=98=20=EC=86=8C?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=9C=EB=A0=A5=20=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/usePianoSound.ts | 40 +++++++++++++++++++++++++ src/pages/practice/PracticePlayPage.tsx | 10 +++++-- 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 src/hooks/usePianoSound.ts diff --git a/src/hooks/usePianoSound.ts b/src/hooks/usePianoSound.ts new file mode 100644 index 0000000..4266c88 --- /dev/null +++ b/src/hooks/usePianoSound.ts @@ -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(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 }; +} diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 701effa..3a3ffb0 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -9,6 +9,7 @@ import MetronomeDots from '@/components/metronome/MetronomeDots'; import BackingTrack from '@/pages/practice/components/BackingTrack'; import { useActiveNotes } from '@/hooks/useActiveNotes'; import { useMetronome } from '@/hooks/useMetronome'; +import { usePianoSound } from '@/hooks/usePianoSound'; import { useSettingStore } from '@/stores/settingsStore'; import { ALL_TRACKS, RECOMMENDED_TRACKS } from '@/pages/practice/mockTracks'; import { buildFallbackProgression, MODE_LABEL } from '@/pages/practice/trackDisplay'; @@ -25,6 +26,7 @@ function PracticePlayPage() { const { practiceId } = useParams(); const { keyCount } = useSettingStore(); const { start, stop } = useMetronome(); + const { noteOn: playNote, noteOff: stopNote } = usePianoSound(); const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId) ?? RECOMMENDED_TRACKS[0]; const beatsPerBar = Number(track.timeSignature.split('/')[0]); // '4/4' → 4 @@ -41,14 +43,16 @@ function PracticePlayPage() { const barIdRef = useRef(0); const heldRef = useRef>(new Map()); // midi → 진행 중 노트바 id - // 친 음: 노트바 생성(성장 시작) → 뗄 때 길이 확정 후 위로 사라짐 - const handleNoteOn = (note: number) => { - if (noteCenterFraction(note, keyCount) < 0) return; // 범위 밖 음은 무시 + // 친 음: 소리 재생 + 노트바 생성(성장 시작) → 뗄 때 소리·길이 확정 후 위로 사라짐 + const handleNoteOn = (note: number, velocity = 100) => { + playNote(note, velocity); // 즉시 소리 (범위와 무관하게 실제 친 음) + if (noteCenterFraction(note, keyCount) < 0) return; // 노트바는 건반 범위 안만 const id = barIdRef.current++; heldRef.current.set(note, id); setNoteBars((prev) => [...prev, { id, midi: note, startTime: performance.now(), endTime: null }]); }; const handleNoteOff = (note: number) => { + stopNote(note); // 즉시 소리 끝 const id = heldRef.current.get(note); if (id === undefined) return; heldRef.current.delete(note); From a34454aa30950ce76e56aed60e5eb05b171144da Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 00:44:53 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat:=EC=BD=94=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD=EC=97=90=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=82=98=ED=83=80=EB=82=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/practice/PracticePlayPage.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 3a3ffb0..f592530 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -62,6 +62,16 @@ function PracticePlayPage() { const { activeNotes } = useActiveNotes(handleNoteOn, handleNoteOff); + // 백킹트랙에서 지금 차례인 코드 (null은 직전 코드 유지) + const flatChords = measures.flat(); + const currentChord = + currentBeat < 0 + ? null + : (flatChords + .slice(0, currentBeat + 1) + .filter(Boolean) + .pop() ?? null); + const stopPlayback = () => { stop(); Tone.getDraw().cancel(); @@ -175,6 +185,13 @@ function PracticePlayPage() { )} + {/* 백킹트랙 현재 코드명 (배경) */} + {!showStart && currentChord && ( + + {currentChord} + + )} + {/* 노트바 클리핑 영역: 백킹트랙 아래 ~ 건반. 노트바가 상단(백킹트랙 밑)에서 잘려 사라진다 */}
{/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */} From f8f65f91e12163f9486122a597681c9befc28cbe Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 00:56:23 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat:=EC=84=A4=EC=A0=95=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/practice/PracticePlayPage.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index f592530..8241c1c 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -18,6 +18,7 @@ import StopIcon from '@/assets/practice/stop.svg?react'; import RefreshIcon from '@/assets/restart.svg?react'; import CheckIcon from '@/assets/check.svg?react'; import ChangeIcon from '@/assets/change.svg?react'; +import SettingsIcon from '@/assets/setting.svg?react'; const PX_PER_BEAT = 120; // 노트바 길이 환산: 1박 = 120px @@ -192,12 +193,24 @@ function PracticePlayPage() { )} - {/* 노트바 클리핑 영역: 백킹트랙 아래 ~ 건반. 노트바가 상단(백킹트랙 밑)에서 잘려 사라진다 */} -
+ {/* 노트바 클리핑 영역: 위(백킹트랙 밑)에서만 자르고 좌우는 열어둠(설정 버튼이 안 잘리도록) */} +
{/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */}
- + navigate(`/practice/${practiceId}/settings`)} + className="flex cursor-pointer flex-col items-center gap-1" + aria-label="설정"> + + 설정 + + } + />
From da155dc3859d9b92b0c443499dd91a2fd0c890b1 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 01:25:20 +0900 Subject: [PATCH 09/15] =?UTF-8?q?fix:=EC=84=A0=ED=83=9D=ED=95=9C=20?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=EB=A7=8C=20=EC=9E=85=EB=A0=A5=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#4?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMidi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useMidi.ts b/src/hooks/useMidi.ts index 9c250bd..616190f 100644 --- a/src/hooks/useMidi.ts +++ b/src/hooks/useMidi.ts @@ -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; From 3dca07d807ce51ab21b1f71161254ad630db1faa Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 01:32:01 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EC=8B=9C=20=EB=A9=94=ED=8A=B8=EB=A1=9C=EB=86=88UI=EB=B0=8F=20?= =?UTF-8?q?=EC=86=8C=EB=A6=AC=EA=B0=80=20=EA=BC=AC=EC=9D=B4=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/practice/PracticePlayPage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 8241c1c..aad3cb8 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -43,6 +43,7 @@ function PracticePlayPage() { const totalBeatRef = useRef(0); const barIdRef = useRef(0); const heldRef = useRef>(new Map()); // midi → 진행 중 노트바 id + const restartTimerRef = useRef | null>(null); // 친 음: 소리 재생 + 노트바 생성(성장 시작) → 뗄 때 소리·길이 확정 후 위로 사라짐 const handleNoteOn = (note: number, velocity = 100) => { @@ -98,7 +99,11 @@ function PracticePlayPage() { const handlePlayToggle = () => (isPlaying ? stopPlayback() : startPlayback()); const handleRestart = () => { stopPlayback(); - startPlayback(); + setNoteBars([]); // 노트바 초기화 + heldRef.current.clear(); + // transport 정지가 반영된 뒤 재시작 (연속 stop→start 글리치 방지) + if (restartTimerRef.current) clearTimeout(restartTimerRef.current); + restartTimerRef.current = setTimeout(() => startPlayback(), 80); }; // 언마운트 시 정리 @@ -106,6 +111,7 @@ function PracticePlayPage() { return () => { stop(); Tone.getDraw().cancel(); + if (restartTimerRef.current) clearTimeout(restartTimerRef.current); }; }, [stop]); From f472e3550cd91d475a4f318d237cf7269b270cb8 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 01:49:22 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix:=20=EC=A0=95=EC=A7=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20UI=20=EB=A9=88?= =?UTF-8?q?=EC=B6=A4=20=EB=B0=8F=20=EC=9E=85=EB=A0=A5=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useActiveNotes.ts | 7 ++++++- src/hooks/useMetronome.ts | 4 +++- src/pages/practice/PracticePlayPage.tsx | 20 +++++++++++++++++--- src/utils/metronome.ts | 6 ++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/hooks/useActiveNotes.ts b/src/hooks/useActiveNotes.ts index 5fe474d..688ebe8 100644 --- a/src/hooks/useActiveNotes.ts +++ b/src/hooks/useActiveNotes.ts @@ -1,22 +1,27 @@ // 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, onNoteOff?: (note: number) => void, // 뗀 순간 (지속시간 측정 등) + enabled: boolean = true, // false면 입력 무시 (하이라이트도 안 함) ) { const [activeNotes, setActiveNotes] = useState>(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-off는 항상 처리 (눌려있던 음 해제 — stuck 방지) setActiveNotes((prev) => { const next = new Set(prev); next.delete(note); diff --git a/src/hooks/useMetronome.ts b/src/hooks/useMetronome.ts index 274c89f..19eaf1a 100644 --- a/src/hooks/useMetronome.ts +++ b/src/hooks/useMetronome.ts @@ -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 }; } diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index aad3cb8..78cbe53 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -26,7 +26,7 @@ function PracticePlayPage() { const navigate = useNavigate(); const { practiceId } = useParams(); const { keyCount } = useSettingStore(); - const { start, stop } = useMetronome(); + const { start, stop, pause, resume } = useMetronome(); const { noteOn: playNote, noteOff: stopNote } = usePianoSound(); const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId) ?? RECOMMENDED_TRACKS[0]; @@ -62,7 +62,7 @@ function PracticePlayPage() { }; const handleBarDone = useCallback((id: number) => setNoteBars((prev) => prev.filter((b) => b.id !== id)), []); - const { activeNotes } = useActiveNotes(handleNoteOn, handleNoteOff); + const { activeNotes } = useActiveNotes(handleNoteOn, handleNoteOff, isPlaying); // 정지 중엔 입력 무시 // 백킹트랙에서 지금 차례인 코드 (null은 직전 코드 유지) const flatChords = measures.flat(); @@ -96,7 +96,21 @@ function PracticePlayPage() { }); }; - const handlePlayToggle = () => (isPlaying ? stopPlayback() : startPlayback()); + // 정지: 화면 그대로 멈춤(진행 상태 유지) / 재생: 이어서 + const pausePlayback = () => { + pause(); + setIsPlaying(false); + }; + const resumePlayback = () => { + resume(); + setIsPlaying(true); + }; + const handlePlayToggle = () => { + if (isPlaying) pausePlayback(); + else if (Tone.getTransport().state === 'paused') + resumePlayback(); // 일시정지 상태면 이어서 + else startPlayback(); // 처음부터 + }; const handleRestart = () => { stopPlayback(); setNoteBars([]); // 노트바 초기화 diff --git a/src/utils/metronome.ts b/src/utils/metronome.ts index 3fc1f74..07bfa5f 100644 --- a/src/utils/metronome.ts +++ b/src/utils/metronome.ts @@ -33,6 +33,12 @@ export function createMetronome() { transport.stop(); transport.cancel(); }, + pause() { + transport.pause(); // 현재 위치 유지하고 멈춤 + }, + resume() { + transport.start(); // 일시정지 지점부터 이어서 재생 + }, dispose() { hiBuffer.dispose(); loBuffer.dispose(); From 5277f96228a76b88617abc68fb60b0d2dd124594 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 01:54:25 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat:=EB=A0=88=EC=9D=B4=ED=84=B4=EC=8B=9C?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EA=B1=B4=EB=B0=98=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=8B=9C=20=EC=86=8C=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/latency/LatencyCheckPage.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/latency/LatencyCheckPage.tsx b/src/pages/latency/LatencyCheckPage.tsx index b7bb6df..bbd1445 100644 --- a/src/pages/latency/LatencyCheckPage.tsx +++ b/src/pages/latency/LatencyCheckPage.tsx @@ -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'; @@ -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('intro'); const [beatInBar, setBeatInBar] = useState(-1); @@ -53,7 +55,8 @@ function LatencyCheckPage() { const finishTimerRef = useRef | 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) { @@ -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)); From b491dc7919c08288eb06f0c5b84f30c4069aef72 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 02:06:24 +0900 Subject: [PATCH 13/15] =?UTF-8?q?feat:=EC=A0=95=EC=A7=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EB=85=B8=ED=8A=B8?= =?UTF-8?q?=EB=B0=94=20=EB=A9=88=EC=B6=94=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/piano/PracticeNoteBars.tsx | 20 +++++++++++++++----- src/pages/practice/PracticePlayPage.tsx | 20 +++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/components/piano/PracticeNoteBars.tsx b/src/components/piano/PracticeNoteBars.tsx index 95a5ec4..f92225a 100644 --- a/src/components/piano/PracticeNoteBars.tsx +++ b/src/components/piano/PracticeNoteBars.tsx @@ -16,19 +16,28 @@ interface PracticeNoteBarsProps { 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 }: PracticeNoteBarsProps) { +export default function PracticeNoteBars({ + bars, + keyCount, + pxPerMs, + onBarDone, + paused = false, + frozenAt = 0, +}: PracticeNoteBarsProps) { const [, setTick] = useState(0); const rafRef = useRef(undefined); - // 활성 노트바가 있는 동안 매 프레임 리렌더 (길이 성장 / 상승 애니메이션) + // 활성 노트바가 있고 재생 중일 때만 매 프레임 리렌더 (정지 중엔 루프 정지 = 얼림) useEffect(() => { - if (bars.length === 0) return; + if (bars.length === 0 || paused) return; const loop = () => { setTick((t) => (t + 1) % 1_000_000); const now = performance.now(); @@ -42,9 +51,10 @@ export default function PracticeNoteBars({ bars, keyCount, pxPerMs, onBarDone }: return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; - }, [bars, onBarDone, pxPerMs]); + }, [bars, onBarDone, pxPerMs, paused]); - const now = performance.now(); + // 정지 중엔 정지 시점(frozenAt)으로 고정 렌더 → 그 자리에 얼림 + const now = paused ? frozenAt : performance.now(); return (
diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 78cbe53..28d6e09 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -44,6 +44,7 @@ function PracticePlayPage() { const barIdRef = useRef(0); const heldRef = useRef>(new Map()); // midi → 진행 중 노트바 id const restartTimerRef = useRef | null>(null); + const pauseStartRef = useRef(performance.now()); // 정지 시각 (노트바 얼림 기준 + 재개 시 보정) // 친 음: 소리 재생 + 노트바 생성(성장 시작) → 뗄 때 소리·길이 확정 후 위로 사라짐 const handleNoteOn = (note: number, velocity = 100) => { @@ -99,9 +100,19 @@ function PracticePlayPage() { // 정지: 화면 그대로 멈춤(진행 상태 유지) / 재생: 이어서 const pausePlayback = () => { pause(); + pauseStartRef.current = performance.now(); setIsPlaying(false); }; const resumePlayback = () => { + // 정지한 시간만큼 노트바 시각을 밀어 이어서 그려지게 (튐 방지) + const pausedMs = performance.now() - pauseStartRef.current; + setNoteBars((prev) => + prev.map((b) => ({ + ...b, + startTime: b.startTime + pausedMs, + endTime: b.endTime !== null ? b.endTime + pausedMs : null, + })), + ); resume(); setIsPlaying(true); }; @@ -217,7 +228,14 @@ function PracticePlayPage() {
{/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */}
- + Date: Thu, 23 Jul 2026 02:10:52 +0900 Subject: [PATCH 14/15] =?UTF-8?q?style:=EC=97=B0=EC=8A=B5=EC=84=A4?= =?UTF-8?q?=EC=A0=95=ED=99=94=EB=A9=B4=20=EB=92=B7=EB=B0=B0=EA=B2=BD=20UI?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/practice/PracticeSettingsPage.tsx | 88 ++++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/src/pages/practice/PracticeSettingsPage.tsx b/src/pages/practice/PracticeSettingsPage.tsx index dbee3c3..037d169 100644 --- a/src/pages/practice/PracticeSettingsPage.tsx +++ b/src/pages/practice/PracticeSettingsPage.tsx @@ -1,29 +1,95 @@ -// 연주 트랙 설정 페이지 +// 연주 트랙 설정 페이지 — 배경은 연습 플레이 화면과 동일한 UI(정적), 그 위에 설정 모달 import { useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { SettingsModal } from '@/components/settings/SettingsModal'; import Piano from '@/components/piano/Piano'; +import MetronomeDots from '@/components/metronome/MetronomeDots'; +import BackingTrack from '@/pages/practice/components/BackingTrack'; import { useSettingStore } from '@/stores/settingsStore'; import { ALL_TRACKS, RECOMMENDED_TRACKS } from '@/pages/practice/mockTracks'; +import { buildFallbackProgression, MODE_LABEL } from '@/pages/practice/trackDisplay'; +import PlayIcon from '@/assets/practice/play.svg?react'; +import RefreshIcon from '@/assets/restart.svg?react'; +import ChangeIcon from '@/assets/change.svg?react'; +import CheckIcon from '@/assets/check.svg?react'; +import SettingsIcon from '@/assets/setting.svg?react'; function PracticeSettingsPage() { const navigate = useNavigate(); const { practiceId } = useParams(); const { keyCount, setBpm, setBeatsPerBar } = useSettingStore(); + const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId) ?? RECOMMENDED_TRACKS[0]; + const beatsPerBar = Number(track.timeSignature.split('/')[0]); // '4/4' → 4 + const measures = track.chordProgression ?? buildFallbackProgression(track.chords, beatsPerBar); + useEffect(() => { - // 추천/전체 목록 모두에서 곡 찾기 - const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId); - if (track) { - setBpm(track.bpm); - setBeatsPerBar(Number(track.timeSignature.split('/')[0])); // '4/4'→4, '3/4'→3 - } - }, [practiceId, setBpm, setBeatsPerBar]); + setBpm(track.bpm); + setBeatsPerBar(beatsPerBar); + }, [track.bpm, beatsPerBar, setBpm, setBeatsPerBar]); return ( -
-
- +
+ {/* 헤더 (연습 화면과 동일 — 배경, 비활성) */} +
+
+
+ +
+
+
{track.title}
+
+ {track.genre.toUpperCase()} + + {track.key} {MODE_LABEL[track.mode]} + + {track.bpm}BPM +
+
+
+ +
+
+ 재시작 + +
+
+ 트랙 변경 + +
+
+ 분석하기 + +
+
+
+ + {/* 본문 (연습 화면과 동일 — 정적) */} +
+ {/* 백킹트랙 + 진행점 */} +
+ +
+ +
+
+ + {/* 건반 */} +
+
+ + + 설정 +
+ } + /> +
+
+
+ navigate(-1)} onStart={() => navigate(`/practice/${practiceId}/play`)} From 47fd61e6f90abb7d349f9df14fe48f3f5045c864 Mon Sep 17 00:00:00 2001 From: LEEMINSEO Date: Thu, 23 Jul 2026 08:51:57 +0900 Subject: [PATCH 15/15] =?UTF-8?q?feat:=EC=97=B0=EC=A3=BC=20=EB=85=B9?= =?UTF-8?q?=EC=9D=8C,=20=EB=A0=88=EC=9D=B4=ED=84=B4=EC=8B=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95=EA=B0=92=EC=9D=84=20=EB=B6=84=EC=84=9D=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=A0=84=EB=8B=AC=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/practice/PracticePlayPage.tsx | 27 +++++++++++++++++++++++-- src/stores/practiceResultStore.ts | 25 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 src/stores/practiceResultStore.ts diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 28d6e09..58c92ef 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -11,6 +11,7 @@ import { useActiveNotes } from '@/hooks/useActiveNotes'; import { useMetronome } from '@/hooks/useMetronome'; import { usePianoSound } from '@/hooks/usePianoSound'; import { useSettingStore } from '@/stores/settingsStore'; +import { usePracticeResultStore, type PlayedNote } from '@/stores/practiceResultStore'; import { ALL_TRACKS, RECOMMENDED_TRACKS } from '@/pages/practice/mockTracks'; import { buildFallbackProgression, MODE_LABEL } from '@/pages/practice/trackDisplay'; import PlayIcon from '@/assets/practice/play.svg?react'; @@ -25,9 +26,10 @@ const PX_PER_BEAT = 120; // 노트바 길이 환산: 1박 = 120px function PracticePlayPage() { const navigate = useNavigate(); const { practiceId } = useParams(); - const { keyCount } = useSettingStore(); + const { keyCount, inputId, latencyByDevice } = useSettingStore(); const { start, stop, pause, resume } = useMetronome(); const { noteOn: playNote, noteOff: stopNote } = usePianoSound(); + const setResult = usePracticeResultStore((s) => s.setResult); const track = [...ALL_TRACKS, ...RECOMMENDED_TRACKS].find((t) => t.id === practiceId) ?? RECOMMENDED_TRACKS[0]; const beatsPerBar = Number(track.timeSignature.split('/')[0]); // '4/4' → 4 @@ -43,12 +45,15 @@ function PracticePlayPage() { const totalBeatRef = useRef(0); const barIdRef = useRef(0); const heldRef = useRef>(new Map()); // midi → 진행 중 노트바 id + const recordingRef = useRef([]); // 연주 녹음 (Transport 시각 기준) const restartTimerRef = useRef | null>(null); const pauseStartRef = useRef(performance.now()); // 정지 시각 (노트바 얼림 기준 + 재개 시 보정) // 친 음: 소리 재생 + 노트바 생성(성장 시작) → 뗄 때 소리·길이 확정 후 위로 사라짐 const handleNoteOn = (note: number, velocity = 100) => { playNote(note, velocity); // 즉시 소리 (범위와 무관하게 실제 친 음) + // 녹음: 실제 친 음 전부 기록 (Transport 시각 = 곡 박자 기준) + recordingRef.current.push({ midi: note, velocity, onSec: Tone.getTransport().seconds, offSec: null }); if (noteCenterFraction(note, keyCount) < 0) return; // 노트바는 건반 범위 안만 const id = barIdRef.current++; heldRef.current.set(note, id); @@ -56,6 +61,14 @@ function PracticePlayPage() { }; const handleNoteOff = (note: number) => { stopNote(note); // 즉시 소리 끝 + // 녹음: 같은 음의 마지막 열린 노트에 뗀 시각 기록 + for (let i = recordingRef.current.length - 1; i >= 0; i--) { + const rec = recordingRef.current[i]; + if (rec.midi === note && rec.offSec === null) { + rec.offSec = Tone.getTransport().seconds; + break; + } + } const id = heldRef.current.get(note); if (id === undefined) return; heldRef.current.delete(note); @@ -86,6 +99,7 @@ function PracticePlayPage() { const startPlayback = async () => { await Tone.start(); // 오디오 잠금 해제 (클릭 핸들러 안에서만 가능) totalBeatRef.current = 0; + recordingRef.current = []; // 처음부터 재생 시 녹음 초기화 setIsPlaying(true); start(track.bpm, beatsPerBar, (time, bib) => { const beat = totalBeatRef.current % totalCells; @@ -131,6 +145,15 @@ function PracticePlayPage() { restartTimerRef.current = setTimeout(() => startPlayback(), 80); }; + // 분석: 녹음 + 레이턴시 보정값을 스토어에 저장하고 분석 화면으로 이동 + const handleAnalyze = () => { + stopPlayback(); + const raw = inputId ? latencyByDevice[inputId] : undefined; + const latencyMs = typeof raw === 'number' ? raw : 0; // 미측정/실패면 0 + setResult({ trackId: practiceId, recording: recordingRef.current, latencyMs }); + navigate(`/practice/${practiceId}/analysis`); + }; + // 언마운트 시 정리 useEffect(() => { return () => { @@ -192,7 +215,7 @@ function PracticePlayPage() {