(function () { function init() { /* ---- refs ---- */ const micBtn = document.getElementById('lumi-mic-btn'); if (!micBtn) return false; // DOM chưa sẵn sàng, thử lại sau const recLabel = document.getElementById('lumi-rec-label'); const canvas = document.getElementById('lumi-canvas'); const ctx2d = canvas.getContext('2d'); const procBar = document.getElementById('lumi-proc-bar'); const playWrap = document.getElementById('lumi-audio-playback'); const audioEl = document.getElementById('lumi-audio-el'); /* ---- state ---- */ let isRecording = false; let mediaRecorder = null; let audioChunks = []; let audioCtx = null; let analyser = null; let micStream = null; let animId = null; /* ---- mic button ---- */ micBtn.addEventListener('click', () => { isRecording ? stopRecording() : startRecording(); }); /* ======================== RECORDING ======================== */ async function startRecording() { try { micStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); } catch (e) { recLabel.textContent = '❌ Không có quyền truy cập micro!'; return; } // Web Audio → visualizer audioCtx = new (window.AudioContext || window.webkitAudioContext)(); analyser = audioCtx.createAnalyser(); analyser.fftSize = 128; audioCtx.createMediaStreamSource(micStream).connect(analyser); // MediaRecorder → lưu blob audioChunks = []; const mime = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg'] .find(t => MediaRecorder.isTypeSupported(t)) || ''; mediaRecorder = new MediaRecorder(micStream, mime ? { mimeType: mime } : {}); mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = onRecordingDone; mediaRecorder.start(100); isRecording = true; micBtn.classList.add('recording'); micBtn.textContent = '⏹️'; recLabel.textContent = '🔴 Đang ghi... Nhấn để dừng'; recLabel.classList.add('recording'); playWrap.style.display = 'none'; procBar.style.display = 'none'; drawWave(); } function stopRecording() { if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); if (micStream) micStream.getTracks().forEach(t => t.stop()); if (audioCtx) audioCtx.close(); if (animId) cancelAnimationFrame(animId); micStream = audioCtx = analyser = null; isRecording = false; micBtn.classList.remove('recording'); micBtn.textContent = '🎙️'; recLabel.textContent = 'Nhấn để ghi âm lại'; recLabel.classList.remove('recording'); procBar.style.display = 'block'; drawFlatLine(); } function onRecordingDone() { const mime = audioChunks[0]?.type || 'audio/webm'; const blob = new Blob(audioChunks, { type: mime }); // Playback const url = URL.createObjectURL(blob); audioEl.src = url; audioEl.oncanplay = () => { procBar.style.display = 'none'; playWrap.style.display = 'flex'; }; injectFileToGradio(blob, mime); } /* ======================== INJECT VÀO GRADIO ======================== */ function injectFileToGradio(blob, mime) { const ext = mime.includes('ogg') ? 'ogg' : 'webm'; const file = new File([blob], 'recording.' + ext, { type: mime }); function attempt(tries) { const inp = document.querySelector('.hidden-audio-wrap input[type="file"]'); if (inp) { const dt = new DataTransfer(); dt.items.add(file); inp.files = dt.files; inp.dispatchEvent(new Event('change', { bubbles: true })); } else if (tries > 0) { setTimeout(() => attempt(tries - 1), 300); } } attempt(10); } /* ======================== WAVEFORM VISUALIZER ======================== */ function drawWave() { if (!analyser) return; animId = requestAnimationFrame(drawWave); const buf = analyser.frequencyBinCount; const data = new Uint8Array(buf); analyser.getByteTimeDomainData(data); const W = canvas.width, H = canvas.height; ctx2d.clearRect(0, 0, W, H); const rms = Math.sqrt(data.reduce((s, v) => s + (v - 128) ** 2, 0) / buf); const intensity = Math.min(rms / 28, 1); // Glow rings if (intensity > 0.04) { const cx = W / 2, cy = H / 2; [0, 1, 2].forEach(r => { ctx2d.beginPath(); ctx2d.arc(cx, cy, 16 + r * 13 + intensity * 18, 0, Math.PI * 2); ctx2d.strokeStyle = `rgba(167,139,250,${(0.20 - r * 0.05) * intensity})`; ctx2d.lineWidth = 9 - r * 2.5; ctx2d.stroke(); }); } // Waveform const g = ctx2d.createLinearGradient(0, 0, W, 0); g.addColorStop(0, 'rgba(124,111,247,0)'); g.addColorStop(0.2, `rgba(124,111,247,${0.45 + intensity * 0.55})`); g.addColorStop(0.5, `rgba(167,139,250,${0.75 + intensity * 0.25})`); g.addColorStop(0.8, `rgba(56,189,248,${0.45 + intensity * 0.55})`); g.addColorStop(1, 'rgba(56,189,248,0)'); ctx2d.beginPath(); ctx2d.lineWidth = 2.5; ctx2d.strokeStyle = g; ctx2d.shadowColor = '#7c6ff7'; ctx2d.shadowBlur = 10 * intensity; const sw = W / buf; for (let i = 0; i < buf; i++) { const y = ((data[i] / 128) * H) / 2; i === 0 ? ctx2d.moveTo(0, y) : ctx2d.lineTo(i * sw, y); } ctx2d.stroke(); ctx2d.shadowBlur = 0; } function drawFlatLine() { ctx2d.clearRect(0, 0, canvas.width, canvas.height); ctx2d.beginPath(); ctx2d.moveTo(0, canvas.height / 2); ctx2d.lineTo(canvas.width, canvas.height / 2); ctx2d.strokeStyle = 'rgba(124,111,247,0.18)'; ctx2d.lineWidth = 1.5; ctx2d.stroke(); } drawFlatLine(); // init return true; } /* ======================== ĐỢI DOM SẴN SÀNG Thử init ngay, nếu chưa được thì MutationObserver sẽ thử lại mỗi khi DOM thay đổi ======================== */ if (!init()) { const observer = new MutationObserver(() => { if (init()) observer.disconnect(); }); observer.observe(document.body, { childList: true, subtree: true }); } })();