Skip to content

[FEAT] 연주 화면 UI 및 기능 구현#48

Open
leeminsel wants to merge 15 commits into
developfrom
feat/#40-practice-play
Open

[FEAT] 연주 화면 UI 및 기능 구현#48
leeminsel wants to merge 15 commits into
developfrom
feat/#40-practice-play

Conversation

@leeminsel

@leeminsel leeminsel commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

#️⃣ 관련 이슈


📝 작업 요약

연주 시작 화면 전체 구현

  • 곡별 메트로놈·백킹트랙
  • 건반 입력에 따른 노트바·소리, 재생/정지/재시작 제어
  • 그리고 연주 녹음과 레이턴시 보정값을 분석 화면으로 넘기는 데이터 파이프라인까지 포함

🔧 변경 사항

  • 연주 화면 UI 구현
  • 건반 입력 시 누른 시간만큼 그려지는 노트바 구현 (BPM 템포에 맞춘 상승 속도, 백킹트랙 아래에서 잘려 사라짐)
  • Tone.PolySynth 신디로 건반 소리 출력(Tone.immediate()로 지연 최소화), 레이턴시 체크 화면에도 건반 소리 추가
  • 선택한 입력 기기(inputId)만 처리하도록 수정
  • practiceResultStore 추가 → 분석하기 시 연주 녹음(Transport 시각 기준)과 레이턴시 보정값을 저장

💬 리뷰어 참고 사항

  • 소리는 라이브 입력용이라 Tone.immediate()로 lookAhead를 우회하고, 메트로놈은 lookAhead를 유지합니다(정확한 박자를 위해서)
  • 분석 담당자분 — 공유 인터페이스는 stores/practiceResultStore.ts의 PlayedNote(midi, velocity, onSec, offSec)·latencyMs예요. usePracticeResultStore()로 읽으시면 되고, onSec/offSec는 곡 시작 기준 초(박자 정렬), offSec가 null이면 안 뗀 음, velocity는 0~127입니다. 보정은 onSec - latencyMs / 1000으로 적용하시면 돼요!
    그리고 녹음은 오디오가 아니라 MIDI 이벤트(PlayedNote)예요. 그래서 오디오 녹음은 넘기지 않습니다. 소리는 신디로 생성되니, 같은 녹음을 신디로 재트리거하면 동일하게 재현됩니다. recording을 onSec/offSec 시각에 맞춰 재생하시면 되고, 세기는 velocity/127로 넘기면 돼요. 구현은 usePianoSound 참고하시면 됩니다!

✅ 체크리스트

  • 커밋 메시지 컨벤션을 준수했습니다.
  • 로컬에서 정상 동작을 확인했습니다.
  • UI 변경 사항을 직접 확인했습니다.
  • 불필요한 console.log를 제거했습니다.
  • 관련 이슈를 연결했습니다.

Summary by CodeRabbit

  • 새 기능

    • 백킹 트랙, 메트로놈, 코드 진행을 확인하며 연습할 수 있는 플레이 화면을 추가했습니다.
    • 연주 음을 실시간으로 재생·녹음하고, 노트바 오버레이로 입력 음과 유지 시간을 표시합니다.
    • 연습 결과와 레이턴시 정보를 저장해 분석 화면으로 이동할 수 있습니다.
    • 트랙 설정에서 곡, BPM, 박자 및 연주 설정을 확인하고 변경할 수 있습니다.
    • 메트로놈 일시정지 및 재개를 지원합니다.
  • 개선

    • MIDI 입력 장치를 정확히 선택해 사용할 수 있습니다.
    • 레이턴시 측정 중 실제 피아노 음을 재생합니다.
    • 텍스트 자간을 조정해 가독성을 개선했습니다.

@leeminsel leeminsel self-assigned this Jul 23, 2026
@leeminsel leeminsel added ✨ Feat New feature or request 🎨 Style UI/UX and visual design tasks labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

연주 화면을 백킹트랙·메트로놈·피아노 입력·실시간 노트바와 연동하고, 재생 제어 및 녹음 결과 저장 흐름을 추가했다. MIDI 입력 활성화 조건, 피아노 사운드, 레이턴시 측정 화면도 확장했다.

Changes

연주 화면 기능

