Spaces:
Sleeping
Sleeping
Update index.html
Browse files- 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 |
-
/* ----
|
| 176 |
-
.
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
.
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
.
|
| 280 |
-
|
| 281 |
-
}
|
| 282 |
-
.
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
.
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
.
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
.
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
}
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
const
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
//
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
//
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 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>
|