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/components/piano/PracticeNoteBars.tsx b/src/components/piano/PracticeNoteBars.tsx new file mode 100644 index 0000000..f92225a --- /dev/null +++ b/src/components/piano/PracticeNoteBars.tsx @@ -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(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 ( +
+ {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 ( + + ); + })} +
+ ); +} diff --git a/src/hooks/useActiveNotes.ts b/src/hooks/useActiveNotes.ts index 4cffc83..688ebe8 100644 --- a/src/hooks/useActiveNotes.ts +++ b/src/hooks/useActiveNotes.ts @@ -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>(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); + }, inputId, // ← 선택된 기기만 입력받도록 전달 ); 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/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; 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/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/latency/LatencyCheckPage.tsx b/src/pages/latency/LatencyCheckPage.tsx index aa31fa2..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)); @@ -233,7 +236,6 @@ function LatencyCheckPage() { {countdown} )} - {/* phase === 'measuring' → 노트바 (나중에) */} {/* 건반 영역 (+ 친 음에서 솟아오르는 노트바) */} diff --git a/src/pages/practice/PracticePlayPage.tsx b/src/pages/practice/PracticePlayPage.tsx index 2c7df31..58c92ef 100644 --- a/src/pages/practice/PracticePlayPage.tsx +++ b/src/pages/practice/PracticePlayPage.tsx @@ -1,20 +1,280 @@ // 연습 플레이 페이지 - 61건반/88건반 +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'; +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'; +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 function PracticePlayPage() { - const { activeNotes } = useActiveNotes(); - const { keyCount } = useSettingStore(); + const navigate = useNavigate(); + const { practiceId } = useParams(); + 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 + 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 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); + setNoteBars((prev) => [...prev, { id, midi: note, startTime: performance.now(), endTime: null }]); + }; + 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); + 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, isPlaying); // 정지 중엔 입력 무시 + + // 백킹트랙에서 지금 차례인 코드 (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(); + setIsPlaying(false); + setBeatInBar(-1); + setCurrentBeat(-1); + }; + + const startPlayback = async () => { + await Tone.start(); // 오디오 잠금 해제 (클릭 핸들러 안에서만 가능) + totalBeatRef.current = 0; + recordingRef.current = []; // 처음부터 재생 시 녹음 초기화 + 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 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); + }; + const handlePlayToggle = () => { + if (isPlaying) pausePlayback(); + else if (Tone.getTransport().state === 'paused') + resumePlayback(); // 일시정지 상태면 이어서 + else startPlayback(); // 처음부터 + }; + const handleRestart = () => { + stopPlayback(); + setNoteBars([]); // 노트바 초기화 + heldRef.current.clear(); + // transport 정지가 반영된 뒤 재시작 (연속 stop→start 글리치 방지) + if (restartTimerRef.current) clearTimeout(restartTimerRef.current); + 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 () => { + stop(); + Tone.getDraw().cancel(); + if (restartTimerRef.current) clearTimeout(restartTimerRef.current); + }; + }, [stop]); + + // 진입 시 START 1초 표시 후 자동 재생 + useEffect(() => { + const timer = setTimeout(() => { + setShowStart(false); + startPlayback(); + }, 1000); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return ( -
-

연습 시작

+
+ {/* 헤더 */} +
+ {/* Track Title: 재생 버튼(좌) + 제목/칩 컬럼 — 칩은 제목 아래 */} +
+ +
+
{track.title}
+
+ {track.genre.toUpperCase()} + + {track.key} {MODE_LABEL[track.mode]} + + {track.bpm}BPM +
+
+
+ +
+ + + +
+
+ + {/* 본문 */} +
+ {/* 백킹트랙 + 진행점 */} +
+ +
+ +
+
+ + {/* 진입 시 START (본문 기준 고정) */} + {showStart && ( + + START + + )} - {/* 나중에 여기에 떨어지는 노트바(canvas) 영역이 들어감 */} -
+ {/* 백킹트랙 현재 코드명 (배경) */} + {!showStart && currentChord && ( + + {currentChord} + + )} - + {/* 노트바 클리핑 영역: 위(백킹트랙 밑)에서만 자르고 좌우는 열어둠(설정 버튼이 안 잘리도록) */} +
+ {/* 건반 + 노트바 (누른 시간만큼 길이가 그려짐) */} +
+ + navigate(`/practice/${practiceId}/settings`)} + className="flex cursor-pointer flex-col items-center gap-1" + aria-label="설정"> + + 설정 + + } + /> +
+
+
); } 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`)} diff --git a/src/pages/practice/components/BackingTrack.tsx b/src/pages/practice/components/BackingTrack.tsx new file mode 100644 index 0000000..d3a43f8 --- /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 ?? '-'} +
+ ); + })} +
+ ); + })} +
+ ); +} diff --git a/src/stores/practiceResultStore.ts b/src/stores/practiceResultStore.ts new file mode 100644 index 0000000..8021acd --- /dev/null +++ b/src/stores/practiceResultStore.ts @@ -0,0 +1,25 @@ +// 연습 연주 결과(녹음 + 레이턴시 보정값)를 분석 화면으로 전달하는 스토어 +import { create } from 'zustand'; + +export interface PlayedNote { + midi: number; + velocity: number; + onSec: number; // Tone.Transport 기준 눌린 시각(초) — 곡 박자에 정렬됨 + offSec: number | null; // 뗀 시각(초). null이면 끝까지 눌린 상태 +} + +interface PracticeResultState { + trackId?: string; + recording: PlayedNote[]; // 사용자 연주 녹음 (원본, 보정 전) + latencyMs: number; // 레이턴시 보정값(ms) — 분석 시 각 노트 시각에서 빼서 보정 + setResult: (r: { trackId?: string; recording: PlayedNote[]; latencyMs: number }) => void; + clear: () => void; +} + +export const usePracticeResultStore = create((set) => ({ + trackId: undefined, + recording: [], + latencyMs: 0, + setResult: ({ trackId, recording, latencyMs }) => set({ trackId, recording, latencyMs }), + clear: () => set({ trackId: undefined, recording: [], latencyMs: 0 }), +})); 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();