Layer / File(s) Summary
MIDI 입력과 사운드 처리
src/hooks/useMidi.ts, src/hooks/useActiveNotes.ts, src/hooks/usePianoSound.ts, src/pages/latency/LatencyCheckPage.tsx
선택된 MIDI 입력만 처리하고, note-on/note-off 콜백과 활성화 플래그를 지원한다. Tone 기반 피아노 사운드를 레이턴시 측정 입력에 연결했다.
트랙 설정과 메트로놈 제어
src/pages/practice/PracticeSettingsPage.tsx, src/pages/practice/components/BackingTrack.tsx, src/hooks/useMetronome.ts, src/utils/metronome.ts
트랙의 BPM·박자·코드 진행을 동기화하고, 백킹트랙 표시 및 메트로놈 일시정지·재개를 구성했다.
실시간 노트바 렌더링
src/components/piano/PracticeNoteBars.tsx
MIDI 음의 지속시간과 해제 후 경과시간을 기준으로 노트바의 크기와 위치를 계산하고, 화면 밖 노트바를 제거한다.
연주 재생과 결과 저장
src/stores/practiceResultStore.ts, src/pages/practice/PracticePlayPage.tsx, src/index.css
연주 녹음, Transport 재생 상태, 일시정지·재개·재시작, 분석 이동과 결과 저장을 구현하고 연주 화면 UI를 구성했다. 타이포그래피 간격도 조정했다.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MIDIInput
  participant useActiveNotes
  participant PracticePlayPage
  participant ToneTransport
  participant PracticeNoteBars
  participant PracticeResultStore
  MIDIInput->>useActiveNotes: note-on/note-off 입력
  useActiveNotes->>PracticePlayPage: 노트 이벤트 전달
  PracticePlayPage->>ToneTransport: 재생 및 연주 시간 기록
  PracticePlayPage->>PracticeNoteBars: 실시간 noteBars 렌더링
  PracticePlayPage->>PracticeResultStore: 녹음 결과와 latencyMs 저장
Loading

Possibly related PRs

  • Musereview/FE#13: useMidiuseActiveNotes의 MIDI note-on/note-off 처리 흐름과 직접 연결된다.
  • Musereview/FE#28: createMetronomeuseMetronome의 공통 메트로놈 제어 API를 확장한다.
  • Musereview/FE#25: 트랙의 코드 진행 데이터와 buildFallbackProgression 사용에 연결된다.

Suggested reviewers: seooyoon, andada-creator, kxxnayun

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning 대부분의 연주 화면 기능은 반영됐지만, 기기별 레이턴시 offset 보정 적용은 요약상 확인되지 않습니다. settingStore의 기기별 latency 값을 노트 표시·재생 타이밍에 반영해 레이턴시 offset 보정을 구현하세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed 추가된 변경들은 연주 화면, 노트바, 메트로놈, 사운드, 분석 전달과 직접 연관되어 보입니다.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 연주 화면의 UI와 기능 구현이라는 핵심 변경을 정확히 요약합니다.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#40-practice-play

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

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

In `@src/pages/practice/PracticePlayPage.tsx`:
- Around line 53-61: Update handleNoteOn and the heldRef/noteBars tracking so
repeated note-on events for the same note before note-off preserve every open
bar instead of overwriting the prior id. Adjust the corresponding note-off
lookup to close the appropriate open bar(s), while keeping recordingRef’s
reverse-search behavior and out-of-range note-bar filtering unchanged.
- Around line 99-112: Remove the delayed autoplay path that invokes
startPlayback after a timeout, so it cannot restart playback or reset recording
state later. Trigger startPlayback only from the START control’s user
click/keyboard handler, where Tone.start runs within the user gesture; preserve
the overlay dismissal in that same handler and prevent duplicate starts through
the existing isPlaying flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 34a244c7-700f-4477-a261-3754c0f8b0be

📥 Commits

Reviewing files that changed from the base of the PR and between f6ab281 and 47fd61e.

⛔ Files ignored due to path filters (2)
  • src/assets/change.svg is excluded by !**/*.svg
  • src/assets/check.svg is excluded by !**/*.svg
📒 Files selected for processing (12)
  • src/components/piano/PracticeNoteBars.tsx
  • src/hooks/useActiveNotes.ts
  • src/hooks/useMetronome.ts
  • src/hooks/useMidi.ts
  • src/hooks/usePianoSound.ts
  • src/index.css
  • src/pages/latency/LatencyCheckPage.tsx
  • src/pages/practice/PracticePlayPage.tsx
  • src/pages/practice/PracticeSettingsPage.tsx
  • src/pages/practice/components/BackingTrack.tsx
  • src/stores/practiceResultStore.ts
  • src/utils/metronome.ts

