// =========================== // Supabase 초기화 // =========================== alert("script.js 로드됨"); const supabaseClient = window.supabase.createClient( "https://kceyrxbzlzfrzonjmkdu.supabase.co", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtjZXlyeGJ6bHpmcnpvbmpta2R1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjUxODc5OTUsImV4cCI6MjA4MDc2Mzk5NX0.TT5KPhwDHD9cwcpEduWxCwPtT0en72H4V3EZyKkX-KE" ); // =========================== // 전역 변수 // =========================== let currentMelody = []; let recorder; let audioChunks = []; let isRecording = false; // =========================== // 난이도 선택 → 문제 생성 // =========================== function selectLevel(level) { document.getElementById("status").innerText = `상태: 난이도 ${level} 문제 생성 중...`; currentMelody = generateMelody(level); renderMelody(currentMelody); document.getElementById("status").innerText = `상태: 난이도 ${level} 문제 준비 완료`; } // =========================== // 난이도별 멜로디 생성기 // =========================== function generateMelody(level) { const notes = []; let scaleLow = 60; // C4 let scaleHigh = 72; // C5 let allowDotted = false; let allowSkip = false; if (level === 1) { scaleHigh = 67; // G4 allowDotted = false; allowSkip = false; } if (level === 2) { scaleHigh = 69; // A4 allowDotted = true; allowSkip = true; } if (level === 3) { scaleHigh = 72; // C5 allowDotted = true; allowSkip = true; } let totalBeats = 8; // 2마디(4/4) while (totalBeats > 0) { let duration = 1; // 기본 4분음표 if (allowDotted && Math.random() < 0.3) { duration = 1.5; // 점4분음표 } else if (Math.random() < 0.4) { duration = 0.5; // 8분음표 } if (duration > totalBeats) continue; let pitch = Math.floor(Math.random() * (scaleHigh - scaleLow + 1)) + scaleLow; if (!allowSkip && notes.length > 0) { let prev = notes[notes.length - 1].pitch; if (Math.abs(prev - pitch) > 3) continue; } notes.push({ pitch, duration }); totalBeats -= duration; } return notes; } // =========================== // 악보 렌더링 (VexFlow) // =========================== function renderMelody(melody) { const VF = Vex.Flow; const canvas = document.getElementById("sheetCanvas"); const renderer = new VF.Renderer(canvas, VF.Renderer.Backends.CANVAS); renderer.resize(750, 200); const ctx = renderer.getContext(); ctx.clearRect(0, 0, 750, 200); const stave = new VF.Stave(10, 20, 720); stave.addClef("treble").addTimeSignature("4/4"); stave.setContext(ctx).draw(); const notes = melody.map(n => { let dur = "q"; // 4분음표 if (n.duration === 0.5) dur = "8"; if (n.duration === 1.5) dur = "dq"; return new VF.StaveNote({ clef: "treble", keys: [midiToNoteName(n.pitch)], duration: dur }); }); notes.forEach((note, i) => { if (melody[i].duration === 1.5) { note.addDotToAll(); } }); VF.Formatter.FormatAndDraw(ctx, stave, notes); } // MIDI → 음이름 변환 function midiToNoteName(midi) { const names = ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]; const name = names[midi % 12]; const octave = Math.floor(midi / 12) - 1; return `${name}/${octave}`; } // =========================== // 녹음 기능 // =========================== async function startRecording() { const status = document.getElementById("status"); status.innerText = "상태: 녹음 중..."; const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); recorder = new MediaRecorder(stream); audioChunks = []; recorder.ondataavailable = e => audioChunks.push(e.data); recorder.onstop = sendAudio; recorder.start(); isRecording = true; } function stopRecording() { if (recorder && isRecording) { recorder.stop(); isRecording = false; document.getElementById("status").innerText = "상태: 녹음 종료, 업로드 중..."; } } // =========================== // Supabase 업로드 // =========================== async function sendAudio() { const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); const fileName = `rec_${Date.now()}.webm`; const status = document.getElementById("status"); // 1) Supabase 업로드 const { data, error } = await supabaseClient.storage .from("recordings") .upload(fileName, audioBlob, { contentType: "audio/webm" }); if (error) { status.innerText = "상태: 업로드 실패"; return; } const publicURL = `https://kceyrxbzlzfrzonjmkdu.supabase.co/storage/v1/object/public/recordings/${fileName}`; status.innerText = "상태: 분석 중..."; // 2) FastAPI 분석 요청 (구글 런 주소로 자동 연결되도록 상대 경로 '/' 사용) // 2) FastAPI 분석 요청 try { const res = await fetch(window.location.origin + "/analyze", { // 경로 수정 method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: publicURL, notes: currentMelody.map(n => ({ midi: n.pitch, quarterLength: n.duration })) // app.py가 이해할 수 있는 형식으로 변환해서 전달 }) }); const result = await res.json(); // 3) 결과 대시보드 시각화 실행 showResultDashboard(result); status.innerText = "상태: 평가 완료"; } catch (e) { status.innerText = "상태: 분석 서버 연결 실패"; } } // 결과를 화면에 뿌려주는 함수 function showResultDashboard(data) { // 대시보드 나타내기 document.getElementById("resultDashboard").style.display = "block"; // 점수 숫자 채우기 document.getElementById("totalScore").innerText = data.total_score; document.getElementById("pScore").innerText = data.pitch_score; document.getElementById("rScore").innerText = data.rhythm_score; // 그래프 그리기 drawPitchGraph("pitchCanvas", data.pitch_errors); drawRhythmGraph("rhythmCanvas", data.rhythm_errors, 80); // BPM 80 기준 // 3줄 피드백 업데이트 updateFeedback(data.pitch_score, data.rhythm_score, data.pitch_errors, data.rhythm_errors); } // 음정 그래프 그리기 function drawPitchGraph(canvasId, centsArray) { const canvas = document.getElementById(canvasId); const ctx = canvas.getContext("2d"); const W = canvas.width = canvas.parentElement.clientWidth; const H = canvas.height = 120; const centerY = H / 2; ctx.clearRect(0, 0, W, H); // 가이드 영역 (±25 cent) ctx.fillStyle = "rgba(56, 189, 248, 0.1)"; ctx.fillRect(0, centerY - 20, W, 40); // 데이터 선 그리기 ctx.strokeStyle = "#38bdf8"; ctx.lineWidth = 2; ctx.beginPath(); const step = W / (centsArray.length - 1); centsArray.forEach((cent, i) => { const x = i * step; const y = centerY - (cent * 2); // 증폭해서 보여줌 if(i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); } // 리듬 그래프 그리기 function drawRhythmGraph(canvasId, msErrors, bpm) { const canvas = document.getElementById(canvasId); const ctx = canvas.getContext("2d"); const W = canvas.width = canvas.parentElement.clientWidth; const H = canvas.height = 100; const centerY = H / 2; ctx.clearRect(0, 0, W, H); ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(W, centerY); ctx.stroke(); const step = W / (msErrors.length + 1); msErrors.forEach((ms, i) => { const x = (i + 1) * step; const y = centerY - (ms / 2); ctx.fillStyle = Math.abs(ms) < 50 ? "#4ade80" : "#fb7185"; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI*2); ctx.fill(); }); } // 3줄 피드백 로직 function updateFeedback(p, r, pErr, rErr) { const f1 = document.getElementById("f1"); const f2 = document.getElementById("f2"); const f3 = document.getElementById("f3"); f1.innerText = p >= 40 ? "• 음정이 매우 정확합니다. 안정적인 발성을 유지하고 계시네요." : "• 특정 구간에서 음정이 불안정합니다. 호흡의 압력을 일정하게 유지해 보세요."; f2.innerText = r >= 40 ? "• 리듬감이 훌륭합니다. 비트의 중심을 정확히 타격하고 있습니다." : "• 박자가 조금 서두르거나(Early) 밀리는(Late) 경향이 보입니다."; f3.innerText = "• 전체적으로 좋은 시도였습니다. 오늘 부족했던 구간을 다시 한 번 집중해서 연습해보세요!"; }