File size: 70,856 Bytes
b84872e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d8ae0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b84872e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d8ae0
b84872e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e02e89b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b84872e
 
 
 
 
 
 
 
 
 
 
 
 
 
e02e89b
b84872e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/*
 * GENERATED by build_bundle.py -- do not edit.
 *
 * The app's ES modules concatenated into one classic script. A private
 * Space's cross-site iframe does not authenticate the module fetches issued
 * while the page is parsing (they 401, and the module map then caches that
 * failure), whereas classic scripts always carry the cookie. Edit the
 * sources listed below and re-run build_bundle.py.
 *
 * Sources, in dependency order: ctc-frontend.js, ctc-tokenizer.js, ctc-engine.js, ctc-streaming.js, app.js
 */
(() => {
'use strict';
// Stands in for the module namespace: each module destructures what it
// imports off this and assigns its exports back onto it.
const __M = {};

// ==== ctc-frontend.js ===================================================
(() => {
/**
 * Audio front-end for the FastConformer CTC model.
 *
 * A port of the model's `CtcConformerProcessor._frontend` (log-mel + deltas +
 * frame stacking) plus the sidecar's `_agc`. The mel filterbank and the STFT
 * window are NOT recomputed here -- they are loaded from `mel_filters.bin` /
 * `stft_window.bin`, dumped by export_ctc_onnx.py straight out of the torchaudio
 * objects the Python path uses. Reimplementing torchaudio's htk mel scale
 * (norm=None) and torch.stft's window centring in JS is the most likely place for
 * a silent mismatch, and a front-end that is subtly wrong degrades WER without
 * ever raising an error.
 *
 * Verified bit-for-bit against the Python features by test-frontend.mjs.
 */

const EPS = 1e-10;

/** In-place iterative radix-2 complex FFT (Cooley-Tukey, decimation in time). */
class FFT {
    constructor(size) {
        this.size = size;
        this.levels = Math.log2(size) | 0;
        if (2 ** this.levels !== size) throw new Error(`FFT size ${size} is not a power of two`);

        // Bit-reversal permutation and twiddle tables, precomputed once: the
        // front-end runs this transform ~100 times per second of audio.
        this.rev = new Uint32Array(size);
        for (let i = 0; i < size; i++) {
            let r = 0;
            for (let b = 0; b < this.levels; b++) r |= ((i >> b) & 1) << (this.levels - 1 - b);
            this.rev[i] = r;
        }
        this.cos = new Float32Array(size / 2);
        this.sin = new Float32Array(size / 2);
        for (let i = 0; i < size / 2; i++) {
            this.cos[i] = Math.cos((-2 * Math.PI * i) / size);
            this.sin[i] = Math.sin((-2 * Math.PI * i) / size);
        }
    }

    /** Transform `re`/`im` in place (both length `size`). */
    run(re, im) {
        const { size, rev, cos, sin } = this;
        for (let i = 0; i < size; i++) {
            const j = rev[i];
            if (j > i) {
                let t = re[i]; re[i] = re[j]; re[j] = t;
                t = im[i]; im[i] = im[j]; im[j] = t;
            }
        }
        for (let len = 2; len <= size; len <<= 1) {
            const half = len >> 1;
            const step = size / len;
            for (let i = 0; i < size; i += len) {
                for (let j = 0, k = 0; j < half; j++, k += step) {
                    const c = cos[k], s = sin[k];
                    const a = i + j, b = a + half;
                    const tre = re[b] * c - im[b] * s;
                    const tim = re[b] * s + im[b] * c;
                    re[b] = re[a] - tre; im[b] = im[a] - tim;
                    re[a] += tre; im[a] += tim;
                }
            }
        }
    }
}

/**
 * Centred moving average, cumsum-based (O(n)).
 *
 * Mirrors the sidecar's `_moving_avg`, including its edge handling: the valid
 * region is padded by replicating the first/last valid value, with the extra
 * sample going on the right when the window is even.
 */
function movingAvg(v, win) {
    const n = v.length;
    win = Math.max(1, win | 0);
    if (win <= 1 || n <= 1) return v;
    win = Math.min(win, n);

    // float64 accumulator: a float32 cumsum over ~10 s of samples loses enough
    // precision to shift the gain envelope (the Python side uses float64 too).
    const csum = new Float64Array(n + 1);
    for (let i = 0; i < n; i++) csum[i + 1] = csum[i] + v[i];

    const valid = n - win + 1;
    const padL = (win - 1) >> 1;
    const out = new Float32Array(n);
    const first = (csum[win] - csum[0]) / win;
    const last = (csum[n] - csum[n - win]) / win;
    for (let i = 0; i < n; i++) {
        const k = i - padL;
        out[i] = k < 0 ? first : k >= valid ? last : (csum[k + win] - csum[k]) / win;
    }
    return out;
}

/**
 * Automatic gain control -- port of the sidecar's `_agc`.
 *
 * Not cosmetic: the CTC front-end normalizes log-mel against the clip's *global
 * peak*, so a quiet tail 20-40 dB below a loud head gets floored and silently
 * dropped. A smoothed moving-RMS gain toward `target` equalizes the levels.
 */
function agc(x, { sampleRate = 16000, target = 0.12, winMs = 150, maxGain = 20 } = {}) {
    const n = x.length;
    if (n === 0) return x;
    const win = Math.max(1, Math.round((sampleRate * winMs) / 1000));

    const sq = new Float32Array(n);
    for (let i = 0; i < n; i++) sq[i] = x[i] * x[i];
    const ms = movingAvg(sq, win);

    const g = new Float32Array(n);
    for (let i = 0; i < n; i++) {
        const env = Math.sqrt(ms[i] + 1e-9);
        g[i] = Math.min(Math.max(target / Math.max(env, 1e-4), 0), maxGain);
    }
    const gs = movingAvg(g, win); // smooth the gain to avoid pumping

    const y = new Float32Array(n);
    let peak = 0;
    for (let i = 0; i < n; i++) {
        y[i] = x[i] * gs[i];
        const a = Math.abs(y[i]);
        if (a > peak) peak = a;
    }
    if (peak > 0.97) {
        const k = 0.97 / peak;
        for (let i = 0; i < n; i++) y[i] *= k;
    }
    return y;
}

class CtcFrontend {
    constructor(config, melFilters, window) {
        this.config = config;
        this.melFilters = melFilters; // [n_mels][n_freqs], mel-major
        this.window = window;         // n_fft, already centred by the exporter
        this.fft = new FFT(config.n_fft);
        this.nFreqs = config.n_fft / 2 + 1;
    }

    static async load(baseUrl = './onnx-ctc/') {
        const get = async (name, as) => {
            const r = await fetch(baseUrl + name);
            if (!r.ok) throw new Error(`front-end asset ${name}: HTTP ${r.status}`);
            return as === 'json' ? r.json() : new Float32Array(await r.arrayBuffer());
        };
        const [config, melFilters, window] = await Promise.all([
            get('frontend_config.json', 'json'),
            get('mel_filters.bin'),
            get('stft_window.bin'),
        ]);
        return new CtcFrontend(config, melFilters, window);
    }

    /** Feature frames a clip of `nSamples` produces (before padding to pad_multiple). */
    frameCount(nSamples) {
        const s = this.config.stack_factor;
        const melFrames = Math.floor(nSamples / this.config.hop_length);
        return s * Math.ceil(melFrames / s);
    }

    /**
     * waveform (Float32Array, 16 kHz mono) -> stacked log-mel(+delta) features.
     *
     * Returns `{ data, frames, dim }` where `data` is [frames, dim] row-major,
     * ready to become the model's `input_features` tensor.
     */
    compute(wav) {
        const { hop_length: hop, n_fft: nFft, n_mels: nMels, stack_factor: stack,
                logmel_floor_db: floorDb, deltas: useDeltas } = this.config;
        const nFrames = this.frameCount(wav.length);
        if (nFrames === 0) return { data: new Float32Array(0), frames: 0, dim: this.config.input_dim };

        // The processor right-pads the waveform so the trailing partial stack
        // group is filled rather than dropped, then slices to exactly nFrames.
        const need = (nFrames - 1) * hop + 1;
        let x = wav;
        if (x.length < need) {
            x = new Float32Array(need);
            x.set(wav);
        }

        // torch.stft(center=True, pad_mode='reflect'): reflect-pad by n_fft/2 so
        // frame i is centred on sample i*hop.
        const half = nFft >> 1;
        const padded = new Float32Array(x.length + nFft);
        padded.set(x, half);
        for (let i = 0; i < half; i++) {
            padded[half - 1 - i] = x[Math.min(i + 1, x.length - 1)];
            padded[half + x.length + i] = x[Math.max(x.length - 2 - i, 0)];
        }

        // Mel spectrogram, mel-major ([nMels][nFrames]) because deltas run along time.
        const mel = new Float32Array(nMels * nFrames);
        const re = new Float32Array(nFft);
        const im = new Float32Array(nFft);
        const power = new Float32Array(this.nFreqs);
        for (let t = 0; t < nFrames; t++) {
            const off = t * hop;
            for (let i = 0; i < nFft; i++) {
                re[i] = padded[off + i] * this.window[i];
                im[i] = 0;
            }
            this.fft.run(re, im);
            for (let k = 0; k < this.nFreqs; k++) power[k] = re[k] * re[k] + im[k] * im[k];
            for (let m = 0; m < nMels; m++) {
                const fb = m * this.nFreqs;
                let acc = 0;
                for (let k = 0; k < this.nFreqs; k++) acc += this.melFilters[fb + k] * power[k];
                mel[m * nFrames + t] = acc;
            }
        }

        // log10 with a floor relative to the clip's global peak, then the /4 +1
        // scaling the model was trained with.
        let mx = -Infinity;
        for (let i = 0; i < mel.length; i++) {
            const v = Math.log10(Math.max(mel[i], EPS));
            mel[i] = v;
            if (v > mx) mx = v;
        }
        const floor = mx - floorDb;
        for (let i = 0; i < mel.length; i++) mel[i] = Math.max(mel[i], floor) / 4 + 1;

        // Deltas: torchaudio.functional.compute_deltas(win_length=3) is
        // (x[t+1] - x[t-1]) / 2 with replicate edge padding.
        const nCh = useDeltas ? nMels * 2 : nMels;
        const chans = new Float32Array(nCh * nFrames);
        chans.set(mel);
        if (useDeltas) {
            for (let m = 0; m < nMels; m++) {
                const src = m * nFrames;
                const dst = (nMels + m) * nFrames;
                for (let t = 0; t < nFrames; t++) {
                    const prev = mel[src + Math.max(t - 1, 0)];
                    const next = mel[src + Math.min(t + 1, nFrames - 1)];
                    chans[dst + t] = (next - prev) / 2;
                }
            }
        }

        // Stack: (nCh, nFrames) -> (nFrames/stack, stack*nCh), i.e. row j is the
        // channels of frame stack*j followed by those of frame stack*j+1.
        const outFrames = nFrames / stack;
        const dim = stack * nCh;
        const data = new Float32Array(outFrames * dim);
        for (let j = 0; j < outFrames; j++) {
            for (let k = 0; k < stack; k++) {
                const t = j * stack + k;
                const base = j * dim + k * nCh;
                for (let c = 0; c < nCh; c++) data[base + c] = chans[c * nFrames + t];
            }
        }
        return { data, frames: outFrames, dim };
    }
}

Object.assign(__M, { agc, CtcFrontend });
})();

// ==== ctc-tokenizer.js ==================================================
(() => {
/**
 * Decoder for the model's 16384-entry ByteLevel BPE vocabulary.
 *
 * Only decoding is needed (the model never encodes text), so export_ctc_onnx.py
 * dumps the id->piece slice that can reach the decoder as vocab.json (~170 KB)
 * instead of shipping the whole tokenizer.json.
 *
 * granite-speech-4.2-470m-turboctc uses a GPT-2 style **ByteLevel** tokenizer:
 * every byte 0-255 is represented by one printable codepoint (space is `Ġ`), so a
 * piece is a string of byte-stand-ins rather than text, and one UTF-8 character
 * may be split across several pieces ("é" arrives as "Ã" + "©"). Decoding is
 * therefore: map each character back to its byte, concatenate, then UTF-8 decode
 * once at the end -- never per piece, or multi-byte characters break.
 *
 * (The earlier C-T26.18 build used a SentencePiece tokenizer whose decoder was
 * Replace(U+2581 -> space) / ByteFallback / Fuse / Strip. That is a different
 * algorithm and would silently produce mojibake here, so the exporter asserts the
 * tokenizer's decoder type matches this implementation.)
 */

/**
 * GPT-2's byte <-> codepoint table, inverted to codepoint -> byte.
 *
 * The 188 bytes that are already printable map to themselves; the remaining 68
 * are moved up to U+0100 and beyond so no piece ever contains a control
 * character or a bare space.
 */
function byteLevelCharToByte() {
    const bytes = [];
    for (let b = 0x21; b <= 0x7e; b++) bytes.push(b); // !..~
    for (let b = 0xa1; b <= 0xac; b++) bytes.push(b); // ¡..¬
    for (let b = 0xae; b <= 0xff; b++) bytes.push(b); // ®..ÿ
    const codepoints = bytes.slice();
    const printable = new Set(bytes);
    let next = 0;
    for (let b = 0; b < 256; b++) {
        if (!printable.has(b)) {
            bytes.push(b);
            codepoints.push(256 + next);
            next++;
        }
    }
    const map = new Map();
    for (let i = 0; i < bytes.length; i++) map.set(codepoints[i], bytes[i]);
    return map;
}

class CtcDecoder {
    constructor(pieces, numSpecialTokens) {
        this.numSpecialTokens = numSpecialTokens;
        const charToByte = byteLevelCharToByte();
        // Pre-resolve every piece to its bytes, so decoding a frame of ids is a
        // few array copies rather than per-call string work.
        this.encoded = new Array(pieces.length);
        for (let i = 0; i < pieces.length; i++) {
            const piece = pieces[i];
            const bytes = new Uint8Array(piece.length);
            let n = 0;
            for (const ch of piece) {
                const b = charToByte.get(ch.codePointAt(0));
                // A codepoint outside the table means this vocab was not produced
                // by a ByteLevel tokenizer; skip rather than emit a wrong byte.
                if (b !== undefined) bytes[n++] = b;
            }
            this.encoded[i] = bytes.subarray(0, n);
        }
        this.utf8Decoder = new TextDecoder('utf-8');
    }

    static async load(baseUrl = './onnx-ctc/') {
        const [vocab, config] = await Promise.all([
            fetch(baseUrl + 'vocab.json').then((r) => {
                if (!r.ok) throw new Error(`vocab.json: HTTP ${r.status}`);
                return r.json();
            }),
            fetch(baseUrl + 'frontend_config.json').then((r) => {
                if (!r.ok) throw new Error(`frontend_config.json: HTTP ${r.status}`);
                return r.json();
            }),
        ]);
        return new CtcDecoder(vocab, config.num_special_tokens);
    }

    /** Decode content ids (all >= numSpecialTokens) to text. */
    decode(ids) {
        let total = 0;
        for (const id of ids) {
            const e = this.encoded[id - this.numSpecialTokens];
            if (e) total += e.length;
        }
        const buf = new Uint8Array(total);
        let at = 0;
        for (const id of ids) {
            const e = this.encoded[id - this.numSpecialTokens];
            if (!e) continue; // out-of-range id: skip rather than poison the string
            buf.set(e, at);
            at += e.length;
        }
        return this.utf8Decoder.decode(buf);
    }
}

/**
 * Greedy CTC collapse: drop repeats, then the blank, then any remaining
 * below-content ids.
 *
 * Order matters and matches the Python path: repeats are collapsed against the
 * *raw* previous id (blanks included), which is what lets a blank between two
 * identical tokens preserve both. For this model `numSpecialTokens` is 1 -- the
 * CTC blank at id 0 is the only non-text id, and everything above it is a
 * tokenizer id as-is.
 */
function ctcCollapse(ids, { blankId = 0, numSpecialTokens = 1 } = {}) {
    const out = [];
    let prev = -1;
    for (let i = 0; i < ids.length; i++) {
        const id = ids[i];
        if (id !== prev && id !== blankId && id >= numSpecialTokens) out.push(id);
        prev = id;
    }
    return out;
}

Object.assign(__M, { CtcDecoder, ctcCollapse });
})();

// ==== ctc-engine.js =====================================================
(() => {
const { CtcFrontend, agc, CtcDecoder, ctcCollapse } = __M;
/**
 * FastConformer CTC inference engine (onnxruntime-web, WebGPU).
 *
 * The model is a custom `ctc_conformer` architecture with no transformers.js
 * support, so this drives an ONNX session directly: features -> one forward pass
 * -> greedy CTC collapse. There is no autoregressive loop, no KV cache and no
 * chat template; a window is one `session.run`.
 *
 * Two contracts come from export_ctc_onnx.py and must hold here:
 *
 *  - **Frames are padded to `pad_multiple`.** The encoder chunks attention into
 *    context_size (128) blocks at every layer and subsamples time by 4, so a
 *    frame count with no partial block anywhere is a multiple of 512 (10.24 s).
 *    The exported graph therefore has no tail-block path, and short windows are
 *    padded up with `padding_mask` marking the pad. Measured cost of the masked
 *    padding on granite-speech-4.2-470m-turboctc: none -- all 14 fixture clips
 *    decode identically to the unpadded path, with zero per-frame argmax flips.
 *    (Its encoder masks inside the conv module specifically so a padded frame
 *    cannot leak through the stride-2 kernel into the last valid frames.)
 *
 *  - **The graph returns ids, not logits.** `[1, T/4, 16384]` float32 logits
 *    would be an ~8 MB device-to-host copy per window; argmax and the
 *    log-softmax max happen inside the graph instead, so a window costs ~1 KB of
 *    output. `top_logprob` is the confidence signal the streaming gate uses.
 */



const CACHE_NAME = 'granite-ctc-models-v1';

/**
 * Cache-first fetch, so a reload doesn't re-download hundreds of MB.
 *
 * The body is streamed to completion first and only then handed to the cache.
 * Doing it the other way round -- `await cache.put(response.clone())` before
 * reading -- makes the tee buffer the entire 312 MB while the cache write drains
 * it, so the progress callback fires nothing for minutes and the UI looks hung.
 * A failed cache write (quota, private mode) must not fail the load either: the
 * bytes are already in hand, and the only cost is re-downloading next time.
 */
async function cachedFetch(url, onProgress) {
    const cache = await caches.open(CACHE_NAME).catch(() => null);
    const hit = await cache?.match(url).catch(() => null);
    if (hit) {
        onProgress?.({ url, loaded: 1, total: 1, cached: true });
        return hit.arrayBuffer();
    }

    const response = await fetch(url);
    if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);

    const total = Number(response.headers.get('content-length')) || 0;
    let buffer;
    if (!response.body) {
        buffer = await response.arrayBuffer();
    } else {
        const reader = response.body.getReader();
        const chunks = [];
        let loaded = 0;
        for (;;) {
            const { done, value } = await reader.read();
            if (done) break;
            chunks.push(value);
            loaded += value.length;
            onProgress?.({ url, loaded, total });
        }
        const bytes = new Uint8Array(loaded);
        let at = 0;
        for (const c of chunks) { bytes.set(c, at); at += c.length; }
        buffer = bytes.buffer;
    }

    if (cache) {
        try {
            await cache.put(url, new Response(buffer.slice(0), {
                headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.byteLength) },
            }));
        } catch (e) {
            console.warn(`could not cache ${url} (will re-download next time):`, e);
        }
    }
    return buffer;
}

class CtcEngine {
    constructor({ session, frontend, decoder, config, device }) {
        this.session = session;
        this.frontend = frontend;
        this.decoder = decoder;
        this.config = config;
        this.device = device;
    }

    /**
     * Create the session and load the front-end assets.
     *
     * `device: 'webgpu'` falls back to wasm if the adapter or a kernel is
     * missing -- a CTC pass is still usable on wasm, just slower.
     */
    static async load({
        baseUrl = './onnx-ctc/',
        modelFile = 'ctc_conformer_q4f16-b16.onnx',
        device = 'webgpu',
        onProgress = null,
        allowFallback = true,
    } = {}) {
        const [frontend, decoder] = await Promise.all([
            CtcFrontend.load(baseUrl),
            CtcDecoder.load(baseUrl),
        ]);

        const modelUrl = baseUrl + modelFile;
        // The exporter writes weights to `<model>.onnx.data` and records that
        // basename inside the graph, so the session's externalData path must
        // match it exactly.
        const dataName = `${modelFile}.data`;
        const [modelBuffer, dataBuffer] = await Promise.all([
            cachedFetch(modelUrl, onProgress),
            cachedFetch(baseUrl + dataName, onProgress),
        ]);

        const options = {
            executionProviders: [device],
            externalData: [{ path: dataName, data: dataBuffer }],
            graphOptimizationLevel: 'all',
        };
        let session;
        let used = device;
        let fallbackError = null;
        try {
            session = await ort.InferenceSession.create(modelBuffer, options);
        } catch (e) {
            if (device !== 'webgpu' || !allowFallback) throw e;
            console.warn('WebGPU session failed, falling back to wasm:', e);
            fallbackError = String(e?.message || e);
            used = 'wasm';
            session = await ort.InferenceSession.create(modelBuffer, {
                ...options,
                executionProviders: ['wasm'],
            });
        }
        const engine = new CtcEngine({ session, frontend, decoder, config: frontend.config, device: used });
        // A WebGPU kernel can also fail on its *first run* rather than at session
        // build (ORT-web reports unsupported MatMulNBits shapes that way), so the
        // warmup pass is where a fallback is really decided.
        const warmupError = await engine.warmup();
        if (warmupError && used === 'webgpu') {
            if (!allowFallback) throw new Error(`WebGPU warmup failed: ${warmupError}`);
            console.warn('WebGPU warmup failed, falling back to wasm:', warmupError);
            fallbackError = warmupError;
            engine.device = 'wasm';
            engine.session = await ort.InferenceSession.create(modelBuffer, {
                ...options,
                executionProviders: ['wasm'],
            });
            await engine.warmup();
        }
        engine.fallbackError = fallbackError;
        return engine;
    }

    /**
     * Run one throwaway pass so shader compilation doesn't land on the user's
     * first spoken window (measured on WebGPU: 1271 ms first call vs 295 ms
     * steady state). Every window is padded to the same frame count, so a single
     * warmup covers the shapes the streaming loop will use.
     */
    async warmup() {
        try {
            const frames = this.config.pad_multiple;
            await this.session.run({
                input_features: new ort.Tensor('float32', new Float32Array(frames * this.config.input_dim), [1, frames, this.config.input_dim]),
                padding_mask: new ort.Tensor('float32', new Float32Array(frames), [1, frames]),
            });
            return null;
        } catch (e) {
            return String(e?.message || e);
        }
    }

    /** Encoder output frames covering `frames` of real audio (both subsample blocks floor-halve). */
    static realOutFrames(frames) {
        return Math.floor(Math.floor(frames / 2) / 2);
    }

    /**
     * Transcribe a waveform (Float32Array, 16 kHz mono).
     *
     * `applyAgc` mirrors the sidecar: the log-mel front-end normalizes against
     * the clip's global peak, so without it a quiet tail after a loud passage is
     * floored away and its words are silently dropped.
     */
    async transcribe(wav, { applyAgc = true } = {}) {
        const t0 = performance.now();
        const audio = applyAgc ? agc(wav, { sampleRate: this.config.sample_rate }) : wav;
        const { data, frames, dim } = this.frontend.compute(audio);
        if (frames === 0) return { text: '', words: [], avgLogprob: null, frames: 0, ms: 0 };

        const multiple = this.config.pad_multiple;
        const padded = Math.ceil(frames / multiple) * multiple;
        const features = new Float32Array(padded * dim);
        features.set(data);
        const mask = new Float32Array(padded).fill(1);
        mask.fill(0, 0, frames);

        const outputs = await this.session.run({
            input_features: new ort.Tensor('float32', features, [1, padded, dim]),
            padding_mask: new ort.Tensor('float32', mask, [1, padded]),
        });

        // The graph emits padded/4 frames; only those covering real audio may
        // contribute tokens, so trim before collapsing.
        const nOut = CtcEngine.realOutFrames(frames);
        const ids = outputs.ids.data.subarray(0, nOut);
        const logp = outputs.top_logprob.data.subarray(0, nOut);

        const content = ctcCollapse(ids, {
            blankId: this.config.blank_id,
            numSpecialTokens: this.config.num_special_tokens,
        });
        const text = this.decoder.decode(content).trim();

        // Confidence: mean top log-probability over non-blank frames. The CTC
        // analogue of a mean per-token logprob -- clean speech sits near -0.03,
        // silence/noise confabulation near -1.85 and below.
        let sum = 0, n = 0;
        for (let i = 0; i < nOut; i++) {
            if (ids[i] !== this.config.blank_id) { sum += logp[i]; n++; }
        }
        return {
            text,
            words: text.length ? text.split(/\s+/) : [],
            avgLogprob: n ? sum / n : null,
            frames,
            paddedFrames: padded,
            ms: performance.now() - t0,
        };
    }
}

Object.assign(__M, { CtcEngine });
})();

