Spaces:
Sleeping
Sleeping
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Bio-Vibe Web</title> | |
| <link rel="icon" href="data:,"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script> | |
| <style> | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background-color: #1a1a2e; | |
| color: #e94560; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 100vh; | |
| margin: 0; | |
| padding: 20px; | |
| box-sizing: border-box; | |
| } | |
| .container { | |
| background-color: #16213e; | |
| padding: 40px; | |
| border-radius: 15px; | |
| text-align: center; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.5); | |
| max-width: 620px; | |
| width: 100%; | |
| } | |
| h1 { color: #fff; margin-bottom: 5px; } | |
| .subtitle { color: #a2a2bd; margin-bottom: 20px; } | |
| #bpm-display { | |
| font-size: 80px; | |
| font-weight: bold; | |
| margin: 15px 0; | |
| text-shadow: 0 0 15px #e94560; | |
| transition: color 0.3s ease; | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); opacity: 1; } | |
| 50% { transform: scale(1.05); opacity: 0.8; } | |
| 100% { transform: scale(1); opacity: 1; } | |
| } | |
| /* ---- TABS ---- */ | |
| .tab-group { | |
| display: flex; | |
| justify-content: center; | |
| gap: 0; | |
| margin-bottom: 20px; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| border: 1px solid #0f3460; | |
| } | |
| .tab-btn { | |
| flex: 1; | |
| background: #0f3460; | |
| color: #a2a2bd; | |
| border: none; | |
| padding: 10px 16px; | |
| font-size: 14px; | |
| cursor: pointer; | |
| transition: 0.2s; | |
| font-weight: bold; | |
| } | |
| .tab-btn.active { background: #4ecca3; color: #1a1a2e; } | |
| .tab-btn:hover:not(.active) { background: #1a3a6e; color: #fff; } | |
| .tab-panel { display: none; } | |
| .tab-panel.active { display: block; } | |
| /* ---- MANUAL INPUT ---- */ | |
| .manual-group { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 12px; | |
| margin: 10px 0 16px; | |
| } | |
| #manual-bpm-input { | |
| background: #0f3460; | |
| border: 1px solid #4ecca3; | |
| border-radius: 8px; | |
| color: #fff; | |
| font-size: 28px; | |
| font-weight: bold; | |
| width: 110px; | |
| text-align: center; | |
| padding: 8px; | |
| } | |
| #manual-bpm-input:focus { outline: 2px solid #4ecca3; } | |
| #btn-manual-set { | |
| background: #4ecca3; | |
| color: #1a1a2e; | |
| border: none; | |
| padding: 10px 18px; | |
| font-size: 15px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| font-weight: bold; | |
| transition: 0.2s; | |
| } | |
| #btn-manual-set:hover { background: #38b499; } | |
| /* ---- BUTTONS ---- */ | |
| .button-group { | |
| display: flex; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| margin-top: 10px; | |
| } | |
| button { | |
| background-color: #0f3460; | |
| color: white; | |
| border: none; | |
| padding: 13px 22px; | |
| font-size: 15px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: 0.3s; | |
| font-weight: bold; | |
| } | |
| button:hover { background-color: #4ecca3; color: #1a1a2e; } | |
| button:disabled { background-color: #2a2a3e; cursor: not-allowed; color: #555; } | |
| #btn-stop { background-color: #8b0000; } | |
| #btn-stop:hover { background-color: #ff4d4d; color: white; } | |
| #btn-stop:disabled { background-color: #2a2a3e; } | |
| /* ---- STATUS ---- */ | |
| #status { | |
| margin-top: 18px; | |
| font-size: 13px; | |
| font-style: italic; | |
| min-height: 20px; | |
| } | |
| .status-ok { color: #4ecca3; } | |
| .status-err { color: #ff4d4d; } | |
| .status-info { color: #a2a2bd; } | |
| /* ---- DEBUG LOG ---- */ | |
| .debug-section { | |
| margin-top: 20px; | |
| text-align: left; | |
| } | |
| .debug-toggle { | |
| background: none; | |
| border: none; | |
| color: #555; | |
| font-size: 12px; | |
| cursor: pointer; | |
| padding: 4px 0; | |
| font-style: italic; | |
| } | |
| .debug-toggle:hover { color: #a2a2bd; background: none; } | |
| #debug-log { | |
| display: none; | |
| background: #0a0a1a; | |
| border-radius: 6px; | |
| padding: 10px; | |
| font-size: 11px; | |
| color: #4ecca3; | |
| font-family: monospace; | |
| max-height: 160px; | |
| overflow-y: auto; | |
| margin-top: 6px; | |
| text-align: left; | |
| } | |
| /* ---- NOTICE ---- */ | |
| .notice { | |
| background: #0f2040; | |
| border-left: 3px solid #e94560; | |
| border-radius: 0 6px 6px 0; | |
| padding: 10px 14px; | |
| margin-bottom: 14px; | |
| font-size: 13px; | |
| color: #a2a2bd; | |
| text-align: left; | |
| } | |
| .notice strong { color: #e94560; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Bio-Vibe 🎧</h1> | |
| <p class="subtitle">Âm nhạc trị liệu AI theo Nhịp tim</p> | |
| <div id="bpm-display">-- BPM</div> | |
| <!-- TABS --> | |
| <div class="tab-group"> | |
| <button class="tab-btn active" onclick="switchTab('ble')">📡 Kết nối BLE</button> | |
| <button class="tab-btn" onclick="switchTab('manual')">✏️ Nhập thủ công</button> | |
| </div> | |
| <!-- TAB: BLE --> | |
| <div id="tab-ble" class="tab-panel active"> | |
| <div class="notice"> | |
| <strong>Lưu ý Xiaomi / Samsung:</strong> Đồng hồ thông minh dùng giao thức riêng, | |
| không truyền nhịp tim qua BLE chuẩn. Thử kết nối — nếu không ra BPM, | |
| hãy dùng tab <em>Nhập thủ công</em> hoặc một chest-strap HR monitor (Polar, Garmin…). | |
| </div> | |
| <div class="button-group"> | |
| <button id="btn-connect">1. Kết nối đồng hồ</button> | |
| </div> | |
| </div> | |
| <!-- TAB: MANUAL --> | |
| <div id="tab-manual" class="tab-panel"> | |
| <p style="color:#a2a2bd; font-size:14px; margin-bottom:10px;"> | |
| Nhập BPM từ app đồng hồ (Mi Fitness, Samsung Health…) rồi nhấn Set. | |
| </p> | |
| <div class="manual-group"> | |
| <input type="number" id="manual-bpm-input" min="30" max="220" value="75" /> | |
| <button id="btn-manual-set" onclick="setManualBpm()">Set BPM</button> | |
| </div> | |
| </div> | |
| <!-- PLAYBACK CONTROLS --> | |
| <div class="button-group" style="margin-top:18px;"> | |
| <button id="btn-generate">2. Sinh nhạc AI</button> | |
| <button id="btn-stop" disabled>3. Dừng nhạc</button> | |
| </div> | |
| <div id="status" class="status-info">Chưa có BPM...</div> | |
| <!-- DEBUG --> | |
| <div class="debug-section"> | |
| <button class="debug-toggle" onclick="toggleDebug()">▶ Xem log BLE debug</button> | |
| <div id="debug-log"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // =============================== | |
| // STATE | |
| // =============================== | |
| let currentBpm = 0; | |
| let bpmHistory = []; | |
| let currentAudio = null; | |
| let debugVisible = false; | |
| let ws = null; // Biến giữ kết nối WebSocket | |
| const bpmDisplay = document.getElementById('bpm-display'); | |
| const statusEl = document.getElementById('status'); | |
| const btnConnect = document.getElementById('btn-connect'); | |
| const btnGenerate = document.getElementById('btn-generate'); | |
| const btnStop = document.getElementById('btn-stop'); | |
| const debugLog = document.getElementById('debug-log'); | |
| // =============================== | |
| // UI HELPERS | |
| // =============================== | |
| function setStatus(msg, type = 'info') { | |
| statusEl.textContent = msg; | |
| statusEl.className = 'status-' + type; | |
| } | |
| function log(msg) { | |
| const line = document.createElement('div'); | |
| line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg; | |
| debugLog.appendChild(line); | |
| debugLog.scrollTop = debugLog.scrollHeight; | |
| console.log(msg); | |
| } | |
| function toggleDebug() { | |
| debugVisible = !debugVisible; | |
| debugLog.style.display = debugVisible ? 'block' : 'none'; | |
| } | |
| function switchTab(name) { | |
| document.querySelectorAll('.tab-btn').forEach((b, i) => { | |
| b.classList.toggle('active', (i === 0 && name === 'ble') || (i === 1 && name === 'manual')); | |
| }); | |
| document.getElementById('tab-ble').classList.toggle('active', name === 'ble'); | |
| document.getElementById('tab-manual').classList.toggle('active', name === 'manual'); | |
| } | |
| // =============================== | |
| // BPM SMOOTHING | |
| // =============================== | |
| function smoothBpm(val) { | |
| bpmHistory.push(val); | |
| if (bpmHistory.length > 5) bpmHistory.shift(); | |
| return Math.round(bpmHistory.reduce((a, b) => a + b, 0) / bpmHistory.length); | |
| } | |
| function applyBpm(raw, source) { | |
| if (raw < 30 || raw > 220) return false; | |
| if (currentBpm !== 0 && Math.abs(raw - currentBpm) > 40) return false; | |
| const smoothed = smoothBpm(raw); | |
| currentBpm = smoothed; | |
| bpmDisplay.textContent = currentBpm + ' BPM'; | |
| bpmDisplay.style.color = '#e94560'; | |
| setStatus('BPM nhận từ: ' + source, 'ok'); | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ bpm: currentBpm })); | |
| } | |
| return true; | |
| } | |
| // =============================== | |
| // MANUAL BPM | |
| // =============================== | |
| function setManualBpm() { | |
| const val = parseInt(document.getElementById('manual-bpm-input').value, 10); | |
| if (isNaN(val) || val < 30 || val > 220) { | |
| setStatus('Giá trị BPM không hợp lệ (30–220)', 'err'); | |
| return; | |
| } | |
| bpmHistory = []; | |
| applyBpm(val, 'Nhập thủ công'); | |
| } | |
| // =============================== | |
| // PARSE CHUẨN BLE Heart Rate (0x2A37) | |
| // =============================== | |
| function parseHeartRateMeasurement(dataView) { | |
| /* | |
| * Byte 0: Flags | |
| * bit 0 = 0 → Heart Rate Value là UINT8 (byte 1) | |
| * bit 0 = 1 → Heart Rate Value là UINT16 (bytes 1-2, little-endian) | |
| */ | |
| const flags = dataView.getUint8(0); | |
| const is16bit = (flags & 0x01) !== 0; | |
| return is16bit | |
| ? dataView.getUint16(1, true) | |
| : dataView.getUint8(1); | |
| } | |
| // =============================== | |
| // HANDLE INCOMING CHARACTERISTIC DATA | |
| // =============================== | |
| function handleCharData(event, uuid, serviceUuid) { | |
| const dv = event.target.value; | |
| const raw = []; | |
| for (let i = 0; i < dv.byteLength; i++) raw.push(dv.getUint8(i)); | |
| log(`UUID ${uuid.substring(0,8)}… DATA: [${raw.join(', ')}]`); | |
| const HR_MEASUREMENT_UUID = '00002a37-0000-1000-8000-00805f9b34fb'; | |
| // 1. Nếu đúng UUID chuẩn Heart Rate Measurement → parse theo spec | |
| if (uuid === HR_MEASUREMENT_UUID) { | |
| if (dv.byteLength >= 2) { | |
| const bpm = parseHeartRateMeasurement(dv); | |
| log(`Standard HR parse → ${bpm} BPM`); | |
| applyBpm(bpm, 'HR chuẩn BLE'); | |
| } | |
| return; | |
| } | |
| // 2. Fallback heuristic cho proprietary chars | |
| // Duyệt từng byte, lấy byte đầu tiên nằm trong [40, 180] | |
| for (let i = 0; i < raw.length; i++) { | |
| const v = raw[i]; | |
| if (v >= 40 && v <= 180) { | |
| log(`Heuristic byte[${i}] = ${v} BPM`); | |
| applyBpm(v, `Heuristic (${uuid.substring(0,8)}…)`); | |
| break; | |
| } | |
| } | |
| } | |
| // =============================== | |
| // BLE CONNECT — auto scan tất cả services | |
| // =============================== | |
| btnConnect.addEventListener('click', async () => { | |
| if (!navigator.bluetooth) { | |
| setStatus('Trình duyệt không hỗ trợ Web Bluetooth!', 'err'); | |
| return; | |
| } | |
| try { | |
| setStatus('Đang mở hộp thoại BLE…', 'info'); | |
| log('Bắt đầu scan BLE...'); | |
| const device = await navigator.bluetooth.requestDevice({ | |
| acceptAllDevices: true, | |
| optionalServices: [ | |
| // Chuẩn BLE | |
| 'heart_rate', | |
| 'battery_service', | |
| 'device_information', | |
| 'generic_access', | |
| // Xiaomi Mi Band 4/5/6/7 — proprietary | |
| '0000fee0-0000-1000-8000-00805f9b34fb', | |
| '0000fee1-0000-1000-8000-00805f9b34fb', | |
| // Xiaomi Mi Band 7 / Amazfit | |
| '0000181d-0000-1000-8000-00805f9b34fb', | |
| // Samsung Galaxy Watch — proprietary health | |
| '0000fd00-0000-1000-8000-00805f9b34fb', | |
| // Generic HR secondary | |
| '0000180d-0000-1000-8000-00805f9b34fb', | |
| ] | |
| }); | |
| log(`Device: ${device.name || '(no name)'} id=${device.id}`); | |
| setStatus('Đang kết nối GATT…', 'info'); | |
| const server = await device.gatt.connect(); | |
| const services = await server.getPrimaryServices(); | |
| log(`Tìm thấy ${services.length} service(s)`); | |
| let subscribed = 0; | |
| for (const svc of services) { | |
| log(`Service: ${svc.uuid}`); | |
| let chars; | |
| try { | |
| chars = await svc.getCharacteristics(); | |
| } catch (e) { | |
| log(` Không đọc được characteristics: ${e.message}`); | |
| continue; | |
| } | |
| for (const char of chars) { | |
| const props = char.properties; | |
| log(` Char: ${char.uuid} [notify=${props.notify} indicate=${props.indicate} read=${props.read}]`); | |
| if (props.notify || props.indicate) { | |
| try { | |
| await char.startNotifications(); | |
| char.addEventListener('characteristicvaluechanged', | |
| (ev) => handleCharData(ev, char.uuid, svc.uuid)); | |
| log(` ✅ Subscribed: ${char.uuid}`); | |
| subscribed++; | |
| } catch (e) { | |
| log(` ❌ Subscribe failed: ${e.message}`); | |
| } | |
| } | |
| } | |
| } | |
| btnConnect.textContent = '✓ BLE Connected'; | |
| btnConnect.disabled = true; | |
| if (subscribed === 0) { | |
| setStatus('Kết nối OK nhưng không có characteristic nào để subscribe. Thử tab Nhập thủ công.', 'err'); | |
| } else { | |
| setStatus(`Đã subscribe ${subscribed} characteristic(s), đang chờ BPM…`, 'ok'); | |
| } | |
| } catch (err) { | |
| log(`Lỗi: ${err.message}`); | |
| setStatus('Lỗi BLE: ' + err.message, 'err'); | |
| } | |
| }); | |
| // =============================== | |
| // GENERATE MUSIC | |
| const limiter = new Tone.Limiter(-1); | |
| // 2. Compressor (giữ âm ổn định) | |
| const compressor = new Tone.Compressor({ | |
| threshold: -18, | |
| ratio: 3, | |
| attack: 0.01, | |
| release: 0.2 | |
| }); | |
| // 3. EQ (làm ấm piano) | |
| const eq = new Tone.EQ3({ | |
| low: 3, // tăng bass nhẹ | |
| mid: -1, // giảm mid đục | |
| high: 2 // tăng sáng nhẹ | |
| }); | |
| // 4. Stereo widen (cho không gian rộng) | |
| const stereo = new Tone.StereoWidener(0.6); | |
| // 5. Reverb (không gian) | |
| const reverb = new Tone.Reverb({ | |
| decay: 4, | |
| wet: 0.3 | |
| }); | |
| // 6. Delay (lofi feel) | |
| const delay = new Tone.FeedbackDelay({ | |
| delayTime: "8n", | |
| feedback: 0.25, | |
| wet: 0.15 | |
| }); | |
| // Connect chain | |
| delay.connect(reverb); | |
| reverb.connect(stereo); | |
| stereo.connect(eq); | |
| eq.connect(compressor); | |
| compressor.connect(limiter); | |
| limiter.toDestination(); | |
| const piano = new Tone.Sampler({ | |
| urls: { | |
| A1: "A1.mp3", | |
| C2: "C2.mp3", | |
| "D#2": "Ds2.mp3", | |
| "F#2": "Fs2.mp3", | |
| A2: "A2.mp3", | |
| C3: "C3.mp3", | |
| "D#3": "Ds3.mp3", | |
| "F#3": "Fs3.mp3", | |
| A3: "A3.mp3", | |
| C4: "C4.mp3", | |
| "D#4": "Ds4.mp3", | |
| "F#4": "Fs4.mp3", | |
| A4: "A4.mp3", | |
| C5: "C5.mp3" | |
| }, | |
| baseUrl: "https://raw.githubusercontent.com/Tonejs/audio/master/salamander/", | |
| release: 1 | |
| }).connect(delay); | |
| // 🌊 PAD (chord) | |
| const pad = new Tone.PolySynth(Tone.Synth, { | |
| volume: -12, | |
| oscillator: { type: "sine" }, | |
| envelope: { | |
| attack: 1, | |
| decay: 0.5, | |
| sustain: 0.8, | |
| release: 3 | |
| } | |
| }).connect(reverb); | |
| // 🥁 KICK | |
| const kick = new Tone.MembraneSynth({ | |
| volume: -8, | |
| pitchDecay: 0.05, | |
| octaves: 6, | |
| envelope: { | |
| attack: 0.001, | |
| decay: 0.3, | |
| sustain: 0 | |
| } | |
| }).connect(compressor); | |
| // 🥁 SNARE | |
| const snare = new Tone.NoiseSynth({ | |
| volume: -9, | |
| noise: { type: "white" }, | |
| envelope: { | |
| attack: 0.001, | |
| decay: 0.15, | |
| sustain: 0 | |
| } | |
| }).connect(compressor); | |
| // Gọi Tone.start() sẵn khi load trang nếu được | |
| log("🎹 Đã khởi tạo Lo-fi Synthesizer (Có Limiter chống rè)."); | |
| setStatus("Sẵn sàng sinh nhạc AI", "ok"); | |
| // =============================== | |
| // GENERATE MUSIC (CẬP NHẬT WEBSOCKET) | |
| // =============================== | |
| btnGenerate.addEventListener('click', async () => { | |
| if (currentBpm === 0) { | |
| alert('Chưa có BPM! Kết nối đồng hồ hoặc nhập thủ công.'); | |
| return; | |
| } | |
| // Bắt buộc trình duyệt cho phép phát âm thanh | |
| await Tone.start(); | |
| await Tone.loaded(); | |
| console.log("✅ Piano samples loaded"); | |
| if (ws) ws.close(); // Đóng kết nối cũ nếu có | |
| setStatus(`Đang kết nối não AI với ${currentBpm} BPM…`, 'info'); | |
| btnGenerate.textContent = 'AI đang truyền nốt…'; | |
| btnGenerate.disabled = true; | |
| btnStop.disabled = false; | |
| bpmDisplay.style.animation = 'pulse 1s infinite'; | |
| // Mở kết nối WebSocket tới Server | |
| // ĐOẠN CODE MỚI ĐÃ FIX LỖI: | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/vibe`; | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| setStatus('🎵 Đang phát nhạc Real-time vô tận…', 'ok'); | |
| // Gửi BPM lên server để AI lấy tempo | |
| ws.send(JSON.stringify({ bpm: currentBpm })); | |
| }; | |
| let startTime = Tone.now() + 0.1; // Thời gian bắt đầu chơi nhạc, cách hiện tại 100ms để đảm bảo đồng bộ | |
| ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| data.notes.forEach(n => { | |
| const note = Tone.Frequency(n.note, "midi").toNote(); | |
| const t = startTime + n.time; // 🔥 timeline chuẩn | |
| if (n.type === "melody") { | |
| piano.triggerAttackRelease(note, n.duration, t); | |
| } | |
| else if (n.type === "chord") { | |
| pad.triggerAttackRelease(note, n.duration, t); | |
| } | |
| else if (n.type === "drum") { | |
| if (n.note === 36) kick.triggerAttackRelease("C1", "8n", t); | |
| if (n.note === 38) snare.triggerAttackRelease("16n", t); | |
| } | |
| }); | |
| startTime += data.step * 16; // advance timeline | |
| }; | |
| ws.onerror = (err) => { | |
| log('WebSocket Error: ' + err); | |
| setStatus('Lỗi kết nối tới AI!', 'err'); | |
| }; | |
| ws.onclose = () => { | |
| btnGenerate.textContent = '2. Sinh nhạc AI'; | |
| btnGenerate.disabled = false; | |
| btnStop.disabled = true; | |
| bpmDisplay.style.animation = 'none'; | |
| }; | |
| }); | |
| // =============================== | |
| // STOP MUSIC (CẬP NHẬT WEBSOCKET) | |
| // =============================== | |
| btnStop.addEventListener('click', () => { | |
| if (ws) { | |
| ws.close(); // Cắt kết nối, Server sẽ tự động dừng suy luận | |
| ws = null; | |
| } | |
| piano.releaseAll(); // Ngắt ngay lập tức các âm vang còn sót lại | |
| setStatus('Đã dừng nhạc.', 'info'); | |
| }); | |
| </script> | |
| </body> | |
| </html> |