-
Notifications
You must be signed in to change notification settings - Fork 1
[FEAT] 연주 화면 UI 및 기능 구현 #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
leeminsel
wants to merge
15
commits into
develop
Choose a base branch
from
feat/#40-practice-play
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
5c632f5
feat:연주화면 헤더, 백킹트랙&메트로롬 UI 구현 (#40)
74d1303
feat: 시작전 START 화면 구현 (#40)
cac85c4
feat:건반 눌렀을 때 노트바 구현 (#40)
af041ac
fix: 노트바 속도를 BPM 템포에 맞추도록 수정 (#40)
326e13d
fix: 노트바 백킹트랙에서 사라지도록 수정 (#40)
ed22357
feat:건반 소리 출력 구현 (#40)
a34454a
feat:코드명 배경에 텍스트로 나타나도록 구현 (#40)
f8f65f9
feat:설정버튼 구현 (#40)
da155dc
fix:선택한 기기만 입력 처리하도록 수정 (#40)
3dca07d
fix: 재시작 시 메트로놈UI및 소리가 꼬이던 문제 수정(#40)
f472e35
fix: 정지 버튼 클릭 시 UI 멈춤 및 입력 차단으로 수정 (#40)
5277f96
feat:레이턴시 화면에서 건반 입력 시 소리 추가 (#40)
b491dc7
feat:정지 버튼 클릭 시 노트바 멈추도록 구현 (#40)
57af216
style:연습설정화면 뒷배경 UI 추가 (#40)
47fd61e
feat:연주 녹음, 레이턴시 보정값을 분석 화면으로 전달 (#40)
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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