-
연습 시작
+
+ {/* 헤더 */}
+
+
+ {/* 본문 */}
+
+ {/* 백킹트랙 + 진행점 */}
+
+
+ {/* 진입 시 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();