// ==== ctc-streaming.js ==================================================
(() => {
/**
 * Live dictation: growing window + LocalAgreement-2, ported from the macOS
 * GraniteLiveDictation app (SpeechGate / Reconciler / StreamingCoordinator).
 *
 *   continuous 16 kHz capture -> rolling window + energy SpeechGate
 *      every `step` while speech is in the window:
 *         window = audio[segment start .. now]        (grows; overlaps the last)
 *         hypo   = words(CTC transcription of window)
 *         a word COMMITS once two consecutive windows agree on it (longest
 *         common prefix); everything past that is TENTATIVE (shown grey)
 *      on a pause: one clean full-window pass commits the segment and re-anchors
 *
 * The window *grows* rather than sliding because the model returns text with no
 * word timestamps -- there is no safe place to trim a fixed window without
 * risking a mid-word cut. Anchoring each window at the start of the current
 * speech segment makes reconciliation a plain prefix comparison and keeps the
 * window bounded (a phrase between pauses).
 *
 * The tuning constants are the ones the macOS app converged on against real
 * far-field recordings, not defaults -- see the comments on each.
 */

const SAMPLE_RATE = 16000;
const INT16_SCALE = 32768; // gate thresholds are in Int16 RMS units (as tuned)

/**
 * URL to hand `audioWorklet.addModule()`.
 *
 * addModule fetches its argument as a *module*, which is the one request type a
 * private Space's cross-site iframe fails to authenticate — measured there, the
 * same file returns 200 to a plain fetch and intermittently fails as a module
 * import. That would break dictation at the moment the user presses record, well
 * after everything else had loaded cleanly. So fetch the source (plain fetches
 * work) and hand addModule a blob: URL, which needs no network request at all.
 * Falls back to the direct URL, which is what works everywhere else.
 *
 * Resolved against this module rather than the document so it survives being
 * served from a subdirectory; build_bundle.py rewrites `document.baseURI` to
 * `document.baseURI` for the bundled build, where the two are the same directory.
 */
let cachedWorkletUrl = null;
async function captureWorkletUrl() {
    if (cachedWorkletUrl) return cachedWorkletUrl;
    const direct = new URL('./ctc-capture-worklet.js', document.baseURI).href;
    try {
        const r = await fetch(direct);
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        const source = await r.text();
        cachedWorkletUrl = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
    } catch (e) {
        console.warn('could not preload the capture worklet; using its URL directly:', e);
        cachedWorkletUrl = direct;
    }
    return cachedWorkletUrl;
}

/**
 * Energy speech/silence gate with an adaptive noise floor.
 *
 * Not a neural VAD -- an RMS gate whose enter/exit thresholds float with the
 * measured background level. That matters for its one job: finding a *pause* so
 * the growing window can flush. With fixed thresholds, a room whose ambient RMS
 * sits above the silence cutoff never produces a silence frame, the endpoint
 * timer never fires, and every window runs to the maxWindow cap and cuts
 * mid-sentence.
 */
class SpeechGate {
    constructor(config = {}) {
        this.config = {
            endpointSilenceMs: 600, // contiguous below-exit audio that ends a segment
            frameMs: 30,
            // Biased toward inclusion: this CTC model doesn't confabulate on
            // silence the way an autoregressive decoder does (it decodes to empty
            // / low confidence, and the confidence gate backstops it), so
            // admitting soft audio costs nothing -- while excluding it dropped
            // real words (soft onsets never triggered, soft tails got endpointed
            // mid-phrase).
            onRatio: 1.5,
            minEnter: 80,
            // Exit stays near the original: dropping it toward ambient stops
            // pause detection altogether and every segment runs to maxWindow,
            // which wrecks paragraphing.
            offRatio: 1.4,
            minExit: 60,
            onFrames: 2, // debounce transient clicks
            // Seeded from the *mean* RMS over the startup window, not the min: the
            // min tracks the quietest valley of fluctuating noise and seeds the
            // floor far below the ambient body, which then lets noise read as
            // speech.
            calibrationMs: 360,
            floorAdapt: 0.05, // per-frame EMA; at 30 ms frames ~0.6 s to settle
            initialNoiseFloor: 60,
            maxNoiseFloor: 4000,
            ...config,
        };
        this.frameLen = Math.max(1, (SAMPLE_RATE * this.config.frameMs) / 1000);
        this.calibrationFrames = Math.floor(this.config.calibrationMs / this.config.frameMs);
        this.onEvent = null;
        this.isSpeaking = false;
        this.noiseFloor = this.config.initialNoiseFloor;
        this.silenceMs = 0;
        this.onCount = 0;
        this.pending = [];
        this.pendingLen = 0;
        this.calibLeft = this.calibrationFrames;
        this.calibSum = 0;
        this.calibCount = 0;
    }

    get enterThreshold() { return Math.max(this.config.minEnter, this.noiseFloor * this.config.onRatio); }
    get exitThreshold() { return Math.max(this.config.minExit, this.noiseFloor * this.config.offRatio); }

    /**
     * Clear per-utterance state. The noise floor and calibration are deliberately
     * preserved: they describe the *room*, not the utterance, and this is called
     * on every commit (including maxWindow cuts taken mid-speech), where
     * re-seeding would calibrate the floor from speech energy and blind the gate.
     */
    reset() {
        this.isSpeaking = false;
        this.silenceMs = 0;
        this.onCount = 0;
        this.pending = [];
        this.pendingLen = 0;
    }

    /** Re-arm calibration -- for a fresh capture session / new environment. */
    recalibrate() {
        this.noiseFloor = this.config.initialNoiseFloor;
        this.calibLeft = this.calibrationFrames;
        this.calibSum = 0;
        this.calibCount = 0;
    }

    /** Feed a chunk of float samples (any length); re-framed into fixed blocks. */
    feed(samples) {
        if (!samples.length) return;
        this.pending.push(samples);
        this.pendingLen += samples.length;
        while (this.pendingLen >= this.frameLen) {
            const frame = this.takeFrame();
            // process() emits events synchronously and a handler may call reset()
            // re-entrantly (which drops `pending`), so the frame is removed first.
            this.process(frame);
        }
    }

    takeFrame() {
        const frame = new Float32Array(this.frameLen);
        let at = 0;
        while (at < this.frameLen) {
            const head = this.pending[0];
            const take = Math.min(head.length, this.frameLen - at);
            frame.set(take === head.length ? head : head.subarray(0, take), at);
            at += take;
            if (take === head.length) this.pending.shift();
            else this.pending[0] = head.subarray(take);
        }
        this.pendingLen -= this.frameLen;
        return frame;
    }

    process(frame) {
        const rms = rmsInt16(frame);

        if (this.calibLeft > 0) {
            this.calibLeft--;
            this.calibSum += rms;
            this.calibCount++;
            this.noiseFloor = Math.min(Math.max(this.calibSum / this.calibCount, 1), this.config.maxNoiseFloor);
            return;
        }

        this.adaptNoise(rms);
        if (this.isSpeaking) {
            if (rms < this.exitThreshold) {
                this.silenceMs += this.config.frameMs;
                if (this.silenceMs >= this.config.endpointSilenceMs) {
                    this.isSpeaking = false;
                    this.silenceMs = 0;
                    this.onCount = 0;
                    this.onEvent?.('speechEnded');
                }
            } else {
                this.silenceMs = 0; // speech resumed; reset the endpoint timer
            }
        } else if (rms >= this.enterThreshold) {
            this.onCount++;
            if (this.onCount >= this.config.onFrames) {
                this.isSpeaking = true;
                this.silenceMs = 0;
                this.onCount = 0;
                this.onEvent?.('speechStarted');
            }
        } else {
            this.onCount = 0;
        }
    }

    /**
     * Learn the floor from ambient frames only, tracking the *body* (mean) of the
     * ambient level. Updating solely when not in a segment AND below the enter
     * threshold keeps sustained speech (or a speech attack) from dragging the
     * floor up to speech level.
     */
    adaptNoise(rms) {
        if (this.isSpeaking || rms >= this.enterThreshold) return;
        const a = this.config.floorAdapt;
        this.noiseFloor = Math.min(Math.max((1 - a) * this.noiseFloor + a * rms, 1), this.config.maxNoiseFloor);
    }
}

function rmsInt16(samples) {
    if (!samples.length) return 0;
    let acc = 0;
    for (let i = 0; i < samples.length; i++) acc += samples[i] * samples[i];
    return Math.sqrt(acc / samples.length) * INT16_SCALE;
}

/**
 * LocalAgreement-2 reconciliation.
 *
 * Each step transcribes the growing window and yields a fresh hypothesis. Because
 * the window overlaps the previous one, the two should agree on the stable part
 * and differ only at the unfinished tail. A word is committed once two
 * *consecutive* hypotheses agree on it (their longest common prefix, compared
 * case-insensitively and ignoring edge punctuation); everything past that is
 * tentative and may still change.
 */
class Reconciler {
    constructor() {
        this.confirmed = [];
        this.prevHypo = [];
    }

    update(hypothesis) {
        const agreed = commonPrefixCount(this.prevHypo, hypothesis);
        if (agreed > this.confirmed.length) {
            this.confirmed.push(...hypothesis.slice(this.confirmed.length, agreed));
        }
        this.prevHypo = hypothesis;
        const tentative = hypothesis.length > this.confirmed.length
            ? hypothesis.slice(this.confirmed.length)
            : [];
        return { confirmed: this.confirmed, tentative };
    }

    /** End of segment: a clean full-window hypothesis is authoritative. */
    finalize(words) {
        this.confirmed = words;
        this.prevHypo = words;
        return this.confirmed;
    }

    reset() {
        this.confirmed = [];
        this.prevHypo = [];
    }
}

/** Lowercase and strip edge punctuation so "Cat," and "cat" match. */
function normalizeWord(word) {
    return word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').toLowerCase();
}

function commonPrefixCount(a, b) {
    const n = Math.min(a.length, b.length);
    let i = 0;
    while (i < n && normalizeWord(a[i]) === normalizeWord(b[i])) i++;
    return i;
}

/**
 * Stock phrases the model confidently emits on ambient/silence -- the classic ASR
 * hallucination. Confidence can't catch these (they score *higher* than genuine
 * soft speech), so they're matched against the whole commit and dropped only when
 * word density is low: a silence-padded hallucination is long and slow, a real
 * crisp "thank you" is short and fast.
 */
const CANNED_HALLUCINATIONS = new Set([
    'thank you', 'thank you very much', 'thanks for watching',
    'thank you for watching', 'thanks for watching everyone',
]);

class StreamingCoordinator {
    /**
     * @param {object} opts
     * @param {import('./ctc-engine.js').CtcEngine} opts.engine
     * @param {(state: {committed: string, tentative: string}) => void} opts.onTranscript
     * @param {(level: number) => void} [opts.onLevel]      0..1 input level
     * @param {(info: object) => void} [opts.onStep]        per-window diagnostics
     * @param {(text: string) => Promise<string>} [opts.punctuate]  committed-text post-pass
     */
    constructor({ engine, onTranscript, onLevel = null, onStep = null, punctuate = null, tuning = {} }) {
        this.engine = engine;
        this.onTranscript = onTranscript;
        this.onLevel = onLevel;
        this.onStep = onStep;
        this.punctuate = punctuate;

        this.tuning = {
            stepMs: 400,        // re-transcribe cadence. A window costs one padded
                                // forward pass (>= 10.24 s of frames, see
                                // ctc-engine), so this is coarser than the macOS
                                // app's 100 ms; an in-flight tick is dropped, so
                                // it self-limits either way.
            minWindowSec: 0.4,  // don't transcribe windows shorter than this
            maxWindowSec: 10.0, // force-commit a window that grows this long with no pause
            newlineGapMs: 1200, // a pause >= this starts a new paragraph
            hardPauseMs: 1500,
            prerollSec: 0.5,    // audio kept before speech onset, so the first word isn't clipped
            // Calibrated on real far-field logs: clean speech ~ -0.03, but genuine
            // soft utterances scored -0.98..-1.34 and were wrongly rejected at a
            // -0.6 gate, dropping whole clips; silence confabulation sits at
            // -1.85 and below, so -1.5 lands in the gap.
            minCommitAvgLogprob: -1.5,
            cannedHallucinationMaxWps: 2.0,
            ...tuning,
        };

        this.gate = new SpeechGate();
        this.gate.onEvent = (e) => this.handleGate(e);
        this.reconciler = new Reconciler();

        this.window = [];          // Float32Array chunks since the last commit
        this.windowLen = 0;
        this.windowHasSpeech = false;
        this.transcribing = false;
        this.windowGen = 0;
        this.paragraphs = [];      // committed text, one entry per paragraph
        this.lastVoiceAt = 0;
        this.segmentGapMs = Infinity;
        this.active = false;
        this.audioContext = null;
        this.mediaStream = null;
        this.stepTimer = null;
        // One ONNX session can't have two concurrent run() calls, and a commit
        // fired by the gate can land while a step transcription is still in
        // flight, so every engine call goes through this chain.
        this.engineLock = Promise.resolve();
        // Commits are started from the gate callback (inside onSamples), which
        // can't await them; stop() drains these so the last words aren't lost.
        this.pending = new Set();
    }

    /** Serialize engine access; returns the callback's result. */
    async withEngine(fn) {
        const prior = this.engineLock;
        let release;
        this.engineLock = new Promise((r) => { release = r; });
        try {
            await prior;
            return await fn();
        } finally {
            release();
        }
    }

    /** Track a fire-and-forget async task so stop() can wait for it. */
    track(promise) {
        this.pending.add(promise);
        promise.catch(() => {}).finally(() => this.pending.delete(promise));
        return promise;
    }

    /** Resolve once no commit or step is outstanding. */
    async idle() {
        while (this.pending.size) await Promise.allSettled([...this.pending]);
        await this.engineLock;
    }

    async start() {
        if (this.active) return;
        this.mediaStream = await navigator.mediaDevices.getUserMedia({
            audio: {
                channelCount: 1,
                echoCancellation: true,
                noiseSuppression: false, // the gate wants the true ambient level
                autoGainControl: false,  // the engine's own AGC handles levels
            },
        });
        this.audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
        await this.audioContext.audioWorklet.addModule(await captureWorkletUrl());
        const source = this.audioContext.createMediaStreamSource(this.mediaStream);
        const node = new AudioWorkletNode(this.audioContext, 'ctc-capture');
        node.port.onmessage = (e) => this.onSamples(e.data);
        source.connect(node);
        // Keep the graph pulling without routing mic audio to the speakers.
        const sink = this.audioContext.createGain();
        sink.gain.value = 0;
        node.connect(sink).connect(this.audioContext.destination);

        this.resetWindow();
        this.gate.recalibrate();
        this.paragraphs = [];
        this.lastVoiceAt = 0;
        this.segmentGapMs = Infinity;
        this.active = true;
        this.publish([], []);
        this.stepTimer = setInterval(() => this.tick(), this.tuning.stepMs);
    }

    async stop() {
        if (!this.active) return;
        this.active = false;
        clearInterval(this.stepTimer);
        this.stepTimer = null;
        this.mediaStream?.getTracks().forEach((t) => t.stop());
        await this.audioContext?.close();
        this.audioContext = null;
        this.onLevel?.(0);
        // Let any commit the gate started finish before deciding whether the
        // window still holds uncommitted speech.
        await this.idle();
        if (this.windowHasSpeech) await this.commit('stop');
        this.resetWindow();
        this.publish([], []);
    }

    // ---------------------------------------------------------------- capture

    onSamples(chunk) {
        this.window.push(chunk);
        this.windowLen += chunk.length;

        // Before speech is detected, keep only a short pre-roll. The window is
        // otherwise cleared only on commit, so a long ambient stretch would
        // accumulate unbounded and the first real utterance would commit an
        // enormous mostly-noise window.
        if (!this.windowHasSpeech) {
            const maxPreroll = Math.floor(SAMPLE_RATE * this.tuning.prerollSec);
            while (this.windowLen > maxPreroll && this.window.length > 1) {
                this.windowLen -= this.window.shift().length;
            }
        }

        const now = performance.now();
        this.onLevel?.(meterLevel(rmsInt16(chunk)));
        this.gate.feed(chunk); // may fire speechEnded -> commit

        // Drive the window's speech state from the gate's debounced,
        // calibration-aware decision rather than a parallel per-chunk RMS test:
        // such a test has no debounce and runs during calibration, so one noise
        // chunk latches the window open and the model transcribes ambient.
        if (this.gate.isSpeaking) {
            if (!this.windowHasSpeech) {
                this.segmentGapMs = this.lastVoiceAt ? now - this.lastVoiceAt : Infinity;
                this.windowHasSpeech = true;
            }
            this.lastVoiceAt = now;
        }
    }

    handleGate(event) {
        if (event !== 'speechEnded') return; // capture is continuous; onset needs nothing
        // Called synchronously from onSamples, so the commit can only be tracked,
        // not awaited (see idle()).
        if (this.windowHasSpeech) this.track(this.commit('pause'));
        else this.resetWindow(); // pure-silence window: drop it
    }

    // -------------------------------------------------------------- step loop

    async tick() {
        if (!this.active || !this.windowHasSpeech || this.transcribing) return;
        if (this.windowLen / SAMPLE_RATE >= this.tuning.maxWindowSec) {
            await this.commit('maxWindow');
            return;
        }
        const speech = this.trimmedSpeech();
        if (speech.length / SAMPLE_RATE < this.tuning.minWindowSec) return;

        const gen = this.windowGen;
        this.transcribing = true;
        try {
            const result = await this.withEngine(() => this.engine.transcribe(speech));
            if (gen !== this.windowGen) return; // window committed meanwhile; discard
            const { confirmed, tentative } = this.reconciler.update(result.words);
            this.publish(confirmed, tentative);
            this.onStep?.({ kind: 'step', windowSec: speech.length / SAMPLE_RATE, ...result });
        } catch (e) {
            console.error('step transcription failed:', e);
        } finally {
            this.transcribing = false;
        }
    }

    /**
     * Commit the current window: snapshot it, immediately clear and re-anchor (so
     * capture continues uninterrupted and any in-flight partial is discarded),
     * then transcribe the snapshot once cleanly and append it.
     */
    async commit(reason) {
        const speech = this.trimmedSpeech();
        const windowSec = speech.length / SAMPLE_RATE;
        if (windowSec < this.tuning.minWindowSec) {
            this.resetWindow();
            return;
        }
        const gapMs = this.segmentGapMs;
        this.resetWindow();
        this.publish([], []);

        let result;
        try {
            result = await this.withEngine(() => this.engine.transcribe(speech));
        } catch (e) {
            console.error('commit transcription failed:', e);
            return;
        }
        const words = result.words;
        const wps = windowSec > 0 ? words.length / windowSec : 0;

        // Confidence gate: a low mean logprob means the model wasn't sure of its
        // own tokens -- the signature of confabulation on noise/silence. A null
        // confidence fails open.
        if (result.avgLogprob !== null && result.avgLogprob < this.tuning.minCommitAvgLogprob) {
            this.onStep?.({ kind: 'reject', why: 'confidence', reason, windowSec, wps, ...result });
            this.publish([], []);
            return;
        }
        const normalized = words.join(' ').toLowerCase();
        if (CANNED_HALLUCINATIONS.has(normalized) && wps < this.tuning.cannedHallucinationMaxWps) {
            this.onStep?.({ kind: 'reject', why: 'canned', reason, windowSec, wps, ...result });
            this.publish([], []);
            return;
        }
        if (!words.length) {
            this.publish([], []);
            return;
        }

        await this.appendSegment(words.join(' '), gapMs, reason);
        this.onStep?.({ kind: 'commit', reason, windowSec, wps, gapMs, ...result });
        this.publish([], []);
    }

    /**
     * Append a committed segment, opening a new paragraph after a real pause.
     *
     * Unlike the macOS app, sentence punctuation and capitalization are NOT
     * synthesized here -- the model emits raw lowercase text and the punctuator
     * restores both. A maxWindow cut is mid-utterance, so it extends the current
     * paragraph and is re-punctuated together with what follows.
     */
    async appendSegment(text, gapMs, reason) {
        const startsParagraph = this.paragraphs.length === 0
            || gapMs >= this.tuning.hardPauseMs
            || (gapMs >= this.tuning.newlineGapMs && reason !== 'maxWindow');

        if (startsParagraph) this.paragraphs.push(text);
        else this.paragraphs[this.paragraphs.length - 1] += ` ${text}`;

        if (this.punctuate) {
            const i = this.paragraphs.length - 1;
            const raw = this.paragraphs[i];
            try {
                this.paragraphs[i] = await this.punctuate(raw);
            } catch (e) {
                console.warn('punctuation failed; keeping raw text:', e);
            }
        }
    }

    // ---------------------------------------------------------------- helpers

    /**
     * The speech-bearing span of the window, with leading/trailing silence
     * removed. Trailing trim matters: the front-end normalizes against the clip's
     * global peak, so an untrimmed quiet tail gets amplified and decoded into
     * spurious words.
     */
    trimmedSpeech() {
        const audio = this.flattenWindow();
        const frame = 480; // 30 ms
        const floor = this.gate.exitThreshold;
        let first = -1, last = -1;
        for (let i = 0; i < audio.length; i += frame) {
            const end = Math.min(i + frame, audio.length);
            if (rmsInt16(audio.subarray(i, end)) >= floor) {
                if (first < 0) first = i;
                last = end;
            }
        }
        if (first < 0) return new Float32Array(0);
        const margin = frame * 3; // ~90 ms of context each side
        return audio.subarray(Math.max(0, first - margin), Math.min(audio.length, last + margin));
    }

    flattenWindow() {
        if (this.window.length === 1) return this.window[0];
        const out = new Float32Array(this.windowLen);
        let at = 0;
        for (const c of this.window) { out.set(c, at); at += c.length; }
        this.window = [out]; // collapse so repeated ticks don't re-copy
        return out;
    }

    resetWindow() {
        this.window = [];
        this.windowLen = 0;
        this.windowHasSpeech = false;
        this.reconciler.reset();
        this.gate.reset();
        this.windowGen++;
    }

    publish(confirmed, tentative) {
        const live = confirmed.join(' ');
        const committed = live
            ? [...this.paragraphs, live].join('\n\n')
            : this.paragraphs.join('\n\n');
        this.onTranscript({ committed, tentative: tentative.join(' ') });
    }

    /** Plain text of everything committed so far. */
    get transcript() {
        return this.paragraphs.join('\n\n');
    }
}

/** Int16 RMS -> 0..1 meter level on a dBFS scale (-60 dBFS -> 0, 0 dBFS -> 1). */
function meterLevel(rms) {
    if (rms <= 0) return 0;
    const dbfs = 20 * Math.log10(rms / 32767);
    return Math.max(0, Math.min(1, (dbfs + 60) / 60));
}

Object.assign(__M, { SpeechGate, Reconciler, StreamingCoordinator });
})();