Comment on lines +23 to +30
(note) => {
// note-off는 항상 처리 (눌려있던 음 해제 — stuck 방지)
setActiveNotes((prev) => {
const next = new Set(prev);
next.delete(note);
return next;
}),
});
onNoteOff?.(note);

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 경로에서 열린 노트를 명시적으로 확정·해제해 일시정지 시간이 노트
길이에 포함되지 않도록 하세요.

Comment on lines +53 to +61
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 }]);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

동일 노트가 note-off 전에 다시 note-on되면 이전 노트바가 고아 상태로 남음

heldRef.current.set(note, id)는 같은 note에 대한 이전 항목을 덮어씁니다. note-off 없이 같은 음이 다시 note-on되면(예: 서스테인 페달, 채터링 등으로 중복 트리거) 이전 바의 id는 heldRef에서 사라지고, 이후 실제 note-off가 와도 최신 id만 종료되어 이전 바는 endTime: null 상태로 영원히 남아 화면에서 계속 자라거나 사라지지 않습니다. recordingRef 쪽은 배열을 역순 탐색해 열린 항목을 정확히 찾으므로 이 문제가 없지만, noteBars/heldRef 쪽만 영향을 받습니다.

🔧 제안 수정안
   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++;
+    const prevId = heldRef.current.get(note);
+    if (prevId !== undefined) {
+      // 같은 음이 아직 열려 있다면 새로 열기 전에 먼저 닫아 고아 바 방지
+      setNoteBars((prev) => prev.map((b) => (b.id === prevId ? { ...b, endTime: performance.now() } : b)));
+    }
     heldRef.current.set(note, id);
     setNoteBars((prev) => [...prev, { id, midi: note, startTime: performance.now(), endTime: null }]);
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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++;
const prevId = heldRef.current.get(note);
if (prevId !== undefined) {
// 같은 음이 아직 열려 있다면 새로 열기 전에 먼저 닫아 고아 바 방지
setNoteBars((prev) => prev.map((b) => (b.id === prevId ? { ...b, endTime: performance.now() } : b)));
}
heldRef.current.set(note, id);
setNoteBars((prev) => [...prev, { id, midi: note, startTime: performance.now(), endTime: null }]);
};
🤖 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/pages/practice/PracticePlayPage.tsx` around lines 53 - 61, Update
handleNoteOn and the heldRef/noteBars tracking so repeated note-on events for
the same note before note-off preserve every open bar instead of overwriting the
prior id. Adjust the corresponding note-off lookup to close the appropriate open
bar(s), while keeping recordingRef’s reverse-search behavior and out-of-range
note-bar filtering unchanged.

Comment on lines +99 to +112
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;
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching PracticePlayPage.tsx:\n'
fd -a 'PracticePlayPage\.tsx$' . || true

file="$(fd 'PracticePlayPage\.tsx$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  printf '\nOutline:\n'
  ast-grep outline "$file" --view compact || true
  printf '\nRelevant lines 70-185:\n'
  sed -n '70,185p' "$file" | nl -ba -v70
  printf '\nSTART/render snippets:\n'
  rg -n "SHOW_START|showStart|Start|startPlayback|useEffect|setTimeout|onClick|handleStart" "$file" -C 3
fi

printf '\nSearch for auto-start patterns nearby:\n'
rg -n "setTimeout\\(|startPlayback\\(|ShowStart|showStart|isPlaying" . -g '*.tsx' -g '*.ts' | head -n 200

Repository: Musereview/FE

Length of output: 473


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$1"
printf 'Referenced file exists: %s\n' "$file"
printf '\nRelevant lines 70-185:\n'
sed -n '70,185p' "$file" | awk '{printf "%6d\t%s\n", NR+69, $0}'
printf '\nSTART/render snippets:\n'
rg -n "SHOW_START|showStart|Start|startPlayback|useEffect|setTimeout|onClick|handleStart" "$file" -C 3
printf '\nAuto-start patterns nearby:\n'
rg -n "setTimeout\\(|startPlayback\\(|ShowStart|showStart|isPlaying" . -g '*.tsx' -g '*.ts' | head -n 200 || true

Repository: Musereview/FE

Length of output: 186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="/home/jailuser/git/src/pages/practice/PracticePlayPage.tsx"
printf 'Referenced file exists: %s\n' "$file"
printf '\nRelevant lines 70-185:\n'
sed -n '70,185p' "$file" | awk '{printf "%6d\t%s\n", NR+69, $0}'
printf '\nSTART/render snippets:\n'
rg -n "SHOW_START|showStart|Start|startPlayback|useEffect|setTimeout|onClick|handleStart" "$file" -C 3
printf '\nAuto-start patterns nearby:\n'
rg -n "setTimeout\\(|startPlayback\\(|ShowStart|showStart|isPlaying" . -g '*.tsx' -g '*.ts' | head -n 200 || true

Repository: Musereview/FE

Length of output: 13279


🌐 Web query:

Tone.js Tone.start user gesture AudioContext auto-play policy documentation

💡 Result:

To comply with modern browser autoplay policies, Tone.js requires the use of Tone.start triggered by a user gesture (e.g., a button click) to initialize the AudioContext [1][2]. Background Browsers block audible audio playback on page load to improve user experience and control [1][3][4]. Under these policies, the Web Audio API's AudioContext begins in a "suspended" state [1][3]. Any attempt to schedule or play audio before the context is in a "running" state will result in silence or scheduling errors [5][2]. How to Implement 1. Wait for a User Gesture: Attach an event listener (like "click" or "keydown") to a UI element, such as a "Play" button [1][6][2]. 2. Call Tone.start: Inside that event handler, invoke Tone.start [1][6]. This function returns a Promise that resolves once the AudioContext has successfully resumed to a "running" state [5][6][2]. Example Code: document.querySelector("button").addEventListener("click", async => { await Tone.start; console.log("Audio is ready"); // You can now safely play sounds or schedule events }); Verification You can check the state of the context at any time using Tone.context.state [1]. It will return "suspended" if the browser is blocking audio, and "running" once the policy requirements have been met [1]. For more detailed control or detection, browsers also provide the Navigator.getAutoplayPolicy API [7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="/home/jailuser/git/src/pages/practice/PracticePlayPage.tsx"
printf 'Relevant startPlayback/handlers and UI:\n'
sed -n '90,148p;180,245p' "$file" | sed -n '1,120p' | awk '{printf "%6d\t%s\n", NR, $0}'

Repository: Musereview/FE

Length of output: 5510


自动播放计时器会绕过 Tone.js 用户手势要求并可能重复启动播放

startPlayback() 会在 setTimeout 回调中执行 await Tone.start(),这不符合 Tone.js 应在点击/键盘用户手势中启动音频上下文的指引;同时该计时器忽略 isPlaying,如果用户在 1 秒内点击“播放”或“再开始”,1 秒后还会再次重置 recordingRef / totalBeatRef 并重启 Transport。建议只在 START 区域的用户点击回调中隐藏 overlay 并调用 startPlayback()

🤖 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/pages/practice/PracticePlayPage.tsx` around lines 99 - 112, Remove the
delayed autoplay path that invokes startPlayback after a timeout, so it cannot
restart playback or reset recording state later. Trigger startPlayback only from
the START control’s user click/keyboard handler, where Tone.start runs within
the user gesture; preserve the overlay dismissal in that same handler and
prevent duplicate starts through the existing isPlaying flow.

@andada-creator andada-creator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

로컬에서 UI랑 메트로놈 동작은 확인했습니다!
건반 입력이나 노트바 생성 부분을 테스트해 보지 못했는데, 혹시 이 부분은 어떻게 테스트를 해보셨는지 궁금합니다


return (
// 컨테이너: width 1510px, padding 12px, justify-between, radius 6px, bg gray-900
<div className={`flex w-[1510px] items-center justify-between rounded-[6px] bg-gray-900 p-3 ${className}`}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Image 로컬에서 브랜치 띄워서 화면 확인해 봤는데, 제 컴퓨터에서 볼 때는 우측 코드 박스 영역이 잘리면서 전체 화면에 가로 스크롤바가 생기더라고요!(위에 사진 첨부했습니다) 이 부분 레이아웃 수정이 필요할 것 같으니 한 번 확인 부탁드립니다~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feat New feature or request 🎨 Style UI/UX and visual design tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] 연주 화면 UI 및 기능 구현

2 participants