BioVibe_v2 / index.html
Hezu06's picture
Update index.html
85f00ee verified
Raw
History Blame Contribute Delete
21.6 kB
<!DOCTYPE html>
<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>