// ==== app.js ============================================================
(() => {
const { CtcEngine, SpeechGate, StreamingCoordinator } = __M;
/**
 * Granite Live Dictation (WebGPU)
 *
 * Real-time in-browser dictation with ibm-granite/granite-speech-4.2-470m-turboctc,
 * a 473M-parameter FastConformer CTC model. Unlike the previous
 * granite-speech-4.1-2b build, this is not a transformers.js pipeline:
 * `ctc_conformer` is a custom architecture, so the ONNX session is driven directly
 * (see ctc-engine.js) -- one forward pass and a greedy collapse per window, no
 * autoregressive decode. That is what makes it fast enough to re-transcribe a
 * growing window several times a second.
 *
 * The model emits raw lowercase, unpunctuated English. Punctuation and
 * capitalization are restored by the separate punctuator model (punctuator.js),
 * which is loaded on demand because it is a 209 MB download of its own.
 *
 * `ort` is the global from the onnxruntime-web script tag in index.html, shared
 * with punctuator.js so both sessions use one runtime.
 */



const MODEL_DIR = './onnx-ctc/';
// 4-bit weights, block 32, on an fp16 graph (312 MB from a 1.9 GB fp32 export).
// This is the only shape WebGPU can actually run: ORT-web's WebGPU EP enforces
// `nbits == 4 || nbits == 2` on MatMulNBits, so an 8-bit build silently falls
// back to wasm (1320 ms per window against 305 ms) and buys nothing -- on 8
// minutes of LibriSpeech the 4-bit weights cost 0.09% WER against the fp32
// export, and what residue remains in the browser comes from fp16 activations,
// which more weight bits would not fix.
const MODEL_FILE = 'ctc_conformer_q4f16.onnx';
const SAMPLE_RATE = 16000;
// One padded forward pass covers 10.24 s of frames, so file segments are capped
// near that: longer segments cost proportionally more and buy nothing.
const MAX_SEGMENT_SEC = 20;

let engine = null;
let coordinator = null;
let punctuatorReady = false;
let punctuatorLoad = null;
let isLoading = false;
let currentAudioData = null;

const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const dictateBtn = document.getElementById('dictateBtn');
const levelFill = document.getElementById('levelFill');
const audioFile = document.getElementById('audioFile');
const fileTile = document.querySelector('.file-label');
const inputCard = document.querySelector('.input-card');
const punctCheckbox = document.getElementById('punctCheckbox');
const transcriptCard = document.getElementById('transcriptCard');
const committedEl = document.getElementById('committedText');
const tentativeEl = document.getElementById('tentativeText');
const copyBtn = document.getElementById('copyBtn');
const downloadBtn = document.getElementById('downloadBtn');
const clearBtn = document.getElementById('clearBtn');
const progressFill = document.getElementById('progressFill');
const progressSection = document.getElementById('progressSection');
const progressText = document.getElementById('progressText');
const gpuInfo = document.getElementById('gpuInfo');
const statsEl = document.getElementById('stats');

function setStatus(state, message) {
    statusDot.className = `status-dot ${state}`;
    statusText.textContent = message;
}

function showProgress(show, text = '') {
    progressSection.style.display = show ? 'block' : 'none';
    if (text) progressText.textContent = text;
}

// ---------------------------------------------------------------- model load

/**
 * Hand ORT its wasm assets directly, fetched with a plain `fetch()`.
 *
 * ORT locates its backend by dynamically `import()`ing
 * ort-wasm-simd-threaded.jsep.mjs. In a private Space's cross-site iframe that
 * module fetch is not authenticated -- the same failure that stopped app.js's own
 * imports and forced the bundle -- and it surfaces from session creation as
 * "no available backend found. ERR: [wasm] previous call to 'initWasm()' failed".
 *
 * Plain fetches *are* authenticated in that context, so the glue is fetched here
 * and handed over as a blob: URL, which `import()` reads with no network request
 * at all, and the wasm bytes go across as `wasmBinary` so nothing else is looked
 * up either. Falls back to letting ORT fetch them itself, which is what works
 * everywhere other than that iframe.
 */
async function configureOrtWasm(baseUrl = './ort/') {
    const dir = new URL(baseUrl, document.baseURI).href;
    const get = async (name, as) => {
        const r = await fetch(dir + name);
        if (!r.ok) throw new Error(`${name}: HTTP ${r.status}`);
        return as === 'text' ? r.text() : r.arrayBuffer();
    };
    try {
        const [glue, binary] = await Promise.all([
            get('ort-wasm-simd-threaded.jsep.mjs', 'text'),
            get('ort-wasm-simd-threaded.jsep.wasm'),
        ]);
        ort.env.wasm.wasmBinary = binary;
        ort.env.wasm.wasmPaths = {
            mjs: URL.createObjectURL(new Blob([glue], { type: 'text/javascript' })),
        };
    } catch (e) {
        console.warn('could not preload the ORT wasm assets; letting ORT fetch them:', e);
        // Absolute, not './ort/': ORT resolves a relative wasmPaths against the
        // location of ort.all.min.js (already in ort/), which would look for
        // ort/ort/ort-wasm-simd-threaded.jsep.mjs and find no backend.
        ort.env.wasm.wasmPaths = dir;
    }
}

async function initEngine() {
    if (engine || isLoading) return engine;
    isLoading = true;
    try {
        // The runtime is a blocking classic script in index.html, so reaching here
        // without it means it was blocked (Edge Tracking Prevention, an extension,
        // a CSP). Say that plainly instead of throwing a bare ReferenceError.
        if (typeof ort === 'undefined') {
            throw new Error('the ONNX runtime failed to load (ort/ort.all.min.js) — '
                + 'check for a blocked request in the network tab');
        }
        if (!navigator.gpu) {
            gpuInfo.textContent = 'WebGPU not available — running on WASM (slower)';
        }
        await configureOrtWasm();
        // Threaded wasm needs cross-origin isolation (COOP *and* COEP). Hugging
        // Face Spaces send COOP but not COEP, so asking for threads there only
        // produces a console warning and a wasted init before ORT falls back.
        ort.env.wasm.numThreads = self.crossOriginIsolated ? (navigator.hardwareConcurrency || 4) : 1;

        setStatus('loading', 'Downloading model...');
        showProgress(true, 'Downloading model...');
        const progress = {};
        engine = await CtcEngine.load({
            baseUrl: MODEL_DIR,
            modelFile: MODEL_FILE,
            device: navigator.gpu ? 'webgpu' : 'wasm',
            onProgress: ({ url, loaded, total, cached }) => {
                progress[url] = { loaded, total, cached };
                let l = 0, t = 0, allCached = true;
                for (const p of Object.values(progress)) {
                    l += p.loaded;
                    t += p.total;
                    if (!p.cached) allCached = false;
                }
                if (allCached) {
                    showProgress(true, 'Loading model from cache...');
                    return;
                }
                const pct = t > 0 ? (l / t) * 100 : 0;
                progressFill.style.width = `${pct}%`;
                showProgress(true, `Downloading model... ${(l / 1e6).toFixed(0)} / ${(t / 1e6).toFixed(0)} MB`);
            },
        });
        progressFill.style.width = '0%';
        showProgress(false);
        // A wasm fallback is ~4x slower per window (1325 ms vs 305 ms for a 10 s
        // window), which is the difference between live and not, so say so
        // rather than letting it look like the model is just slow.
        gpuInfo.textContent = engine.device === 'webgpu'
            ? 'Backend: WebGPU'
            : 'Backend: WASM — WebGPU unavailable, so dictation will lag well behind speech';
        setStatus('ready', 'Ready — start dictation or upload audio');
        dictateBtn.disabled = false;
        audioFile.disabled = false;
        if (punctCheckbox.checked) startPunctuatorLoad();
        return engine;
    } catch (e) {
        console.error('Model loading failed:', e);
        setStatus('error', `Error: ${e.message || e}`);
        showProgress(false);
        throw e;
    } finally {
        isLoading = false;
    }
}

/**
 * Load the punctuator (209 MB of its own).
 *
 * Kicked off in the background as soon as the CTC engine is ready, so it is
 * usually done before the user presses record -- loading it on the click would
 * put a ~30 s download between "start dictation" and hearing anything. Calls
 * share one promise, and only a caller that actually has to wait shows progress.
 */
function startPunctuatorLoad() {
    if (!punctuatorLoad && typeof window.loadPunctuator === 'function') {
        punctuatorLoad = window.loadPunctuator()
            .then(() => { punctuatorReady = true; })
            .catch((e) => {
                console.warn('punctuator failed to load; transcript stays lowercase:', e);
                punctuatorReady = false;
            });
    }
    return punctuatorLoad;
}

async function ensurePunctuator() {
    if (punctuatorReady || !punctCheckbox.checked) return punctuatorReady;
    const load = startPunctuatorLoad();
    if (!load) return false;
    const stillLoading = !punctuatorReady;
    if (stillLoading) showProgress(true, 'Loading punctuation model...');
    await load;
    if (stillLoading) showProgress(false);
    return punctuatorReady;
}

/** Punctuate committed text, or pass it through if the model is off/unavailable. */
async function punctuate(text) {
    if (!punctCheckbox.checked || !punctuatorReady) return text;
    return window.applyPunctuation(text);
}

// ------------------------------------------------------------ live dictation

function renderTranscript({ committed, tentative }) {
    transcriptCard.style.display = 'block';
    committedEl.textContent = committed;
    tentativeEl.textContent = tentative ? (committed ? ` ${tentative}` : tentative) : '';
    const out = committedEl.parentElement;
    out.scrollTop = out.scrollHeight;
    const hasText = Boolean(committed || tentative);
    copyBtn.disabled = !hasText;
    downloadBtn.disabled = !hasText;
}

async function toggleDictation() {
    if (coordinator) {
        dictateBtn.disabled = true;
        setStatus('processing', 'Finishing…');
        try {
            await coordinator.stop();
        } finally {
            coordinator = null;
            dictateBtn.classList.remove('recording');
            dictateBtn.querySelector('span').textContent = 'Start dictation';
            dictateBtn.disabled = false;
            levelFill.style.width = '0%';
            setStatus('ready', 'Ready — start dictation or upload audio');
        }
        return;
    }

    await initEngine();
    await ensurePunctuator();

    coordinator = new StreamingCoordinator({
        engine,
        onTranscript: renderTranscript,
        onLevel: (level) => { levelFill.style.width = `${(level * 100).toFixed(0)}%`; },
        onStep: (info) => {
            if (info.kind === 'step') {
                statsEl.textContent = `${info.windowSec.toFixed(1)}s window · ${info.ms.toFixed(0)} ms · conf ${info.avgLogprob?.toFixed(2) ?? 'n/a'}`;
            } else if (info.kind === 'reject') {
                console.log(`[reject:${info.why}] "${info.text}" conf=${info.avgLogprob?.toFixed(2)} wps=${info.wps.toFixed(2)}`);
            } else {
                console.log(`[commit:${info.reason}] ${info.windowSec.toFixed(1)}s ${info.ms.toFixed(0)}ms conf=${info.avgLogprob?.toFixed(2)} "${info.text}"`);
            }
        },
        punctuate,
    });

    try {
        await coordinator.start();
        dictateBtn.classList.add('recording');
        dictateBtn.querySelector('span').textContent = 'Stop dictation';
        setStatus('recording', 'Listening — speak naturally, pause between sentences');
    } catch (e) {
        console.error('dictation failed to start:', e);
        coordinator = null;
        setStatus('error', e.name === 'NotAllowedError' ? 'Microphone access denied' : `Error: ${e.message || e}`);
    }
}

// --------------------------------------------------------- file transcription

/**
 * Split a file into speech segments with the same energy gate the live path uses.
 *
 * This replaces the old Silero VAD download: the gate is already here, it is what
 * the streaming path is tuned against, and long audio has to be split anyway --
 * a single pass over a 5-minute file would allocate a ~250 MB logits tensor
 * inside the graph.
 */
function segmentAudio(audio) {
    const gate = new SpeechGate();
    const frame = 480;
    const segments = [];
    let start = null;
    let consumed = 0;

    gate.onEvent = (event) => {
        const t = consumed / SAMPLE_RATE;
        if (event === 'speechStarted') start = t;
        else if (start !== null) {
            segments.push({ start, end: t });
            start = null;
        }
    };
    for (let i = 0; i < audio.length; i += frame) {
        const chunk = audio.subarray(i, Math.min(i + frame, audio.length));
        consumed = i + chunk.length;
        gate.feed(chunk);
    }
    if (start !== null) segments.push({ start, end: audio.length / SAMPLE_RATE });
    if (!segments.length) return [{ start: 0, end: audio.length / SAMPLE_RATE }];

    // Pad each segment slightly (the gate's onset debounce trims the first
    // phoneme) and cap the length.
    const out = [];
    for (const seg of segments) {
        let from = Math.max(0, seg.start - 0.2);
        const to = Math.min(audio.length / SAMPLE_RATE, seg.end + 0.2);
        while (to - from > MAX_SEGMENT_SEC) {
            out.push({ start: from, end: from + MAX_SEGMENT_SEC });
            from += MAX_SEGMENT_SEC;
        }
        out.push({ start: from, end: to });
    }
    return out;
}

async function transcribeFile() {
    if (!currentAudioData) return;
    await initEngine();
    await ensurePunctuator();

    setStatus('processing', 'Transcribing…');
    transcriptCard.style.display = 'block';
    const segments = segmentAudio(currentAudioData);
    const parts = [];
    let totalMs = 0;

    for (let i = 0; i < segments.length; i++) {
        const seg = segments[i];
        showProgress(true, `Segment ${i + 1} / ${segments.length}`);
        progressFill.style.width = `${((i + 1) / segments.length) * 100}%`;
        const slice = currentAudioData.subarray(
            Math.floor(seg.start * SAMPLE_RATE),
            Math.floor(seg.end * SAMPLE_RATE),
        );
        const result = await engine.transcribe(slice);
        totalMs += result.ms;
        if (result.text) {
            parts.push(await punctuate(result.text));
            renderTranscript({ committed: parts.join('\n\n'), tentative: '' });
        }
    }

    showProgress(false);
    progressFill.style.width = '0%';
    const audioSec = currentAudioData.length / SAMPLE_RATE;
    statsEl.textContent = `${audioSec.toFixed(1)}s audio · ${segments.length} segment(s) · ${totalMs.toFixed(0)} ms · ${(audioSec / (totalMs / 1000)).toFixed(0)}x realtime`;
    if (!parts.length) renderTranscript({ committed: '(no speech detected)', tentative: '' });
    setStatus('ready', 'Transcription complete');
}

/** Decode any audio file to 16 kHz mono float32. */
async function loadAudioFile(file) {
    setStatus('processing', 'Decoding audio…');
    try {
        const audioCtx = new AudioContext({ sampleRate: SAMPLE_RATE });
        const buffer = await audioCtx.decodeAudioData(await file.arrayBuffer());
        let audio = buffer.getChannelData(0);
        if (buffer.numberOfChannels > 1) {
            const mixed = new Float32Array(audio.length);
            for (let c = 0; c < buffer.numberOfChannels; c++) {
                const ch = buffer.getChannelData(c);
                for (let i = 0; i < mixed.length; i++) mixed[i] += ch[i] / buffer.numberOfChannels;
            }
            audio = mixed;
        }
        // decodeAudioData already resampled to the context rate.
        await audioCtx.close();
        currentAudioData = audio;
        await transcribeFile();
    } catch (e) {
        console.error('audio decode failed:', e);
        setStatus('error', `Could not read that file: ${e.message || e}`);
    }
}

// -------------------------------------------------------------- transcript IO

function transcriptText() {
    return committedEl.textContent + (tentativeEl.textContent || '');
}

async function copyTranscript() {
    await navigator.clipboard.writeText(transcriptText());
    const original = copyBtn.title;
    copyBtn.title = 'Copied';
    setTimeout(() => { copyBtn.title = original; }, 1200);
}

function downloadTranscript() {
    const blob = new Blob([transcriptText()], { type: 'text/plain' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = `transcript-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`;
    a.click();
    URL.revokeObjectURL(a.href);
}

function clearTranscript() {
    renderTranscript({ committed: '', tentative: '' });
    transcriptCard.style.display = 'none';
    statsEl.textContent = '';
    if (coordinator) coordinator.paragraphs = [];
}

// ---------------------------------------------------------------------- wiring

dictateBtn.addEventListener('click', toggleDictation);
audioFile.addEventListener('change', (e) => {
    const file = e.target.files[0];
    if (file) loadAudioFile(file);
    e.target.value = '';
});
copyBtn.addEventListener('click', copyTranscript);
downloadBtn.addEventListener('click', downloadTranscript);
clearBtn.addEventListener('click', clearTranscript);

// Drag and drop onto the input card
for (const [event, handler] of [
    ['dragover', (e) => { e.preventDefault(); inputCard.classList.add('drag-over'); }],
    ['dragleave', () => inputCard.classList.remove('drag-over')],
    ['drop', (e) => {
        e.preventDefault();
        inputCard.classList.remove('drag-over');
        const file = e.dataTransfer.files[0];
        if (file?.type.startsWith('audio/')) loadAudioFile(file);
        else setStatus('error', 'Please drop an audio file');
    }],
]) {
    inputCard.addEventListener(event, handler);
    fileTile.addEventListener(event, (e) => e.stopPropagation(), { capture: false });
}

punctCheckbox.addEventListener('change', () => {
    if (punctCheckbox.checked) ensurePunctuator();
});

// Space bar toggles dictation, unless a control has focus.
document.addEventListener('keydown', (e) => {
    if (e.code !== 'Space' || e.repeat) return;
    if (['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON'].includes(document.activeElement?.tagName)) return;
    e.preventDefault();
    toggleDictation();
});

setStatus('loading', 'Loading…');
initEngine().catch(() => {});
})();

})();