Hezu06 commited on
Commit
85f00ee
·
verified ·
1 Parent(s): 161e79d

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +648 -1231
index.html CHANGED
@@ -1,1232 +1,649 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Bio-Vibe Web</title>
7
- <link rel="icon" href="data:,">
8
- <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
9
- <style>
10
- body {
11
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
12
- background-color: #1a1a2e;
13
- color: #e94560;
14
- display: flex;
15
- flex-direction: column;
16
- align-items: center;
17
- justify-content: center;
18
- min-height: 100vh;
19
- margin: 0;
20
- padding: 20px;
21
- box-sizing: border-box;
22
- }
23
- .container {
24
- background-color: #16213e;
25
- padding: 40px;
26
- border-radius: 15px;
27
- text-align: center;
28
- box-shadow: 0 10px 30px rgba(0,0,0,0.5);
29
- max-width: 620px;
30
- width: 100%;
31
- }
32
- h1 { color: #fff; margin-bottom: 5px; }
33
- .subtitle { color: #a2a2bd; margin-bottom: 20px; }
34
-
35
- #bpm-display {
36
- font-size: 80px;
37
- font-weight: bold;
38
- margin: 15px 0;
39
- text-shadow: 0 0 15px #e94560;
40
- transition: color 0.3s ease;
41
- }
42
-
43
- @keyframes pulse {
44
- 0% { transform: scale(1); opacity: 1; }
45
- 50% { transform: scale(1.05); opacity: 0.8; }
46
- 100% { transform: scale(1); opacity: 1; }
47
- }
48
-
49
- /* ---- TABS ---- */
50
- .tab-group {
51
- display: flex;
52
- justify-content: center;
53
- gap: 0;
54
- margin-bottom: 20px;
55
- border-radius: 8px;
56
- overflow: hidden;
57
- border: 1px solid #0f3460;
58
- }
59
- .tab-btn {
60
- flex: 1;
61
- background: #0f3460;
62
- color: #a2a2bd;
63
- border: none;
64
- padding: 10px 16px;
65
- font-size: 14px;
66
- cursor: pointer;
67
- transition: 0.2s;
68
- font-weight: bold;
69
- }
70
- .tab-btn.active { background: #4ecca3; color: #1a1a2e; }
71
- .tab-btn:hover:not(.active) { background: #1a3a6e; color: #fff; }
72
-
73
- .tab-panel { display: none; }
74
- .tab-panel.active { display: block; }
75
-
76
- /* ---- MANUAL INPUT ---- */
77
- .manual-group {
78
- display: flex;
79
- align-items: center;
80
- justify-content: center;
81
- gap: 12px;
82
- margin: 10px 0 16px;
83
- }
84
- #manual-bpm-input {
85
- background: #0f3460;
86
- border: 1px solid #4ecca3;
87
- border-radius: 8px;
88
- color: #fff;
89
- font-size: 28px;
90
- font-weight: bold;
91
- width: 110px;
92
- text-align: center;
93
- padding: 8px;
94
- }
95
- #manual-bpm-input:focus { outline: 2px solid #4ecca3; }
96
- #btn-manual-set {
97
- background: #4ecca3;
98
- color: #1a1a2e;
99
- border: none;
100
- padding: 10px 18px;
101
- font-size: 15px;
102
- border-radius: 8px;
103
- cursor: pointer;
104
- font-weight: bold;
105
- transition: 0.2s;
106
- }
107
- #btn-manual-set:hover { background: #38b499; }
108
-
109
- /* ---- BUTTONS ---- */
110
- .button-group {
111
- display: flex;
112
- justify-content: center;
113
- flex-wrap: wrap;
114
- gap: 12px;
115
- margin-top: 10px;
116
- }
117
- button {
118
- background-color: #0f3460;
119
- color: white;
120
- border: none;
121
- padding: 13px 22px;
122
- font-size: 15px;
123
- border-radius: 8px;
124
- cursor: pointer;
125
- transition: 0.3s;
126
- font-weight: bold;
127
- }
128
- button:hover { background-color: #4ecca3; color: #1a1a2e; }
129
- button:disabled { background-color: #2a2a3e; cursor: not-allowed; color: #555; }
130
-
131
- #btn-stop { background-color: #8b0000; }
132
- #btn-stop:hover { background-color: #ff4d4d; color: white; }
133
- #btn-stop:disabled { background-color: #2a2a3e; }
134
-
135
- /* ---- STATUS ---- */
136
- #status {
137
- margin-top: 18px;
138
- font-size: 13px;
139
- font-style: italic;
140
- min-height: 20px;
141
- }
142
- .status-ok { color: #4ecca3; }
143
- .status-err { color: #ff4d4d; }
144
- .status-info { color: #a2a2bd; }
145
-
146
- /* ---- DEBUG LOG ---- */
147
- .debug-section {
148
- margin-top: 20px;
149
- text-align: left;
150
- }
151
- .debug-toggle {
152
- background: none;
153
- border: none;
154
- color: #555;
155
- font-size: 12px;
156
- cursor: pointer;
157
- padding: 4px 0;
158
- font-style: italic;
159
- }
160
- .debug-toggle:hover { color: #a2a2bd; background: none; }
161
- #debug-log {
162
- display: none;
163
- background: #0a0a1a;
164
- border-radius: 6px;
165
- padding: 10px;
166
- font-size: 11px;
167
- color: #4ecca3;
168
- font-family: monospace;
169
- max-height: 160px;
170
- overflow-y: auto;
171
- margin-top: 6px;
172
- text-align: left;
173
- }
174
-
175
- /* ---- QUICK BPM ZONES ---- */
176
- .zone-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin:12px 0; }
177
- .zone-btn { background:#0f3460; border:1px solid #1a3a6e; border-radius:10px; color:#fff;
178
- padding:10px 4px; font-size:13px; cursor:pointer; transition:0.2s;
179
- display:flex; flex-direction:column; align-items:center; gap:3px; }
180
- .zone-btn:hover { background:#1a4070; border-color:#4ecca3; }
181
- .zone-btn.active-zone { border-color:#4ecca3; background:#0a2844; }
182
- .zone-btn .zone-emoji { font-size:20px; }
183
- .zone-btn .zone-label { font-size:10px; color:#a2a2bd; }
184
- .zone-btn .zone-bpm { font-size:12px; color:#4ecca3; font-weight:bold; }
185
- .auto-row { display:flex; align-items:center; justify-content:center; gap:10px;
186
- margin-top:8px; font-size:13px; color:#a2a2bd; }
187
- .toggle-wrap { position:relative; width:36px; height:20px; }
188
- .toggle-wrap input { opacity:0; width:100%; height:100%; position:absolute; cursor:pointer; margin:0; z-index:2; }
189
- .toggle-track { position:absolute; inset:0; background:#1a3a6e; border-radius:10px; transition:background 0.2s; }
190
- .toggle-wrap input:checked ~ .toggle-track { background:#4ecca3; }
191
- .toggle-thumb { position:absolute; top:3px; left:3px; width:14px; height:14px;
192
- background:#fff; border-radius:50%; transition:transform 0.2s; pointer-events:none; }
193
- .toggle-wrap input:checked ~ .toggle-thumb { transform:translateX(16px); }
194
- #refresh-interval-wrap { display:none; align-items:center; gap:6px; }
195
- #refresh-interval-wrap.visible { display:flex; }
196
- #refresh-sec { width:52px; background:#0f3460; border:1px solid #4ecca3;
197
- border-radius:6px; color:#fff; font-size:14px; text-align:center; padding:3px; }
198
-
199
- /* ---- NOTICE ---- */
200
- .notice {
201
- background: #0f2040;
202
- border-left: 3px solid #e94560;
203
- border-radius: 0 6px 6px 0;
204
- padding: 10px 14px;
205
- margin-bottom: 14px;
206
- font-size: 13px;
207
- color: #a2a2bd;
208
- text-align: left;
209
- }
210
- .notice strong { color: #e94560; }
211
-
212
- /* ---- BLE DEVICE CARD ---- */
213
- #ble-idle { }
214
- #ble-card { display:none; }
215
-
216
- .scan-hint {
217
- font-size: 12px; color: #4a5a7a; margin-top: 8px; font-style: italic;
218
- }
219
-
220
- /* Scanning animation */
221
- .scan-ring-wrap {
222
- display: flex; align-items: center; justify-content: center;
223
- gap: 14px; margin: 12px 0 8px;
224
- }
225
- .scan-ring {
226
- width: 44px; height: 44px; border-radius: 50%;
227
- border: 3px solid transparent;
228
- border-top-color: #4ecca3;
229
- border-right-color: #4ecca366;
230
- animation: spin 1s linear infinite;
231
- }
232
- @keyframes spin { to { transform: rotate(360deg); } }
233
- .scan-label { color: #4ecca3; font-size: 14px; font-weight: bold; }
234
-
235
- /* Connected device card */
236
- .device-card {
237
- background: linear-gradient(135deg, #0a2040 0%, #0f3060 100%);
238
- border: 1px solid #4ecca355;
239
- border-radius: 14px;
240
- padding: 16px 18px;
241
- margin: 10px 0;
242
- display: flex;
243
- align-items: center;
244
- gap: 14px;
245
- text-align: left;
246
- position: relative;
247
- overflow: hidden;
248
- }
249
- .device-card::before {
250
- content: '';
251
- position: absolute;
252
- inset: 0;
253
- background: radial-gradient(ellipse at 20% 50%, #4ecca318 0%, transparent 60%);
254
- pointer-events: none;
255
- }
256
- .device-icon-wrap {
257
- width: 52px; height: 52px; border-radius: 50%;
258
- background: linear-gradient(135deg, #0f3460, #1a4a80);
259
- border: 2px solid #4ecca355;
260
- display: flex; align-items: center; justify-content: center;
261
- font-size: 26px; flex-shrink: 0;
262
- }
263
- .device-info { flex: 1; min-width: 0; }
264
- .device-name {
265
- color: #fff; font-size: 15px; font-weight: bold;
266
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
267
- }
268
- .device-sub {
269
- color: #a2a2bd; font-size: 12px; margin-top: 2px;
270
- }
271
- .device-badges {
272
- display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
273
- }
274
- .badge {
275
- background: #0f3460; border: 1px solid #1a4a80;
276
- border-radius: 20px; padding: 2px 9px;
277
- font-size: 11px; color: #a2a2bd;
278
- }
279
- .badge.connected {
280
- background: #0a3020; border-color: #4ecca355; color: #4ecca3;
281
- }
282
- .badge.subscribing {
283
- background: #2a1a00; border-color: #e9a02055; color: #e9a020;
284
- }
285
- .badge.no-hr {
286
- background: #300a0a; border-color: #e9456055; color: #e94560;
287
- }
288
-
289
- /* Signal bars */
290
- .signal-wrap {
291
- display: flex; align-items: flex-end; gap: 2px; height: 18px;
292
- }
293
- .sig-bar {
294
- width: 4px; border-radius: 2px; background: #4ecca3;
295
- opacity: 0.25; transition: opacity 0.4s;
296
- }
297
- .sig-bar.active { opacity: 1; }
298
- .sig-bar:nth-child(1) { height: 5px; }
299
- .sig-bar:nth-child(2) { height: 9px; }
300
- .sig-bar:nth-child(3) { height: 13px; }
301
- .sig-bar:nth-child(4) { height: 18px; }
302
-
303
- /* Disconnect button */
304
- #btn-disconnect {
305
- background: transparent;
306
- border: 1px solid #e9456066;
307
- color: #e94560;
308
- padding: 6px 13px;
309
- font-size: 12px;
310
- border-radius: 8px;
311
- cursor: pointer;
312
- font-weight: bold;
313
- transition: 0.2s;
314
- flex-shrink: 0;
315
- }
316
- #btn-disconnect:hover {
317
- background: #e9456020; border-color: #e94560;
318
- }
319
-
320
- /* Reconnect button (shown when disconnected) */
321
- #btn-reconnect {
322
- display: none;
323
- background: #4ecca322;
324
- border: 1px solid #4ecca355;
325
- color: #4ecca3;
326
- padding: 6px 13px;
327
- font-size: 12px;
328
- border-radius: 8px;
329
- cursor: pointer;
330
- font-weight: bold;
331
- transition: 0.2s;
332
- flex-shrink: 0;
333
- }
334
- #btn-reconnect:hover { background: #4ecca344; }
335
-
336
- /* Pulse dot (connection alive indicator) */
337
- .pulse-dot {
338
- width: 8px; height: 8px; border-radius: 50%;
339
- background: #4ecca3;
340
- box-shadow: 0 0 0 0 #4ecca3aa;
341
- animation: pulseDot 1.8s ease infinite;
342
- flex-shrink: 0;
343
- }
344
- @keyframes pulseDot {
345
- 0% { box-shadow: 0 0 0 0 #4ecca3aa; }
346
- 70% { box-shadow: 0 0 0 7px #4ecca300; }
347
- 100% { box-shadow: 0 0 0 0 #4ecca300; }
348
- }
349
- .pulse-dot.dead {
350
- background: #e94560; box-shadow: none; animation: none;
351
- }
352
- </style>
353
- </head>
354
- <body>
355
- <div class="container">
356
- <h1>Bio-Vibe 🎧</h1>
357
- <p class="subtitle">Âm nhạc trị liệu AI theo Nhịp tim</p>
358
-
359
- <div id="bpm-display">-- BPM</div>
360
-
361
- <!-- TABS -->
362
- <div class="tab-group">
363
- <button class="tab-btn active" onclick="switchTab('ble')">📡 Kết nối BLE</button>
364
- <button class="tab-btn" onclick="switchTab('manual')">✏️ Nhập thủ công</button>
365
- </div>
366
-
367
- <!-- TAB: BLE -->
368
- <div id="tab-ble" class="tab-panel active">
369
-
370
- <!-- STATE: idle / scanning -->
371
- <div id="ble-idle">
372
- <div class="notice">
373
- <strong>Galaxy Watch / Xiaomi / Huawei:</strong> Các đồng hồ này dùng giao thức riêng,
374
- <em>không expose Heart Rate qua Web Bluetooth</em>.
375
- Dùng tab <strong>Nhập thủ công</strong> hoặc chest-strap Polar/Garmin.
376
- </div>
377
- <div class="button-group">
378
- <button id="btn-connect">📡 Quét thiết bị BLE</button>
379
- </div>
380
- <p class="scan-hint">Trình duyệt sẽ mở hộp thoại chọn thiết bị Bluetooth</p>
381
- </div>
382
-
383
- <!-- STATE: scanning spinner (shown briefly) -->
384
- <div id="ble-scanning" style="display:none; padding:8px 0 4px;">
385
- <div class="scan-ring-wrap">
386
- <div class="scan-ring"></div>
387
- <span class="scan-label">Đang kết nối…</span>
388
- </div>
389
- </div>
390
-
391
- <!-- STATE: connected device card -->
392
- <div id="ble-card" style="display:none;">
393
- <div class="device-card">
394
- <div class="device-icon-wrap" id="dev-icon">⌚</div>
395
- <div class="device-info">
396
- <div class="device-name" id="dev-name">Unknown Device</div>
397
- <div class="device-sub" id="dev-sub">Đang kiểm tra characteristics…</div>
398
- <div class="device-badges">
399
- <span class="badge connected" id="badge-conn">● Đã kết nối</span>
400
- <span class="badge" id="badge-hr" style="display:none"></span>
401
- <span class="badge" id="badge-svc"></span>
402
- </div>
403
- </div>
404
- <div style="display:flex; flex-direction:column; align-items:center; gap:8px;">
405
- <div class="signal-wrap" id="signal-bars">
406
- <div class="sig-bar active"></div>
407
- <div class="sig-bar active"></div>
408
- <div class="sig-bar active"></div>
409
- <div class="sig-bar"></div>
410
- </div>
411
- <div class="pulse-dot" id="pulse-dot"></div>
412
- </div>
413
- <div style="display:flex; flex-direction:column; gap:6px; align-items:flex-end;">
414
- <button id="btn-disconnect">Ngắt kết nối</button>
415
- <button id="btn-reconnect">🔄 Kết nối lại</button>
416
- </div>
417
- </div>
418
- </div>
419
-
420
- </div>
421
-
422
- <!-- TAB: MANUAL -->
423
- <div id="tab-manual" class="tab-panel">
424
- <p style="color:#a2a2bd; font-size:13px; margin-bottom:6px;">
425
- Nhìn BPM trên Samsung Health → chọn vùng hoặc nhập chính xác bên dưới.
426
- </p>
427
-
428
- <!-- Quick zone buttons -->
429
- <div class="zone-grid">
430
- <button class="zone-btn" onclick="setZone(55,'Nghỉ ngơi')">
431
- <span class="zone-emoji">😴</span>
432
- <span class="zone-label">Nghỉ ngơi</span>
433
- <span class="zone-bpm">~55 BPM</span>
434
- </button>
435
- <button class="zone-btn" onclick="setZone(70,'Nhẹ nhàng')">
436
- <span class="zone-emoji">🚶</span>
437
- <span class="zone-label">Nhẹ nhàng</span>
438
- <span class="zone-bpm">~70 BPM</span>
439
- </button>
440
- <button class="zone-btn" onclick="setZone(95,'Vận động')">
441
- <span class="zone-emoji">🏃</span>
442
- <span class="zone-label">Vận động</span>
443
- <span class="zone-bpm">~95 BPM</span>
444
- </button>
445
- <button class="zone-btn" onclick="setZone(130,'Cường độ cao')">
446
- <span class="zone-emoji">🔥</span>
447
- <span class="zone-label">Cường độ cao</span>
448
- <span class="zone-bpm">~130 BPM</span>
449
- </button>
450
- </div>
451
-
452
- <!-- Precise input -->
453
- <div class="manual-group">
454
- <input type="number" id="manual-bpm-input" min="30" max="220" value="75"
455
- onkeydown="if(event.key==='Enter') setManualBpm()" />
456
- <button id="btn-manual-set" onclick="setManualBpm()">Áp dụng</button>
457
- </div>
458
-
459
- <!-- Auto refresh reminder -->
460
- <div class="auto-row">
461
- <label class="toggle-wrap">
462
- <input type="checkbox" id="auto-refresh-toggle" onchange="toggleAutoRefresh(this.checked)">
463
- <span class="toggle-track"></span>
464
- <span class="toggle-thumb"></span>
465
- </label>
466
- <span>Nhắc cập nhật BPM mỗi</span>
467
- <span id="refresh-interval-wrap">
468
- <input type="number" id="refresh-sec" min="10" max="300" value="30"> giây
469
- </span>
470
- </div>
471
- </div>
472
-
473
- <!-- PLAYBACK CONTROLS -->
474
- <div class="button-group" style="margin-top:18px;">
475
- <button id="btn-generate">2. Sinh nhạc AI</button>
476
- <button id="btn-stop" disabled>3. Dừng nhạc</button>
477
- </div>
478
-
479
- <div id="status" class="status-info">Chưa BPM...</div>
480
-
481
- <!-- DEBUG -->
482
- <div class="debug-section">
483
- <button class="debug-toggle" onclick="toggleDebug()">▶ Xem log BLE debug</button>
484
- <div id="debug-log"></div>
485
- </div>
486
- </div>
487
-
488
- <script>
489
- // ===============================
490
- // STATE
491
- // ===============================
492
- let currentBpm = 0;
493
- let bpmHistory = [];
494
- let currentAudio = null;
495
- let debugVisible = false;
496
- let ws = null; // Biến giữ kết nối WebSocket
497
-
498
- const bpmDisplay = document.getElementById('bpm-display');
499
- const statusEl = document.getElementById('status');
500
- const btnConnect = document.getElementById('btn-connect');
501
- const btnGenerate = document.getElementById('btn-generate');
502
- const btnStop = document.getElementById('btn-stop');
503
- const debugLog = document.getElementById('debug-log');
504
-
505
- // ===============================
506
- // UI HELPERS
507
- // ===============================
508
- function setStatus(msg, type = 'info') {
509
- statusEl.textContent = msg;
510
- statusEl.className = 'status-' + type;
511
- }
512
-
513
- function log(msg) {
514
- const line = document.createElement('div');
515
- line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
516
- debugLog.appendChild(line);
517
- debugLog.scrollTop = debugLog.scrollHeight;
518
- console.log(msg);
519
- }
520
-
521
- function toggleDebug() {
522
- debugVisible = !debugVisible;
523
- debugLog.style.display = debugVisible ? 'block' : 'none';
524
- }
525
-
526
- function switchTab(name) {
527
- document.querySelectorAll('.tab-btn').forEach((b, i) => {
528
- b.classList.toggle('active', (i === 0 && name === 'ble') || (i === 1 && name === 'manual'));
529
- });
530
- document.getElementById('tab-ble').classList.toggle('active', name === 'ble');
531
- document.getElementById('tab-manual').classList.toggle('active', name === 'manual');
532
- }
533
-
534
- // ===============================
535
- // BPM SMOOTHING
536
- // ===============================
537
- function smoothBpm(val) {
538
- bpmHistory.push(val);
539
- if (bpmHistory.length > 5) bpmHistory.shift();
540
- return Math.round(bpmHistory.reduce((a, b) => a + b, 0) / bpmHistory.length);
541
- }
542
-
543
- function applyBpm(raw, source) {
544
- if (raw < 30 || raw > 220) return false;
545
- if (currentBpm !== 0 && Math.abs(raw - currentBpm) > 40) return false;
546
-
547
- const smoothed = smoothBpm(raw);
548
- currentBpm = smoothed;
549
- bpmDisplay.textContent = currentBpm + ' BPM';
550
- bpmDisplay.style.color = '#e94560';
551
- setStatus('BPM nhận từ: ' + source, 'ok');
552
-
553
- if (ws && ws.readyState === WebSocket.OPEN) {
554
- ws.send(JSON.stringify({ bpm: currentBpm }));
555
- }
556
-
557
- return true;
558
- }
559
-
560
- // ===============================
561
- // MANUAL BPM
562
- // ===============================
563
- function setManualBpm() {
564
- const val = parseInt(document.getElementById('manual-bpm-input').value, 10);
565
- if (isNaN(val) || val < 30 || val > 220) {
566
- setStatus('Giá trị BPM không hợp lệ (30–220)', 'err');
567
- return;
568
- }
569
- bpmHistory = [];
570
- applyBpm(val, 'Nhập thủ công');
571
- }
572
-
573
- function setZone(bpm, label) {
574
- document.getElementById('manual-bpm-input').value = bpm;
575
- bpmHistory = [];
576
- applyBpm(bpm, label);
577
- // Highlight active zone
578
- document.querySelectorAll('.zone-btn').forEach(b => b.classList.remove('active-zone'));
579
- event.currentTarget.classList.add('active-zone');
580
- }
581
-
582
- let autoRefreshTimer = null;
583
- function toggleAutoRefresh(on) {
584
- const wrap = document.getElementById('refresh-interval-wrap');
585
- wrap.classList.toggle('visible', on);
586
- if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = null; }
587
- if (on) {
588
- const sec = parseInt(document.getElementById('refresh-sec').value, 10) || 30;
589
- autoRefreshTimer = setInterval(() => {
590
- setStatus(`⏰ Hãy kiểm tra BPM trên Samsung Health và cập nhật!`, 'info');
591
- // Flash BPM display
592
- bpmDisplay.style.opacity = '0.4';
593
- setTimeout(() => bpmDisplay.style.opacity = '1', 600);
594
- }, sec * 1000);
595
- setStatus(`Sẽ nhắc cập nhật BPM mỗi ${sec} giây`, 'ok');
596
- }
597
- }
598
-
599
- // ===============================
600
- // PARSE CHUẨN BLE Heart Rate (0x2A37)
601
- // ===============================
602
- function parseHeartRateMeasurement(dataView) {
603
- /*
604
- * Byte 0: Flags
605
- * bit 0 = 0 → Heart Rate Value là UINT8 (byte 1)
606
- * bit 0 = 1 → Heart Rate Value là UINT16 (bytes 1-2, little-endian)
607
- */
608
- const flags = dataView.getUint8(0);
609
- const is16bit = (flags & 0x01) !== 0;
610
- return is16bit
611
- ? dataView.getUint16(1, true)
612
- : dataView.getUint8(1);
613
- }
614
-
615
- // ===============================
616
- // HANDLE INCOMING CHARACTERISTIC DATA
617
- // ===============================
618
- function handleCharData(event, uuid, serviceUuid) {
619
- const dv = event.target.value;
620
- const raw = [];
621
- for (let i = 0; i < dv.byteLength; i++) raw.push(dv.getUint8(i));
622
- log(`UUID ${uuid.substring(0,8)}… DATA: [${raw.join(', ')}]`);
623
-
624
- const HR_MEASUREMENT_UUID = '00002a37-0000-1000-8000-00805f9b34fb';
625
-
626
- // 1. Nếu đúng UUID chuẩn Heart Rate Measurement → parse theo spec
627
- if (uuid === HR_MEASUREMENT_UUID) {
628
- if (dv.byteLength >= 2) {
629
- const bpm = parseHeartRateMeasurement(dv);
630
- log(`Standard HR parse → ${bpm} BPM`);
631
- applyBpm(bpm, 'HR chuẩn BLE');
632
- }
633
- return;
634
- }
635
-
636
- // 2. Fallback heuristic cho proprietary chars
637
- // Duyệt từng byte, lấy byte đầu tiên nằm trong [40, 180]
638
- for (let i = 0; i < raw.length; i++) {
639
- const v = raw[i];
640
- if (v >= 40 && v <= 180) {
641
- log(`Heuristic byte[${i}] = ${v} BPM`);
642
- applyBpm(v, `Heuristic (${uuid.substring(0,8)}…)`);
643
- break;
644
- }
645
- }
646
- }
647
-
648
- // ===============================
649
- // BLE STATE
650
- // ===============================
651
- let bleDevice = null;
652
- let bleServer = null;
653
- let bleSubCount = 0;
654
-
655
- const bleIdle = document.getElementById('ble-idle');
656
- const bleScanning = document.getElementById('ble-scanning');
657
- const bleCard = document.getElementById('ble-card');
658
- const devIcon = document.getElementById('dev-icon');
659
- const devName = document.getElementById('dev-name');
660
- const devSub = document.getElementById('dev-sub');
661
- const badgeConn = document.getElementById('badge-conn');
662
- const badgeHr = document.getElementById('badge-hr');
663
- const badgeSvc = document.getElementById('badge-svc');
664
- const pulseDot = document.getElementById('pulse-dot');
665
- const btnDisconnect = document.getElementById('btn-disconnect');
666
- const btnReconnect = document.getElementById('btn-reconnect');
667
-
668
- function guessDeviceIcon(name) {
669
- if (!name) return '📡';
670
- const n = name.toLowerCase();
671
- if (n.includes('polar') || n.includes('hrm') || n.includes('chest')) return '💓';
672
- if (n.includes('garmin')) return '🏃';
673
- if (n.includes('galaxy') || n.includes('watch') || n.includes('samsung')) return '⌚';
674
- if (n.includes('mi band') || n.includes('xiaomi') || n.includes('amazfit')) return '📿';
675
- if (n.includes('fitbit')) return '💪';
676
- return '⌚';
677
- }
678
-
679
- function showBleCard(name, subText) {
680
- bleIdle.style.display = 'none';
681
- bleScanning.style.display = 'none';
682
- bleCard.style.display = 'block';
683
- devIcon.textContent = guessDeviceIcon(name);
684
- devName.textContent = name || 'Unknown Device';
685
- devSub.textContent = subText || '';
686
- }
687
-
688
- function setBleConnected() {
689
- badgeConn.textContent = '● Đã kết nối';
690
- badgeConn.className = 'badge connected';
691
- pulseDot.classList.remove('dead');
692
- btnDisconnect.style.display = 'inline-block';
693
- btnReconnect.style.display = 'none';
694
- }
695
-
696
- function setBleDisconnected() {
697
- badgeConn.textContent = '○ Mất kết nối';
698
- badgeConn.className = 'badge no-hr';
699
- pulseDot.classList.add('dead');
700
- btnDisconnect.style.display = 'none';
701
- btnReconnect.style.display = 'inline-block';
702
- // Animate signal bars off
703
- document.querySelectorAll('.sig-bar').forEach(b => b.classList.remove('active'));
704
- }
705
-
706
- function resetBleUI() {
707
- bleCard.style.display = 'none';
708
- bleScanning.style.display = 'none';
709
- bleIdle.style.display = 'block';
710
- badgeHr.style.display = 'none';
711
- bleDevice = null; bleServer = null; bleSubCount = 0;
712
- }
713
-
714
- // ===============================
715
- // BLE CONNECT — auto scan tất cả services
716
- // ===============================
717
- async function doBleConnect() {
718
- if (!navigator.bluetooth) {
719
- setStatus('Trình duyệt không hỗ trợ Web Bluetooth!', 'err');
720
- return;
721
- }
722
-
723
- try {
724
- setStatus('Đang mở hộp thoại BLE…', 'info');
725
- log('Bắt đầu scan BLE...');
726
-
727
- // Show scanning state while picker is open
728
- bleIdle.style.display = 'none';
729
- bleScanning.style.display = 'block';
730
-
731
- const device = await navigator.bluetooth.requestDevice({
732
- acceptAllDevices: true,
733
- optionalServices: [
734
- 'heart_rate', 'battery_service', 'device_information', 'generic_access',
735
- '0000fee0-0000-1000-8000-00805f9b34fb',
736
- '0000fee1-0000-1000-8000-00805f9b34fb',
737
- '0000181d-0000-1000-8000-00805f9b34fb',
738
- '0000fd00-0000-1000-8000-00805f9b34fb',
739
- '0000180d-0000-1000-8000-00805f9b34fb',
740
- ]
741
- });
742
-
743
- bleDevice = device;
744
- log(`Device: ${device.name || '(no name)'} id=${device.id}`);
745
- showBleCard(device.name, 'Đang kết nối GATT…');
746
- setStatus('Đang kết nối GATT…', 'info');
747
-
748
- // Listen for unexpected disconnects
749
- device.addEventListener('gattserverdisconnected', onGattDisconnected);
750
-
751
- bleServer = await device.gatt.connect();
752
- const services = await bleServer.getPrimaryServices();
753
- log(`Tìm thấy ${services.length} service(s)`);
754
-
755
- badgeSvc.textContent = `${services.length} services`;
756
-
757
- let subscribed = 0;
758
-
759
- for (const svc of services) {
760
- log(`Service: ${svc.uuid}`);
761
- let chars;
762
- try { chars = await svc.getCharacteristics(); }
763
- catch (e) { log(` Không đọc được chars: ${e.message}`); continue; }
764
-
765
- for (const char of chars) {
766
- const props = char.properties;
767
- log(` Char: ${char.uuid} [notify=${props.notify} indicate=${props.indicate} read=${props.read}]`);
768
- if (props.notify || props.indicate) {
769
- try {
770
- await char.startNotifications();
771
- char.addEventListener('characteristicvaluechanged',
772
- (ev) => handleCharData(ev, char.uuid, svc.uuid));
773
- log(` ✅ Subscribed: ${char.uuid}`);
774
- subscribed++;
775
- } catch (e) { log(` ❌ Subscribe failed: ${e.message}`); }
776
- }
777
- }
778
- }
779
-
780
- bleSubCount = subscribed;
781
- setBleConnected();
782
-
783
- if (subscribed === 0) {
784
- badgeHr.textContent = '✗ Không có HR';
785
- badgeHr.className = 'badge no-hr';
786
- badgeHr.style.display = 'inline-block';
787
- devSub.textContent = 'Không đọc được HR — thử tab Nhập thủ công';
788
- setStatus('Kết nối OK nhưng thiết bị không stream HR. Thử tab Nhập thủ công.', 'err');
789
- } else {
790
- badgeHr.textContent = `♥ ${subscribed} HR char`;
791
- badgeHr.className = 'badge subscribing';
792
- badgeHr.style.display = 'inline-block';
793
- devSub.textContent = `Đã subscribe ${subscribed} characteristic — đang chờ BPM…`;
794
- setStatus(`Đã subscribe ${subscribed} characteristic, đang chờ BPM…`, 'ok');
795
- }
796
-
797
- } catch (err) {
798
- log(`Lỗi: ${err.message}`);
799
- setStatus('Lỗi BLE: ' + err.message, 'err');
800
- resetBleUI();
801
- }
802
- }
803
-
804
- // ===============================
805
- // GATT DISCONNECTED EVENT
806
- // ===============================
807
- function onGattDisconnected() {
808
- log('⚠ GATT server disconnected (đồng hồ tự ngắt hoặc ra khỏi tầm)');
809
- setStatus('⚠ Mất kết nối BLE — nhấn "Kết nối lại" để thử lại.', 'err');
810
- setBleDisconnected();
811
- devSub.textContent = 'Mất kết nối — ra khỏi tầm Bluetooth?';
812
- }
813
-
814
- // ===============================
815
- // DISCONNECT
816
- // ===============================
817
- function doDisconnect() {
818
- if (bleDevice && bleDevice.gatt.connected) {
819
- bleDevice.gatt.disconnect();
820
- log('Đã ngắt kết nối BLE theo yêu cầu');
821
- }
822
- setBleDisconnected();
823
- devSub.textContent = 'Đã ngắt kết nối';
824
- setStatus('Đã ngắt kết nối BLE.', 'info');
825
- }
826
-
827
- // ===============================
828
- // RECONNECT
829
- // ===============================
830
- async function doReconnect() {
831
- if (!bleDevice) { resetBleUI(); return; }
832
- try {
833
- devSub.textContent = 'Đang kết nối lại…';
834
- btnReconnect.disabled = true;
835
- setStatus('Đang kết nối lại GATT…', 'info');
836
-
837
- bleServer = await bleDevice.gatt.connect();
838
- const services = await bleServer.getPrimaryServices();
839
- let subscribed = 0;
840
-
841
- for (const svc of services) {
842
- let chars;
843
- try { chars = await svc.getCharacteristics(); } catch { continue; }
844
- for (const char of chars) {
845
- const props = char.properties;
846
- if (props.notify || props.indicate) {
847
- try {
848
- await char.startNotifications();
849
- char.addEventListener('characteristicvaluechanged',
850
- (ev) => handleCharData(ev, char.uuid, svc.uuid));
851
- subscribed++;
852
- } catch {}
853
- }
854
- }
855
- }
856
-
857
- bleSubCount = subscribed;
858
- setBleConnected();
859
- devSub.textContent = `Kết nối lại thành công — ${subscribed} char`;
860
- setStatus('Kết nối lại BLE thành công!', 'ok');
861
- } catch (err) {
862
- log(`Reconnect failed: ${err.message}`);
863
- setStatus('Kết nối lại thất bại: ' + err.message, 'err');
864
- devSub.textContent = 'Kết nối lại thất bại';
865
- } finally {
866
- btnReconnect.disabled = false;
867
- }
868
- }
869
-
870
- btnConnect.addEventListener('click', doBleConnect);
871
- btnDisconnect.addEventListener('click', doDisconnect);
872
- btnReconnect.addEventListener('click', doReconnect);
873
-
874
- // ================================================================
875
- // AUDIO CHAIN — volume boosted, gain staging cẩn thận
876
- // ================================================================
877
- const masterGain = new Tone.Gain(1.5).toDestination(); // +3.5dB tổng thể
878
- const limiter = new Tone.Limiter(-0.3).connect(masterGain); // limiter thoáng hơn
879
- const compressor = new Tone.Compressor({
880
- threshold: -14, ratio: 3, attack: 0.008, release: 0.18
881
- }).connect(limiter);
882
- const eq = new Tone.EQ3({ low: 5, mid: -1, high: 3 }).connect(compressor);
883
- const stereo = new Tone.StereoWidener(0.65).connect(eq);
884
- const reverb = new Tone.Reverb({ decay: 3.5, wet: 0.38 }).connect(stereo); // wet sẽ thay đổi theo BPM
885
- const delay = new Tone.FeedbackDelay({ delayTime: "8n", feedback: 0.28, wet: 0.18 }).connect(reverb);
886
-
887
- // 🎹 PIANO — volume tăng lên, kết nối thẳng vào delay
888
- const piano = new Tone.Sampler({
889
- volume: 4, // +4dB so với trước (trước là 0)
890
- urls: {
891
- A1:"A1.mp3", C2:"C2.mp3", "D#2":"Ds2.mp3", "F#2":"Fs2.mp3",
892
- A2:"A2.mp3", C3:"C3.mp3", "D#3":"Ds3.mp3", "F#3":"Fs3.mp3",
893
- A3:"A3.mp3", C4:"C4.mp3", "D#4":"Ds4.mp3", "F#4":"Fs4.mp3",
894
- A4:"A4.mp3", C5:"C5.mp3"
895
- },
896
- baseUrl: "https://raw.githubusercontent.com/Tonejs/audio/master/salamander/",
897
- release: 1
898
- }).connect(delay);
899
-
900
- // 🌊 PAD — volume tăng, attack thay đổi theo BPM
901
- const pad = new Tone.PolySynth(Tone.Synth, {
902
- volume: -6, // -6dB (trước là -12dB)
903
- oscillator: { type: "sine" },
904
- envelope: { attack: 1.2, decay: 0.5, sustain: 0.8, release: 3 }
905
- }).connect(reverb);
906
-
907
- // 🥁 KICK — volume tăng đáng kể
908
- const kick = new Tone.MembraneSynth({
909
- volume: -2, // -2dB (trước là -8dB)
910
- pitchDecay: 0.06, octaves: 7,
911
- envelope: { attack: 0.001, decay: 0.35, sustain: 0 }
912
- }).connect(compressor);
913
-
914
- // 🥁 SNARE
915
- const snare = new Tone.NoiseSynth({
916
- volume: -4, // -4dB (trước là -9dB)
917
- noise: { type: "white" },
918
- envelope: { attack: 0.001, decay: 0.13, sustain: 0 }
919
- }).connect(compressor);
920
-
921
- // 🥁 HIHAT
922
- const hihat = new Tone.MetalSynth({
923
- volume: -16, // -16dB (trước là -22dB)
924
- frequency: 400,
925
- envelope: { attack: 0.001, decay: 0.055, release: 0.01 },
926
- harmonicity: 5.1, modulationIndex: 32, resonance: 4000, octaves: 1.5
927
- }).connect(compressor);
928
-
929
- log("🎹 Đã khởi tạo audio chain. Volume boosted.");
930
- setStatus("Sẵn sàng sinh nhạc AI", "ok");
931
-
932
- // ================================================================
933
- // DRUM ENGINE — client-side, Tone.Transport grid, không bao giờ lệch
934
- // ================================================================
935
-
936
- // 16-step patterns (mỗi step = 1 sixteenth note)
937
- // vel: 0 = tắt, 1–127 = velocity
938
- // =======================
939
- // DRUM 2.0 — CLEAN & MUSICAL
940
- // =======================
941
-
942
- const DRUM_PATTERNS = {
943
-
944
- // 🌙 CALM — rất thưa, đúng lofi
945
- calm: {
946
- kick: [90,0,0,0, 0,0,0,0, 70,0,0,0, 0,0,0,0],
947
-
948
- // Snare CHUẨN: beat 2 & 4
949
- snare: [0,0,0,0, 85,0,0,0, 0,0,0,0, 80,0,0,0],
950
-
951
- // Hi-hat 8th nhẹ
952
- hihat: [50,0,45,0, 50,0,45,0, 50,0,45,0, 50,0,45,0],
953
- },
954
-
955
- calm_var: {
956
- kick: [90,0,0,0, 0,0,65,0, 70,0,0,0, 0,0,0,0],
957
-
958
- snare: [0,0,0,0, 85,0,0,0, 0,0,0,0, 80,0,0,0],
959
-
960
- // thêm chút swing
961
- hihat: [50,30,45,0, 50,0,45,20, 50,30,45,0, 50,0,45,20],
962
- },
963
-
964
-
965
- // 🚶 GROOVE — balanced
966
- groove: {
967
- kick: [90,0,0,0, 0,0,70,0, 85,0,0,0, 0,0,60,0],
968
-
969
- snare: [0,0,0,0, 90,0,0,0, 0,0,0,0, 85,0,0,0],
970
-
971
- hihat: [60,0,50,0, 60,0,50,0, 60,0,50,0, 60,0,50,0],
972
- },
973
-
974
- groove_var: {
975
- kick: [90,0,0,0, 60,0,70,0, 85,0,0,0, 60,0,70,0],
976
-
977
- // ghost cực nhẹ → không phá groove
978
- snare: [0,0,0,0, 90,0,0,0, 0,0,20,0, 85,0,0,0],
979
-
980
- hihat: [60,30,50,0, 60,0,50,30, 60,30,50,0, 60,0,50,30],
981
- },
982
-
983
-
984
- // 🔥 ACTIVE — vẫn giữ control, KHÔNG spam
985
- active: {
986
- kick: [90,0,70,0, 0,0,75,0, 88,0,0,0, 70,0,65,0],
987
-
988
- snare: [0,0,0,0, 95,0,0,0, 0,0,0,0, 90,0,0,0],
989
-
990
- hihat: [65,40,55,0, 65,40,55,0, 65,40,55,0, 65,40,55,0],
991
- },
992
-
993
- active_var: {
994
- kick: [90,0,70,0, 60,0,75,0, 88,0,0,0, 70,0,65,45],
995
-
996
- snare: [0,0,0,20, 95,0,0,0, 0,20,0,0, 90,0,0,0],
997
-
998
- hihat: [65,40,55,20, 65,40,55,0, 65,40,55,20, 65,40,55,0],
999
- },
1000
-
1001
-
1002
- // 🌫 BREAKDOWN — cực quan trọng để “thở”
1003
- breakdown: {
1004
- kick: [80,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0],
1005
- snare: [0,0,0,0, 70,0,0,0, 0,0,0,0, 0,0,0,0],
1006
- hihat: [40,0,0,0, 40,0,0,0, 40,0,0,0, 40,0,0,0],
1007
- }
1008
- };
1009
-
1010
-
1011
- // =======================
1012
- // SMART PATTERN SELECT
1013
- // =======================
1014
- function choosePattern(bpm, barIdx) {
1015
- const cycle = barIdx % 8;
1016
-
1017
- // luôn có khoảng nghỉ
1018
- if (cycle === 6) return 'breakdown';
1019
-
1020
- const isVar = (cycle % 4) >= 2;
1021
-
1022
- // Entrainment logic (rất đúng hướng bạn đang làm)
1023
- if (bpm >= 105) return isVar ? 'calm_var' : 'calm';
1024
- if (bpm >= 75) return isVar ? 'groove_var' : 'groove';
1025
- return isVar ? 'active_var' : 'active';
1026
- }
1027
-
1028
- let drumRepeatId = null;
1029
- let drumBarCount = 0;
1030
-
1031
- function startDrumEngine(bpm) {
1032
- stopDrumEngine();
1033
- Tone.getTransport().bpm.value = bpm;
1034
-
1035
- const step16 = 60 / bpm / 4; // duration của 1 sixteenth note (giây)
1036
-
1037
- drumRepeatId = Tone.getTransport().scheduleRepeat((barTime) => {
1038
- const patName = choosePattern(currentBpm, drumBarCount);
1039
- const pat = DRUM_PATTERNS[patName];
1040
- const s16 = 60 / currentBpm / 4; // re-read mỗi bar để phản ánh BPM mới
1041
-
1042
- for (let i = 0; i < 16; i++) {
1043
- const t = barTime + i * s16;
1044
- const hu = () => (Math.random() - 0.5) * 0.012; // humanize ±6ms
1045
-
1046
- if (pat.kick[i]) kick .triggerAttackRelease('C1', '16n', t + hu(), pat.kick[i] / 127);
1047
- if (pat.snare[i]) snare.triggerAttackRelease('16n', t + hu(), pat.snare[i] / 127);
1048
- if (pat.hihat[i]) hihat.triggerAttackRelease('32n', t + Math.abs(hu()), pat.hihat[i] / 127);
1049
- }
1050
- drumBarCount++;
1051
- }, '1m'); // repeat mỗi 1 measure
1052
-
1053
- Tone.getTransport().start();
1054
- log(`🥁 Drum engine started — pattern: ${choosePattern(bpm, 0)}`);
1055
- }
1056
-
1057
- function stopDrumEngine() {
1058
- if (drumRepeatId !== null) {
1059
- Tone.getTransport().clear(drumRepeatId);
1060
- drumRepeatId = null;
1061
- }
1062
- Tone.getTransport().stop();
1063
- Tone.getTransport().cancel();
1064
- drumBarCount = 0;
1065
- }
1066
-
1067
- // ================================================================
1068
- // BPM-REACTIVE: thay đổi texture theo nhịp tim
1069
- // ================================================================
1070
- function applyBpmTexture(bpm) {
1071
- // ── ENTRAINMENT LOGIC ──────────────────────────────────────────
1072
- // Nhịp tim cao = cơ thể đang stress → nhạc kéo ngược lại: chậm,
1073
- // mơ màng, ambient hơn để hạ nhiệt.
1074
- // Nhịp tim thấp = nghỉ ngơi → nhạc có thể nhẹ nhàng groove hơn.
1075
-
1076
- // Reverb: BPM CAO = nhiều reverb hơn (mơ màng, bay bổng, dịu)
1077
- // BPM thấp = ít reverb (rõ ràng, có chiều sâu nhẹ)
1078
- const reverbWet = bpm < 70 ? 0.28 :
1079
- bpm < 90 ? 0.35 :
1080
- bpm < 110 ? 0.46 : 0.58;
1081
- reverb.wet.rampTo(reverbWet, 2.5);
1082
-
1083
- // Delay feedback: BPM cao = echo dài hơn (hypnotic, calming)
1084
- const delayFb = bpm < 80 ? 0.16 : bpm < 110 ? 0.24 : 0.34;
1085
- delay.feedback.rampTo(delayFb, 2.5);
1086
-
1087
- // Pad volume: BPM cao = pad nổi hơn (ambient nhấn chìm, che melody)
1088
- // BPM thấp = pad lùi, melody groove rõ hơn
1089
- const padVol = bpm < 75 ? -12 : bpm < 100 ? -8 : -4;
1090
- pad.volume.rampTo(padVol, 2.5);
1091
-
1092
- // Pad attack: BPM cao = attack chậm (drifting), thấp = attack vừa
1093
- const padAttack = bpm < 75 ? 0.6 : bpm < 100 ? 1.2 : 2.2;
1094
- pad.set({ envelope: { attack: padAttack } });
1095
-
1096
- // Nhạc tempo: BPM cao → tempo chậm hơn để kéo nhịp tim xuống
1097
- // map BPM [40,180] → music tempo [85, 55] BPM (inverse linear)
1098
- const musicTempo = Math.round(85 - (bpm - 40) * (30 / 140));
1099
- Tone.getTransport().bpm.rampTo(Math.max(52, Math.min(88, musicTempo)), 3);
1100
-
1101
- // BPM display color theo mức độ stress
1102
- // (màu vẫn phản ánh nhịp tim thực, không phải tempo nhạc)
1103
- const color = bpm < 70 ? '#4ecca3' : // xanh — bình tĩnh
1104
- bpm < 90 ? '#a0d8ef' : // xanh dương — nhẹ nhàng
1105
- bpm < 110 ? '#f9c74f' : // vàng — đang tăng
1106
- '#e94560'; // đỏ — cần hạ nhiệt
1107
- bpmDisplay.style.color = color;
1108
- bpmDisplay.style.textShadow = `0 0 20px ${color}`;
1109
-
1110
- log(`🎚 Texture (entrainment) → reverb:${reverbWet} delayFb:${delayFb} padVol:${padVol} musicTempo:${musicTempo}`);
1111
- }
1112
-
1113
- // Override applyBpm để gọi texture update
1114
- const _origApplyBpm = applyBpm;
1115
- // Không thể override function declaration, thêm hook vào ws.send thay thế
1116
- function sendBpmToServer(bpm) {
1117
- if (ws && ws.readyState === WebSocket.OPEN) {
1118
- ws.send(JSON.stringify({ bpm }));
1119
- }
1120
- applyBpmTexture(bpm);
1121
- }
1122
-
1123
- // ================================================================
1124
- // WEBSOCKET — sinh nhạc
1125
- // ================================================================
1126
- btnGenerate.addEventListener('click', async () => {
1127
- if (currentBpm === 0) {
1128
- alert('Chưa có BPM! Kết nối đồng hồ hoặc nhập thủ công.');
1129
- return;
1130
- }
1131
-
1132
- await Tone.start();
1133
- await Tone.loaded();
1134
- log("✅ Piano samples loaded");
1135
-
1136
- if (ws) ws.close();
1137
-
1138
- setStatus(`Đang kết nối AI với ${currentBpm} BPM…`, 'info');
1139
- btnGenerate.textContent = 'AI đang truyền nốt…';
1140
- btnGenerate.disabled = true;
1141
- btnStop.disabled = false;
1142
- bpmDisplay.style.animation = 'pulse 1s infinite';
1143
-
1144
- // Khởi động texture + drum engine ngay
1145
- applyBpmTexture(currentBpm);
1146
- startDrumEngine(currentBpm);
1147
-
1148
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1149
- ws = new WebSocket(`${protocol}//${window.location.host}/ws/vibe`);
1150
-
1151
- ws.onopen = () => {
1152
- setStatus('🎵 Đang phát nhạc Real-time…', 'ok');
1153
- ws.send(JSON.stringify({ bpm: currentBpm }));
1154
- };
1155
-
1156
- let startTime = Tone.now() + 0.15;
1157
-
1158
- ws.onmessage = (event) => {
1159
- const data = JSON.parse(event.data);
1160
-
1161
- // Nếu BPM thay đổi (server gửi về), cập nhật texture
1162
- if (data.bpm && data.bpm !== currentBpm) {
1163
- applyBpmTexture(data.bpm);
1164
- }
1165
-
1166
- data.notes.forEach(n => {
1167
- const noteStr = Tone.Frequency(n.note, "midi").toNote();
1168
- const t = startTime + n.time;
1169
-
1170
- if (n.type === "melody") {
1171
- // Velocity melody cũng phản ánh BPM: cao → piano mạnh hơn
1172
- const velBoost = currentBpm > 100 ? 1.15 : 1.0;
1173
- const vel = Math.min(1, (n.velocity / 127) * velBoost);
1174
- piano.triggerAttackRelease(noteStr, n.duration, t, vel);
1175
- }
1176
- else if (n.type === "chord") {
1177
- pad.triggerAttackRelease(noteStr, n.duration, t, n.velocity / 127);
1178
- }
1179
- // drum events từ server bị bỏ qua — drum engine client đã xử lý
1180
- });
1181
-
1182
- startTime += data.batch_duration;
1183
- };
1184
-
1185
- // Mỗi khi BPM thay đổi từ đồng hồ/manual → cập nhật texture + drum
1186
- const origApply = applyBpm;
1187
- // Patch ws.send để bắt BPM change
1188
- const _origSend = ws.send.bind(ws);
1189
- ws.send = function(data) {
1190
- try {
1191
- const parsed = JSON.parse(data);
1192
- if (parsed.bpm) applyBpmTexture(parsed.bpm);
1193
- } catch {}
1194
- return _origSend(data);
1195
- };
1196
-
1197
- ws.onerror = (err) => {
1198
- log('WebSocket Error: ' + err);
1199
- setStatus('Lỗi kết nối tới AI!', 'err');
1200
- };
1201
-
1202
- ws.onclose = () => {
1203
- btnGenerate.textContent = '2. Sinh nhạc AI';
1204
- btnGenerate.disabled = false;
1205
- btnStop.disabled = true;
1206
- bpmDisplay.style.animation = 'none';
1207
- stopDrumEngine();
1208
- };
1209
- });
1210
-
1211
- // ================================================================
1212
- // STOP — fade masterGain ngay lập tức, không chờ note scheduled
1213
- // ================================================================
1214
- btnStop.addEventListener('click', () => {
1215
- if (ws) { ws.close(); ws = null; }
1216
- stopDrumEngine();
1217
-
1218
- // Fade out nhanh (0.25s) để tắt ngay cả note đã schedule trước
1219
- masterGain.gain.rampTo(0, 0.25);
1220
-
1221
- setTimeout(() => {
1222
- piano.releaseAll();
1223
- pad.releaseAll();
1224
- // Restore volume cho lần phát tiếp theo
1225
- masterGain.gain.rampTo(1.5, 0.1);
1226
- }, 300);
1227
-
1228
- setStatus('Đã dừng nhạc.', 'info');
1229
- });
1230
- </script>
1231
- </body>
1232
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Bio-Vibe Web</title>
7
+ <link rel="icon" href="data:,">
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
9
+ <style>
10
+ body {
11
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
12
+ background-color: #1a1a2e;
13
+ color: #e94560;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ justify-content: center;
18
+ min-height: 100vh;
19
+ margin: 0;
20
+ padding: 20px;
21
+ box-sizing: border-box;
22
+ }
23
+ .container {
24
+ background-color: #16213e;
25
+ padding: 40px;
26
+ border-radius: 15px;
27
+ text-align: center;
28
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
29
+ max-width: 620px;
30
+ width: 100%;
31
+ }
32
+ h1 { color: #fff; margin-bottom: 5px; }
33
+ .subtitle { color: #a2a2bd; margin-bottom: 20px; }
34
+
35
+ #bpm-display {
36
+ font-size: 80px;
37
+ font-weight: bold;
38
+ margin: 15px 0;
39
+ text-shadow: 0 0 15px #e94560;
40
+ transition: color 0.3s ease;
41
+ }
42
+
43
+ @keyframes pulse {
44
+ 0% { transform: scale(1); opacity: 1; }
45
+ 50% { transform: scale(1.05); opacity: 0.8; }
46
+ 100% { transform: scale(1); opacity: 1; }
47
+ }
48
+
49
+ /* ---- TABS ---- */
50
+ .tab-group {
51
+ display: flex;
52
+ justify-content: center;
53
+ gap: 0;
54
+ margin-bottom: 20px;
55
+ border-radius: 8px;
56
+ overflow: hidden;
57
+ border: 1px solid #0f3460;
58
+ }
59
+ .tab-btn {
60
+ flex: 1;
61
+ background: #0f3460;
62
+ color: #a2a2bd;
63
+ border: none;
64
+ padding: 10px 16px;
65
+ font-size: 14px;
66
+ cursor: pointer;
67
+ transition: 0.2s;
68
+ font-weight: bold;
69
+ }
70
+ .tab-btn.active { background: #4ecca3; color: #1a1a2e; }
71
+ .tab-btn:hover:not(.active) { background: #1a3a6e; color: #fff; }
72
+
73
+ .tab-panel { display: none; }
74
+ .tab-panel.active { display: block; }
75
+
76
+ /* ---- MANUAL INPUT ---- */
77
+ .manual-group {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ gap: 12px;
82
+ margin: 10px 0 16px;
83
+ }
84
+ #manual-bpm-input {
85
+ background: #0f3460;
86
+ border: 1px solid #4ecca3;
87
+ border-radius: 8px;
88
+ color: #fff;
89
+ font-size: 28px;
90
+ font-weight: bold;
91
+ width: 110px;
92
+ text-align: center;
93
+ padding: 8px;
94
+ }
95
+ #manual-bpm-input:focus { outline: 2px solid #4ecca3; }
96
+ #btn-manual-set {
97
+ background: #4ecca3;
98
+ color: #1a1a2e;
99
+ border: none;
100
+ padding: 10px 18px;
101
+ font-size: 15px;
102
+ border-radius: 8px;
103
+ cursor: pointer;
104
+ font-weight: bold;
105
+ transition: 0.2s;
106
+ }
107
+ #btn-manual-set:hover { background: #38b499; }
108
+
109
+ /* ---- BUTTONS ---- */
110
+ .button-group {
111
+ display: flex;
112
+ justify-content: center;
113
+ flex-wrap: wrap;
114
+ gap: 12px;
115
+ margin-top: 10px;
116
+ }
117
+ button {
118
+ background-color: #0f3460;
119
+ color: white;
120
+ border: none;
121
+ padding: 13px 22px;
122
+ font-size: 15px;
123
+ border-radius: 8px;
124
+ cursor: pointer;
125
+ transition: 0.3s;
126
+ font-weight: bold;
127
+ }
128
+ button:hover { background-color: #4ecca3; color: #1a1a2e; }
129
+ button:disabled { background-color: #2a2a3e; cursor: not-allowed; color: #555; }
130
+
131
+ #btn-stop { background-color: #8b0000; }
132
+ #btn-stop:hover { background-color: #ff4d4d; color: white; }
133
+ #btn-stop:disabled { background-color: #2a2a3e; }
134
+
135
+ /* ---- STATUS ---- */
136
+ #status {
137
+ margin-top: 18px;
138
+ font-size: 13px;
139
+ font-style: italic;
140
+ min-height: 20px;
141
+ }
142
+ .status-ok { color: #4ecca3; }
143
+ .status-err { color: #ff4d4d; }
144
+ .status-info { color: #a2a2bd; }
145
+
146
+ /* ---- DEBUG LOG ---- */
147
+ .debug-section {
148
+ margin-top: 20px;
149
+ text-align: left;
150
+ }
151
+ .debug-toggle {
152
+ background: none;
153
+ border: none;
154
+ color: #555;
155
+ font-size: 12px;
156
+ cursor: pointer;
157
+ padding: 4px 0;
158
+ font-style: italic;
159
+ }
160
+ .debug-toggle:hover { color: #a2a2bd; background: none; }
161
+ #debug-log {
162
+ display: none;
163
+ background: #0a0a1a;
164
+ border-radius: 6px;
165
+ padding: 10px;
166
+ font-size: 11px;
167
+ color: #4ecca3;
168
+ font-family: monospace;
169
+ max-height: 160px;
170
+ overflow-y: auto;
171
+ margin-top: 6px;
172
+ text-align: left;
173
+ }
174
+
175
+ /* ---- NOTICE ---- */
176
+ .notice {
177
+ background: #0f2040;
178
+ border-left: 3px solid #e94560;
179
+ border-radius: 0 6px 6px 0;
180
+ padding: 10px 14px;
181
+ margin-bottom: 14px;
182
+ font-size: 13px;
183
+ color: #a2a2bd;
184
+ text-align: left;
185
+ }
186
+ .notice strong { color: #e94560; }
187
+ </style>
188
+ </head>
189
+ <body>
190
+ <div class="container">
191
+ <h1>Bio-Vibe 🎧</h1>
192
+ <p class="subtitle">Âm nhạc trị liệu AI theo Nhịp tim</p>
193
+
194
+ <div id="bpm-display">-- BPM</div>
195
+
196
+ <!-- TABS -->
197
+ <div class="tab-group">
198
+ <button class="tab-btn active" onclick="switchTab('ble')">📡 Kết nối BLE</button>
199
+ <button class="tab-btn" onclick="switchTab('manual')">✏️ Nhập thủ công</button>
200
+ </div>
201
+
202
+ <!-- TAB: BLE -->
203
+ <div id="tab-ble" class="tab-panel active">
204
+ <div class="notice">
205
+ <strong>Lưu ý Xiaomi / Samsung:</strong> Đồng hồ thông minh dùng giao thức riêng,
206
+ không truyền nhịp tim qua BLE chuẩn. Thử kết nối — nếu không ra BPM,
207
+ hãy dùng tab <em>Nhập thủ công</em> hoặc một chest-strap HR monitor (Polar, Garmin…).
208
+ </div>
209
+ <div class="button-group">
210
+ <button id="btn-connect">1. Kết nối đồng hồ</button>
211
+ </div>
212
+ </div>
213
+
214
+ <!-- TAB: MANUAL -->
215
+ <div id="tab-manual" class="tab-panel">
216
+ <p style="color:#a2a2bd; font-size:14px; margin-bottom:10px;">
217
+ Nhập BPM từ app đồng hồ (Mi Fitness, Samsung Health…) rồi nhấn Set.
218
+ </p>
219
+ <div class="manual-group">
220
+ <input type="number" id="manual-bpm-input" min="30" max="220" value="75" />
221
+ <button id="btn-manual-set" onclick="setManualBpm()">Set BPM</button>
222
+ </div>
223
+ </div>
224
+
225
+ <!-- PLAYBACK CONTROLS -->
226
+ <div class="button-group" style="margin-top:18px;">
227
+ <button id="btn-generate">2. Sinh nhạc AI</button>
228
+ <button id="btn-stop" disabled>3. Dừng nhạc</button>
229
+ </div>
230
+
231
+ <div id="status" class="status-info">Chưa có BPM...</div>
232
+
233
+ <!-- DEBUG -->
234
+ <div class="debug-section">
235
+ <button class="debug-toggle" onclick="toggleDebug()">▶ Xem log BLE debug</button>
236
+ <div id="debug-log"></div>
237
+ </div>
238
+ </div>
239
+
240
+ <script>
241
+ // ===============================
242
+ // STATE
243
+ // ===============================
244
+ let currentBpm = 0;
245
+ let bpmHistory = [];
246
+ let currentAudio = null;
247
+ let debugVisible = false;
248
+ let ws = null; // Biến giữ kết nối WebSocket
249
+
250
+ const bpmDisplay = document.getElementById('bpm-display');
251
+ const statusEl = document.getElementById('status');
252
+ const btnConnect = document.getElementById('btn-connect');
253
+ const btnGenerate = document.getElementById('btn-generate');
254
+ const btnStop = document.getElementById('btn-stop');
255
+ const debugLog = document.getElementById('debug-log');
256
+
257
+ // ===============================
258
+ // UI HELPERS
259
+ // ===============================
260
+ function setStatus(msg, type = 'info') {
261
+ statusEl.textContent = msg;
262
+ statusEl.className = 'status-' + type;
263
+ }
264
+
265
+ function log(msg) {
266
+ const line = document.createElement('div');
267
+ line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
268
+ debugLog.appendChild(line);
269
+ debugLog.scrollTop = debugLog.scrollHeight;
270
+ console.log(msg);
271
+ }
272
+
273
+ function toggleDebug() {
274
+ debugVisible = !debugVisible;
275
+ debugLog.style.display = debugVisible ? 'block' : 'none';
276
+ }
277
+
278
+ function switchTab(name) {
279
+ document.querySelectorAll('.tab-btn').forEach((b, i) => {
280
+ b.classList.toggle('active', (i === 0 && name === 'ble') || (i === 1 && name === 'manual'));
281
+ });
282
+ document.getElementById('tab-ble').classList.toggle('active', name === 'ble');
283
+ document.getElementById('tab-manual').classList.toggle('active', name === 'manual');
284
+ }
285
+
286
+ // ===============================
287
+ // BPM SMOOTHING
288
+ // ===============================
289
+ function smoothBpm(val) {
290
+ bpmHistory.push(val);
291
+ if (bpmHistory.length > 5) bpmHistory.shift();
292
+ return Math.round(bpmHistory.reduce((a, b) => a + b, 0) / bpmHistory.length);
293
+ }
294
+
295
+ function applyBpm(raw, source) {
296
+ if (raw < 30 || raw > 220) return false;
297
+ if (currentBpm !== 0 && Math.abs(raw - currentBpm) > 40) return false;
298
+
299
+ const smoothed = smoothBpm(raw);
300
+ currentBpm = smoothed;
301
+ bpmDisplay.textContent = currentBpm + ' BPM';
302
+ bpmDisplay.style.color = '#e94560';
303
+ setStatus('BPM nhận từ: ' + source, 'ok');
304
+
305
+ if (ws && ws.readyState === WebSocket.OPEN) {
306
+ ws.send(JSON.stringify({ bpm: currentBpm }));
307
+ }
308
+
309
+ return true;
310
+ }
311
+
312
+ // ===============================
313
+ // MANUAL BPM
314
+ // ===============================
315
+ function setManualBpm() {
316
+ const val = parseInt(document.getElementById('manual-bpm-input').value, 10);
317
+ if (isNaN(val) || val < 30 || val > 220) {
318
+ setStatus('Giá trị BPM không hợp lệ (30–220)', 'err');
319
+ return;
320
+ }
321
+ bpmHistory = [];
322
+ applyBpm(val, 'Nhập thủ công');
323
+ }
324
+
325
+ // ===============================
326
+ // PARSE CHUẨN BLE Heart Rate (0x2A37)
327
+ // ===============================
328
+ function parseHeartRateMeasurement(dataView) {
329
+ /*
330
+ * Byte 0: Flags
331
+ * bit 0 = 0 → Heart Rate Value là UINT8 (byte 1)
332
+ * bit 0 = 1 → Heart Rate Value là UINT16 (bytes 1-2, little-endian)
333
+ */
334
+ const flags = dataView.getUint8(0);
335
+ const is16bit = (flags & 0x01) !== 0;
336
+ return is16bit
337
+ ? dataView.getUint16(1, true)
338
+ : dataView.getUint8(1);
339
+ }
340
+
341
+ // ===============================
342
+ // HANDLE INCOMING CHARACTERISTIC DATA
343
+ // ===============================
344
+ function handleCharData(event, uuid, serviceUuid) {
345
+ const dv = event.target.value;
346
+ const raw = [];
347
+ for (let i = 0; i < dv.byteLength; i++) raw.push(dv.getUint8(i));
348
+ log(`UUID ${uuid.substring(0,8)}… DATA: [${raw.join(', ')}]`);
349
+
350
+ const HR_MEASUREMENT_UUID = '00002a37-0000-1000-8000-00805f9b34fb';
351
+
352
+ // 1. Nếu đúng UUID chuẩn Heart Rate Measurement → parse theo spec
353
+ if (uuid === HR_MEASUREMENT_UUID) {
354
+ if (dv.byteLength >= 2) {
355
+ const bpm = parseHeartRateMeasurement(dv);
356
+ log(`Standard HR parse → ${bpm} BPM`);
357
+ applyBpm(bpm, 'HR chuẩn BLE');
358
+ }
359
+ return;
360
+ }
361
+
362
+ // 2. Fallback heuristic cho proprietary chars
363
+ // Duyệt từng byte, lấy byte đầu tiên nằm trong [40, 180]
364
+ for (let i = 0; i < raw.length; i++) {
365
+ const v = raw[i];
366
+ if (v >= 40 && v <= 180) {
367
+ log(`Heuristic byte[${i}] = ${v} BPM`);
368
+ applyBpm(v, `Heuristic (${uuid.substring(0,8)}…)`);
369
+ break;
370
+ }
371
+ }
372
+ }
373
+
374
+ // ===============================
375
+ // BLE CONNECT auto scan tất cả services
376
+ // ===============================
377
+ btnConnect.addEventListener('click', async () => {
378
+ if (!navigator.bluetooth) {
379
+ setStatus('Trình duyệt không hỗ trợ Web Bluetooth!', 'err');
380
+ return;
381
+ }
382
+
383
+ try {
384
+ setStatus('Đang mở hộp thoại BLE…', 'info');
385
+ log('Bắt đầu scan BLE...');
386
+
387
+ const device = await navigator.bluetooth.requestDevice({
388
+ acceptAllDevices: true,
389
+ optionalServices: [
390
+ // Chuẩn BLE
391
+ 'heart_rate',
392
+ 'battery_service',
393
+ 'device_information',
394
+ 'generic_access',
395
+ // Xiaomi Mi Band 4/5/6/7 — proprietary
396
+ '0000fee0-0000-1000-8000-00805f9b34fb',
397
+ '0000fee1-0000-1000-8000-00805f9b34fb',
398
+ // Xiaomi Mi Band 7 / Amazfit
399
+ '0000181d-0000-1000-8000-00805f9b34fb',
400
+ // Samsung Galaxy Watch — proprietary health
401
+ '0000fd00-0000-1000-8000-00805f9b34fb',
402
+ // Generic HR secondary
403
+ '0000180d-0000-1000-8000-00805f9b34fb',
404
+ ]
405
+ });
406
+
407
+ log(`Device: ${device.name || '(no name)'} id=${device.id}`);
408
+ setStatus('Đang kết nối GATT…', 'info');
409
+
410
+ const server = await device.gatt.connect();
411
+ const services = await server.getPrimaryServices();
412
+ log(`Tìm thấy ${services.length} service(s)`);
413
+
414
+ let subscribed = 0;
415
+
416
+ for (const svc of services) {
417
+ log(`Service: ${svc.uuid}`);
418
+ let chars;
419
+ try {
420
+ chars = await svc.getCharacteristics();
421
+ } catch (e) {
422
+ log(` Không đọc được characteristics: ${e.message}`);
423
+ continue;
424
+ }
425
+
426
+ for (const char of chars) {
427
+ const props = char.properties;
428
+ log(` Char: ${char.uuid} [notify=${props.notify} indicate=${props.indicate} read=${props.read}]`);
429
+
430
+ if (props.notify || props.indicate) {
431
+ try {
432
+ await char.startNotifications();
433
+ char.addEventListener('characteristicvaluechanged',
434
+ (ev) => handleCharData(ev, char.uuid, svc.uuid));
435
+ log(` ✅ Subscribed: ${char.uuid}`);
436
+ subscribed++;
437
+ } catch (e) {
438
+ log(` ❌ Subscribe failed: ${e.message}`);
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ btnConnect.textContent = '✓ BLE Connected';
445
+ btnConnect.disabled = true;
446
+
447
+ if (subscribed === 0) {
448
+ setStatus('Kết nối OK nhưng không có characteristic nào để subscribe. Thử tab Nhập thủ công.', 'err');
449
+ } else {
450
+ setStatus(`Đã subscribe ${subscribed} characteristic(s), đang chờ BPM…`, 'ok');
451
+ }
452
+
453
+ } catch (err) {
454
+ log(`Lỗi: ${err.message}`);
455
+ setStatus('Lỗi BLE: ' + err.message, 'err');
456
+ }
457
+ });
458
+
459
+ // ===============================
460
+ // GENERATE MUSIC
461
+
462
+ const limiter = new Tone.Limiter(-1);
463
+
464
+ // 2. Compressor (giữ âm ổn định)
465
+ const compressor = new Tone.Compressor({
466
+ threshold: -18,
467
+ ratio: 3,
468
+ attack: 0.01,
469
+ release: 0.2
470
+ });
471
+
472
+ // 3. EQ (làm ấm piano)
473
+ const eq = new Tone.EQ3({
474
+ low: 3, // tăng bass nhẹ
475
+ mid: -1, // giảm mid đục
476
+ high: 2 // tăng sáng nhẹ
477
+ });
478
+
479
+ // 4. Stereo widen (cho không gian rộng)
480
+ const stereo = new Tone.StereoWidener(0.6);
481
+
482
+ // 5. Reverb (không gian)
483
+ const reverb = new Tone.Reverb({
484
+ decay: 4,
485
+ wet: 0.3
486
+ });
487
+
488
+ // 6. Delay (lofi feel)
489
+ const delay = new Tone.FeedbackDelay({
490
+ delayTime: "8n",
491
+ feedback: 0.25,
492
+ wet: 0.15
493
+ });
494
+
495
+ // Connect chain
496
+ delay.connect(reverb);
497
+ reverb.connect(stereo);
498
+ stereo.connect(eq);
499
+ eq.connect(compressor);
500
+ compressor.connect(limiter);
501
+ limiter.toDestination();
502
+
503
+ const piano = new Tone.Sampler({
504
+ urls: {
505
+ A1: "A1.mp3",
506
+ C2: "C2.mp3",
507
+ "D#2": "Ds2.mp3",
508
+ "F#2": "Fs2.mp3",
509
+ A2: "A2.mp3",
510
+ C3: "C3.mp3",
511
+ "D#3": "Ds3.mp3",
512
+ "F#3": "Fs3.mp3",
513
+ A3: "A3.mp3",
514
+ C4: "C4.mp3",
515
+ "D#4": "Ds4.mp3",
516
+ "F#4": "Fs4.mp3",
517
+ A4: "A4.mp3",
518
+ C5: "C5.mp3"
519
+ },
520
+ baseUrl: "https://raw.githubusercontent.com/Tonejs/audio/master/salamander/",
521
+ release: 1
522
+ }).connect(delay);
523
+
524
+ // 🌊 PAD (chord)
525
+ const pad = new Tone.PolySynth(Tone.Synth, {
526
+ volume: -12,
527
+ oscillator: { type: "sine" },
528
+ envelope: {
529
+ attack: 1,
530
+ decay: 0.5,
531
+ sustain: 0.8,
532
+ release: 3
533
+ }
534
+ }).connect(reverb);
535
+
536
+ // 🥁 KICK
537
+ const kick = new Tone.MembraneSynth({
538
+ volume: -8,
539
+ pitchDecay: 0.05,
540
+ octaves: 6,
541
+ envelope: {
542
+ attack: 0.001,
543
+ decay: 0.3,
544
+ sustain: 0
545
+ }
546
+ }).connect(compressor);
547
+
548
+ // 🥁 SNARE
549
+ const snare = new Tone.NoiseSynth({
550
+ volume: -9,
551
+ noise: { type: "white" },
552
+ envelope: {
553
+ attack: 0.001,
554
+ decay: 0.15,
555
+ sustain: 0
556
+ }
557
+ }).connect(compressor);
558
+
559
+ // Gọi Tone.start() sẵn khi load trang nếu được
560
+ log("🎹 Đã khởi tạo Lo-fi Synthesizer (Có Limiter chống rè).");
561
+ setStatus("Sẵn sàng sinh nhạc AI", "ok");
562
+
563
+ // ===============================
564
+ // GENERATE MUSIC (CẬP NHẬT WEBSOCKET)
565
+ // ===============================
566
+ btnGenerate.addEventListener('click', async () => {
567
+ if (currentBpm === 0) {
568
+ alert('Chưa có BPM! Kết nối đồng hồ hoặc nhập thủ công.');
569
+ return;
570
+ }
571
+
572
+ // Bắt buộc trình duyệt cho phép phát âm thanh
573
+ await Tone.start();
574
+ await Tone.loaded();
575
+ console.log("✅ Piano samples loaded");
576
+
577
+ if (ws) ws.close(); // Đóng kết nối cũ nếu có
578
+
579
+ setStatus(`Đang kết nối não AI với ${currentBpm} BPM…`, 'info');
580
+ btnGenerate.textContent = 'AI đang truyền nốt…';
581
+ btnGenerate.disabled = true;
582
+ btnStop.disabled = false;
583
+ bpmDisplay.style.animation = 'pulse 1s infinite';
584
+
585
+ // Mở kết nối WebSocket tới Server
586
+ // ĐOẠN CODE MỚI ĐÃ FIX LỖI:
587
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
588
+ const wsUrl = `${protocol}//${window.location.host}/ws/vibe`;
589
+ ws = new WebSocket(wsUrl);
590
+
591
+ ws.onopen = () => {
592
+ setStatus('🎵 Đang phát nhạc Real-time vô tận…', 'ok');
593
+ // Gửi BPM lên server để AI lấy tempo
594
+ ws.send(JSON.stringify({ bpm: currentBpm }));
595
+ };
596
+
597
+ 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ộ
598
+
599
+ ws.onmessage = (event) => {
600
+ const data = JSON.parse(event.data);
601
+
602
+ data.notes.forEach(n => {
603
+ const note = Tone.Frequency(n.note, "midi").toNote();
604
+ const t = startTime + n.time; // 🔥 timeline chuẩn
605
+
606
+ if (n.type === "melody") {
607
+ piano.triggerAttackRelease(note, n.duration, t);
608
+ }
609
+
610
+ else if (n.type === "chord") {
611
+ pad.triggerAttackRelease(note, n.duration, t);
612
+ }
613
+
614
+ else if (n.type === "drum") {
615
+ if (n.note === 36) kick.triggerAttackRelease("C1", "8n", t);
616
+ if (n.note === 38) snare.triggerAttackRelease("16n", t);
617
+ }
618
+ });
619
+
620
+ startTime += data.step * 16; // advance timeline
621
+ };
622
+
623
+ ws.onerror = (err) => {
624
+ log('WebSocket Error: ' + err);
625
+ setStatus('Lỗi kết nối tới AI!', 'err');
626
+ };
627
+
628
+ ws.onclose = () => {
629
+ btnGenerate.textContent = '2. Sinh nhạc AI';
630
+ btnGenerate.disabled = false;
631
+ btnStop.disabled = true;
632
+ bpmDisplay.style.animation = 'none';
633
+ };
634
+ });
635
+
636
+ // ===============================
637
+ // STOP MUSIC (CẬP NHẬT WEBSOCKET)
638
+ // ===============================
639
+ btnStop.addEventListener('click', () => {
640
+ if (ws) {
641
+ ws.close(); // Cắt kết nối, Server sẽ tự động dừng suy luận
642
+ ws = null;
643
+ }
644
+ piano.releaseAll(); // Ngắt ngay lập tức các âm vang còn sót lại
645
+ setStatus('Đã dừng nhạc.', 'info');
646
+ });
647
+ </script>
648
+ </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  </html>