Spaces:
Sleeping
Sleeping
File size: 159,706 Bytes
0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 11330a4 0547f76 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 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 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 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 | #!/usr/bin/env node
// follower_sim.mjs — offline simulator for the frame-based score follower
// (spec: docs/frame-follower-spec.md §8). Node 22 ESM, no dependencies.
//
// WHY: the follower's numbers cannot be tuned on the live rig (one run per
// minute, no ground truth better than typed keys). The sim synthesises a
// deliberately mismatched piano from a trace's score, adds the accompaniment
// bleed on the true timeline exactly as the app reports it, runs the SAME
// FrameAnalyzer and ScoreFollower the browser runs, and scores commits against
// the true onsets. Every scenario in §8.4 has a pass/fail target; exit code 1
// on any miss.
//
// node scripts/follower_sim.mjs --all
// node scripts/follower_sim.mjs --scenario bleed --seeds 3 --wav /tmp/bleed.wav --out /tmp/bleed.json
// node scripts/follower_sim.mjs --scenario bleed --timeline # per-event: onset, commit, hold reasons
// node scripts/follower_sim.mjs --scenario bleed --synth legacy # the pre-2026-09-15 tone, for audit
// node scripts/follower_sim.mjs --scenario phone-bleed # the mobile case: speaker and mic inches apart
// SIM_DEBUG_MIX=1 node scripts/follower_sim.mjs --scenario phone-bleed # print the mix's measured SER and nonlinear residual
//
// THE TONE THIS SYNTHESISES was measured off the rig on 2026-09-15, not
// guessed: see the RIG block below and scenario table's re-baseline note. Until
// then it was 8 partials with h^-1.3 amplitudes — fundamental-dominant, nothing
// above 784 Hz at a G2 — which is to say it was a picture of the notes the
// engine's shipped 6-partial template already fits, so every attempt to model
// the real rig's upper tail looked like a regression here. `--synth legacy`
// restores that tone exactly (same random draws, same order) so every
// before/after number in the scenario table can be re-checked in one command.
//
// Debug environment (frame-level, for one scenario at a time):
// SIM_DEBUG=t0,t1 print one line per frame between t0 and t1 seconds of audio
// SIM_DEBUG_H=1 add the candidate/committed hypothesis internals (caps, gains, onset score, pitch margin)
// SIM_DEBUG_ELL=1 add the candidate's and committed state's likelihoods and NNLS gains
// SIM_DEBUG_X=1 dump the whitened observation and the best hypothesis' templates
// SIM_DEBUG_RES=1 dump the committed fit, its residual and the observation's top rows
// SIM_DEBUG_BEAM=1 list every live hypothesis (index, weight, evidence, birth, parent) per frame
// SIM_VERBOSE=1 print false/missed lists and hold histograms for passing scenarios too
//
// KNOWN LIMIT, found while building `low-note-weak` and not fixed here. Through
// a laptop speaker a G2 arrives as its partials, and its strongest one is
// 196 Hz — which IS G3. So a plain G3 fits a fundamental-starved G2 template
// almost exactly, while a G2 does NOT fit a G3 template (294 Hz is left over).
// The confusion is one-way, and it is sharp: with a G2 written immediately
// before a G3, the G3's attack spawns a fresh "the G2 starts NOW" hypothesis
// that out-weighs the real one, the committed state's onset clock jumps a beat
// late, and the future guard then refuses the G3 for a whole IOI — measured
// recall 0.05 with the engine tracking the right rhythm five events behind.
// Twinkle never writes that interval (all five of its G2s are followed by B2 or
// C3), so no scenario here contains it; a piece that did would need the engine
// to price the missing 294 Hz, not just tolerate a missing fundamental.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');
const STATIC = path.join(ROOT, 'static');
// Import the very modules the browser loads (query strings are part of the
// specifier in follower.js; Node resolves them as distinct file URLs).
const salience = await import(pathToFileURL(path.join(STATIC, 'salience.js')).href);
const followerMod = await import(pathToFileURL(path.join(STATIC, 'follower.js')).href);
const { FrameAnalyzer } = salience;
const { ScoreFollower, FOLLOWER_DEFAULTS, FOLLOWER_VERSION, normaliseEvents } = followerMod;
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const a = { opt: {}, seeds: 1, seed: 1, scenario: null, all: false, sr: 48000, json: false, quiet: false, dumpSal: false, trackerDebounce: false, reverb: false };
for (let i = 0; i < argv.length; i++) {
const k = argv[i];
const next = () => argv[++i];
switch (k) {
case '--trace': a.trace = next(); break;
case '--score': a.score = next(); break;
case '--scenario': a.scenario = next(); break;
case '--all': a.all = true; break;
case '--seeds': a.seeds = Number(next()); break;
case '--seed': a.seed = Number(next()); break;
case '--bpm': a.bpm = Number(next()); break;
case '--tempo-scale': a.tempoScale = Number(next()); break;
case '--bleed': a.bleed = Number(next()); break;
case '--snr': a.snr = Number(next()); break;
case '--hpf': a.hpf = Number(next()); break;
case '--jitter': a.jitter = Number(next()); break;
case '--rubato': a.rubato = Number(next()); break;
case '--bleed-lag': a.bleedLag = Number(next()); break;
case '--bleed-clip': a.bleedClip = Number(next()); break; // phone-speaker soft clip drive (0 = linear)
case '--sr': a.sr = Number(next()); break;
case '--synth': a.synth = next(); break; // rig (measured, default) | legacy (pre-2026-09-15 8-partial h^-1.3)
case '--opt': { const s = next(); const eq = s.indexOf('='); a.opt[s.slice(0, eq)] = parseOptValue(s.slice(eq + 1)); break; }
case '--wav': a.wav = next(); break;
case '--out': a.out = next(); break;
case '--replay': a.replay = next(); break;
case '--json': a.json = true; break;
case '--dump-sal': a.dumpSal = true; break;
case '--quiet': a.quiet = true; break;
case '--reverb': a.reverb = true; break;
case '--tracker-debounce': a.trackerDebounce = true; break;
case '--timeline': a.timeline = true; break;
case '--help': case '-h': usage(); process.exit(0);
default: console.error('unknown argument ' + k); usage(); process.exit(2);
}
}
if (a.scenario === 'all') { a.all = true; a.scenario = null; }
return a;
}
function parseOptValue(s) {
if (s === 'true') return true;
if (s === 'false') return false;
if (s.startsWith('[') || s.startsWith('{')) return JSON.parse(s);
const n = Number(s);
return Number.isFinite(n) ? n : s;
}
function usage() {
console.log(`node scripts/follower_sim.mjs [--trace PATH] [--score twinkle|repeated] [--scenario NAME|all|--all] [--seeds N] [--seed N]
[--bpm X] [--tempo-scale X] [--bleed G] [--snr DB] [--hpf HZ] [--jitter MS] [--rubato PCT] [--bleed-lag MS] [--bleed-clip G]
[--sr 48000|44100] [--synth rig|legacy] [--opt key=value ...] [--wav OUT.wav] [--out TRACE.json] [--replay SESSION.json]
[--json] [--dump-sal] [--quiet] [--reverb] [--tracker-debounce]`);
}
// ---------------------------------------------------------------------------
// Deterministic randomness
// ---------------------------------------------------------------------------
function makeRng(seed) {
let s = (seed >>> 0) || 1;
const rnd = () => {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
return {
uniform: (lo = 0, hi = 1) => lo + (hi - lo) * rnd(),
normal: (mu = 0, sd = 1) => {
let u = 0; while (u === 0) u = rnd();
return mu + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd());
},
next: rnd,
};
}
// ---------------------------------------------------------------------------
// Scores
// ---------------------------------------------------------------------------
// Twinkle as recorded in .accompy_traces (solo G2–A3 with a chord at the ends,
// accompaniment one octave up — the harmonic-overlap bleed the rig actually has).
const TWINKLE_RH = [[[48],0],[[55],1],[[52],2],[[55],3],[[53],4],[[57],5],[[48],6],[[55],7],[[43],8],[[47],9],[[48],10],[[52],11],[[43],12],[[47],13],[[48,52],14],[[52],16],[[55],17],[[50],18],[[55],19],[[48],20],[[55],21],[[47],22],[[55],23],[[52],24],[[55],25],[[50],26],[[55],27],[[48],28],[[55],29],[[47],30],[[43],31],[[48],32],[[55],33],[[52],34],[[55],35],[[53],36],[[57],37],[[48],38],[[55],39],[[43],40],[[47],41],[[48],42],[[52],43],[[43],44],[[47],45],[[48,52],46]];
const TWINKLE_LH = [[[60],0],[[60],1],[[67],2],[[67],3],[[69],4],[[69],5],[[67],6],[[65],8],[[65],9],[[64],10],[[64],11],[[62],12],[[62],13],[[60],14],[[67],16],[[67],17],[[65],18],[[65],19],[[64],20],[[64],21],[[62],22],[[67],24],[[67],25],[[65],26],[[65],27],[[64],28],[[64],29],[[62],30],[[60],32],[[60],33],[[67],34],[[67],35],[[69],36],[[69],37],[[67],38],[[65],40],[[65],41],[[64],42],[[64],43],[[62],44],[[62],45],[[60],46]];
// Repeats and unison-with-bleed that Twinkle lacks (§8.1).
const REPEATED_RH = [48, 48, 48, 48, 55, 55, 48, 48, 52, 52, 48, 48].map((p, i) => [[p], i, 1]);
const REPEATED_LH = [0, 2, 4, 6, 8, 10].map((b) => [[REPEATED_RH[b][0][0] + 12], b, 2]);
// The index-8/index-12 configuration of .accompy_traces/20260915-081347,
// stretched out. G2 (MIDI 43, 98.00 Hz) is the lowest note of the solo part,
// and every one of its audible partials belongs to something else in the piece:
// 2f0 = 196.0 Hz is G3 (MIDI 55, ITSELF a solo pitch), 3f0 = 294.0 Hz is D4
// (MIDI 62, +2 cents — the same FFT bin at 48 kHz/2048) and 4f0 = 392.0 Hz is
// G4 (MIDI 67, exact); D4 and G4 are the two commonest accompaniment pitches
// here. B2 (123.47 Hz) is the same trap one step up: 3f0 = 370.4 Hz is F#4.
// The accompaniment strikes G4 and D4 under the low notes, including two
// D4+G4 chords, so the bleed component and the solo template compete for the
// same rows — the configuration in which the real session died. Bars 1–2 are
// Twinkle's own opening, which is what the real engine got right (12 clean
// advances in 16.7 s) before it met index 8; from there on half the events are
// G2 or B2.
//
// One ordering is deliberately absent, and it is not a convenience: NO G2 is
// followed by a G3. With the fundamental gone a G2 IS a G3 plus a 294 Hz
// partial, so a G3 fits a starved-G2 template while a G2 does NOT fit a G3
// template (the 294 Hz is unexplained) — the confusion is one-way. Twinkle
// never writes that interval either (its five G2s are followed by B2 or C3
// every time), so a scenario that used it would be testing a figure the
// repertoire does not contain. See the note in the header of this file.
const LOWNOTE_RH = [
48, 55, 52, 55, 53, 57, 48, 55, // Twinkle bar 1: the beam locks on
43, 47, 48, 52, 43, 47, 48, 52, // the real trace's index 8–15
43, 47, 43, 48, 52, 43, 47, 43, // G2-dense, D4/G4 underneath
48, 43, 47, 43,
].map((p, i) => [[p], i, 1]);
const LOWNOTE_LH = [[60], [67], [69], [67], [62], [67], [62], [67], [62, 67], [62], [67], [62], [67], [62, 67]].map((p, i) => [p, i * 2, 2]);
// The note density that broke the engine, taken from the score that broke it:
// .accompy_traces/20260916-113517_cello-concerto-no1-in-c-major-hobviib1-joseph-ha
// (Haydn cello concerto no. 1, 143 solo notes over 120 beats at 60 bpm, recall
// 7 %). `DENSE_PITCHES` and `DENSE_GAPS24` ARE that trace's meta.rh, first 200
// solo events, read out verbatim — not a fast pattern invented here. Two
// properties are taken from the real score together, because it is the pair
// that makes the passage hard and either one alone is easy:
//
// 1. THE SPACING. Gaps in this prefix: p10 0.125 b, p25 0.125 b, MEDIAN
// 0.250 b, p75 0.500 b; 66 of 199 (33 %) are under 0.25 beat. The whole
// excerpt reads p10 0.125 / median 0.250 with 128 of 350 under 0.25 beat
// (37 %), so the prefix carries the same distribution. At bps 1 that is
// 250 ms median and 125 ms in the fastest decile, against an engine whose
// own end-to-end commit latency on the two reference Twinkles is 175-182 ms
// median and 203-212 ms p90. A third of these notes are therefore OVER
// before the evidence for them has been accumulated — by construction, not
// by bad luck. (Both Twinkles' shortest IOI is 985 ms: nothing in the
// existing suite is within 4x of this.)
// 2. THE PITCH AMBIGUITY. 16 distinct pitches over 200 events, MIDI 60-84,
// with 79/74/76/72 carrying most of them. On the full excerpt one pitch
// leaves a median of 37 candidate positions and it takes a 5-gram before
// the median position is unique. A dense score written out of a scale
// would be localisable from one frame and would test the commit rule
// instead of the search, which is the thing under test here.
//
// Gaps longer than 1.5 beats are CLAMPED to 1.5. The real prefix contains a
// 16-beat and a 103-beat tutti; a long rest is the one thing that hands the
// engine a free re-lock, and this scenario is the runaway player. Clamping
// drops 121 beats of silence, so the piece runs 83 beats instead of 204.
const DENSE_PITCHES = [
72, 74, 72, 71, 72, 74, 76, 77, 77, 76, 74, 76, 77, 76, 71, 72, 67, 64, 60, 72,
71, 67, 74, 72, 72, 74, 72, 71, 72, 74, 76, 77, 77, 76, 74, 69, 81, 79, 77, 76,
74, 72, 71, 83, 83, 81, 81, 81, 81, 79, 79, 79, 76, 78, 78, 79, 79, 79, 81, 83,
84, 81, 83, 84, 83, 84, 83, 84, 83, 79, 79, 74, 76, 72, 71, 69, 67, 69, 69, 71,
69, 71, 69, 71, 69, 71, 67, 79, 79, 84, 84, 79, 82, 82, 81, 80, 81, 79, 77, 76,
74, 72, 74, 74, 76, 74, 76, 74, 76, 74, 76, 72, 79, 81, 79, 79, 79, 84, 84, 84,
84, 84, 84, 84, 83, 81, 79, 77, 77, 76, 74, 72, 74, 76, 74, 76, 74, 76, 74, 76,
72, 72, 72, 79, 81, 79, 78, 79, 81, 83, 84, 84, 83, 81, 83, 84, 83, 78, 79, 79,
77, 76, 76, 78, 79, 78, 81, 81, 79, 79, 77, 76, 76, 78, 79, 78, 79, 81, 79, 79,
81, 83, 79, 74, 71, 72, 74, 71, 67, 76, 78, 79, 76, 72, 72, 74, 76, 72, 69, 69,
];
// Twenty-fourths of a beat, so the score's own quantisation survives: 3 = 0.125 b
// (x56), 6 = 0.25 b (x55), 12 = 0.5 b (x33), 24 = 1 b (x17), plus the 4s and 9s
// of the triplet and dotted figures.
const DENSE_GAPS24 = [
18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 18, 3, 3, 12, 12, 12, 12, 24, 12, 12,
24, 12, 12, 24, 18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 6, 6, 12, 6, 6, 6,
6, 24, 36, 12, 36, 12, 36, 12, 36, 12, 12, 12, 30, 6, 6, 6, 30, 6, 6, 6,
30, 6, 6, 6, 24, 24, 24, 24, 6, 6, 6, 6, 6, 6, 6, 6, 18, 6, 3, 3,
3, 3, 3, 3, 3, 3, 36, 36, 36, 6, 24, 6, 6, 6, 6, 6, 6, 6, 6, 6,
6, 18, 6, 3, 3, 3, 3, 3, 3, 3, 3, 12, 24, 24, 12, 24, 30, 6, 6, 6,
30, 6, 6, 6, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
12, 12, 36, 18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 18, 3, 3, 12, 12, 12, 24,
12, 12, 16, 4, 4, 4, 4, 4, 12, 24, 12, 12, 16, 4, 4, 4, 4, 4, 6, 3,
3, 6, 6, 6, 3, 3, 6, 6, 6, 3, 3, 6, 6, 6, 3, 3, 6, 6, 6,
];
const DENSE_TRANSPOSE = -12; // see REGISTER below
const DENSE_RH = DENSE_PITCHES.map((p, i) => {
let b = 0; for (let k = 0; k < i; k++) b += DENSE_GAPS24[k] / 24;
return [[p + DENSE_TRANSPOSE], +b.toFixed(4)];
});
// REGISTER. The solo is transposed DOWN AN OCTAVE and the accompaniment line
// sits above it, which is the one place this scenario departs from the
// recording, so it is stated plainly rather than buried.
//
// The real excerpt writes the solo at MIDI 60-84 over an orchestral reduction
// whose commonest pitches are 72 (x538), 62 (x507), 67 (x477) and 60 (x472) —
// i.e. the accompaniment is mostly BELOW the solo. In the 33..96 bank that
// configuration inverts the partial counts: a MIDI 84 solo note shows TWO
// partials below the bank top (1046 and 2093 Hz, bankTopHz 2154) while a MIDI
// 60 accompaniment note shows eight and a MIDI 55 one shows ten, so the bleed
// component's comb covers every row the solo template owns and a great many
// more. Measured with the accompaniment there: the bleed explained a median
// 0.73 of the frame against the expected solo note's 0.11, `ev` never rose
// above 0 on the committed event, and the scenario read recall 0.10 with the
// accompaniment ON against 0.54 with it OFF. The wide search went with it:
// top-1 error median +4 events, only 6 % within +-1, 25 of 31 scans pointing
// AHEAD of the pianist — which the 1.5-nat margin bar correctly refused every
// single time (31 scans, 31 `ambiguous`), so nothing unsafe happened, but the
// scenario was measuring an inverted-register bleed and not the note density.
//
// That register is ALSO the one part of this bench that has never been
// measured. The RIG profile was taken on Twinkle, where the accompaniment is
// an octave ABOVE the solo, and both other built-in scores keep it there. So
// the choice is between reproducing an unvalidated corner of the synth and
// testing the thing the scenario is named for. It tests the density: the solo
// moves to MIDI 48-72 (Twinkle's own register, where a note has 8-21 visible
// partials) and the accompaniment to a line in 72-79 above it, which is the
// relationship BASE's `bleed` 1.0 was calibrated against. The RHYTHM — the
// only thing under test — is untouched.
//
// What that costs in fidelity is worth naming: with the accompaniment below,
// this scenario reproduces the real session almost exactly (recall 0.10 here
// against 0.07 on the rig). Above, it reads 0.45. The difference is the
// register, not the density, and it is the measurement that says the Haydn's
// remaining 90 % is a BLEED problem in the solo's own register — the same
// conclusion the mic-distance work reached from the other side. Set
// DENSE_TRANSPOSE to 0 and DENSE_LH_LINE back below the solo to re-run it.
const DENSE_LH_LINE = [72, 79, 76, 74, 72, 77, 79, 76, 74, 72, 76, 79, 77, 74, 72, 76];
const DENSE_LH = (() => {
const out = [];
for (let b = 0; b <= Math.floor(DENSE_RH[DENSE_RH.length - 1][1]); b++) out.push([[DENSE_LH_LINE[b % DENSE_LH_LINE.length]], b, 1]);
return out;
})();
function loadScore(args) {
if (args.score === 'repeated') return { name: 'repeated', rh: REPEATED_RH, lh: REPEATED_LH, bps: 1 };
if (args.score === 'lownote') return { name: 'lownote', rh: LOWNOTE_RH, lh: LOWNOTE_LH, bps: 1 };
if (args.score === 'dense') return { name: 'dense', rh: DENSE_RH, lh: DENSE_LH, bps: 1 };
const tracePath = args.trace || path.join(ROOT, '.accompy_traces', 'latest.json');
try {
const tr = JSON.parse(fs.readFileSync(tracePath, 'utf8'));
const m = tr.meta || {};
if (Array.isArray(m.rh) && m.rh.length) return { name: m.score || path.basename(tracePath), rh: m.rh, lh: Array.isArray(m.lh) ? m.lh : [], bps: m.initialBps || 1 };
} catch (e) { /* fall through to the built-in copy */ }
return { name: 'twinkle (built-in)', rh: TWINKLE_RH, lh: TWINKLE_LH, bps: 1 };
}
// ---------------------------------------------------------------------------
// Synthesis (§8.2) — the measured rig, with `--synth legacy` as the fallback
// ---------------------------------------------------------------------------
const midiToFreq = (m) => 440 * 2 ** ((m - 69) / 12);
// ---------------------------------------------------------------------------
// THE MEASURED RIG (§8.2). One block: edit this and nothing else when the rig
// is re-measured.
// ---------------------------------------------------------------------------
// SOURCE: .accompy_traces/20260915-083040_twinkle-twinkle-little-star.json.
// Every verbose follow.frame's quantised `sal` was decoded with the engine's
// own dequantiseSal and re-whitened with the follower's own noise-floor
// parameters as recorded in that trace's follower.init (nfMargin 2, nfDown 0.3,
// nfUp 1.002, floorInitFrames 20, nfSmoothBins 4, compress 0.5); each typed
// input.key gave a (post − pre) mean-POWER spectrum — sustain +160..+340 ms,
// attack +40..+140 ms, pre-roll −350..−100 ms — which was deconvolved against
// the analyser's own toneResponse() columns by NNLS (one column per partial,
// plus free columns for any accompaniment pitch ringing in the span and for the
// sub-fundamental bins). 43 presses with no other key within ±400 ms: MIDI 43
// ×16 (12 with no accompaniment ringing at all), 55 ×9, 48 ×5, 52 ×5, 47 ×3,
// 50 ×2, 41/53/57 ×1. NNLS R² 0.983–0.9997. Profile script and the full JSON
// (per-pitch a(h) with p25/p75, the noise floor, the overlap tables):
// scratchpad/partial_profile5.mjs → scratchpad/rig_partial_profile.json
//
// Everything below is in the RAW (post − pre) power domain, never the whitened
// one. Whitening divides by the min-statistics floor, which on this session is
// +1.88 dB at row 43 and −13.22 dB at row 89 — a ~15 dB tilt that belongs to
// the mic and the analyser, not to the note. Partials fitted to whitened
// numbers would apply that tilt twice; the sim's own pink noise reproduces it
// (measured floor −3.94 dB/octave in this constant-Q bank against pink's −3.0,
// i.e. right to within ~1 dB — the floor is NOT what the bench got wrong).
//
// WHAT THE OLD SYNTH GOT WRONG — three independent things, and it is worth
// naming them because five attempts at modelling the real rig's upper tail were
// reverted for "regressing" scenarios that could not contain the phenomenon:
//
// 1. TRUNCATION. `for (let h = 1; h <= 8; h++)` stopped every pitch at 8
// partials, so a synthetic G2 had NOTHING above 784 Hz. The real G2 is a
// near-flat-topped comb running to the top of the 33..96 bank, sitting at
// −7 to −13 dB (re its strongest partial) all the way to h = 20 (1960 Hz,
// row 95). h^-1.3 puts h8 at −11.7 dB and h9+ at −∞; the rig is 20–40 dB
// louder than that over half the bank. The deep raw NULLS in between
// (MIDI 43 h8 at −34.2 dB with h7 at −10.4 and h10 at −7.0) prove this is
// LINE structure, not a broadband hiss a noise floor could stand in for.
// 2. NO PLAYBACK RESPONSE. The exponent was never the problem: alternating
// least squares on L(pitch,h) = C_pitch − 20·alpha·log10(h) + R(f) over all
// nine measured pitches gives alpha = 1.14 (rmse 5.12 dB) against 8.20 dB
// for the same fit with no R, and the per-pitch exponents then agree with
// each other (0.92–1.30). One source law plus ONE rig curve explains every
// pitch; there is no pitch-specific tone. R rises ~22 dB from 110 Hz to
// 1.8 kHz — that rise is what creates the tail, and the old synth had no
// counterpart to it at all.
// 3. THE WRONG LOW END. speakerHpf 180 / speakerOrder 4 takes 21.2 dB off a
// 98 Hz fundamental. Measured, the rig takes 12–14 dB off 98–139 Hz
// relative to its 440–880 Hz plateau — roughly 1st-order with a corner near
// 200–250 Hz, and a TILT rather than a corner. The laptop mic shares a
// chassis with the laptop speaker, so the fundamental arrives
// structure-borne: 1.8 dB (row readout) / 5.2 dB (deconvolved) below the
// strongest partial in a G2's sustain. What buries it in the ENGINE's
// domain is the noise floor's tilt, not the speaker.
//
// Cross-check of the whole chain against numbers already recorded in
// FOLLOWER_DEFAULTS for the real G2 at 15.657 s — expected whitened peak row 83,
// row94 0.70, row95 0.66, row74 0.70, row81 0.53, rows40–47 0.09; the profile
// pipeline reproduces 83 / 0.682 / 0.608 / 0.613 / 0.510 / 0.094.
const RIG = {
// H(m) = floor(bankTopHz / f0): every partial the 33..96 filterbank can see.
// 2154 Hz = midiToFreq(96)·2^(1/24), the top row's upper edge. 24 partials at
// MIDI 41, 21 at G2 (43), 16 at C3, 10 at G3, 8 at C4, 4 at C5.
bankTopHz: 2154,
// Source law a_src(h) = h^-alpha. Measured 1.14 over nine pitches; 1.10 is
// the value the per-pitch medians centre on once R is removed (41:0.92,
// 43:1.08, 47:1.06, 48:1.10, 50:1.22, 52:1.10, 53:1.58, 55:1.12, 57:1.30).
alpha: 1.10,
// Playback response R(f), 1/3-octave bands, dB, with the press count each
// band was averaged over. +8.5 dB resonance at 349 Hz, −4.6 dB dip at 698 Hz,
// +7..+9 dB above 1.1 kHz. Interpolated in log f, clamped to the end values.
// CAVEAT, stated rather than buried: the f0 span of the measurement is only
// 1.3 octaves, so the alpha/R split is under-determined — treat R as a
// measured curve of this laptop, not as physics.
responseDb: [
[110, -13.56], [139, -12.17], [175, -6.23], [220, -1.63], [277, 3.94],
[349, 8.52], [440, 0.11], [554, -0.34], [698, -4.56], [880, -2.48],
[1109, 3.78], [1397, 7.06], [1760, 9.30], [2217, 8.26],
],
// Every partial except the fundamental repeats press to press: the measured
// p75−p25 per partial has a median of 1.9 dB at MIDI 43 (16 presses) and
// 0.6–1.3 dB at 48/52/55, so each note draws an independent ±2 dB ripple on
// h ≥ 2 (uniform(−2,2) has an inter-quartile range of 2 dB).
partialRippleDb: 2,
// ...the fundamental alone is stochastic, and only at the bottom of the
// range. Measured p75−p25 of a(1): MIDI 43 (98 Hz, 16 presses) 17.0 dB,
// 52 (165 Hz, 5) 25.4 dB, 48 (131 Hz, 5) 7.6 dB, 47 (123 Hz, 3) 6.3 dB,
// against 0.0 dB at MIDI 55 (196 Hz) over NINE presses, where a(1) is the
// strongest partial every single time. So below `fundamentalSpreadBelowHz`
// the fundamental is drawn with MIDI 43's own quartiles (median −5.2 dB re
// the strongest partial, p25 −18.8, p75 −1.8 → σ = 20.2 dB below the median
// and 5.0 dB above it) and above it the law's own a(1) stands. "The note
// that loses its fundamental" is a per-PRESS event, not a property of the
// pitch, and no scenario produced one before this.
// These are MIDI 43's own quartiles, which is what the profile recommends;
// pooling all 31 presses below 180 Hz (43, 47, 48, 50, 52) weighted by press
// count gives median −8.9 / p25 −20.9 / p75 −6.1 instead, so the choice moves
// the fundamental by under 4 dB and is not load-bearing. Below p25 the normal
// tail is an extrapolation — `min` bounds it at −45 dB, and MIDI 52's
// measured p25 of −39.6 dB says collapses that deep do happen.
fundamentalDb: { median: -5.2, sdLow: 20.2, sdHigh: 5.0, max: 2, min: -45 },
fundamentalSpreadBelowHz: 180,
// The profile above is a SUSTAIN profile, measured +160..+340 ms after the
// input.key EVENT — and the rig's speaker→mic loop is ~110 ms (the app's own
// measured median on this session, see low-note-lag-over below), so in NOTE
// time that window is +50..+230 ms and its centre is 140 ms. Partial
// amplitudes are referred to t = profileSec, so a rendered note matches the
// measured a(h) in the window a(h) was measured in rather than at t = 0.
// This also reproduces, for free, the measured "partials above h = 15 are
// 5–15 dB louder in the attack than in the sustain" (MIDI 43 h20: rawAttDb
// −6.45 against rawSusDb −17.22).
profileSec: 0.14,
// The player types on the LAPTOP keyboard, a few centimetres from the mic:
// 28 % of the power a G2 press adds in the attack window sits BELOW the
// fundamental, falling to 7 % by the sustain. The old hammer burst was
// band-passed 1–5 kHz and had no low thump at all.
thumpShareAttack: 0.28,
thumpHz: 100,
thumpTauSec: 0.035,
thumpWindowSec: 0.14,
// Inharmonicity B in fh = h·f0·√(1 + B·h²). The old 4e-4 was a piano STRING
// and it was harmless while h stopped at 8 (+1.3 % at h8); at h = 20 it is
// +6.5 %, more than a semitone, so every partial above ~h12 landed in the
// wrong filterbank row. The rig's solo voice is the app's own digital synth
// through a speaker: measured, MIDI 43's partials sit on their harmonic rows
// out to h = 21 (h10 → row 83, h14 → row 89, h20 → row 95). 2e-5 keeps a
// touch of stretch (+7 cents at h20, +10 at h24) without moving rows.
inharmonicity: 2e-5,
};
// The pre-2026-09-15 synth, kept so every number in this file stays auditable:
// `--synth legacy` (or `synth: 'legacy'` in a scenario mut) restores it exactly
// — 8 partials, h^-1.3, no playback response, no fundamental spread, no thump.
// It is fundamental-dominant by construction (h8 at −11.7 dB, nothing above),
// which is to say it is a picture of the notes the engine's shipped 6-partial
// template already fits well. Do not baseline against it.
const LEGACY_SYNTH = { partials: 8, alpha: 1.3, inharmonicity: 4e-4 };
// R(f) in dB, log-f interpolation between the measured 1/3-octave bands.
function rigResponseDb(f) {
const B = RIG.responseDb;
if (!(f > B[0][0])) return B[0][1];
if (f >= B[B.length - 1][0]) return B[B.length - 1][1];
for (let i = 1; i < B.length; i++) {
if (f <= B[i][0]) {
const k = Math.log(f / B[i - 1][0]) / Math.log(B[i][0] / B[i - 1][0]);
return B[i - 1][1] + k * (B[i][1] - B[i - 1][1]);
}
}
return B[B.length - 1][1];
}
// Two-sided normal: the fundamental's press-to-press spread is strongly skewed
// (p25 −18.8, median −5.2, p75 −1.8), so one σ per side, both from the quartiles.
function splitNormalDb(rng, { median, sdLow, sdHigh, max, min }) {
const z = rng.normal(0, 1);
return Math.min(max, Math.max(min, median + z * (z < 0 ? sdLow : sdHigh)));
}
// Piano-like additive tone, deliberately NOT the engine's template. Under the
// default `rig` model (see RIG above) it is the laptop speaker + laptop mic
// measured on 20260915-083040: H(m) = floor(2154/f0) partials, a(h) =
// h^-1.10 · R(h·f0) referred to the sustain window, ±3 dB of per-press ripple
// on h ≥ 2, a fundamental drawn with its own 17 dB spread, inharmonicity,
// per-partial decays with random spread, a hammer burst, a keyboard thump
// (solo only — the accompaniment is not typed) and a release tail. Amplitudes
// are normalised so `gain` still means "the strongest partial's amplitude",
// exactly as it did when the strongest partial was always h = 1.
// `legacy: true` restores the old 8-partial h^-1.3 tone. Renders into `mix` at
// `t0` seconds.
function renderNote(mix, sr, rng, { midi, t0, noteOffSec, gain, detuneCents = 0, legacy = false, thumpShare = 0 }) {
const f0 = midiToFreq(midi) * 2 ** (detuneCents / 1200);
const start = Math.max(0, Math.round(t0 * sr));
const relSec = 0.15;
const endSec = noteOffSec + relSec + 0.05;
const nSamp = Math.min(mix.length - start, Math.ceil(endSec * sr));
if (nSamp <= 0) return;
const hMax = legacy ? LEGACY_SYNTH.partials : Math.max(1, Math.floor(RIG.bankTopHz / f0));
const alpha = legacy ? LEGACY_SYNTH.alpha : RIG.alpha;
const B = legacy ? LEGACY_SYNTH.inharmonicity : RIG.inharmonicity;
const parts = [];
for (let h = 1; h <= hMax; h++) {
const fh = h * f0 * Math.sqrt(1 + B * h * h);
if (fh >= sr / 2) break;
const tau = 0.9 * (200 / fh) ** 0.35 * rng.uniform(0.8, 1.2);
// `sus` is the decay already suffered by t = profileSec: dividing it out
// makes the SUSTAIN spectrum equal the measured a(h) instead of the t = 0
// one, which also reproduces the measured "h ≥ 15 is 5–15 dB louder in the
// attack than in the sustain" for free.
const sus = legacy ? 1 : Math.exp(-RIG.profileSec / tau);
let amp = h ** -alpha;
if (!legacy) amp *= 10 ** ((rigResponseDb(fh) + (h === 1 ? 0 : rng.uniform(-RIG.partialRippleDb, RIG.partialRippleDb))) / 20) / sus;
parts.push({ amp, sus, w: 2 * Math.PI * fh / sr, tau, ph: rng.uniform(0, 2 * Math.PI) });
}
if (!parts.length) return;
if (!legacy) {
// The fundamental is drawn against the strongest of the OTHER partials,
// which is where it was measured from; for a G2 that strongest partial is
// h3 (294 Hz, on the rig's 349 Hz resonance), never h1.
let peakSus = 0;
for (let i = 1; i < parts.length; i++) peakSus = Math.max(peakSus, parts[i].amp * parts[i].sus);
if (peakSus > 0 && f0 < RIG.fundamentalSpreadBelowHz) parts[0].amp = peakSus * 10 ** (splitNormalDb(rng, RIG.fundamentalDb) / 20) / parts[0].sus;
// `gain` keeps meaning "amplitude of the strongest partial in the sustain".
let peak = 0;
for (const p of parts) peak = Math.max(peak, p.amp * p.sus);
if (peak > 0) for (const p of parts) p.amp /= peak;
}
const attackN = Math.max(1, Math.round(0.004 * sr));
const offN = Math.round(noteOffSec * sr);
const relN = Math.round(relSec * sr);
for (let i = 0; i < nSamp; i++) {
const t = i / sr;
let env = i < attackN ? i / attackN : 1;
if (i >= offN) { const k = (i - offN) / relN; if (k >= 1) break; env *= 1 - k; }
let v = 0;
for (const p of parts) v += p.amp * Math.exp(-t / p.tau) * Math.sin(p.w * i + p.ph);
mix[start + i] += gain * env * v;
}
// Hammer: 15 ms of band-passed (1–5 kHz) noise at −20 dB relative to the tone.
const hN = Math.round(0.015 * sr);
const bp = biquadBandpass(sr, 1000, 5000);
for (let i = 0; i < hN && start + i < mix.length; i++) {
const n = bp.process(rng.normal(0, 1)) * 0.3;
mix[start + i] += gain * 0.1 * n * (1 - i / hN);
}
// Keyboard thump — the one part of a press that is not a note. Measured: 28 %
// of the power a G2 press adds over the attack window sits below f0. Scaled
// against this note's own tonal power over the same window, so the share is
// what was measured rather than an absolute level.
if (thumpShare > 0 && thumpShare < 1) {
const tN = Math.min(mix.length - start, Math.round(RIG.thumpWindowSec * sr));
if (tN > 0) {
let msTone = 0;
for (const p of parts) { // mean square of Σ amp·e^{−t/τ}·sin(...) over the window
const r = Math.exp(-2 / (p.tau * sr));
msTone += 0.5 * p.amp * p.amp * (r === 1 ? tN : (1 - r ** tN) / (1 - r)) / tN;
}
const lp1 = biquadLowpass(sr, RIG.thumpHz), lp2 = biquadLowpass(sr, RIG.thumpHz);
const buf = new Float64Array(tN);
let msBuf = 0;
for (let i = 0; i < tN; i++) { buf[i] = lp2.process(lp1.process(rng.normal(0, 1))) * Math.exp(-(i / sr) / RIG.thumpTauSec); msBuf += buf[i] * buf[i]; }
msBuf /= tN;
if (msBuf > 0 && msTone > 0) {
const g = gain * Math.sqrt(thumpShare / (1 - thumpShare) * msTone / msBuf);
for (let i = 0; i < tN; i++) mix[start + i] += g * buf[i];
}
}
}
}
// Second-order sections. Butterworth high-pass (Q = 1/√2) for the speaker/mic
// model; a wide band-pass for the hammer burst.
function biquad(b0, b1, b2, a1, a2) {
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
return { process(x) { const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y; } };
}
function biquadHighpass(sr, fc, Q = Math.SQRT1_2) {
const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
const a0 = 1 + al;
return biquad((1 + c) / 2 / a0, -(1 + c) / a0, (1 + c) / 2 / a0, -2 * c / a0, (1 - al) / a0);
}
// A laptop speaker, not a microphone: almost nothing below ~150–200 Hz reaches
// the room. Two cascaded Butterworth sections roll off as (f/fc)^4, so at
// fc = 180 Hz a G2 loses 21.2 dB of its 98 Hz fundamental and 0.3 dB of its
// 196 Hz second partial — the physics behind the index-12 stall in
// 20260915-081347, where the lowest solo note reached the mic as its harmonics
// and nothing else. `hpf` (the mic's own high-pass) stays separate: this one
// sits in the PLAYBACK path, so it shapes the solo and the accompaniment
// together and leaves the room noise alone.
function speakerChain(sr, fc, order) {
const secs = [];
for (let i = 0; i < Math.max(1, Math.round(order / 2)); i++) secs.push(biquadHighpass(sr, fc));
return { process(x) { for (const s of secs) x = s.process(x); return x; } };
}
// Two of these in series make the laptop-chassis keyboard thump: a soft
// low-pass at 100 Hz, i.e. below every solo fundamental in the repertoire.
function biquadLowpass(sr, fc, Q = Math.SQRT1_2) {
const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
const a0 = 1 + al;
return biquad((1 - c) / 2 / a0, (1 - c) / a0, (1 - c) / 2 / a0, -2 * c / a0, (1 - al) / a0);
}
function biquadBandpass(sr, fLo, fHi) {
const fc = Math.sqrt(fLo * fHi), Q = fc / (fHi - fLo);
const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
const a0 = 1 + al;
return biquad(al / a0, 0, -al / a0, -2 * c / a0, (1 - al) / a0);
}
// Pink noise, Voss–McCartney with 16 rows.
function pinkNoise(n, rng) {
const rows = 16, vals = new Float64Array(rows);
let sum = 0;
for (let r = 0; r < rows; r++) { vals[r] = rng.normal(); sum += vals[r]; }
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
// Row k updates every 2^k samples (trailing-zero count of the counter).
let k = 0, c = i + 1;
while ((c & 1) === 0 && k < rows - 1) { c >>= 1; k++; }
sum -= vals[k]; vals[k] = rng.normal(); sum += vals[k];
out[i] = (sum + rng.normal()) / Math.sqrt(rows + 1);
}
return out;
}
function rmsOf(buf) { let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i]; return Math.sqrt(s / buf.length); }
// Build the performance: true onset times, which events are played/wrong/
// skipped, the accompaniment fire list, and the audio mix.
function synthesise(score, cfg, rng) {
const sr = cfg.sr;
const bps = cfg.bps * cfg.tempoScale;
const rubato = cfg.rubato / 100;
// Rubato is a ±rubato TEMPO modulation over a 16-beat cycle, integrated so
// time stays monotonic: dT/dbeat = (1 − rubato·sin(2π·beat/16)) / bps.
const A = rubato * 16 / (2 * Math.PI);
const timeOfBeat = (beat) => (beat - A * (1 - Math.cos(2 * Math.PI * beat / 16))) / bps;
const tempoAtBeat = (beat) => bps / (1 - rubato * Math.sin(2 * Math.PI * beat / 16));
const solo = normaliseEvents(score.rh);
const acc = normaliseEvents(score.lh);
// Which tone model renders every note below (see RIG / LEGACY_SYNTH above).
const legacy = cfg.synth === 'legacy';
const thumpShare = legacy ? 0 : RIG.thumpShareAttack;
const preRoll = 1.0; // the mic is open before Play: the floors must learn on noise alone
// Session started at a later event (startAt): the prefix is never played
// and the accompaniment starts from that beat, as in the app. The engine is
// seeked there startLead × IOI before the first note (see runEngine).
const startAt = Math.min(Math.max(0, cfg.startAt || 0), solo.length - 1);
const startBeat = startAt > 0 ? solo[startAt].beat : -Infinity;
// A run of notes that are in the piece nowhere: cfg.wrongInsert = [index, n]
// puts n intruders in front of event `index` and pushes the rest of the piece
// — the solo AND the ideal accompanist with it — n beats later. The pitches
// miss every pitch in the score and every pitch a partial of one would land
// on, so the WRONG absorber is the only thing that can explain those frames
// and takes the mass legitimately: the state the real session entered at
// 17.5 s and never left.
//
// They are INSERTED, not substituted, so that when the pianist recovers he is
// playing the very event the engine is already waiting for. Substituting
// score events would also demand a four-event skip on the way back, which is
// what `skipped-note` measures; this scenario is about whether the beam can
// come back at all once the absorber owns the posterior.
const inPiece = new Set();
for (const e of solo) for (const p of e.pitches) for (const d of [0, 12, 19, 24, 28]) inPiece.add(p + d);
for (const e of acc) for (const p of e.pitches) for (const d of [0, 12, 19]) inPiece.add(p + d);
// The nearest pitch to `p` that the piece does not contain — and that the
// engine can actually TELL is not in the piece. Below pitchMinMidi (54) an
// off-score pitch is not off-score to the follower: at 48 kHz/2048 rows 40–44
// are all bin 4, so an A2 played where a G2 is written is the same
// observation and an intruder there would be asking the engine to hear a
// difference that is not in the signal (it cost 18 early advances when this
// scenario first put its intruders at 42/45/46).
const offScore = (p) => {
const q = Math.min(84, Math.max(54, Math.round(p)));
for (let d = 0; d <= 24; d++) for (const s of d ? [1, -1] : [1]) { const m = q + d * s; if (m >= 54 && m <= 84 && !inPiece.has(m)) return m; }
return q;
};
const ins = cfg.wrongInsert || null;
const insBeat = ins ? solo[Math.min(ins[0], solo.length - 1)].beat : Infinity;
const insDelay = ins ? ins[1] / bps : 0;
const shift = (beat) => (beat >= insBeat ? insDelay : 0);
const events = solo.map((e, k) => {
const ioi = k + 1 < solo.length ? timeOfBeat(solo[k + 1].beat) - timeOfBeat(e.beat) : (e.durBeats ?? 1) / bps;
const T = preRoll + timeOfBeat(e.beat) + shift(e.beat) + rng.normal(0, cfg.jitter / 1000);
let kind = 'played', pitches = e.pitches.slice();
if (cfg.wrongEvery && (k + 1) % cfg.wrongEvery === 0 && k > 0) {
kind = 'wrong';
// A fumble must not land on a pitch the score writes NEXT DOOR. Twinkle
// writes B2 at index 9 and C3 at index 10, so the old `p + 1` played
// index 10's own note one beat early and then scored the engine's
// (correct) recognition of it as an early advance. Under the
// fundamental-dominant synth that never bit — a B2 and a C3 arrived as
// nearly the same observation. Under the measured 21-partial comb the
// engine can tell them apart, and the un-guarded mutation cost 1 early
// advance and 12 false ones (recall 1.00 → 0.22) the moment the synth
// became real. `wrongInsert`'s offScore() has carried exactly this guard
// since it was written, for exactly this reason; this is the same rule.
const near = new Set();
for (let d = -1; d <= 2; d++) { const e2 = solo[k + d]; if (e2) for (const p of e2.pitches) near.add(p); }
const order = rng.next() < 0.5 ? [1, 2, -1, -2, 3, -3] : [2, 1, -2, -1, 3, -3];
pitches = pitches.map((p) => { for (const d of order) if (!near.has(p + d)) return p + d; return p + order[0]; });
}
if (cfg.skipEvery && (k + 1) % cfg.skipEvery === 0 && k > 0) { kind = 'skipped'; }
if (cfg.noPlay || k < startAt) kind = 'silent';
const restrike = cfg.doubleStrike && kind === 'played' && rng.next() < 0.25;
const durSec = Math.min(0.95 * ioi, (e.durBeats ?? ioi * bps) / bps);
return { k, beat: e.beat, T, ioi, kind, pitches, expected: e.pitches, durSec, restrike, tempo: tempoAtBeat(e.beat) };
});
const lastEnd = Math.max(...events.map((e) => e.T + e.durSec)) + 1.5;
const n = Math.ceil(lastEnd * sr);
const soloBuf = new Float32Array(n);
for (const e of events) {
if (e.kind === 'skipped' || e.kind === 'silent') continue;
const vel = 0.6 * rng.uniform(0.7, 1.4);
for (const p of e.pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: e.T, noteOffSec: e.durSec, gain: vel, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
if (e.restrike) for (const p of e.pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: e.T + 0.09, noteOffSec: Math.max(0.05, e.durSec - 0.09), gain: vel * 0.8, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
}
// The intruders, in the n beats the rest of the piece was pushed back by.
const intruders = [];
if (ins) {
const base = solo[Math.min(ins[0], solo.length - 1)];
for (let j = 0; j < ins[1]; j++) {
const T = preRoll + timeOfBeat(base.beat) + j / bps + rng.normal(0, cfg.jitter / 1000);
// A fumble in the pianist's own register, not down where the filterbank
// gives up: these must be audibly, resolvably not the written note.
const pitches = [offScore(58 + [0, 7, -3, 11, 3, 8][j % 6])];
const durSec = 0.95 / bps;
intruders.push({ T, pitches, durSec });
const vel = 0.6 * rng.uniform(0.7, 1.4);
for (const p of pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: T, noteOffSec: durSec, gain: vel, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
}
}
// Accompaniment on the true solo timeline of its beat (an ideal accompanist),
// audible bleedLag later; the fire is reported at fireMs, before the loop.
const accBuf = new Float32Array(n);
const fires = [];
for (let k = 0; k < acc.length; k++) {
const a = acc[k];
if (a.beat < startBeat) continue;
const nextBeat = k + 1 < acc.length ? acc[k + 1].beat : a.beat + (a.durBeats ?? 1);
const durSec = (a.durBeats ?? (nextBeat - a.beat)) / bps;
const fireSec = preRoll + timeOfBeat(a.beat) + shift(a.beat);
fires.push({ pitches: a.pitches, fireSec, durSec, beat: a.beat });
// Same speaker, therefore the same R(f) and the same partial-count rule;
// no keyboard thump, because the accompanist is not typing. Level: measured
// median −5.0 dB re the solo note it covers (range −2.2 to −27.3 over 22
// presses), which BASE's bleed 1.0 against the solo's 0.6·uniform(0.7,1.4)
// already brackets — the bleed path is the one part of this bench that
// matched the rig, and it is unchanged.
if (cfg.bleed > 0) for (const p of a.pitches) renderNote(accBuf, sr, rng, { midi: p, t0: fireSec + cfg.bleedLag / 1000, noteOffSec: durSec, gain: 0.6 * cfg.bleed, detuneCents: rng.uniform(-3, 3), legacy });
}
// PHONE SPEAKER NONLINEARITY (`bleedClip`, off unless a scenario asks).
// A memoryless sigmoidal saturation on the accompaniment ONLY — the solo
// reaches the mic through the air from a real piano and never goes through
// our loudspeaker. This is the Hammerstein front end the AEC literature uses
// for hand-held devices (Hofmann et al., EURASIP JASP 2016:113: "increasingly
// common nonlinear distortions produced by miniaturized amplifiers and
// loudspeakers in modern portable devices"), and it is exactly what the
// ICASSP AEC Challenge applies to 80 % of its far-end signals: "clipping the
// maximum amplitude, using a sigmoidal function ... or applying learned
// distortion functions" (Cutler et al., arXiv:2009.04972). Peak-preserving so
// `bleed` stays the level knob and `bleedClip` is purely the drive:
// y = A·tanh(g·x/A)/tanh(g), A = peak|accBuf|, g = cfg.bleedClip
// g = 0 is the identity (bit-exact skip, so every existing scenario is
// untouched). The residual it injects is measured and printed by
// SIM_DEBUG_MIX=1 as `nl` — see the phone-bleed comment for the number.
let nlResidDb = null;
if (cfg.bleedClip > 0) {
let A = 0; for (let i = 0; i < n; i++) A = Math.max(A, Math.abs(accBuf[i]));
if (A > 0) {
const g = cfg.bleedClip;
// Two passes: shape, then restore the RMS the linear signal had, so
// `bleed` stays the only level knob and `bleedClip` is pure drive.
let l2 = 0, y2 = 0;
for (let i = 0; i < n; i++) { const x = accBuf[i], y = Math.tanh(g * x / A); l2 += x * x; y2 += y * y; }
const k = y2 > 0 ? Math.sqrt(l2 / y2) : 1;
let d2 = 0;
for (let i = 0; i < n; i++) {
const x = accBuf[i], y = k * Math.tanh(g * x / A);
d2 += (y - x) * (y - x);
accBuf[i] = y;
}
nlResidDb = l2 > 0 ? 10 * Math.log10(d2 / l2) : null;
}
}
// The playback path. Under the default `rig` synth the measured response
// R(f) is ALREADY in every partial's amplitude, so this chain is off unless a
// scenario asks for it by name: with speakerHpf set it is the explicit
// `speaker-dead-low` variant — a laptop whose low end is deader than the one
// that was measured — and NOT the model of this rig (see RIG note 3). Under
// `--synth legacy` it is the only low-end model there is. Solo and
// accompaniment leave the SAME speaker, so it applies to both (every
// accompaniment pitch here is ≥ 261 Hz, so in practice it only bites the low
// solo notes). Filtered before the mix so the --snr noise is still measured
// against the solo level the mic actually hears; the room noise itself
// reaches the mic directly and is not filtered.
if (cfg.speakerHpf > 0) {
const spS = speakerChain(sr, cfg.speakerHpf, cfg.speakerOrder ?? 4), spA = speakerChain(sr, cfg.speakerHpf, cfg.speakerOrder ?? 4);
for (let i = 0; i < n; i++) { soloBuf[i] = spS.process(soloBuf[i]); accBuf[i] = spA.process(accBuf[i]); }
}
const mix = new Float32Array(n);
for (let i = 0; i < n; i++) mix[i] = soloBuf[i] + accBuf[i];
// SIGNAL-TO-ECHO RATIO, measured rather than asserted. The AEC literature's
// mixing parameter is the SER over DOUBLE-TALK frames only (ICASSP AEC
// Challenge: "a signal to echo ratio uniformly sampled from -10 dB to 10 dB"),
// not over the whole file — the accompaniment's duty cycle is far higher than
// the solo's, so a whole-file ratio would flatter it. Measured here over
// 512-sample hops in which BOTH sources are audibly present, as
// 20·log10(median soloRms / median accRms). Reported so every scenario's
// `bleed` gain can be quoted in the field's own units.
let serDb = null;
{
const H = 512, sHop = [], aHop = [];
for (let o = 0; o + H <= n; o += H) {
let ss = 0, aa = 0;
for (let i = o; i < o + H; i++) { ss += soloBuf[i] * soloBuf[i]; aa += accBuf[i] * accBuf[i]; }
sHop.push(Math.sqrt(ss / H)); aHop.push(Math.sqrt(aa / H));
}
const p90 = (v) => { const q = v.slice().sort((x, y) => x - y); return q.length ? q[Math.min(q.length - 1, Math.floor(0.9 * q.length))] : 0; };
const sOn = 0.1 * p90(sHop), aOn = 0.1 * p90(aHop);
const sD = [], aD = [];
for (let i = 0; i < sHop.length; i++) if (sHop[i] > sOn && aHop[i] > aOn) { sD.push(sHop[i]); aD.push(aHop[i]); }
const med = (v) => { const q = v.slice().sort((x, y) => x - y); return q.length ? q[Math.floor(q.length / 2)] : 0; };
if (sD.length > 8 && med(aD) > 0) serDb = 20 * Math.log10(med(sD) / med(aD));
if (process.env.SIM_DEBUG_MIX) console.log(` mix: bleed ${cfg.bleed} lag ${cfg.bleedLag}ms clip ${cfg.bleedClip} -> SER ${serDb === null ? '-' : serDb.toFixed(1)} dB over ${sD.length}/${sHop.length} double-talk hops (${(100 * sD.length / sHop.length).toFixed(0)}%), nl residual ${nlResidDb === null ? '-' : nlResidDb.toFixed(1)} dB`);
}
// Pink noise at --snr dB below the solo RMS (a silent solo uses the nominal level).
const soloRms = rmsOf(soloBuf) || 0.05;
const noise = pinkNoise(n, rng);
const noiseGain = soloRms / rmsOf(noise) * 10 ** (-cfg.snr / 20);
for (let i = 0; i < n; i++) mix[i] += noiseGain * noise[i];
if (cfg.reverb) { const d = Math.round(0.04 * sr), g = 10 ** (-12 / 20); for (let i = n - 1; i >= d; i--) mix[i] += g * mix[i - d]; }
if (cfg.hpf > 0) { const hp = biquadHighpass(sr, cfg.hpf); for (let i = 0; i < n; i++) mix[i] = hp.process(mix[i]); }
// Keep the mix inside ±1 like a real capture would.
let peak = 0; for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(mix[i]));
if (peak > 0.98) { const g = 0.98 / peak; for (let i = 0; i < n; i++) mix[i] *= g; }
const seekSec = startAt > 0 ? events[startAt].T - (cfg.startLead ?? 0.3) * events[startAt].ioi : null;
return { mix, events, fires, intruders, sr, bps, preRoll, startAt, seekSec, serDb, nlResidDb };
}
// ---------------------------------------------------------------------------
// Pipeline (§8.3): identical to the worklet
// ---------------------------------------------------------------------------
const SIM_TRANSPORT_MS = 15; // audio → caller clock (models main-thread delivery)
// The app's role: forward-only, maxJump, ack; optional classic debounce mirror.
class FakeTracker {
constructor(score, opts = {}) {
this.score = score; this.position = 0; this.commits = []; this.rejects = 0;
this.debounce = !!opts.debounce; this.bps = opts.bps || 1;
this._lastTime = null; this._lastBeat = null; this._lastPitch = null;
}
advanceTo(idx, { maxJump, inputPitch }, nowSec) {
if (idx < this.position) { this.rejects++; return null; }
if (idx > this.position + maxJump) { this.rejects++; return null; }
if (idx >= this.score.length) { this.rejects++; return null; }
const ev = this.score[idx];
if (this.debounce && this._lastTime !== null) {
const elapsed = nowSec - this._lastTime;
const beatDelta = Math.max(0, ev.beat - this._lastBeat);
const expected = beatDelta > 0 ? beatDelta / this.bps : 0.18;
const minAdvanceSec = Math.min(0.18, Math.max(0.07, expected * 0.45));
const minRepeatedSec = Math.min(0.34, Math.max(0.12, expected * 0.65));
if (elapsed < minAdvanceSec) { this.rejects++; return null; }
if (this._lastPitch !== null && Math.abs(this._lastPitch - inputPitch) <= 1 && elapsed < minRepeatedSec) { this.rejects++; return null; }
}
this._lastTime = nowSec; this._lastBeat = ev.beat; this._lastPitch = inputPitch;
this.position = idx + 1;
return ev.beat;
}
}
function runEngine(perf, score, cfg, hooks = {}) {
const { mix, events, fires, sr } = perf;
const win = cfg.opt.win ?? 2048, hop = cfg.opt.hop ?? 512;
const engineOpts = { ...cfg.opt };
delete engineOpts.win; delete engineOpts.hop;
const analyzer = new FrameAnalyzer(sr, { win, hop, minMidi: 33, maxMidi: 96, startAt: 0 });
const follower = new ScoreFollower({
solo: score.rh, accompaniment: score.lh, bps: cfg.bps, sampleRate: sr, win, hop,
filterbank: analyzer.filterbank, position: 0,
options: { accompFallback: false, ...engineOpts },
});
const solo = normaliseEvents(score.rh);
const tracker = new FakeTracker(solo, { debounce: cfg.trackerDebounce, bps: cfg.bps * cfg.tempoScale });
const gate = cfg.gate ?? 0.001;
const outputs = []; // { tMs, out } for scoring pWrong etc.
const advances = [];
const resyncs = []; // out.resync: the follower's wide-window jumps (follower.js _resyncScan)
const scans = []; // out.resyncScan: EVERY armed wide scan, fired or refused, with its reason
const holds = [];
const bleeds = [];
const states = [];
const frames = [];
let fireIdx = 0;
const chunk = new Float32Array(128);
let seqCheck = -1, lastToIndex = -1, offered = new Set();
let seeked = perf.seekSec === null;
let seekAudioMs = null; // when the seek below actually fired, so buildTrace can record it (see follow.seek there)
for (let off = 0; off < mix.length; off += 128) {
const nCh = Math.min(128, mix.length - off);
for (let i = 0; i < 128; i++) chunk[i] = i < nCh ? mix[off + i] : 0;
for (const f of analyzer.push(chunk)) {
const audioMs = f.at / sr * 1000;
const nowMs = audioMs + SIM_TRANSPORT_MS;
// Start at a later beat: the app moves the tracker and rebuilds the
// beam there with seek(position, performance.now()) while the engine
// already runs on the open mic. Position 0 never exercises this path
// (the pre-start state is exempt from the future guard).
if (!seeked && audioMs >= perf.seekSec * 1000) { seeked = true; seekAudioMs = audioMs; follower.seek(perf.startAt, nowMs); tracker.position = perf.startAt; }
// Report accompaniment fires whose time has come, exactly as the app does (before the loop delay).
while (fireIdx < fires.length && fires[fireIdx].fireSec * 1000 <= audioMs) {
const fr = fires[fireIdx++];
const atMs = fr.fireSec * 1000 + SIM_TRANSPORT_MS;
follower.accompanimentFired({ pitches: fr.pitches, atMs, durMs: fr.durSec * 1000, beat: fr.beat });
hooks.onFire?.({ pitches: fr.pitches, beat: fr.beat, atMs, durMs: fr.durSec * 1000, audioMs: fr.fireSec * 1000 });
}
const frame = {
type: 'frame', seq: f.seq, at: f.at, sr, win: f.win, hop: f.hop, rms: f.rms, db: f.db, floorDb: f.floorDb,
flux: f.flux, onset: f.onset, hfc: f.hfc, sal: f.sal, peak: f.peak, noiseFloor: null, gated: f.rms < gate, attack: false,
};
if (cfg.dumpSal) console.log(`${(audioMs / 1000).toFixed(3)}s db=${f.db.toFixed(1)} floor=${f.floorDb.toFixed(1)} onset=${f.onset.toFixed(2)} peak=${f.peak}`);
const out = follower.observe(frame, nowMs);
// §3.3 guarantees, asserted.
if (out.seq !== frame.seq) throw new Error('output.seq mismatch');
if (seqCheck >= 0 && out.seq !== seqCheck + 1) throw new Error('seq gap in sim');
seqCheck = out.seq;
// RESYNCHRONISATION (follower 2026-09-16.3). Its own emission, never
// counted as a detection: the app moves the tracker with the engine's
// OWN explicit maxJump (MAX_ACOUSTIC_EVENT_JUMP = 1 would refuse it) and
// then seeks the follower, which is what _acceptOffer does internally on
// the ack. Modelled here so `--replay` measures the same behaviour the
// app will produce.
if (out.resyncScan) scans.push({ ...out.resyncScan, tMs: out.tMs, audioMs });
if (out.resync) {
const r = out.resync;
const beat = tracker.advanceTo(r.toIndex, { maxJump: r.maxJump, inputPitch: r.pitches[r.pitches.length - 1] }, audioMs / 1000);
follower.ack(r.id, beat !== null);
if (beat !== null) { offered.add(r.toIndex); lastToIndex = r.toIndex; }
resyncs.push({ ...r, accepted: beat !== null, tMs: out.tMs, audioMs: out.tMs - SIM_TRANSPORT_MS });
}
if (out.advance) {
const adv = out.advance;
if (adv.toIndex < tracker.position) throw new Error(`advance.toIndex ${adv.toIndex} < position ${tracker.position}`);
if (offered.has(adv.toIndex)) throw new Error(`toIndex ${adv.toIndex} offered twice without a refused ack`);
const beat = tracker.advanceTo(adv.toIndex, { maxJump: adv.maxJump, inputPitch: adv.pitches[adv.pitches.length - 1] }, audioMs / 1000);
follower.ack(adv.id, beat !== null);
if (beat !== null) offered.add(adv.toIndex);
advances.push({ ...adv, accepted: beat !== null, tMs: out.tMs, audioMs: out.tMs - SIM_TRANSPORT_MS, mode: out.mode, heardMidi: adv.heardMidi });
lastToIndex = adv.toIndex;
} else if (out.hold) holds.push({ ...out.hold, tMs: out.tMs, pos: out.position });
if (out.state) states.push({ ...out.state, tMs: out.tMs, pos: out.position, p: out.best?.p ?? null });
// The watchdog moved off out.state (it was overwritten by BACKTRACK and
// by the mode change); keep it visible in the states column.
if (out.recovered) states.push({ mode: 'RECOVER', reason: out.recovered.reason, tMs: out.tMs, pos: out.position, p: out.best?.p ?? null });
if (out.bleed) bleeds.push({ ...out.bleed });
outputs.push({ tMs: out.tMs, audioMs, pWrong: out.pWrong, best: out.best, informative: out.informative });
frames.push({ frame, out });
hooks.onFrame?.(frame, out, follower);
}
}
return { follower, tracker, advances, resyncs, scans, holds, bleeds, states, outputs, frames, events, lastToIndex, seekAudioMs };
}
// ---------------------------------------------------------------------------
// Scoring (§8.4)
// ---------------------------------------------------------------------------
const quantile = (arr, q) => {
if (!arr.length) return null;
const a = arr.slice().sort((x, y) => x - y);
const pos = (a.length - 1) * q, lo = Math.floor(pos), hi = Math.ceil(pos);
return a[lo] + (a[hi] - a[lo]) * (pos - lo);
};
const EARLY_MS = 60; // a commit this far before the true onset is an advance AHEAD of the pianist
// A wide-window jump may land this many events BEHIND the pianist's true
// event and no more (see resyncBad in scoreRun for the measurement this comes
// from). THE TOLERANCE IS ONE-SIDED, and the asymmetry is the whole point: a
// jump that lands short is a catch-up the next ordinary advance closes, while
// a jump that lands on an event the pianist has NOT BEGUN is the accompaniment
// playing a note nobody played -- the one direction `early` forbids for
// ordinary advances, and the one the comment above resyncBad says was never
// observed. A symmetric assertion passed exactly that: on `dense-drift
// --seed 1` the jump 13 -> 62 at t 34293 ms, with the pianist's last begun
// event 61 and event 62 starting 161 ms later, read `resyncBad 0` while
// directly causing the run's only `early` (event 64 committed 235 ms before
// its onset, 448 ms after the jump).
const RESYNC_LAND_EVENTS = 1;
// Drift the engine is allowed to carry without it counting as an interval of
// being behind: 1 is what perfect tracking reads, 2 is one note of slack for a
// commit that lands after the next onset — which at this bench's 125 ms fastest
// decile is unavoidable and is not the failure being measured.
const DRIFT_OK = 2;
function scoreRun(res, perf) {
const events = perf.events;
const played = events.filter((e) => e.kind === 'played');
const hits = new Map();
const falses = [];
const latencies = [], onsetErrs = [], tempoErrs = [];
for (const adv of res.advances) {
if (!adv.accepted) continue;
const e = events[adv.toIndex];
const tCommit = adv.audioMs / 1000;
const inWindow = e && e.kind === 'played' && tCommit >= e.T - 0.05 && tCommit <= e.T + 0.4 && !hits.has(adv.toIndex);
if (inWindow) {
hits.set(adv.toIndex, adv);
latencies.push((tCommit - e.T) * 1000);
onsetErrs.push(adv.onsetAtMs - SIM_TRANSPORT_MS - e.T * 1000);
tempoErrs.push(adv.bps - e.tempo);
} else falses.push(adv);
}
const missed = played.filter((e) => !hits.has(e.k));
// Early advances: any accepted commit more than EARLY_MS before the true
// onset of the event it names, whatever that event's kind. A subset of
// `false`, counted separately because it is the one failure the follower
// exists to eliminate (the accompaniment moving ahead of the pianist) and
// no scenario may tolerate even one of them, including those whose `false`
// target allows a stray late or mis-indexed commit.
const early = res.advances.filter((a) => a.accepted && events[a.toIndex] && a.audioMs / 1000 < events[a.toIndex].T - EARLY_MS / 1000);
// Resync after skips: a hit on one of the next two played events.
let resyncFails = 0;
for (const e of events) if (e.kind === 'skipped') {
const after = events.filter((x) => x.k > e.k && x.kind === 'played').slice(0, 2);
if (after.length && !after.some((x) => hits.has(x.k))) resyncFails++;
}
// EVERY SCENARIO, not only the ones whose target names resync: a wide-window
// jump may only land where the pianist actually is. The Study phase measured
// the search on the real Haydn audio at the shipped 1.5-nat bar — 97 % of
// fires landed exactly, 100 % within one event, 0 % landed AHEAD of the
// player — and on the reference Twinkle every fire was a distance-1 jump the
// ordinary rule would have made anyway. +-1 event is therefore the band the
// mechanism is CLAIMED to hold, so anything outside it is a jump the engine
// should not have made, whatever it does to recall. This is the resync's
// counterpart to `early` and it fails every scenario the same way.
//
// `trueK` is the last event that had BEGUN when the scan fired — including
// wrong and skipped ones, because the pianist's hands are past them either
// way. A scenario in which nothing has been played yet (no-play) leaves it
// null and any accepted jump at all is a failure.
// How far the wide search's top-1 was from where the pianist actually was, on
// EVERY armed scan — fired or refused. This is the calibration number behind
// `resyncMarginNats`: a suite in which the refused scans were right is a
// suite whose bar is too high, and one in which the fired scans were wrong is
// a precision bug. Reported alongside the refusal histogram.
const scanAtTrue = (t) => { let k = null; for (const e of events) if (e.kind !== 'silent' && e.T <= t) k = e.k; return k; };
const scanErrs = [], scanErrsFired = [];
for (const sc of res.scans || []) {
const k = scanAtTrue(sc.audioMs / 1000);
if (k === null || sc.toIndex < 0) continue;
scanErrs.push(sc.toIndex - k);
if (!sc.refused) scanErrsFired.push(sc.toIndex - k);
}
const resyncBad = [];
for (const r of res.resyncs || []) {
if (!r.accepted) continue;
const t = r.audioMs / 1000;
let trueK = null;
for (const e of events) if (e.kind !== 'silent' && e.T <= t) trueK = e.k;
const err = trueK === null ? null : r.toIndex - trueK;
if (err === null || err > 0 || err < -RESYNC_LAND_EVENTS) resyncBad.push({ to: r.toIndex, at: Math.round(r.audioMs), trueK, err });
}
// DRIFT — the metric the Haydn failure was measured in (Study phase): how many
// events the pianist is ahead of the engine's committed position, sampled at
// every played onset. An engine tracking perfectly reads 1, because event k's
// commit lands DURING event k and at k's onset the engine still holds k-1; the
// real Haydn session read 8 at t=3.6 s, 57 at t=41 s and 98 at t=82 s and
// never came back. `driftRunSec` is the longest unbroken stretch of time spent
// above DRIFT_OK, i.e. how long the accompaniment is left playing music from
// somewhere else — the quantity the user heard as "completely disorganized".
// Resyncs count towards the committed position exactly as advances do: it is
// the accompaniment's position that matters, not how it got there.
const moves = [...res.advances, ...(res.resyncs || [])].filter((a) => a.accepted)
.map((a) => ({ t: a.audioMs / 1000, to: a.toIndex })).sort((a, b) => a.t - b.t);
// A session that starts at a later event (startAt) has the tracker SEEKED
// there rather than advanced there, so the seek is not in `moves` and the
// prefix it skipped is not drift — start-late read 8 before this line.
let driftMax = 0, driftRunSec = 0, driftFrom = null, mi = 0, committedIdx = (perf.startAt || 0) - 1;
for (const e of played) {
while (mi < moves.length && moves[mi].t <= e.T) committedIdx = Math.max(committedIdx, moves[mi++].to);
const d = e.k - committedIdx;
driftMax = Math.max(driftMax, d);
if (d > DRIFT_OK) { if (driftFrom === null) driftFrom = e.T; driftRunSec = Math.max(driftRunSec, e.T + e.durSec - driftFrom); }
else driftFrom = null;
}
// pWrong while a note the score does not contain is sounding — substituted
// (wrongEvery) or inserted (wrongInsert) alike.
const pWrongs = [];
const wrongWins = [
...events.filter((e) => e.kind === 'wrong').map((e) => [e.T * 1000 + 100, e.T * 1000 + 0.9 * e.ioi * 1000]),
...(perf.intruders || []).map((e) => [e.T * 1000 + 100, (e.T + 0.9 * e.durSec) * 1000]),
];
for (const [a, b] of wrongWins) for (const o of res.outputs) if (o.informative && o.audioMs >= a && o.audioMs <= b) pWrongs.push(o.pWrong);
// Longest unbroken run of SOUND in which the WRONG absorber owned the
// posterior. This is the quantity the engine's wrongDwellMs watchdog counts,
// so it is the only way to tell from the outside whether a scenario reaches
// it: in 20260915-081347 the longest legitimate run was 854 ms and the
// failure was 49 523 ms, and a scenario that never passes wrongDwellMs is not
// testing recovery however high its median pWrong looks.
let pWrongRunMs = 0, runMs = 0, runPrev = null;
for (const o of res.outputs) {
if (!o.informative) continue;
if (o.pWrong >= 0.5) { runMs += runPrev === null ? 0 : o.audioMs - runPrev; pWrongRunMs = Math.max(pWrongRunMs, runMs); } else runMs = 0;
runPrev = o.audioMs;
}
// Longest stall: the longest stretch in which the pianist is playing notes the
// engine is supposed to be following and NO advance is accepted.
//
// The old form — the largest gap between consecutive hits — could not see the
// failure this metric exists for. It only measured intervals that ENDED in a
// hit, so the real session (20260915-081347: 28 keys and zero advances over
// the last 45 s, the engine dead and never recovering) would have scored its
// stall from the last hit it ever made and reported nothing. Here the
// timeline is split at accepted advances and each segment is measured from
// the first played onset still waiting to the end of the last one, or to the
// advance that ended the wait — so a segment that never ends is the whole
// remaining piece, which is what a dead engine deserves.
//
// Wrong and skipped events are not counted as waiting: holding through a note
// the score does not contain is correct behaviour, not a stall. What that
// leaves in a wrong-note scenario is exactly the recovery time — the gap from
// the first CORRECT note after the run to the advance that re-locks on it.
const advT = res.advances.filter((a) => a.accepted).map((a) => a.audioMs / 1000).sort((a, b) => a - b);
let stall = 0, segFrom = -Infinity;
for (const bound of [...advT, Infinity]) {
const waiting = played.filter((e) => e.T > segFrom && e.T < bound);
if (waiting.length) {
const last = waiting[waiting.length - 1];
stall = Math.max(stall, Math.min(bound, last.T + last.durSec) - waiting[0].T);
}
segFrom = bound;
}
const holdsByReason = {};
for (const h of res.holds) holdsByReason[h.reason] = (holdsByReason[h.reason] || 0) + 1;
return {
played: played.length, hits: hits.size, recall: played.length ? hits.size / played.length : 1,
false: falses.length,
// `lateMs` is how far past the named event's own onset the commit landed.
// On a dense score most of the `false` list is not a mis-indexed commit at
// all but the RIGHT event claimed after the 400 ms hit window had closed —
// the same failure `drift` measures, seen from the other end. Printed so
// the two can be told apart without re-running.
falseList: falses.map((a) => ({ to: a.toIndex, at: Math.round(a.audioMs), lateMs: events[a.toIndex] ? Math.round(a.audioMs - events[a.toIndex].T * 1000) : null, kind: events[a.toIndex]?.kind, repeatLike: a.repeatLike, ev: +a.ev.toFixed(2), p: +a.p.toFixed(2) })),
missed: missed.length, missedList: missed.map((e) => e.k),
early: early.length, earlyList: early.map((a) => ({ to: a.toIndex, at: Math.round(a.audioMs), aheadMs: Math.round(events[a.toIndex].T * 1000 - a.audioMs), mode: a.mode })),
latencyMedian: quantile(latencies, 0.5), latencyP90: quantile(latencies, 0.9),
onsetErrMedian: quantile(onsetErrs, 0.5), onsetErrP90: quantile(onsetErrs, 0.9),
tempoRmse: tempoErrs.length ? Math.sqrt(tempoErrs.reduce((s, v) => s + v * v, 0) / tempoErrs.length) : null,
rejects: res.tracker.rejects, stall, pWrongMedian: quantile(pWrongs, 0.5), pWrongRunMs, resyncFails, holdsByReason,
resyncs: (res.resyncs || []).filter((r) => r.accepted).length, resyncBad: resyncBad.length, resyncBadList: resyncBad, driftMax, driftRunSec,
scans: (res.scans || []).length, scansByReason: (res.scans || []).reduce((a, x) => (a[x.refused || 'fired'] = (a[x.refused || 'fired'] || 0) + 1, a), {}),
scanMarginMed: quantile((res.scans || []).map((x) => x.marginNats).filter((v) => v !== null && Number.isFinite(v)), 0.5),
scanMarginP90: quantile((res.scans || []).map((x) => x.marginNats).filter((v) => v !== null && Number.isFinite(v)), 0.9),
scanLeadMed: quantile((res.scans || []).map((x) => x.leadNats).filter((v) => v !== null && Number.isFinite(v)), 0.5),
scanDistMed: quantile((res.scans || []).map((x) => x.distance), 0.5),
scanErrMed: quantile(scanErrs, 0.5), scanErrAbs1: scanErrs.length ? scanErrs.filter((e) => Math.abs(e) <= 1).length / scanErrs.length : null,
scanErrAbs2: scanErrs.length ? scanErrs.filter((e) => Math.abs(e) <= 2).length / scanErrs.length : null,
// "did a jump that ACTUALLY FIRED pick a place the pianist had not
// reached?" — over the fired scans only, and at e > 0, not e > 1. Over all
// scans and at e > 1 it printed `ahead 0` for the dense-drift seed 1 run
// whose accepted jump to event 62 preceded that event's onset by 161 ms,
// i.e. it read 0 on exactly the run it exists to catch.
scanErrAhead: scanErrsFired.filter((e) => e > 0).length, scanErrFiredList: scanErrsFired,
bleedLagMedian: quantile(res.bleeds.map((b) => b.lagMs), 0.5), states: res.states.map((s) => `${s.mode}@${Math.round(s.tMs)}`),
};
}
// ---------------------------------------------------------------------------
// Scenarios and targets (§8.4)
// ---------------------------------------------------------------------------
// `synth: 'rig'` is the measured laptop-speaker/laptop-mic model (see RIG);
// `'legacy'` is the pre-2026-09-15 8-partial h^-1.3 tone, kept for audit only.
const BASE = { bleed: 1.0, snr: 30, hpf: 0, jitter: 25, rubato: 8, bleedLag: 58, bleedClip: 0, tempoScale: 1.0, score: null, synth: 'rig' };
// 2026-09-15 RE-BASELINE against the measured rig synth (see RIG above).
// Nothing in the engine changed and no target below was touched: the synth
// stopped being a fundamental-dominant 8-partial piano and became the
// 21-partial comb this laptop speaker actually produces, so every number moved.
// `--synth legacy` reproduces the "before" column below exactly, press for
// press (the legacy path draws the same random numbers in the same order), so
// any line here can be re-checked in one command.
//
// BEFORE (legacy synth) RIG SYNTH, engine .13 RIG SYNTH, engine .14
// clean 0.96 f0 e0 lat 68/90 ok 0.98 f0 e0 lat 75/152 ok 0.98 f0 e0 ok
// bleed 1.00 f0 e0 stall 0.14 ok 0.87 f0 e0 stall 3.90 FAIL 0.98 f0 e0 ok
// bleed-loud 0.98 f0 e0 ok 0.00 f21 e0 stall 5.84 FAIL 0.91 f0 e0 ok
// noisy 0.98 f0 e0 ok 0.89 f0 e0 ok 0.98 f0 e0 ok
// no-fundamental 1.00 f0 e0 ok 0.93 f0 e0 ok 0.93 f0 e0 ok
// double-strike 0.98 f0 e0 ok 0.96 f0 e0 ok 0.96 f0 e0 ok
// wrong-note 1.00 f0 e0 ok 1.00 f0 e0 ok 1.00 f0 e0 ok (bench bug fixed, see below)
// skipped-note 1.00 f0 e0 ok 0.95 f1 e0 FAIL 0.92 f2 e0 FAIL
// repeated-notes 1.00 f0 e0 ok 0.92 f0 e0 ok 0.92 f0 e0 ok
// no-play – f0 e0 ok – f0 e0 ok – f0 e0 ok
// fast 0.93 f0 e0 ok 0.91 f1 e0 ok 0.96 f0 e0 ok
// rubato-heavy 1.00 f0 e0 ok 0.96 f0 e0 ok 0.96 f0 e0 ok
// bleed-lag-sweep 0.98 f0 e0 ok 0.91 f1 e1 FAIL 0.91 f1 e1 FAIL
// sr44 0.91 f0 e0 ok 0.89 f0 e0 FAIL 0.91 f0 e0 ok
// start-late 0.97 f0 e0 stall 1.12 ok 0.87 f0 e0 stall 5.99 FAIL 0.93 f0 e0 ok
// low-note-weak 0.93 f0 e0 ok 0.86 f1 e0 FAIL 0.89 f0 e0 ok
// low-note-lag-over 0.89 f0 e0 ok 0.89 f0 e0 ok 0.89 f0 e0 ok
// wrong-lock-recovery 0.98 f0 e0 ok 0.98 f0 e0 ok 0.98 f0 e0 ok
// (seed 1 throughout; f = false, e = early. No target was changed in either
// step. Seed 2 under engine .14: everything ok except double-strike 0.91 f1
// and start-late 0.95 f1, both of which read f1 under engine .13 as well.)
//
// ENGINE .16 IS BEHAVIOUR-IDENTICAL TO .15 on this bench, line for line — it
// only adds FOLLOWER_DEFAULTS.rigResponse, which ships null. Re-measured after
// that change, so the seed-2 column above has current numbers next to it:
// bleed-loud seed 1 0.91 f0 ok seed 2 0.83 f1 FAIL (recall < 0.85 too)
// double-strike seed 1 0.96 f0 ok seed 2 0.93 f1 FAIL
// skipped-note seed 1 0.92 f2 FAIL seed 2 1.00 f0 ok
// All five of those `false`s are LATE commits of notes the player really did
// play — +414, +483, +994 and +2017 ms against the scorer's +400 ms window
// (scoreRun) — and `early` is 0 in every one of them. None of them is the
// accompaniment moving ahead of the pianist; they are the follower catching up
// out of a stall, and two of the four are within 20 % of the bar. The two
// cold-start ones (skipped-note #0, bleed-loud #1 after #0 is lost) are the
// index-0 collapsed-fundamental story already named above, still unsolved.
//
// THE ENGINE STEP (follower 2026-09-15.14) is two changes, both of which this
// bench is what found:
// * the ACCOMPANIMENT's template now runs to the top of the bank
// (FOLLOWER_DEFAULTS upperWeight, _extWeights). Its six-partial truncation
// was the same bug as the solo one and it bites harder, because the
// accompaniment is an octave above the solo and therefore covers the solo
// note's EVEN partials: what identifies a C3 under a C4 is C3's odd
// partials, and from the 7th up neither template could describe them. In
// bleed-loud seed 1 the bleed explained 0.91-0.97 of every frame with
// index 0's own share at 0.00 and the piece never locked.
// * the future guard (§ 'nothing committed ahead of the newest evidence')
// now measures from the onset the last ACCEPTED commit asserted, not from
// whichever hypothesis at the committed index is heaviest. The pre-start
// state spawns a rival for the committed event on the NEXT note's onset;
// with the rig synth it regularly outweighed the committed state, so the
// guard read "event j is a whole IOI away" and refused the child that
// onset belonged to (bleed seed 1: index 1 at p 0.98, ev 6, held
// 'future' with ahead 984 / span 1000 for a second; indices 1-4 lost).
// The rule is unchanged and `early` is still 0 everywhere it was.
//
// WHAT THE ENGINE STEP DID NOT FIX, stated plainly: the SOLO template is
// still truncated at six partials, so the G2 stall the replay was built to
// measure is still there — the replay reads the same 28 hits it did before
// (at a better latency: p90 339 -> 336 ms, index 0 committed +178 ms instead
// of +371 ms). Every way of extending the solo side was measured and every
// one costs more than it buys; the numbers are in FOLLOWER_DEFAULTS above
// `harmonicWeights`, and 2026-09-15.15 adds three more measured dead ends
// there as off-by-default knobs (soloUpperFrom, xTilt, wrongPartialMax) so the
// seventh attempt is a one-line sweep rather than a rebuild. One of them is
// not quite a dead end and is worth knowing about: `--opt wrongPartialMax=6`
// bars WRONG from the 6th partial of an expected pitch and DOES commit the
// index-12 G2 that no shipped build has committed — it just cannot be scored
// on this recording, for the reason under the `--replay` line below.
// skipped-note and bleed-lag-sweep were the two scenarios
// still red. bleed-lag-sweep is now GREEN (0.91 f1 e1 -> 0.93 f0 e0): its
// `early` turned out NOT to be the collapsed fundamental but one onset
// claiming two events, and two hold-only gates fixed it — see the scenario's
// own comment. skipped-note is still red at f2 (index 43 as well as index 0),
// the bleed tail's cost, declared rather than hidden; index 0 is the
// still-unsolved first-note-in-LISTENING collapsed fundamental. Measured, the
// tail is worth its one false: `--opt upperWeight=0` takes skipped-note to
// 0.95 f1 but bleed-loud seed 1 to 0.00 with 21 false, so removing it from
// the commit path is a net loss and was not taken.
//
// The `--replay` line on the real session: replay bypasses the SYNTH, not the
// ENGINE — it re-runs the recorded frames through the live follower, so hits
// and latency can and did move with the .14 step (see p90 339 -> 336 above).
// What is untouched is what the contract asks for, `hits 28` and `false 0`:
// keys 56 hits 28 recall 0.50 false 0 latency med 188 p90 336 ms.
//
// READ THE SECOND LINE BEFORE THE FIRST. 2026-09-15.15 makes `--replay` print
// the CEILING of its own metric, and on this session it is 30 of 56 keys (35
// under the ±12 rule the scorer actually uses). "recall 0.50" was never 0.50
// of what is achievable: 24 of the 28 unscored keys are G2 presses made where
// the score asks for C3 or B2, and no follower may advance on those. The
// headroom this recording can demonstrate is two keys. Five solo-template
// rewrites were reverted on differences of five and eleven hits inside that
// band — see replayCeiling for what those differences actually are.
//
// WHAT MOVED, and why. Attributed by rebuilding each scenario with ONE part of
// the new synth switched off:
//
// * The UPPER TAIL is what costs recall. Capping partials at 784 Hz (the old
// h8 of a G2) and changing nothing else restores bleed 0.87 → 0.98 and
// bleed-loud 0.00 → 0.91. That is the shipped 6-partial template failing to
// explain rows 74–96 and the WRONG absorber taking them — the exact
// real-session pathology, which no scenario could reproduce before because
// no synthetic note had anything up there. These are NOT regressions of the
// engine; they are the bench finally containing the failure it was built to
// measure, and the engine genuinely cannot do it yet.
// * The FUNDAMENTAL DRAW costs the first note. bleed-lag-sweep's `early 1`
// needs BOTH the tail and the draw: with either switched off it goes back to
// 0. It is index 0's C3 arriving with a collapsed fundamental in LISTENING
// mode, read as index 1's G3 (C3's h3/h6/h9 are G3's h2/h4/h6) and committed
// 585 ms early. Same story for skipped-note (index 0 committed 1.0 s late,
// scored false) and for bleed-loud on seed 1, where index 0 is lost and the
// whole piece then runs ~6 s late; seeds 2 and 3 of bleed-loud give 0.98 and
// 0.91. A press that loses its fundamental is measured, not invented
// (MIDI 43: p25 −18.8 dB re the strongest partial over 16 presses), and no
// scenario produced one before.
// * start-late and sr44 lose their margin, not their behaviour: start-late
// stalls 5.99 s across indices 11–18 (against a 5 s bound) and sr44 misses
// five instead of four. Both are the same thin-margin story.
// * wrong-note's collapse was a BENCH BUG, and it is fixed above rather than
// re-baselined: `wrongEvery` perturbed a pitch by +1/+2 with no guard, so at
// index 9 it played index 10's own written C3. The fundamental-dominant
// synth could not tell B2 from C3 and nothing happened; the measured comb
// can, and the engine's correct recognition scored as 1 early and 12 false
// advances (recall 1.00 → 0.22). With the guard the scenario reads 1.00 f0
// e0 under BOTH synths. `wrongInsert` has always had that guard.
//
// NOT re-baselined, and worth saying plainly: the bench is still 25–45 dB
// cleaner than the rig in the only domain the engine sees. Per-row note-to-floor
// on a synthetic G2 is 40–63 dB against the rig's measured 3–18 dB, so the
// whitening's (sal/2·floor − 1)^0.5 barely bites and the simulated G2's
// WHITENED peak is still its own h3 (row 62) where the rig's is row 89. The RAW
// (post − pre) spectrum now matches the rig to 5.9 dB rmse over h ≥ 2 — the
// same order as the profile's own model rmse of 5.12 dB — and peaks on row 62
// exactly as the rig does, which is what makes a 7..21-partial template
// testable here at all. Lowering --snr does not fix the whitened domain
// (measured at 10, −5 and −17 dB: it flattens the whole spectrum toward the
// noise instead of reshaping it). The cause is that this bench has real silence
// between notes and a dry room, so its min-statistics floor falls back to the
// pink noise in every gap, while the rig's floor rides on a continuously
// sounding speaker: the rig's own session-mean salience is only 3.4 dB above
// its floor at row 43 and 10.3 dB at row 89. That is a FOURTH bench error, one
// the phase-1 profile's "keep pink at the current SNR" did not catch, and it is
// the next thing to fix here.
const SCENARIOS = {
'clean': { mut: { bleed: 0, snr: 40, rubato: 0 }, tgt: { recall: 0.95, false: 0, latMed: 120, latP90: 250 } },
// 2026-09-15: 1.00 → 0.87 under the rig synth, FAILING its 0.90 — capping
// the synth's partials at 784 Hz restored 0.98, so the loss was the engine's
// 6-partial template against the measured tail. Engine work, and it was
// done: follower 2026-09-15.14 reads 0.98 f0 e0 (seed 2: 0.20 f14 → 0.98).
// The target is untouched.
'bleed': { mut: {}, tgt: { recall: 0.90, false: 0, latMed: 150 } },
// 2026-09-15: 0.98 → 0.00 with 21 false on seed 1 under the rig synth, where
// index 0's C3 draws a collapsed fundamental, is never committed, and the
// piece runs ~6 s late from there. Seeds 2 and 3 read 0.98 and 0.91; capping
// partials at 784 Hz gave 0.91 on seed 1. Tail + first-note collapse
// together — and this scenario is what identified the ACCOMPANIMENT's
// truncated template as the larger half of the tail problem (the bleed
// explained 0.91-0.97 of every frame while index 0's own share was 0.00).
// follower 2026-09-15.14 reads 0.91 f0 e0. The target is untouched.
'bleed-loud': { mut: { bleed: 2.0 }, tgt: { recall: 0.85, false: 0 } },
// -------------------------------------------------------------------------
// phone-bleed / phone-bleed-lag-wrong — THE MOBILE CASE (added 2026-09-16).
// BOTH ARE RED TODAY, ON PURPOSE. They are the acceptance test for the
// mobile bleed work, not a picture of something that already passes.
//
// WHY THEY EXIST. Every bleed scenario above models a LAPTOP: the
// accompaniment leaves a laptop speaker, crosses a room, and arrives at a
// laptop mic 58–130 ms later at a level BELOW the solo. On a phone or tablet
// the speaker and the microphone are inches apart in one body, so the
// accompaniment arrives LOUDER than the soloist and almost immediately, and
// partly through the case rather than only through the air. The product
// direction is webapp first, phone second, and nothing here measured that.
//
// EVERY NUMBER, AND WHERE IT COMES FROM.
//
// bleed 3.0 -> MEASURED SER −6.4 dB (SIM_DEBUG_MIX=1, over the 94 % of hops
// in which both sources sound). The field's mixing parameter is the
// signal-to-echo ratio over DOUBLE-TALK frames, and the ICASSP 2021/2022
// AEC Challenges sample it "uniformly ... from -10 dB to 10 dB" over
// 10 000+ real devices (Cutler et al., arXiv:2009.04972, arXiv:2202.13290).
// −6.4 dB is inside that range and on its adverse half, which is where the
// 2022 challenge puts mobile: it reports mobile scoring 0.75 MOS BELOW
// desktop in the double-talk degradation category — its largest gap of any
// category — "because in mobile devices, the loudspeaker is closer to the
// microphone, so the signal-to-echo ratio in these devices is lower on
// average". For calibration the existing scenarios measure SER +3.7 dB
// (`bleed`) and −2.3 dB (`bleed-loud`); +3.7 dB matches the rig's own
// "median −5.0 dB re the solo note it covers" to within 1.3 dB, which is
// what validates the metric. phone-bleed is therefore 10.1 dB louder than
// `bleed` and 4.1 dB louder than `bleed-loud`.
//
// bleedLag 6 ms -> the direct + structure-borne path, NOT a room path. A
// phone's speaker-to-mic spacing is 1–15 cm = 0.03–0.44 ms of air, and the
// case-borne path is faster still; what is left is the platform's own
// output buffer, which the traces bound at meta.latency.measuredOutputMs
// 18.3–33.3 ms. 6 ms is the coupling alone (the app's fire report already
// buffer-compensated); the buffer-inclusive case is covered by the 30 ms
// row of the ladder below. Do NOT confuse this with the challenge's "RT60
// ranges from 200 ms to 1200 ms" — that is the reverb TAIL, a different
// quantity from the direct-path delay the scalar `bleedLagMs` models.
//
// bleedClip 2 -> MEASURED distortion residual −19.4 dB re the linear
// accompaniment (10.7 % RMS): "mild", and applied to the accompaniment
// only, since the solo reaches the mic from a real piano and never passes
// through our loudspeaker. Small loudspeakers are strongly nonlinear
// (Hofmann et al., EURASIP JASP 2016:113) and the AEC Challenge applies a
// nonlinearity to 80 % of its far-end signals, naming "clipping the maximum
// amplitude, using a sigmoidal function ... or applying learned distortion
// functions"; the sigmoid is the one used here. --bleed-clip 3 and 5 give
// −15.1 and −10.9 dB. The shaping is RMS-preserving, so `bleed` and
// `bleedClip` are orthogonal (SER moves 1.0 dB from clip 0 to clip 5).
//
// WHAT THE SHIPPED ENGINE ACTUALLY DOES, MEASURED (engine 2026-09-16.10).
//
// 1. THE NONLINEARITY IS FREE. At a level the engine survives (bleed 1.0,
// lag 6, engine seeded 6) clip 0 / 2 / 5 all read 0.98 (45/46) f0 e0 —
// identical hits, only p90 latency moves (128/115/107 ms). That is the
// predicted result and not a lucky one: a memoryless nonlinearity driven
// by a harmonic tone produces mostly that tone's OWN harmonics, and the
// bleed template is already a harmonic comb on those rows. What a comb
// cannot cover is intermodulation between two simultaneous accompaniment
// pitches, which this scenario does contain and which is evidently small.
// Nonlinear-AEC work is therefore NOT where the mobile effort should go.
//
// 2. THE LEVEL IS THE KILLER, AND IT IS A CLIFF, NOT A SLOPE. Level ladder
// at the room lag 58 with the engine seeded truthfully, seed 1:
// bleed 2.00 SER −2.3 dB recall 0.91 (42/46) f0 e0
// bleed 2.05 SER −2.5 dB recall 0.00 ( 0/46) f10 stall 9.80s
// bleed 2.25 SER −3.3 dB recall 0.00 ( 0/46) f18 stall 7.29s
// bleed 3.00 SER −5.8 dB recall 0.00 ( 0/46) f11 stall 12.08s
// ONE DECIBEL between 0.91 and 0.00. Seed 2 puts the cliff further out
// (bleed 2.25 -> 1.00 f0; bleed 3.00 -> 0.89 f1), so the cliff's position
// is the first-note draw and its existence is not. Read the seed-1 0.00
// correctly: `early` is 0 in EVERY cell, so the engine never advances
// ahead of the pianist. Index 0 is lost, the piece then runs 5.7–13.0 s
// late, and the bench scores those late commits as `false`. This is the
// cold-start cascade the `bleed`/`wrong-note` comments above already
// describe, now reachable 10 dB earlier.
//
// 3. THE SHORT DELAY IS GENUINELY HARDER THAN THE ROOM DELAY AT THE SAME
// LEVEL. Phone mix, seed 2, engine seeded 58, estimator live:
// true lag 110 ms (room) recall 1.00 (46/46) f0 estimate 131 ms
// true lag 30 ms recall 0.87 (40/46) f0 estimate 46 ms
// true lag 6 ms (phone) recall 0.85 (39/46) f1 estimate 25 ms
// Same SER, 0.15 recall and the first false bought purely by collapsing
// the loop delay. Two reasons, both structural: the estimate can never
// reach 6 ms because follower.js:4880 folds samples in as
// clamp(sample, 20, 250) — 20 ms is a FLOOR, and 25 ms is where the EMA
// settles from any seed — and at a 6 ms loop the accompaniment's onset and
// the soloist's onset land in the SAME frame, so the
// bleedOnsetWindowMs [−30, 90] suppression around atMs + 25 ms covers
// −5…+115 ms after the fire, which is exactly where the soloist's own
// attack lives when both are on the beat.
//
// 4. ON A PHONE THE LAG SEED STOPS MATTERING, AND THAT IS NOT GOOD NEWS.
// phone-bleed with opt bleedLagMs seeded 6 / 58 / 106 (a 0, +52 and
// +100 ms error) reads 0.00 f16 on seed 1 and 0.85 f1 on seed 2 in ALL
// THREE cases, latency 93 vs 94 ms — because the bleed is now loud enough
// that _calibrateBleed's 2× band test trips on nearly every fire and the
// 0.3 EMA reaches 25 ms within a few events whatever it started from.
// Freezing the estimator outright (--opt bleedMinBand=1e9) confirms it:
// 0.85 / 0.83 / 0.85 across the same 100 ms swing.
//
// 5. THE ESTIMATOR IS BLIND AT LAPTOP LEVEL — the trace pathology, reproduced
// on the bench for the first time. bleed 1.0, clip 2, engine seeded 58:
// true lag 6 ms -> estimate 25 ms true lag 58 ms -> estimate 36 ms
// true lag 15 ms -> estimate 35 ms true lag 110 ms -> estimate 37 ms
// true lag 30 ms -> estimate 36 ms
// A 104 ms range of truths maps to a 12 ms range of estimates. The 2×
// band-energy test at follower.js:4878 is a Geigel detector with no
// reference correlation, so at this level it trips on the SOLOIST's own
// energy, not on the echo's arrival — which is the near-end sample cluster
// under 20 ms that holds 7–65 % of samples per session in the real traces,
// and the reason the recorded assumptions read 58 vs a measured 112, 132
// vs 236, 236 vs 99.
//
// 6. AND THE BLINDNESS ONLY COSTS SOMETHING WHEN THE BLEED SITS ON THE
// SOLO'S OWN ROWS. Over a 100 ms swing in a FROZEN assumed lag, Twinkle
// moves by at most one event (0.93 -> 0.96 at true lag 110; 0.98 flat at
// true lag 6) with false 0 everywhere — because Twinkle's accompaniment is
// written an octave above its solo (see the score comment above) and the
// two barely share rows. On the `lownote` score, where the accompaniment
// strikes the very partials the solo's fundamentals are missing, the same
// swing is the difference between working and not. See
// phone-bleed-lag-wrong.
//
// phone-bleed gives the engine the TRUE lag (opt bleedLagMs 6) BY DESIGN: it
// isolates the shipped bleed MODEL from the shipped bleed ESTIMATOR, so
// "how far does the model get when the delay is right" can be told apart from
// "what the delay estimator costs". The estimator's cost is item 6's job.
// MEASURED: seed 1 recall 0.00 (0/46) f16 e0 stall 6.64s, missing all 46,
// six LOST states, drift 15 events / 44.9 s, estimate 25 ms;
// seed 2 recall 0.85 (39/46) f1 e0 stall 3.05s, missing
// [0,1,2,3,4,5,12] — the first six events and then a recovery.
// The target is the GOAL, taken from `bleed-loud`'s 0.85 because that is the
// nearest scenario in kind and there is no reason a phone should be allowed
// to be worse than a loud laptop. false 0 is not negotiable on any scenario.
'phone-bleed': { mut: { bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 6 } }, tgt: { recall: 0.85, false: 0 } },
// phone-bleed-lag-wrong — THE DELAY, ISOLATED, WHERE IT ACTUALLY BITES.
// The `lownote` score (whose accompaniment strikes the solo's own partial
// rows) under the phone mix, with the estimator FROZEN at a wrong value.
//
// WHY FROZEN. With the estimator live this cannot be tested at all: item 4
// above measures 0.85 / 0.85 / 0.85 across seeds of 6, 58 and 106 ms, because
// the estimator overwrites the seed within a few fires. Freezing is done from
// the outside with `bleedMinBand: 1e9` — follower.js:4878 accepts a sample
// only when `e >= 2 * a.ref && e >= o.bleedMinBand`, so an unreachable
// bleedMinBand admits none and `_bleedLagMs` stays at the seed. No engine
// change, and `bleedLag med` prints `-` to prove no sample was folded in.
// This is the same fault injection `low-note-lag-over` already does with
// bleedLagMs 130, taken one step further so the injection actually holds.
//
// WHY IT IS A LEGITIMATE FAULT AND NOT A STRAW MAN: item 5 shows the shipped
// estimator reading 25–37 ms for every true lag from 6 to 110 ms at laptop
// level, i.e. a frozen-wrong lag is what a blind estimator PRODUCES. The
// traces agree — assumed 236 ms against a measured median of 99, assumed 132
// against 236 — and the report prints 78–100 ms misses routinely.
//
// MEASURED, seed 1, phone mix (bleed 3.0 / lag 6 / clip 2) on `lownote`:
// reference: low-note-weak as it ships recall 0.89 (25/28) f0 e0 stall 1.18s
// frozen lag 6 ms (correct) recall 0.61 (17/28) f0 e0 stall 4.71s
// frozen lag 58 ms (+52, shipped seed) recall 0.61 (17/28) f0 e0 stall 4.72s
// frozen lag 106 ms (+100) recall 0.00 ( 0/28) f6 e0 stall 9.16s
// estimator LIVE from either seed recall 0.61 (17/28) f0 e0, estimate 25 ms
// So the phone MIX alone costs 0.89 -> 0.61, a +52 ms lag error costs
// nothing further, and a +100 ms error costs everything that is left — the
// template is switched on 100 ms after the energy it is supposed to explain
// has already been attributed to the expected note. `early` stays 0 in every
// cell: the six falses at +100 ms are late commits, not advances ahead of
// the pianist. The sweep's worst point is the +100 ms one, which is what the
// scenario reports.
// The stall bound is the one from low-note-weak (4 s) for the same reason it
// is there: a low note the engine cannot identify costs one advance, a low
// note that takes the wrong absorber with it costs the rest of the piece.
'phone-bleed-lag-wrong': { sweep: [
{ score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 58, bleedMinBand: 1e9 } },
{ score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 106, bleedMinBand: 1e9 } },
], tgt: { recall: 0.85, false: 0, stall: 4 } },
'noisy': { mut: { snr: 10 }, tgt: { recall: 0.85, false: 0 } },
'no-fundamental': { mut: { hpf: 180 }, tgt: { recall: 0.90, false: 0, latMed: 180 } },
'double-strike': { mut: { doubleStrike: true }, tgt: { recall: 0.90, false: 0 } },
'wrong-note': { mut: { wrongEvery: 5 }, tgt: { recall: 0.90, false: 0, pWrong: 0.5 } },
// 2026-09-15: 1.00 f0 → 0.95 f1 under the rig synth. The one false is index
// 0 committed at 2.05 s against a 1.0 s onset — the first note again, not
// the skips (every deliberately skipped event is still correctly withheld).
// STILL RED at follower 2026-09-15.14, and one commit worse: 0.92 f2, index
// 43 joining index 0. Both are LATE commits landing after the next onset
// (`early` 0); the extra one is the price of the accompaniment tail, and it
// is declared rather than papered over. Seed 2 reads 1.00 f0 e0. The
// unsolved case is the first note in LISTENING with a collapsed
// fundamental; widening the fundamental/overtone split to every pitch the
// rig loses (< 180 Hz, RIG.fundamentalSpreadBelowHz) was tried and fixes
// bleed-lag-sweep's `early` but takes bleed-loud back to 0.00, because the
// same freedom lets index 0's template fit the loud octave bleed.
// Dropping the accompaniment tail out of the commit path was measured too
// and is the same trade: `--opt upperWeight=0` gives 0.95 f1 here (index 43
// clears, index 0 does not) and 0.00 f21 on bleed-loud seed 1. So index 43
// is the tail's price and it is worth paying; index 0 is the open case, and
// it is NOT the f0/overtone split — at 48 kHz semitoneBins(48) = 0.332, so
// a C3 is above f0UnresolvedBins 0.3 and never takes the lowSplit path.
'skipped-note': { mut: { skipEvery: 6 }, tgt: { recall: 0.90, false: 0, resync: true } },
'repeated-notes': { mut: { score: 'repeated' }, tgt: { recall: 0.85, false: 1, latP90: 300 } },
'no-play': { mut: { noPlay: true }, tgt: { false: 0 } },
'fast': { mut: { tempoScale: 1.8 }, tgt: { recall: 0.85, false: 1 } },
'rubato-heavy': { mut: { jitter: 60, rubato: 20 }, tgt: { recall: 0.85, false: 1 } },
// 2026-09-15: 0.98 f0 e0 → 0.91 f1 e1. The early is index 0's C3 read as
// index 1's G3 585 ms ahead in LISTENING (C3's h3/h6/h9 ARE G3's h2/h4/h6);
// it needs both the tail and the collapsed fundamental — switch either off
// and early goes back to 0.
// Was STILL RED at follower 2026-09-15.14 (0.91 f1 e1 at bleedLag 120; the
// other two lags are clean, and seed 2 is clean at all three) — the one
// place in the suite where `early` was not 0.
// NOW GREEN, 0.93 f0 e0, and NOT by a target change: the early was not the
// collapsed fundamental after all. Index 1 was committed at t 1387 with
// aheadMs 649 in LISTENING while index 0's OWN child, born on the same
// 1044 ms onset (the C3's), was still live and audible — one onset claiming
// two events, which gate 12 (`future`) cannot catch in LISTENING because
// _spanMs is Infinity for a pre-start anchor. Two spawn/commit gates were
// added for it, both hold-only: the `same-onset` commit reason (a skip may
// not share its onset with the event it skips) and an onset-driven child's
// own 60 ms spawn refractory from birth (an index-0 child born at t 1050
// fathered an index-1 child 48 ms later off the same attack, against
// ioiMinMs 150). 0.91 f1 e1 -> 0.93 f0 e0 here; bleed-loud, the counterweight
// named above, is unchanged at 0.91 f0 e0.
'bleed-lag-sweep': { sweep: [20, 58, 120].map((lag) => ({ bleedLag: lag })), tgt: { recall: 0.85, false: 0 } },
// 2026-09-15: 0.91 → 0.89 under the rig synth, five misses instead of four —
// margin, not behaviour (seed 2 was the sharp one: 0.02 with 14 false).
// follower 2026-09-15.14 reads 0.91 f0 e0 on seed 1 and 0.98 on seed 2.
'sr44': { mut: { sr: 44100 }, tgt: { recall: 0.90, false: 0, latMed: 150 } },
// Start at a later beat; the first note follows the seek by 0.2–0.4 × IOI.
// 2026-09-15: 0.97 → 0.87 under the rig synth, with a 5.99 s stall over
// indices 11–18 (bound 5 s). follower 2026-09-15.14 reads 0.93 f0 e0 stall
// 1.15 s on seed 1; seed 2 keeps the f1 it had under .13.
'start-late': { sweep: [{ startAt: 1, startLead: 0.2 }, { startAt: 1, startLead: 0.4 }, { startAt: 6, startLead: 0.3 }], tgt: { recall: 0.90, false: 0, latMed: 150 } },
// The two scenarios below reproduce .accompy_traces/20260915-081347, in which
// the engine advanced 12 times in 16.7 s and then stalled dead at index 12
// (G2) while the pianist played 28 more notes over 45 s — precision 100 %,
// recall 29 %. Neither failure mode had a scenario before.
//
// low-note-weak: the lowest solo notes through a laptop speaker (−21 dB on
// G2's fundamental) with the accompaniment striking the very pitches its
// partials land on. The stall bound is the point: a G2 the engine cannot
// identify costs one advance, and that is what `recall` is for — a G2 that
// takes the WRONG absorber with it costs the rest of the piece.
//
// 2026-09-15: under the rig synth this is now explicitly the `speaker-dead-low`
// variant. R(f) already takes 13.6 dB off 98 Hz, so speakerHpf 180 / order 4
// adds another 21.2 on top — a laptop deader at the bottom than the one that
// was measured, which is exactly the case this scenario is for, but it is no
// longer a picture of THIS rig. Re-baselined 0.93 f0 → 0.86 f1; the one false
// was index 12 (G2) committed 1.2 s late, and 0/8/12/27 were missed.
// follower 2026-09-15.14 reads 0.89 f0 e0 (seed 2: 0.96 f0 e0).
'low-note-weak': { mut: { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30 }, tgt: { recall: 0.85, false: 0, stall: 4 } },
// low-note-lag-over: the same rig with the engine's ASSUMED speaker→mic loop
// 72 ms LONGER than the real one (130 ms against BASE's 58). That is the
// sign the app gets wrong in practice — 20260915-081347 started from
// measuredOutputMs + 25 = 58 ms against a measured median of 112 ms, and
// 20260915-081347's own fundamental-starved trim believed 145.7 ms against
// 101 ms — and it opens a window in which accompaniment energy is already in
// the mic while _envA still reports zero. Nothing covered it: on
// low-note-weak even --opt bleedLagMs=250 stays green, because the trim read
// _bleedLive, which by construction is empty for exactly those frames.
// Measured at 130 ms over seeds 1-6: worst-seed recall 0.93 reading
// _bleedLive against 0.89 reading _maskAcc — the four extra misses are G2s
// the engine now declines to claim on rows a D4/G4 bleed owns, and false
// stays 0, which is the trade this scenario exists to hold in place.
'low-note-lag-over': { mut: { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, opt: { bleedLagMs: 130 } }, tgt: { recall: 0.85, false: 0, stall: 4 } },
// wrong-lock-recovery: eight notes in a row that are in the score nowhere —
// long enough for the absorber to saturate, since the longest LEGITIMATE run
// of pWrong ≥ 0.5 in the real session was 854 ms and the failure was
// 49 523 ms — and then the pianist resumes the written part on the very event
// the engine was waiting for. Three targets, each pulling a different way:
// `pWrong` says the absorber must really take the mass (otherwise the
// scenario is not testing recovery at all), `pWrongRun` says it must then
// give it back — measured 7.0 s of sound with the wrongDwellMs watchdog
// disabled against 2.5 s with it, so this is the target that holds that
// watchdog in place — and `stall` measures the re-lock itself, because the
// intruders are excluded from it.
//
// The intruders are inserted at index 12 rather than at index 8, and that is
// a statement about what is knowable, not a choice of an easy spot. Twinkle
// writes G2 at index 8 and again at index 12, four beats apart. Insert eight
// beats in front of index 8 and the pianist's next G2 is BOTH the note the
// engine is waiting for and exactly where its tempo clock now points (index
// 12) — pitch and clock agree on the wrong answer, and no evidence in the
// audio separates "the pianist played eight unwritten beats" from "the engine
// missed indices 8–11". Measured: 18 advances up to 8.7 s ahead of the
// pianist. At index 12 the eight-beat skip lands on index 20 (C3), the pitch
// gate refuses it, and the question reduces to the one being asked.
'wrong-lock-recovery': { mut: { wrongInsert: [12, 8] }, tgt: { recall: 0.80, false: 0, pWrong: 0.5, pWrongRun: 4.0, stall: 4 } },
// dense-drift: the failure of
// .accompy_traces/20260916-113517_cello-concerto-no1-in-c-major-hobviib1-joseph-ha
// (143 solo notes over 120 beats, recall 7 %, the accompaniment 40 s adrift)
// reproduced synthetically. 200 events of that excerpt's OWN score at its own
// spacing — median 250 ms, fastest decile 125 ms, 66 of 199 gaps under
// 0.25 beat — with the long tutti rests clamped out, so the pianist never
// stops and the engine never gets a free re-lock. See DENSE_RH for the
// provenance of every number and for the one deliberate departure (register).
//
// WHAT IT REPRODUCES. The drift is monotone and it is the density that
// produces it, not the noise: `snr` is BASE's 30 dB and the solo is in
// Twinkle's own register. Measured on the shipped engine (2026-09-16.9),
// `--trace /dev/null`, one seed per command:
// seed 1 recall 0.39 (78/200) false 14 early 0 drift 47 / 31.7 s stall 11.2 s
// seed 2 recall 0.55 (109/200) false 9 early 0 drift 25 / 6.2 s stall 7.9 s
// against Twinkle's 0.93-0.98 on the same synth. THE SCENARIO IS RED AND IS
// DECLARED RED (see TARGETS below); these numbers are what it does, not what
// it has to do. Latency where the engine IS tracking is 76 ms median / 155 ms
// p90 — under the 125 ms fastest decile at the median, i.e. the engine CAN
// claim a 125 ms note — and it dies in the 125 ms passages: seed 1 loses
// events 14-88 outright and again 92-117, seed 2 the runs at #168 on. Nothing
// else in the suite contains a 125 ms passage; both Twinkles' shortest IOI is
// 985 ms.
//
// WHERE THE `false` COUNT COMES FROM, measured rather than assumed: every one
// of the 14 (seed 1) and 9 (seed 2) is the RIGHT event committed late — 410 ms
// to 24.2 s late, see `lateMs` in the false list. Zero are mis-indexed, zero
// land on an unplayed event, and `early` is 0 on both seeds. On this scenario
// `false` is `drift` seen from the other end, and the precision that matters
// is intact.
//
// THE RESYNC IS CORRECT HERE AND IT IS NOT ENOUGH. 8 armed wide scans per
// seed, 3 fired and 5 refused `ambiguous` on both; `resyncBad` is 0 on both,
// i.e. no jump landed on an event the pianist had not begun. The `ahead 1` in
// the scans line is the ALIGNMENT's own winner, not the landing: the search
// picks one event past the pianist (`top-1 err med 1`, `|err|<=1 1.00`) and
// `resyncLandBack` is what turns that into a landing one event short. Read
// the two together — `ahead` says what the search chose, `resyncBad` says
// where the accompaniment went.
//
// But a jump needs `resyncContext` 16 onsets plus two LOST episodes to arm,
// which is ~2-4 s of music, while a 16-note 125 ms run puts the engine 16
// events behind in 2 s. The mechanism closes a gap it cannot prevent.
//
// WHAT WAS MEASURED AND NOT SHIPPED, so it is not re-tried blind:
// - Loosening the dense-passage accumulation gate (`denseMinEvNats` 2.5->1.5,
// `denseMinEvNatsSoft` 5->3, `denseMinCommitGapMs` 70->40, all of which
// apply ONLY where the score's rolling 5-event median IOI is under
// 250 ms) makes this scenario WORSE, not better: 0.45 -> 0.20, false
// 16 -> 27, and `early` 0 -> 1. Accumulation speed is not the binding gate
// at 125 ms and buying it costs the one thing that may not be traded. Same
// conclusion the engine owner reached on the Haydn replay, now with a
// bench that can see it.
// - `resyncArmLostEpisodes` 2 -> 1 (arm on the FIRST lost episode rather
// than the second) is the single constant that moves this scenario:
// recall 0.45 -> 0.56, false 16 -> 8, drift 28 -> 23, drift run
// 10.3 -> 7.7 s, with all three fires still exact and none ahead. Both
// reference Twinkle replays stay bit-identical (39/42 and 38/42, false 0,
// resync 0) because `resyncArmOnsets` 8 alone excludes them — they never
// exceed 3 onsets between commits. It is NOT shipped here for one measured
// reason: the Haydn replay's false count goes 3 -> 4 (a third jump fires
// at t 83.0 s, 118 -> 131), and `2` is the value whose justification is
// written into the engine as "the measured populations do not overlap".
// Trading a real-trace precision point for a synthetic recall point on the
// strength of a scenario written the same afternoon is the wrong way round.
// It belongs to whoever owns the engine, with the rig run to check it.
// - Removing the jump prior altogether (`resyncMarginNats` 0,
// `resyncPenaltyNats` 0, `resyncMinNewShare` 0, arm on everything, no rate
// limit) DOES reach the recall target — 0.60 (120/200), drift 28 -> 15,
// false 16 -> 5 — and pays for it exactly where Nakamura's s_j sweep says
// it will: `early` 0 -> 1 and one jump landing two events past the pianist
// (#120 while he was on #118). That is the whole argument for the shipped
// 1.5-nat bar in one command, and it doubles as the self-test that the
// `resyncBad` assertion below actually binds: it is the only configuration
// found that trips it.
//
// TARGETS. These are the brief's, and they are a statement of what the tool
// has to do rather than a record of what it does — this scenario is RED and
// is expected to stay red until the detection side of the resync can arm
// without waiting for two LOST episodes. `skipped-note` carries the same
// kind of declared-red target (see its note). None of them may be lowered.
// - recall 0.60. Justified from this bench's own measurements, not chosen to
// pass: the engine's commit latency in the stretches where it IS tracking
// here is 76 ms median / 155 ms p90, at or below the 125 ms fastest decile
// at the median, so there is no structural reason a dense note cannot be
// claimed; and seed 2 already holds 109/200 (0.55) as shipped and 113/200
// (0.56) with the one arming change described above. 0.60 is the nearest
// round number above what a single arming change already reaches.
// - drift 4 events and drift run 4 s are the LOAD-BEARING targets, not
// recall. The product point is that the accompaniment is in the right
// place: an engine that misses half the notes but stays within four
// events is doing its job, and one that catches every third note sixty
// events late is what the user heard as "completely disorganized". 4
// events is ~1 s at this spacing; perfect tracking reads 1 (see DRIFT_OK).
// - false 0 and early 0 are the safety pair and are never relaxed. `early`
// is 0 on both seeds as shipped and must stay there. It has been 1 once:
// before `resyncLandBack`, seed 1 read `early 1` (event 64 committed
// 235 ms BEFORE its onset, 448 ms after a jump that landed on event 62
// while the pianist's last begun event was 61), and `--opt
// resyncMaxJump=0` took it back to 0 — i.e. the jump was what enabled it.
// That is why the landing is now one event short and why `resyncBad` is
// one-sided; if either is ever loosened, re-run this seed first.
'dense-drift': { mut: { score: 'dense' }, tgt: { recall: 0.60, false: 0, drift: 4, driftRun: 4.0 } },
};
// Every scenario, not only the two that name it: the pianist may never be left
// playing for this long with the engine standing still. 5 s is ~3.5 beats at
// the rig's 60 bpm and 1/9 of the real session's stall.
const STALL_MAX_SEC = 5;
function evaluate(m, tgt) {
const fails = [];
if (tgt.recall !== undefined && m.recall < tgt.recall) fails.push(`recall ${m.recall.toFixed(2)} < ${tgt.recall}`);
if (tgt.false !== undefined && m.false > tgt.false) fails.push(`false ${m.false} > ${tgt.false}`);
// Every scenario: no advance ahead of the pianist, ever (see EARLY_MS).
if (m.early > 0) fails.push(`early ${m.early} > 0`);
if (tgt.latMed !== undefined && (m.latencyMedian === null || m.latencyMedian > tgt.latMed)) fails.push(`lat med ${fmt(m.latencyMedian)} > ${tgt.latMed}`);
if (tgt.latP90 !== undefined && (m.latencyP90 === null || m.latencyP90 > tgt.latP90)) fails.push(`lat p90 ${fmt(m.latencyP90)} > ${tgt.latP90}`);
if (tgt.pWrong !== undefined && (m.pWrongMedian === null || m.pWrongMedian < tgt.pWrong)) fails.push(`pWrong ${fmt(m.pWrongMedian, 2)} < ${tgt.pWrong}`);
if (tgt.resync && m.resyncFails > 0) fails.push(`resync failed ${m.resyncFails}×`);
// Every scenario: no jump the engine should not have made (see resyncBad).
if (m.resyncBad > 0) fails.push(`resync landed wrong ${m.resyncBad}×`);
if (tgt.drift !== undefined && m.driftMax > tgt.drift) fails.push(`drift ${m.driftMax} > ${tgt.drift} events`);
if (tgt.driftRun !== undefined && m.driftRunSec > tgt.driftRun) fails.push(`drift run ${fmt(m.driftRunSec, 1)}s > ${tgt.driftRun}s`);
const stallMax = tgt.stall ?? STALL_MAX_SEC;
if (m.stall > stallMax) fails.push(`stall ${fmt(m.stall, 2)}s > ${stallMax}s`);
if (tgt.pWrongRun !== undefined && m.pWrongRunMs / 1000 > tgt.pWrongRun) fails.push(`pWrong run ${fmt(m.pWrongRunMs / 1000, 1)}s > ${tgt.pWrongRun}s`);
return fails;
}
const fmt = (v, d = 0) => (v === null || v === undefined || Number.isNaN(v) ? '-' : Number(v).toFixed(d));
function buildCfg(args, mut) {
const cfg = { ...BASE, ...mut, sr: mut.sr ?? args.sr, opt: { ...(mut.opt || {}), ...args.opt }, trackerDebounce: args.trackerDebounce, reverb: args.reverb, dumpSal: args.dumpSal };
// Explicit CLI flags override the scenario mutation (they are the user's experiment).
if (args.bleed !== undefined) cfg.bleed = args.bleed;
if (args.snr !== undefined) cfg.snr = args.snr;
if (args.hpf !== undefined) cfg.hpf = args.hpf;
if (args.jitter !== undefined) cfg.jitter = args.jitter;
if (args.rubato !== undefined) cfg.rubato = args.rubato;
if (args.bleedLag !== undefined) cfg.bleedLag = args.bleedLag;
if (args.bleedClip !== undefined) cfg.bleedClip = args.bleedClip;
if (args.tempoScale !== undefined) cfg.tempoScale = args.tempoScale;
if (args.synth !== undefined) cfg.synth = args.synth;
return cfg;
}
function runOne(args, scenarioName, mut, seed, hooks) {
const cfg = buildCfg(args, mut);
const score = loadScore({ ...args, score: cfg.score || args.score });
cfg.bps = args.bpm ? args.bpm / 60 : score.bps;
const rng = makeRng(seed * 7919 + 17);
const perf = synthesise(score, cfg, rng);
const res = runEngine(perf, score, cfg, hooks);
const metrics = scoreRun(res, perf);
return { cfg, score, perf, res, metrics };
}
// Worst run over seeds: the metric that would fail hardest wins.
function worstOf(runs, tgt) {
let worst = runs[0];
for (const r of runs) {
const f = evaluate(r.metrics, tgt), fw = evaluate(worst.metrics, tgt);
if (f.length > fw.length) worst = r;
else if (f.length === fw.length && (r.metrics.early > worst.metrics.early || r.metrics.recall < worst.metrics.recall || r.metrics.false > worst.metrics.false || r.metrics.stall > worst.metrics.stall || r.metrics.driftMax > worst.metrics.driftMax)) worst = r;
}
return worst;
}
// Per-event timeline: what the engine did around each true onset.
function printTimeline(run) {
const { perf, res } = run;
const acc = res.advances.filter((a) => a.accepted);
for (const e of perf.events) {
const win = acc.filter((a) => a.toIndex === e.k);
const t0 = e.T * 1000;
const holds = {};
for (const h of res.holds) if (h.tMs - SIM_TRANSPORT_MS >= t0 - 50 && h.tMs - SIM_TRANSPORT_MS <= t0 + 450 && h.index === e.k) holds[h.reason] = (holds[h.reason] || 0) + 1;
const hit = win.map((a) => `${Math.round(a.audioMs - t0)}ms${a.repeatLike ? ' rep' : ''} p${a.p.toFixed(2)} ev${a.ev.toFixed(1)}`).join('; ');
const pitches = e.pitches.map(pitchName).join('+');
console.log(` #${String(e.k).padStart(2)} ${pitches.padEnd(6)} beat ${String(e.beat).padStart(3)} T ${(e.T).toFixed(2)}s ${e.kind.padEnd(7)} ${hit ? 'commit +' + hit : 'MISS'} holds ${JSON.stringify(holds)}`);
}
}
// ---------------------------------------------------------------------------
// Debug outputs (§8.5)
// ---------------------------------------------------------------------------
function writeWav(file, mix, sr) {
const n = mix.length, buf = Buffer.alloc(44 + n * 2);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVE', 8);
buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(sr, 24); buf.writeUInt32LE(sr * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34);
buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(mix[i] * 32767))), 44 + i * 2);
fs.writeFileSync(file, buf);
}
const r1 = (v) => (v === null || v === undefined ? null : Math.round(v * 10) / 10);
const r2 = (v) => (v === null || v === undefined ? null : Math.round(v * 100) / 100);
const r3 = (v) => (v === null || v === undefined ? null : Math.round(v * 1000) / 1000);
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const pitchName = (m) => (Number.isFinite(m) ? NOTE_NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1) : '?');
// NPTrace-shaped file so scripts/trace_report.py renders sim runs unchanged (§8.5).
function buildTrace(run, args) {
const { cfg, score, perf, res } = run;
const t0 = perf.preRoll * 1000; // trace t = ms from piece start
const events = [];
const log = (t, type, data) => events.push({ t: Math.round((t - t0) * 10) / 10, type, ...data });
const solo = normaliseEvents(score.rh);
// One key per EVENT with its lead pitch (the rig types one key per event and
// the follower emits one mic.note per event; a key per chord pitch made the
// report's ground truth steal the next event's note as a wrong pitch).
for (const e of perf.events) if (e.kind !== 'skipped' && e.kind !== 'silent') {
const lead = solo[e.k]?.lead ?? e.pitches[e.pitches.length - 1];
log(e.T * 1000, 'input.key', { midi: lead, name: pitchName(lead), pitches: e.pitches, src: 'sim', index: e.k, kind: e.kind });
}
// The intruders are keys the pianist really pressed: they belong in the
// ground truth even though no advance should ever name them.
for (const e of perf.intruders || []) log(e.T * 1000, 'input.key', { midi: e.pitches[e.pitches.length - 1], name: pitchName(e.pitches[e.pitches.length - 1]), pitches: e.pitches, src: 'sim', index: null, kind: 'intruder' });
const hookFires = run.fires || [];
for (const f of hookFires) log(f.audioMs, 'accomp.fire', { pitches: f.pitches, beat: f.beat });
// atMs on the trace clock (same as the app): --replay passes it straight to accompanimentFired.
for (const f of hookFires) log(f.audioMs, 'follow.accomp', { pitches: f.pitches, beat: f.beat, atMs: Math.round(f.atMs - SIM_TRANSPORT_MS - t0), durMs: Math.round(f.durMs) });
// The sim's own seek (runEngine), recorded the way app.js:6522 records the
// app's. Without it --replay finds no follow.seek, falls back to seekPos 0
// and replays a DIFFERENT session: start-late seed 1 reports recall 0.97 live
// but 0.38 (keys 40, hits 15) replayed from its own --out trace; with the
// event it replays back to 0.97 / false 0.
if (res.seekAudioMs !== null && res.seekAudioMs !== undefined) {
log(res.seekAudioMs, 'follow.seek', { position: perf.startAt, atMs: Math.round(res.seekAudioMs - t0) });
}
let lastBest = null, lastHoldKey = new Map();
for (const { frame, out } of res.frames) {
const audioMs = out.tMs - SIM_TRANSPORT_MS;
const bestChanged = (out.best?.i ?? null) !== lastBest; lastBest = out.best?.i ?? null;
// Every frame, verbose (quantised `sal` + `pk`), the app's FOLLOWER_TRACE 'frames'
// format: that is what `--replay` consumes, so a sim trace replays into itself.
{
const P = new Map(out.posterior.map((p) => [p.i, p.p]));
log(audioMs, 'follow.frame', {
engine: 'follower', k: out.seq, at: frame.at, mode: out.mode, pos: out.position, best: out.best?.i ?? null, p: r3(out.best?.p),
pcom: r3(P.get(out.committed) || 0), pcur: r3(P.get(out.committed + 1) || 0), pnext: r3(P.get(out.committed + 2) || 0), pw: r3(out.pWrong),
ev: r2(out.advance?.ev ?? out.hold?.ev ?? null), db: r1(frame.db), on: r2(out.onset.ratio), peak: frame.peak, inf: out.informative,
sal: Array.from(salience.quantiseSal(frame.sal)), pk: r1(10 * Math.log10(Math.max(...frame.sal) + 1e-20)),
g: frame.gated ? 1 : 0, a: frame.attack ? 1 : 0, fl: r1(frame.floorDb),
ur: out.maskRows || 0, gr: r2(out.bleedRef || 0),
});
}
if (out.state) log(audioMs, 'follow.state', { engine: 'follower', mode: out.state.mode, reason: out.state.reason, pos: out.position, p: r3(out.best?.p) });
if (out.recovered) log(audioMs, 'follow.recover', { engine: 'follower', mode: out.recovered.mode, reason: out.recovered.reason, pos: out.position, pw: r3(out.pWrong) });
if (out.bleed) log(audioMs, 'follow.bleed', { engine: 'follower', pitches: out.bleed.pitches, atMs: Math.round(out.bleed.atMs), seenMs: Math.round(out.bleed.seenMs), lagMs: Math.round(out.bleed.lagMs), strength: r2(out.bleed.strength) });
// THE WIDE JUMP, in its own event types, beside the advance and never
// inside it (see trace_report.py's follow.resync renderer: an advance is
// "the player played this note", a resync is "the player is not here at
// all", and precision may only ever be computed from the first). Without
// these two arms a --out trace showed the tracker teleporting with nothing
// to explain it: `--scenario dense-drift --seed 1 --out <path>` wrote a
// type histogram of follow.advance 84 / tracker 84 and ZERO follow.resync
// for a run that accepted three jumps (13->62, 91->111, 119->144).
// Field names follow app.js traceFollowerResync / the resyncScan log, so
// one report renders a sim trace and a session trace the same way.
if (out.resyncScan) {
const sc = out.resyncScan;
log(audioMs, 'follow.resyncScan', {
engine: 'follower', fromIndex: sc.fromIndex, toIndex: sc.toIndex, distance: sc.distance, refused: sc.refused ?? null,
contextOnsets: sc.contextOnsets, contextMs: sc.contextMs, windowSize: sc.windowSize, reach: sc.reach,
marginNats: r2(sc.marginNats), leadNats: r2(sc.leadNats), meanNewShare: r2(sc.meanNewShare),
mode: sc.mode, lostEpisodes: sc.lostEpisodes, onsetsSinceAccept: sc.onsetsSinceAccept,
});
}
if (out.resync) {
const rs = out.resync, rec = res.resyncs.find((r) => r.id === rs.id);
const accepted = !!rec?.accepted;
log(audioMs, 'follow.resync', {
engine: 'follower', id: rs.id, accepted, reason: accepted ? null : 'tracker refused',
// As in app.js: `fromIndex` is the tracker's convention (the next event
// expected), `engineFrom` the engine's committed one.
fromIndex: rs.fromIndex + 1, toIndex: rs.toIndex, engineFrom: rs.fromIndex,
beat: rs.beat, jump: rs.toIndex - (rs.fromIndex + 1),
onsetsConfirmed: rs.onsetsConfirmed, contextMs: rs.contextMs,
marginNats: r2(rs.marginNats), leadNats: r2(rs.leadNats), meanNewShare: r2(rs.meanNewShare),
windowSize: rs.windowSize, reach: rs.reach,
sinceOnsetEvidenceMs: Math.round(rs.sinceOnsetEvidenceMs), mode: rs.mode,
nInSession: res.resyncs.filter((r) => r.id <= rs.id && r.accepted).length,
expected: rs.pitches,
});
// The tracker moved, so the tracker event has to say so — `resync: true`
// is what tells the report this row is not an ordinary advance.
const rev = solo[rs.toIndex], expEv = solo[rs.fromIndex + 1] || null;
log(audioMs, 'tracker', {
src: 'follower', resync: true, input: rev.pitches, inputName: rev.pitches.map(pitchName).join(' '),
posBefore: rs.fromIndex + 1, posAfter: accepted ? rs.toIndex + 1 : rs.fromIndex + 1,
expected: expEv ? expEv.pitches : [], expectedName: expEv ? expEv.pitches.map(pitchName).join(' ') : '', expectedBeat: expEv ? expEv.beat : null,
result: accepted ? 'match' : 'reject', beat: accepted ? rs.beat : null,
engine: 'follower', ev: null, sinceOnsetMs: null, lagMs: Math.round(rs.lagMs), bps: r3(rs.bps), mode: out.mode,
});
}
if (out.advance) {
const adv = out.advance, rec = res.advances.find((a) => a.id === adv.id);
const ev = solo[adv.toIndex], lead = ev.lead;
log(audioMs, 'mic.note', { midi: lead, name: pitchName(lead), engine: 'follower', reason: 'follower', matched: false, clarity: null, freq: null, calibrating: false,
heardMidi: adv.heardMidi, heardName: pitchName(adv.heardMidi), p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev),
sinceOnsetMs: Math.round(adv.sinceOnsetMs), lagMs: Math.round(adv.lagMs), onsetDriven: adv.onsetDriven, repeatLike: adv.repeatLike });
// Same bag as app.js traceTrackerResult (the report reads `result`, `inputName`, `expectedName`, `posAfter`).
const accepted = !!rec?.accepted, expEv = solo[adv.fromIndex + 1] || null;
log(audioMs, 'tracker', {
src: 'follower', input: ev.pitches, inputName: ev.pitches.map(pitchName).join(' '),
posBefore: adv.fromIndex + 1, posAfter: accepted ? adv.toIndex + 1 : adv.fromIndex + 1,
expected: expEv ? expEv.pitches : [], expectedName: expEv ? expEv.pitches.map(pitchName).join(' ') : '', expectedBeat: expEv ? expEv.beat : null,
result: accepted ? 'match' : 'reject', beat: accepted ? adv.beat : null,
engine: 'follower', p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev), sinceOnsetMs: Math.round(adv.sinceOnsetMs), lagMs: Math.round(adv.lagMs), bps: r3(adv.bps), mode: out.mode, repeatLike: adv.repeatLike,
});
log(audioMs, 'follow.advance', { engine: 'follower', id: adv.id, fromIndex: adv.fromIndex, toIndex: adv.toIndex, beat: adv.beat, accepted: !!rec?.accepted, p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev),
sinceOnsetMs: Math.round(adv.sinceOnsetMs), onsetAtMs: Math.round(adv.onsetAtMs), lagMs: Math.round(adv.lagMs), bps: r3(adv.bps),
onsetDriven: adv.onsetDriven, repeatLike: adv.repeatLike, onsetScore: adv.onsetScore, heardMidi: adv.heardMidi,
explained: { bleed: r2(adv.explained.bleed), prev: r2(adv.explained.prev), new: r2(adv.explained.new), other: r2(adv.explained.other) },
hyps: adv.hyps.map((h) => ({ i: h.i, tOn: Math.round(h.tOnMs), bps: r3(h.bps), w: r3(h.w), ev: r2(h.ev) })) });
} else if (out.hold) {
const key = out.hold.reason + ':' + out.hold.index, last = lastHoldKey.get(key);
if (last === undefined || audioMs - last >= 250) {
lastHoldKey.set(key, audioMs);
const h = out.hold;
log(audioMs, 'follow.hold', { engine: 'follower', reason: h.reason, index: h.index, pos: out.position, p: r3(h.p), margin: r3(h.margin), ev: r2(h.ev), onsetSeen: h.onsetSeen, dwellMs: Math.round(h.dwellMs) });
if (h.onsetSeen) {
const exp = solo[h.index];
log(audioMs, 'mic.drop', { engine: 'follower', reason: 'held:' + h.reason, expMidi: exp?.lead ?? null, expName: exp ? pitchName(exp.lead) : null, index: h.index, p: r3(h.p), margin: r3(h.margin), ev: r2(h.ev) });
}
}
}
}
events.sort((a, b) => a.t - b.t);
const rhDur = normaliseEvents(score.rh).map((e, i, arr) => e.durBeats ?? (i + 1 < arr.length ? arr[i + 1].beat - e.beat : 1));
return {
version: 1, app: 'notepilot', startedAt: new Date().toISOString(),
meta: { inputMode: 'mic', micEngine: 'follower', followMode: 'leader', score: score.name, initialBps: cfg.bps, rh: score.rh, lh: score.lh, rhDur,
sim: { scenario: run.scenario, seed: run.seed, cfg: { ...cfg, opt: cfg.opt } },
// No `tag`: that is the app's ?v= cache-buster for /follower.js, and the
// sim imports the module directly, so any value here is a guess that goes
// stale (it was two bumps behind). version is the real identity.
follower: { version: FOLLOWER_VERSION, sr: cfg.sr, win: cfg.opt.win ?? 2048, hop: cfg.opt.hop ?? 512, minMidi: 33, maxMidi: 96, options: res.follower.options } },
events,
};
}
// --replay: feed a verbose session's follow.frame events through a fresh engine (§8.5).
function replay(args) {
const tr = JSON.parse(fs.readFileSync(args.replay, 'utf8'));
const meta = tr.meta || {};
const fmeta = meta.follower || {};
const sr = fmeta.sr || 48000, win = fmeta.win || 2048, hop = fmeta.hop || 512;
const frames = tr.events.filter((e) => e.type === 'follow.frame' && Array.isArray(e.sal));
if (!frames.length) { console.error('replay: no verbose follow.frame events (sal) in ' + args.replay); process.exit(2); }
const engineOpts = { ...(fmeta.options || {}), ...args.opt }; delete engineOpts.win; delete engineOpts.hop;
const follower = new ScoreFollower({ solo: meta.rh, accompaniment: meta.lh || [], bps: meta.initialBps || 1, sampleRate: sr, win, hop, position: 0, options: { ...engineOpts, accompFallback: false } });
const solo = normaliseEvents(meta.rh);
const tracker = new FakeTracker(solo, { bps: meta.initialBps || 1 });
const keys = tr.events.filter((e) => e.type === 'input.key');
const accomp = tr.events.filter((e) => e.type === 'follow.accomp');
let accIdx = 0;
// Auto-skips are part of the session, not noise: skipCurrentNote (app.js:6008)
// moves the tracker AND calls _scoreFollower.seek(state.tracker.position),
// which rebuilds the beam at the new index. A replay that drops them keeps
// arguing for the event the live engine stepped over, so the two engines are
// no longer running the same session. Measured on 20260916-154809 (7 skips,
// all `auto`): without this the replay reads 212 advances / last index 241 /
// 1 spurious resync and diverges at advance #1; with it, 230 / 252 / 0 and
// the first divergence is at advance #153, against the live 237 / 254 / 0.
// `skip.toIndex` IS the `state.tracker.position` app.js passes to seek().
// reason 'out-of-bank' (app.js:7788) is the one skip path that does NOT seek
// the follower — it only walks the tracker — so it must not seek here either.
const skips = tr.events.filter((e) => e.type === 'skip' && Number.isFinite(e.toIndex));
let skIdx = 0;
// The app does TWO things between `new ScoreFollower` and the first frame
// (resetScoreFollower: seek() then seedFollowerAccompaniment()), and a replay
// that skips them is not replaying the same engine. Without the seek the
// committed state is unstamped, so it looks like it has been sounding since
// the page loaded; without the seed the notes already ringing when the engine
// started are missing from the bleed model. Measured on 20260915-083040:
// unpatched, index 0 committed at t=191ms — 1141ms before the first key and
// 1523ms before the live engine's own commit — on frames peaking at MIDI 60,
// i.e. the seeded C4; the replay reported recall 0.45 with `false` 2 for a
// session whose recorded precision is 100 %. With both steps it commits at
// t=1628ms (86ms from the live 1714ms) and reports recall 0.46 / false 1 —
// the live figures — and 0.48 / false 0 once `fl` is used as well.
const seekEv = tr.events.find((e) => e.type === 'follow.seek');
const seedEv = tr.events.find((e) => e.type === 'follow.accomp.seed');
const t0 = frames[0].t;
const seekPos = seekEv && Number.isFinite(seekEv.position) ? seekEv.position : 0;
follower.seek(seekPos, seekEv && Number.isFinite(seekEv.atMs) ? seekEv.atMs : t0);
tracker.position = seekPos;
// Pre-`follow.accomp.seed` traces (before follower 2026-09-15.10) still carry
// the information: an accomp.fire with no follow.accomp beside it is a note
// that sounded before the engine existed, which is exactly what the seed is.
const seedList = seedEv ? (seedEv.list || []) : (() => {
const firstAcc = accomp.length ? accomp[0].t : Infinity;
const durMs = accomp.length ? quantile(accomp.map((a) => a.durMs).filter(Number.isFinite), 0.5) : 1000 / (meta.initialBps || 1);
// "No follow.accomp beside it", not merely "before the first one": the fire
// and the engine's notification are logged microseconds apart (2422.7 vs
// 2422.9 ms in 20260915-083040), so a plain t < firstAcc test seeds the
// first reported fire a second time and doubles its bleed.
const reported = (e) => accomp.some((a) => Math.abs(a.t - e.t) <= 100 && String(a.pitches) === String(e.pitches));
return tr.events.filter((e) => e.type === 'accomp.fire' && e.t < firstAcc && !reported(e))
.map((e) => ({ pitches: e.pitches, atMs: e.t, durMs, beat: e.beat }));
})();
if (seedList.length) follower.seedAccompaniment(seedList);
const hopMs = hop / sr * 1000;
let Lprev = null; const fluxHist = [];
let floorDb = null;
// The level floor comes from the frame's own `fl` (app.js records it for
// exactly this). Only pre-`fl` traces fall back to recomputing it from the
// logged `db` with the analyzer's own rise rate: floorDb drives
// lambda = clamp((db - floorDb - gateDb) / rangeDb, 0, 1), the weight behind
// `informative`, the onset bar and every evidence term, and on
// 20260915-083040 the reconstruction was off by 0.39 dB mean / 1.78 dB max
// over 5750 frames — up to 15 % of rangeDb = 12. Not cosmetic: on that trace
// it moves the replay from recall 0.46 / false 1 / p90 313 ms to 0.48 / 0 /
// 361 ms.
const floorRise = new FrameAnalyzer(sr, { win, hop }).floorRiseDbPerSec * hop / sr;
const advances = [], resyncs = [];
for (const fe of frames) {
while (skIdx < skips.length && skips[skIdx].t <= fe.t) {
const sk = skips[skIdx++];
if (sk.reason !== 'out-of-bank') follower.seek(sk.toIndex, sk.t);
tracker.position = sk.toIndex;
}
while (accIdx < accomp.length && accomp[accIdx].t <= fe.t) { const a = accomp[accIdx++]; follower.accompanimentFired({ pitches: a.pitches, atMs: a.atMs, durMs: a.durMs, beat: a.beat }); }
const sal = salience.dequantiseSal(Uint8Array.from(fe.sal), fe.pk ?? 0); // pk = peak salience power in dB
const db = fe.db ?? -60;
floorDb = Number.isFinite(fe.fl) ? fe.fl : (floorDb === null ? db : Math.min(floorDb + floorRise, db));
let flux = 0; const L = new Float32Array(sal.length);
for (let b = 0; b < sal.length; b++) { L[b] = Math.log(sal[b] + 1e-10); if (Lprev) { const d = L[b] - Lprev[b]; if (d > 0) flux += d; } }
Lprev = L;
let onset = 0;
if (fluxHist.length >= 8) { const s = fluxHist.slice().sort((a, b) => a - b); const med = s.length & 1 ? s[s.length >> 1] : 0.5 * (s[(s.length >> 1) - 1] + s[s.length >> 1]); onset = flux / (med + 1e-6); }
fluxHist.push(flux); if (fluxHist.length > 64) fluxHist.shift();
// The onset ratio comes from the frame's own `on` (app.js records it, like
// `fl` above, for exactly this); the reconstruction beside it is only for
// traces that predate the field. `on` is the ONLY frame field observe()
// reads besides sal/db/floorDb/gated/attack, and reconstructing it from
// the recorded salience cannot be exact: `sal` is quantised to half-dB
// steps against a 0.1 dB frame peak (quantiseSal), and the ratio is a
// sum of 64 LOG differences over a 64-frame median, so the quantisation
// does not average out. Measured over both sessions the error is tiny —
// mean |d| 0.024, p95 0.06 — and that is exactly why it was not caught:
// only 19 frames of 15604 on 20260915-204851 cross the level-scaled flux
// bar at all (onsetThreshRepeat + (onsetThresh - onsetThreshRepeat)*λ),
// and ONE of them disagrees. That one frame is k10074 at 4919 ms, live
// ratio 3.39 against a bar of 3.375 at λ 0.35, reconstructed 3.31: it is
// the onset evidence that confirms index 0, so without it the replay held
// `no-onset-evidence` through the whole first phrase and committed index 0
// at 54.9 s instead of the live engine's 4.99 s. The rest of the session
// followed from there: hits 6 either way, but every posterior after the
// first minute belonged to a session the user never played. With `on` the
// replay reproduces the live commits to the frame (i0 4994 vs 4995 ms,
// i1 8994 vs 8996, i2 10178 vs 10179), which is what a replay is for.
if (Number.isFinite(fe.on)) onset = fe.on;
const rms = 10 ** (db / 20);
// `g`/`a` are the worklet's gate and attack flags as recorded (older verbose traces lack them: treated as false).
const frame = { type: 'frame', seq: fe.k, at: fe.at ?? Math.round(fe.t / 1000 * sr), sr, win, hop, rms, db, floorDb, flux, onset, hfc: 0, sal, peak: 33 + sal.indexOf(Math.max(...sal)), noiseFloor: null, gated: !!fe.g, attack: !!fe.a };
const out = follower.observe(frame, fe.t);
// See the resync block in runEngine: its own emission, its own explicit
// maxJump, never scored as a detection.
if (out.resync) {
const beat = tracker.advanceTo(out.resync.toIndex, { maxJump: out.resync.maxJump, inputPitch: 0 }, fe.t / 1000);
follower.ack(out.resync.id, beat !== null);
if (beat !== null) resyncs.push({ ...out.resync, tMs: out.tMs });
}
if (out.advance) {
const beat = tracker.advanceTo(out.advance.toIndex, { maxJump: out.advance.maxJump, inputPitch: 0 }, fe.t / 1000);
follower.ack(out.advance.id, beat !== null);
if (beat !== null) advances.push({ ...out.advance, tMs: out.tMs });
}
}
// Score against typed keys: same hit rule as the sim (−50..+400 ms), match on midi or ±12.
let hits = 0; const used = new Set(); const lat = [];
for (const k of keys) {
const cand = advances.find((a) => !used.has(a.id) && a.tMs >= k.t - 50 && a.tMs <= k.t + 400 && a.pitches.some((p) => p === k.midi || Math.abs(p - k.midi) === 12));
if (cand) { used.add(cand.id); hits++; lat.push(cand.tMs - k.t); }
}
// GROUND TRUTH WITHOUT A KEYBOARD. `hits`/`falses` above are scored against
// typed keys, and a MICROPHONE session has none: 20260916-154809 carries 0
// `input.key` events, so the unguarded `advances.length - used.size` read
// `false 212` for a session whose recorded precision is 237 advances with
// ZERO false ones — a phantom precision collapse, and the exact reading this
// file's header warns about. When there are no keys, score the replay against
// what the LIVE engine did instead: the trace's own accepted `follow.advance`
// records. Same index, within ±2 s of the recorded commit, each record used
// once and in order; recall is then "how much of the live session did this
// engine reproduce" and `false` is "advances the live engine never made".
let falses = keys.length ? advances.length - used.size : null;
let recall = keys.length ? hits / keys.length : null;
let truth = 'keys';
if (!keys.length) {
const live = tr.events.filter((e) => e.type === 'follow.advance' && e.accepted !== false);
if (live.length) {
const usedA = new Set();
let matched = 0;
for (const rec of live) {
const cand = advances.find((a) => !usedA.has(a.id) && a.toIndex === rec.toIndex && Math.abs(a.tMs - rec.t) <= 2000);
if (cand) { usedA.add(cand.id); matched++; lat.push(cand.tMs - rec.t); }
}
hits = matched;
falses = advances.length - usedA.size;
recall = matched / live.length;
truth = 'follow.advance x' + live.length;
}
}
const ceil = replayCeiling(keys, normaliseEvents(meta.rh));
// SINGLE NOTES AND CHORDS, SCORED SEPARATELY. A two-note event is a different
// problem from a one-note event — the fit has a voice BALANCE to get right as
// well as a pitch (see chordVoices in follower.js) — and one recall number
// over a score that is 95 % single notes hides it completely: on
// 20260916-154809 the session that reads 95 % overall is 95 % on its 241
// single notes and 64 % on its 14 chords. "Detected" is "some advance landed
// on that index", over the events up to the furthest one reached, which is
// the same population the engine had a chance at.
const det = new Set(advances.map((a) => a.toIndex));
const reached = advances.length ? Math.max(...advances.map((a) => a.toIndex)) : -1;
let nS = 0, hS = 0, nC = 0, hC = 0; const missC = [];
for (let i = 0; i <= reached; i++) {
const ps = solo[i] ? solo[i].pitches : [];
if (ps.length > 1) { nC++; if (det.has(i)) hC++; else missC.push(`${i}[${ps.join(',')}]`); }
else if (ps.length === 1) { nS++; if (det.has(i)) hS++; }
}
// PRECISION WITHOUT A KEYBOARD. `false` above counts advances the live engine
// did not make at the same time, and most of those are the SAME event
// committed earlier or later than the live session managed — not a note the
// player never played. The failure that actually matters is the engine
// getting AHEAD of the player, so this reports the worst the replay's
// committed index ever ran over the live session's, that session's own
// precision being the reference (20260916-154809: 237 advances, zero false).
let lead = null;
if (truth !== 'keys') {
const liveAdv = tr.events.filter((e) => e.type === 'follow.advance' && e.accepted !== false).slice().sort((a, b) => a.t - b.t);
let li = 0, lc = -1, ri = 0, rc = -1;
lead = -Infinity;
for (const t of [...new Set(liveAdv.map((a) => a.t).concat(advances.map((a) => a.tMs)))].sort((a, b) => a - b)) {
while (li < liveAdv.length && liveAdv[li].t <= t) lc = liveAdv[li++].toIndex;
while (ri < advances.length && advances[ri].tMs <= t) rc = advances[ri++].toIndex;
if (rc - lc > lead) lead = rc - lc;
}
}
const m = { keys: keys.length, hits, recall, false: falses, truth, advances: advances.length, latencyMedian: quantile(lat, 0.5), latencyP90: quantile(lat, 0.9), ceiling: ceil.exact, ceilingOctave: ceil.octave, resyncs: resyncs.length,
reached, singles: `${hS}/${nS}`, chords: `${hC}/${nC}`, chordRate: nC ? hC / nC : null, leadOverLive: Number.isFinite(lead) ? lead : null };
if (args.json) console.log(JSON.stringify(m));
else {
console.log(`replay ${path.basename(args.replay)}: keys ${m.keys} hits ${hits} recall ${fmt(m.recall, 2)} false ${fmt(falses, 0)} adv ${advances.length} latency med ${fmt(m.latencyMedian)} p90 ${fmt(m.latencyP90)} ms resync ${resyncs.length}`);
if (truth !== 'keys') console.log(` no keyboard ground truth in this trace (0 input.key); scored against the session's own ${truth} records, so recall = share of the live commits this engine reproduced`);
console.log(` reached event ${reached} singles ${m.singles} (${fmt(100 * hS / Math.max(1, nS))}%) CHORDS ${m.chords} (${nC ? fmt(100 * hC / nC) : '-'}%)${missC.length ? ' missed: ' + missC.join(' ') : ''}`);
if (m.leadOverLive !== null) console.log(` worst lead over the live engine's committed index: ${m.leadOverLive >= 0 ? '+' : ''}${m.leadOverLive} events`);
for (const r of resyncs) console.log(` resync t ${(r.tMs / 1000).toFixed(1)}s ${r.fromIndex} -> ${r.toIndex} (+${r.distance}) margin ${fmt(r.marginNats, 2)} lead ${fmt(r.leadNats, 2)} new ${fmt(r.meanNewShare, 2)} ctx ${r.contextOnsets}/${r.contextMs}ms ${r.mode}`);
// The ceiling is a statement about TYPED KEYS (see the note below); with no
// keys there is nothing to take a ceiling of, and printing 0/0 reads as a
// failure rather than an absence.
if (keys.length) console.log(` ceiling ${ceil.exact}/${keys.length} keys are reachable in score order (${ceil.octave} under the scorer's own ±12 rule); hits ${hits} = ${fmt(100 * hits / Math.max(1, ceil.octave))}% of it`);
}
}
// THE CEILING, and why the line above exists. `recall` here is hits over TYPED
// KEYS, and a typed key is not a thing a score follower can always answer: it
// may be a note the score does not contain at that point, and the engine is
// RIGHT to refuse it. On 20260915-083040 that is not a detail — 24 of the 28
// keys the shipped engine "misses" are G2 presses made while the score asks
// for C3 or B2 (the player held G2 down 15.7-21.5 s and again 38.5-53.7 s,
// testing the very note this workflow is about), so they are unreachable by
// anything that does not advance on notes the player did not play.
//
// The bound below is generous on purpose: the longest subsequence of keys that
// can be matched to score events in increasing order, one event per key. It
// ignores timing, ignores the onset gates and lets the engine skip ahead for
// free, so nothing real can beat it. On 20260915-083040 it is 30 of 56 keys
// (35 if octave answers are allowed, which is what the ±12 hit rule does) —
// i.e. "recall 0.50" against a reachable 0.54/0.63, not against 1.00, and the
// headroom this session can show is TWO keys, not twenty-eight. Five template
// rewrites were judged on the difference between 28 and 23; that difference is
// within the alignment noise of a session where the scored hits include a jump
// of five events credited to a G2 because the event landed on was its octave.
function replayCeiling(keys, events) {
const K = keys.map((k) => k.midi), E = events.map((e) => e.pitches);
const lcs = (match) => {
const n = K.length, m = E.length;
let next = new Int32Array(m + 1), cur = new Int32Array(m + 1);
for (let i = n - 1; i >= 0; i--) {
cur = new Int32Array(m + 1);
for (let j = m - 1; j >= 0; j--) {
let best = Math.max(next[j], cur[j + 1]);
if (E[j].some((p) => match(K[i], p))) best = Math.max(best, 1 + next[j + 1]);
cur[j] = best;
}
next = cur;
}
return cur[0];
};
return { exact: lcs((k, p) => k === p), octave: lcs((k, p) => k === p || Math.abs(k - p) === 12) };
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.replay) { replay(args); return; }
const names = args.all ? Object.keys(SCENARIOS) : [args.scenario || 'bleed'];
for (const n of names) if (!SCENARIOS[n]) { console.error('unknown scenario ' + n + '; known: ' + Object.keys(SCENARIOS).join(', ')); process.exit(2); }
const seeds = []; for (let s = 0; s < Math.max(1, args.seeds); s++) seeds.push(args.seed + s);
const summary = {};
const rows = [];
let anyFail = false;
for (const name of names) {
const sc = SCENARIOS[name];
const variants = sc.sweep ? sc.sweep : [sc.mut];
const runs = [];
for (const mut of variants) for (const seed of seeds) {
const fires = [];
// SIM_DEBUG=t0,t1 (seconds of audio) prints one line per frame in that window.
const dbg = process.env.SIM_DEBUG ? process.env.SIM_DEBUG.split(',').map(Number) : null;
const onFrame = dbg ? (frame, out, follower) => {
const t = (out.tMs - SIM_TRANSPORT_MS) / 1000;
if (t < dbg[0] || t > dbg[1]) return;
const post = out.posterior.map((p) => `${p.i}:${p.p.toFixed(2)}`).join(' ');
const ex = out.explained ? `b${out.explained.bleed.toFixed(2)} p${out.explained.prev.toFixed(2)} n${out.explained.new.toFixed(2)} o${out.explained.other.toFixed(2)}` : '-';
let hj = '';
if (process.env.SIM_DEBUG_H && (out.hold || out.advance)) {
const idx = out.hold ? out.hold.index : out.advance.toIndex;
const h = follower._hyps.filter((q) => q.i === idx).sort((a, b) => b.logW - a.logW)[0];
const par = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
if (h) hj = ` H[dist${(h.distinct || 0).toFixed(3)} mask${(() => { try { const m = follower._distinctMask(h.i); const rows = []; for (let b = 0; b < m.length; b++) if (m[b]) rows.push(b + 33); return rows.join('/'); } catch (e) { return '?'; } })()} cap${h.pvCap === Infinity ? 'inf' : h.pvCap.toFixed(0)} gPv${h.gPv.toFixed(0)} gNw${h.gNw.toFixed(0)} born${((h.born - SIM_TRANSPORT_MS) / 1000).toFixed(2)} tOn${((h.tOn - SIM_TRANSPORT_MS) / 1000).toFixed(2)} ${h.onsetDriven ? 'on' : 'soft'}]`;
if (h && par && process.env.SIM_DEBUG_ELL) {
let X2 = 0; for (const v of follower._x) X2 += v * v;
const Bs = follower._bleedVectors(out.tMs);
const rh = follower._hypLikelihood(h, follower._x, X2, Bs), rp = follower._hypLikelihood(par, follower._x, X2, Bs);
hj += ` acc[${follower._accomp.map((a) => a.pitches.join('+') + '@' + ((a.atMs - SIM_TRANSPORT_MS) / 1000).toFixed(2) + (follower._envA(a, out.tMs) > 0.05 ? '*' : '')).join(' ')}] nB${Bs.length} gH[${Array.from(rh.gains || []).map((g) => g.toFixed(0)).join(',')}] gP[${Array.from(rp.gains || []).map((g) => g.toFixed(0)).join(',')}]`;
hj += ` ell H${rh.ell.toFixed(2)} P${rp.ell.toFixed(2)} rssH${(rh.rss / X2).toFixed(3)} rssP${(rp.rss / X2).toFixed(3)} exH n${rh.explained.new.toFixed(2)} p${rh.explained.prev.toFixed(2)} logW H${h.logW.toFixed(1)} P${par.logW.toFixed(1)}`;
}
if (process.env.SIM_DEBUG_RES && follower._residual) {
const res = follower._residual, x = follower._x;
let X2 = 0; for (const v of x) X2 += v * v;
const top = Array.from(res).map((v, b) => [v * v / X2, b + 33]).sort((a, b) => b[0] - a[0]).slice(0, 8);
hj += ` RES[${top.map(([e, r]) => r + ':' + (e * 100).toFixed(1) + '%').join(' ')}]`;
const hc = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
if (hc && hc.res) hj += ` C[${hc.res.comps.map((c, k) => c.name + '=' + hc.res.gains[k].toFixed(0)).join(' ')} caps f0${hc.nwCapF0 === Infinity ? 'inf' : hc.nwCapF0.toFixed(0)} ov${hc.nwCapOv === Infinity ? 'inf' : hc.nwCapOv.toFixed(0)}]`;
const xt = Array.from(x).map((v, b) => [v * v / X2, b + 33]).sort((a, b) => b[0] - a[0]).slice(0, 8);
hj += ` X[${xt.map(([e, r]) => r + ':' + (e * 100).toFixed(1) + '%').join(' ')}]`;
}
if (h && h.res) { let X2 = 0; for (const v of follower._x) X2 += v * v; hj += ` pitchΔ${follower._pitchCheck(h, follower._x, X2, follower._bleedNow).toFixed(2)}`; }
// The leading alternatives of the pitch gate (q:accumulated nats), so a 'pitch' hold names its rival.
if (h && h.alt && h.alt.size) hj += ` alt[${[...h.alt.entries()].sort((a, b) => b[1].acc - a[1].acc).slice(0, 3).map(([q, v]) => pitchName(q) + ':' + v.acc.toFixed(2) + '/n' + (h.altN - v.n0)).join(' ')} n${h.altN}]`;
hj += ` os${follower._committed + 1 < follower._N ? follower._onsetScore(follower._committed + 1, out.tMs).toFixed(2) : '-'}`;
}
// The committed state's caps on every frame (not only on holds): a cap that
// never armed shows up as 'inf' long before the note it fails to bound.
if (process.env.SIM_DEBUG_H) {
const par = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
if (par) hj += ` P[cap${par.pvCap === Infinity ? 'inf' : par.pvCap.toFixed(0)} L${par.pvL.toFixed(0)} gPv${par.gPv.toFixed(0)} gNw${par.gNw.toFixed(0)} capF0${par.nwCapF0 === Infinity ? 'inf' : par.nwCapF0.toFixed(0)} ex n${par.lastExplained ? par.lastExplained.new.toFixed(2) : '-'} p${par.lastExplained ? par.lastExplained.prev.toFixed(2) : '-'} o${par.lastExplained ? par.lastExplained.other.toFixed(2) : '-'}]`;
}
// SIM_DEBUG_BEAM=1: every live hypothesis (index, weight, evidence, birth, parent) per frame.
if (process.env.SIM_DEBUG_BEAM) {
const beam = follower._hyps.slice().sort((a, b) => b.logW - a.logW).map((h) => `${h.i}${h.repeatLike ? 'r' : ''}${h.onsetDriven ? '' : 's'}<${h.from ?? '-'} w${h.logW.toFixed(1)} ev${h.ev.toFixed(1)} n${h.lastExplained ? h.lastExplained.new.toFixed(2) : '-'} b${((h.born - SIM_TRANSPORT_MS) / 1000).toFixed(2)}`);
hj += ` BEAM[${beam.join(' | ')}] W${follower._wrong.logW.toFixed(1)}`;
}
if (process.env.SIM_DEBUG_X) {
const x = follower._x; const b = follower._best();
const tpl = b && b.i >= 0 && b.i < follower._N ? follower._ev[b.i].template : null;
const row = (v) => Array.from(v).map((q, i) => q > 0.05 ? `${i + 33}:${q.toFixed(2)}` : null).filter(Boolean).join(' ');
console.log(' x ', row(x)); if (tpl) console.log(' Nw ', row(tpl)); if (b && follower._pv[b.i]) console.log(' Pv ', row(follower._pv[b.i]));
}
console.log(`${t.toFixed(3)} ${out.informative ? 'I' : '.'} λ${out.lambda.toFixed(2)} db${frame.db.toFixed(0)} on${out.onset.ratio.toFixed(1)}${out.onset.hit ? '*' : ' '} r${out.onset.rise.toFixed(1)}${out.onset.evidence ? '!' : ' '} pk${frame.peak} best${out.best ? out.best.i : '-'} [${post}] pW${out.pWrong.toFixed(3)} ex ${ex}${out.hold ? ' HOLD ' + out.hold.reason + '@' + out.hold.index + ' p' + out.hold.p.toFixed(2) + ' m' + out.hold.margin.toFixed(2) + ' ev' + out.hold.ev.toFixed(1) + (out.hold.aheadMs !== null ? ' ahead' + Math.round(out.hold.aheadMs) + '/' + Math.round(out.hold.spanMs) + ' bps' + out.hold.bps.toFixed(2) : '') : ''}${out.advance ? ' ADV->' + out.advance.toIndex + ' ev' + out.advance.ev.toFixed(1) + ' new' + out.advance.explained.new.toFixed(2) : ''}${out.state ? ' STATE ' + out.state.mode : ''}${hj}`);
} : null;
const run = runOne(args, name, mut, seed, { onFire: (f) => fires.push(f), onFrame });
run.fires = fires; run.scenario = name; run.seed = seed; run.mut = mut;
runs.push(run);
}
const worst = worstOf(runs, sc.tgt);
const fails = evaluate(worst.metrics, sc.tgt);
if (fails.length) anyFail = true;
const m = worst.metrics;
summary[name] = { recall: r3(m.recall), false: m.false, early: m.early, missed: m.missed, latencyMedian: r1(m.latencyMedian), latencyP90: r1(m.latencyP90), onsetErrMedian: r1(m.onsetErrMedian), tempoRmse: r3(m.tempoRmse), pass: fails.length === 0, seed: worst.seed, variant: worst.mut };
rows.push({ name, m, fails, worst });
if (!args.quiet && !args.json) {
const label = sc.sweep ? `${name} (${Object.entries(worst.mut).map(([k, v]) => k + ' ' + (v && typeof v === 'object' ? JSON.stringify(v) : v)).join(' ')})` : name;
console.log(`${(fails.length ? 'FAIL' : 'ok ')} ${label.padEnd(24)} recall ${fmt(m.recall, 2)} (${m.hits}/${m.played}) false ${m.false} early ${m.early} miss ${m.missed} lat ${fmt(m.latencyMedian)}/${fmt(m.latencyP90)} ms onset ${fmt(m.onsetErrMedian)}/${fmt(m.onsetErrP90)} tempo ${fmt(m.tempoRmse, 3)} rej ${m.rejects} stall ${fmt(m.stall, 2)}s drift ${m.driftMax}/${fmt(m.driftRunSec, 1)}s resync ${m.resyncs}${m.resyncBad ? '!' + m.resyncBad : ''} pW ${fmt(m.pWrongMedian, 2)}/${fmt(m.pWrongRunMs / 1000, 1)}s seed ${worst.seed}${fails.length ? ' <- ' + fails.join('; ') : ''}`);
if (fails.length || process.env.SIM_VERBOSE) {
if (m.falseList.length) console.log(' false:', JSON.stringify(m.falseList));
if (m.earlyList.length) console.log(' early:', JSON.stringify(m.earlyList));
if (m.resyncBadList.length) console.log(' resync landed wrong:', JSON.stringify(m.resyncBadList));
if (m.missedList.length) console.log(' missed:', JSON.stringify(m.missedList));
console.log(' holds:', JSON.stringify(m.holdsByReason), ' states:', m.states.join(' '), ' bleedLag med', fmt(m.bleedLagMedian));
// Every armed wide scan and why it did or did not fire — the resync's
// own audit line (follower.js out.resyncScan), so a scenario that
// drifts can be told from one whose search was refused.
console.log(' wide scans:', m.scans, JSON.stringify(m.scansByReason), ' margin med/p90', fmt(m.scanMarginMed, 2), '/', fmt(m.scanMarginP90, 2), ' lead med', fmt(m.scanLeadMed, 2), ' dist med', fmt(m.scanDistMed),
' top-1 err med', fmt(m.scanErrMed), ' |err|<=1', fmt(m.scanErrAbs1, 2), ' |err|<=2', fmt(m.scanErrAbs2, 2), ' ahead', m.scanErrAhead);
}
}
if (args.timeline) printTimeline(worst);
if (args.wav && names.length === 1) writeWav(args.wav, worst.perf.mix, worst.cfg.sr);
if (args.out && names.length === 1) fs.writeFileSync(args.out, JSON.stringify(buildTrace(worst, args)));
}
if (args.json) console.log(JSON.stringify(summary, null, 1));
else if (!args.quiet) {
const passed = rows.filter((r) => !r.fails.length).length;
console.log(`${passed}/${rows.length} scenarios pass (seeds ${seeds.join(',')}; engine ${FOLLOWER_VERSION})`);
}
process.exit(anyFail ? 1 : 0);
}
main();
|