rvoip-codec-core 0.3.8

G.711 and optional G.729/Opus/AMR audio codec implementations for RVOIP
Documentation
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
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
# AMR implementation status

## Where things stand

| Component | State |
|---|---|
| **AMR-WB decoder** | **Bit-exact against TS 26.173, all nine rates** |
| **AMR-NB decoder** | **Bit-exact against TS 26.073, all eight rates** |
| **AMR-WB encoder** | **Byte-identical bitstream, all nine rates, 50 frames** |
| **AMR-NB encoder** | **Byte-identical bitstream, all eight rates, 50 frames** |
| **Concealment** | **Bit-exact, both variants** — damaged frames and lost frames |
| **All four paths reachable through `AmrCodec`** | **Done, and byte-exact through the public API** |
| **Oracle qualification** | **Measured** — see below |
| **AMR-WB DTX, encoder side** | **Byte-identical SIDs on the reference's own schedule**, 150 frames |
| **AMR-WB DTX, decoder side** | **Sample-exact over a 150-frame DTX stream** |
| **AMR-WB DTX through `AmrCodec`** | **Both directions, byte- and sample-exact** |
| **AMR-NB VAD1** | **Bit-exact** — whole state, 150 frames |
| **AMR-NB DTX, encoder side** | **Byte-identical bitstream, all eight rates, 150 frames** |
| **AMR-NB DTX, decoder side** | **Sample-exact, all eight rates, plus the muting stream** |
| **AMR-NB DTX through `AmrCodec`** | **Both directions, byte- and sample-exact** |
| **AMR-WB homing frames** | **Done** — the encoder emits each mode's pattern |
| **AMR-NB homing frames** | **Done** — the encoder emits each mode's pattern |
| **AMR-WB conformance, speech** | **All nine TS 26.173 vectors, both directions** |
| **AMR-WB conformance, DTX encode** | **`tst_md.cod`, every frame type and payload** |
| **AMR-WB conformance, DTX decode** | **`tst_md`, 200 frames, sample for sample** |
| **AMR-NB conformance, encode** | **`spch_dos`, 425 frames, every bit** |
| **AMR-NB conformance, decode** | **`spch_dos`, 425 frames, sample for sample** |
| **AMR reachable through media-core** | **Both variants resolve, encode and decode end to end** |
| **AMR through `rvoip-core`'s media graph** | **Wired**, behind rvoip-core's `amr-nb`/`amr-wb` — frames cross both directions, tone-verified |
| **Non-20 ms senders** | **Re-framed** — 10 ms and 30 ms sources transcode into AMR |
| **AMR over a QUIC datagram** | **Payload type survives; an unlabellable frame is dropped, not mislabelled** |
| **Every mode in a live call** | **All 8 narrowband and all 9 wideband, walked by CMR** |
| **AMR over SDES-SRTP, in process** | **Both variants, tone-verified** |
| **Transcoding** | **Six AMR pairs, tested by property** |
| **`mode-change-period` / `-neighbor`** | **Honoured** — they were parsed and obeyed by nothing |
| **Performance** | **Measured, with a gate** — see below |
| **SDP to a working codec** | **An AMR-WB offer negotiates and codes** |
| **A real AMR call** | **Three, over loopback, with verified audio** |
| **RFC 4867 wire format** | **136 payloads agree with Wireshark's dissector** |
| **Live PBX interop** | **Both PBXes, both variants, relay AND forced-transcode tiers, UDP ×3 + TLS, quality-gated** |
| **Per-rate PBX interop** | **All 17 modes against Asterisk over UDP *and* TLS+SRTP, each pinned by `mode-set`** — see below |
| **`mode-set` negotiation** | **Offered, echoed in answers, and obeyed by the encoder** |
| **Live proxy interop** | **Kamailio and OpenSIPS with rtpengine relaying all four framings verbatim** |
| **AMR-NB VAD2** | **Bit-exact against TS 26.073** — whole state, 300 half-frames |
| **Interface formats** | **IF1 and IF2, both variants**, from TS 26.101 and TS 26.201 |
| **Redundancy and interleaving** | **`max-red` scheduling with dedup; receive-side deinterleaving** |
| **Soak** | **Opt-in long-run encode/decode, `RVOIP_AMR_SOAK_SECS`** |
| **Fuzz** | **Encoders and decoders both have targets** |

Every claim above is a test, not a note. Both decoders reproduce the reference
decoders sample for sample; both encoders reproduce the reference *bitstream*
byte for byte, magic and table of contents included. Each is asserted twice —
once inside the module and once through `AmrCodec` — because per-stage
exactness cannot see a wiring layer that resets state between frames, and each
comparison is checked for vacuity.

**What "AMR-WB DTX, encoder side" claims, exactly.** The comfort-noise ISF
quantiser matches `Qisf_ns` over 64 vectors; the DTX kernel matches
`dtx_buffer` and `dtx_enc` over 40 frames on all five ISF indices, the energy
index, the dithering flag and the excitation; and with DTX enabled the encoder
reproduces the reference's own speech / SID / `NO_DATA` sequence over all 150
frames of the committed fixture — and every one of the twelve transmitted SID
payloads is byte-identical, STI bit, mode indication and `SID_FIRST` blanking
included. A right kernel fed the wrong residual or the wrong history would
produce a well-formed SID with wrong bits, which the frame-type sequence alone
would not notice.

**What "AMR-WB DTX, decoder side" claims, exactly.** Two things. The kernel —
state machine, backward analysis, interpolation, `DTX_MUTE` fade,
comfort-noise generator — is bit-exact against `rx_dtx_handler`, `dtx_dec` and
`dtx_dec_activity_update` over an 89-frame sequence visiting all three states.
And the assembled decoder reproduces the reference decoder **sample for
sample** over the whole 150-frame DTX stream: speech, comfort noise and gaps,
two transitions into silence and two back.

Two defects stood between those, and neither was in the kernel. The background
energy history is measured on the excitation *brought back out of the frame's
scaling*; skipping that inflated every stored energy by `2^Q_new` and reached
the output as comfort noise hundreds of times too loud while the spectrum was
already exact. And `Reset_decoder(st, 0)` clears more than the excitation —
the ISF predictor, the pitch-lag history, the innovation tilt, the phase
dispersion memory and the noise enhancer's threshold — an omission invisible
until the first speech frame *after* the silence.

**And two more that only the normative stream could reach.** The committed
fixture passed while both were present, which is the whole argument for
fetching TS 26.174 rather than trusting a locally generated one.

The energy history has to be captured from an excitation in *one* exponent.
`rescale_to` rewrites the entire history when a subframe needs a different one,
so by the end of a frame the reference's buffer is uniform and a single
`Scale_sig` undoes it — but snapshotting each subframe as it is built leaves
four exponents mixed together. It corrupts exactly the frames where the scaling
moved, three of eight ring slots on this stream and none on the fixture.

And `CN_dithering` draws its generator **twice** per perturbation and sums the
halves — a triangular variate, not a uniform one — and enforces the 448-unit
ISF spacing *inline* against the coefficient just written rather than in a pass
afterwards. Written from a summary of the algorithm it was wrong in both
respects, and only a stream whose encoder actually sets the dithering bit can
tell. Ours does; the fixture's does not.

**What the narrowband DTX claims, exactly.** With DTX enabled the encoder
reproduces the reference's own speech / SID / `NO_DATA` sequence over all 150
frames at **every one of the eight rates**, and each of the twelve transmitted
SIDs is byte-identical — description, STI bit and mode indication. The decoder
reproduces the reference decoder sample for sample over the same eight streams,
and over the muting stream as well.

Four things cost more than the port itself.

The `_mute` fixture never muted. It dropped every SID from frame 50 on,
including the one that opens the silence, so the decoder never learned DTX had
begun and read all 61 empty frames as lost speech. Its build-time guard passed
because the output does differ from the intact stream — just not for the reason
claimed. With the opening SID kept, `DTX_MUTE` is reached for eleven frames and
the fade is finally covered: deleting the 0.75 dB step moves 374 samples.

The first speech frame out of `DTX_MUTE` must be muted, so `prev_bf` is written
*before* the concealers read it. Snapshotting the frame quality first left them
looking at the value from before the silence.

Narrowband transmits a description on a `SID_FIRST`; wideband blanks one.
Carrying the wideband habit across gives five octets of `00 00 00 00 02` where
the reference has a full description — and since both decoders derive a
`SID_FIRST`'s spectrum by backward analysis and ignore the payload, nothing
sounds wrong. Only a bitstream comparison catches it.

And the speech path has to run the receiver too. Decoding a speech frame
without `rx_dtx_handler` and `dtx_dec_activity_update` enters the next silence
with an empty history and a stale hangover count: 1928 of 24000 samples wrong
at 4.75 kbit/s, every one of them inside the comfort noise.

**And the narrowband verification stream, which the fixtures cannot imitate.**
`spch_dos` is 425 frames driven through `allmodes.txt`, so the rate changes on
424 of them, with DTX on throughout. The encoder reproduces all 36575
transmitted bits — every frame's type, rate word and payload — and the decoder
reproduces all 68000 output samples. Each committed fixture is a single
constant rate; a rate switch carries the LSF predictor, both gain predictors,
the pitch history and the DTX rings across a change in what those numbers mean,
and no fixture reaches that state at all. It contains no homing frame, and the
tests assert that rather than implying otherwise.

## Performance, measured

`cargo bench -p rvoip-codec-core --all-features --bench amr_codec`, then
`python3 tools/check-amr-rtf.py`. A 20 ms frame has a 20 ms budget, so the
real-time factor is `time / 20 ms`.

| Path | Worst rate | RTF |
|---|---|---|
| AMR-NB encode | 12.2 | 0.0095 |
| AMR-NB decode | 7.95 | 0.0010 |
| AMR-WB encode | 23.85 | 0.0229 |
| AMR-WB decode | 23.85 | 0.0056 |
| AMR-NB conceal || 0.0007 |
| AMR-WB conceal || 0.0031 |

A duplex leg — encode plus decode, since a call does both — costs 0.0105 at
narrowband and 0.0285 at wideband: about 95 and 35 legs per core. The plan's
goal was 20 concurrent legs.

The gate is a script over Criterion's own output rather than an assertion in
`cargo test`, following the policy the G.711 benchmark's header sets out: a
debug build under a loaded scheduler produces numbers that are meaningless as
a pass/fail signal. It fails if it finds *no* AMR results, because a
performance gate that silently checks nothing is the same failure this branch
has hit four times elsewhere.

## Running the conformance sequences

```bash
crates/media/codec-core/tools/run-amr-conformance.sh
```

It fetches and builds both reference trees if they are absent, sets the two
environment variables, and runs the six `#[ignore]`d tests. It fails unless all
six pass — a conformance run that checks five of six is not a pass, and the
count cargo happens to report is not the claim.

**These six cannot run in CI, and that is structural.** The sequences are 3GPP
copyright; only generated output is committed. So the six strongest rows in the
table above are the ones continuous integration protects least, and they are
distinguishable from the fixture-backed rows on exactly that basis. Everything
else — including all four bit-exactness paths against the committed fixtures —
runs on every pull request through the `codec-features` gate.

## The wire format, checked against someone else's dissector

```bash
crates/media/codec-core/tools/verify-amr-rtp-framing.sh
```

136 payloads across eight selections — both variants, both framings, every
speech mode, with and without a mode request, and single- and two-frame
payloads — packed by this crate and read back by **Wireshark's** AMR dissector,
which is an independent implementation of RFC 4867. Frame type and CMR agree on
all of them.

This closes a gap that neither the 3GPP vectors nor an rvoip-to-rvoip call can
reach. The codec *bits* are bit-exact against the reference implementations and
their sorting is checked against reference-produced `.amr` files, but
everything RFC 4867 adds for RTP — the CMR nibble, the table-of-contents chain,
octet-aligned padding — was otherwise verified only by packing and unpacking
with our own code. A round trip cannot catch a symmetric mistake: put the CMR
in the wrong four bits and our depacker reads it out of the wrong four bits,
the audio is perfect, and no peer can read the stream. Two rvoip endpoints
calling each other cannot find that either, for exactly the same reason.

Verified by mutation: rotating the first octet's nibbles makes the dissector
disagree — 47 of the 68 payloads the corpus held when that check was run by
hand. The script has no mutation mode, so that figure is a record of a
one-off run against the smaller corpus, not something the current 136-payload
run reproduces.

## The other two interface formats, IF1 and IF2

```bash
cargo test -p rvoip-codec-core --features amr interface_format
```

RFC 4867 is how AMR crosses an IP network, but it is not how AMR crosses a
radio access network or a 3GPP-defined interface. TS 26.101 (narrowband) and
TS 26.201 (wideband) define IF1 and IF2 for that, and `interface_format.rs`
implements both for both variants.

The two are not one format with a width parameter, and assuming they were is
what made the first attempt wrong. IF2 differs between variants in every field
that matters: narrowband puts the frame type in the **low** nibble and orders
bits **LSB-first** with no frame-quality bit and a 39-bit SID; wideband puts it
in the **high** nibble, orders bits **MSB-first**, carries a 1-bit FQI and a
40-bit SID. IF1 adds the codec CRC, whose generator `G(x) = x⁸+x⁶+x⁵+x⁴+1` is
the bit-reversal of the polynomial RFC 4867 uses for its payload CRC — close
enough to look like a typo and produce wrong bytes silently.

**What this cost, and the lesson.** An earlier wideband IF2 implementation was
committed as "oracle-verified" and was wrong: it omitted the FQI bit, so every
frame was one bit short. The oracle was tshark, which reads the frame-type
nibble and never checks the payload length — so it agreed with a stream no
real IF2 peer could parse. The error surfaced only when the field tables were
read directly. An oracle that does not examine the field you got wrong is not
evidence about that field, however green it prints.

## AMR-NB VAD2

```bash
cargo test -p rvoip-codec-core --features amr-nb vad2
```

TS 26.094 specifies two voice-activity detectors for narrowband and the encoder
selects between them; VAD1 alone is half the specification. VAD2 is a 128-point
FFT over 16 channels feeding an SNR and hangover state machine, bit-exact here
against the reference over 300 half-frames of committed trace covering the
whole state — every counter and all three sixteen-element arrays, not just the
boolean.

Comparing the decision alone would have been close to worthless. It is one bit,
it agrees with a constant most of the time, and VAD2's frame decision is the OR
of two calls per frame — so a wrong half-frame can be masked entirely by its
partner. `tools/nb_vad2_probe.c` dumps the full per-half-frame state from the
reference so a divergence localises to a stage instead of to "somewhere".

## Soak and fuzz

```bash
crates/media/codec-core/tools/run-amr-soak.sh
crates/media/codec-core/tools/run-amr-fuzz.sh
```

The bit-exactness fixtures are 50 frames. Nothing in them can catch state that
degrades over minutes, or an allocation that grows once per thousand frames.
`soak.rs` holds `#[ignore]`d long-run encode/decode tests scaled by
`RVOIP_AMR_SOAK_SECS` so the default `cargo test` stays fast and the long run
is deliberate.

Fuzzing covers both directions. The decoders had a target already — the obvious
one, since decoders eat hostile input by definition. The encoders now have
`fuzz/fuzz_targets/amr_encode.rs` too, because mode changes, DTX transitions and
CMR requests form a state machine that arbitrary drivers can walk into corners
that fixtures never visit.

## Interop, against both PBXes and both proxies

The SDP half is done and tested: an AMR-WB offer with
`octet-align=1; mode-set=0,2,4` negotiates through the SIP layer, reaches
media-core with its name, dynamic payload type, 16 kHz clock rate and fmtp
intact, and a codec built from exactly that negotiation round-trips a frame.
Each of those four was separately broken during this work, so the test asserts
all four together.

The live calls are done, in three tiers that prove different things.

**Tier 1 — `amr_call`, the relay tier.** Both legs offer the same codec, so
both PBXes forward our RTP octets untouched — verified from their own
behaviour: Asterisk logs `bridge_native_rtp.c: Locally RTP bridged` with
`ReadTranscode: No` and `codec_amr.so` use count 0; FreeSWITCH's egress
payloads are byte-identical to its ingress (75/75 narrowband, 38/38 wideband).
The media path is rvoip's encoder to rvoip's own decoder with a forwarder in
between, so this tier proves SDP/fmtp negotiation, per-leg dynamic-PT mapping,
and framing survival — **not** that a foreign codec can read our bitstream. A
bug shared by our encoder and decoder cancels out here, the same
symmetric-mistake blindness `tools/verify-amr-rtp-framing.sh` covers one layer
down. (This tier was briefly claimed as more than it is; the correction is
recorded in the history rather than erased.)

**Tier 2 — `amr_transcode_call`, the tier that closes that hole.** The caller
and callee offer *disjoint* codecs (AMR on one leg, PCMU on the other), so the
PBX physically cannot native-bridge: its own AMR implementation must decode
every frame we send and encode every frame we receive. The 440 Hz the PCMU leg
records exists only because the PBX's AMR decoder read our frames; the 880 Hz
the AMR leg records exists only because the PBX's AMR encoder produced frames
our decoder read. Corroborated from Asterisk's side: the transcode calls stay
on `simple_bridge` with `codec_amr.so` use count 2 mid-call (captured as an
artifact by `PBX_DIAG=1`), where every matched-codec call switches to
`native_rtp` with use count 0.

The guard on tier 2 is a unit test — every pairing's two legs intersect to
telephone-event alone — not the call passing. Measured deliberately: forcing
both legs onto one codec via `ENDPOINT_{user}_CODEC_PROFILE` flips Asterisk
back to `Locally RTP bridged` and the call *still passes*, which is exactly
why a passing call cannot be the guard.

All cells pass the quality gate (below), three consecutive repeats each on
UDP, plus TLS+SRTP — the first AMR-over-SRTP evidence in the repo:

| PBX | Scenario | Cells | UDP ×3 | TLS |
|---|---|---|---|---|
| Asterisk 20.20.1 | relay | amrnb, amrwb |||
| Asterisk 20.20.1 | transcode | amrnb_pcmu, amrwb_pcmu |||
| FreeSWITCH 1.10.12 | relay | amrnb_be, amrwb_be |||
| FreeSWITCH 1.10.12 | transcode | amrnb_be_pcmu, amrwb_be_pcmu |||

The `amrwb_pcmu` cells also exercise the PBX's own 16 kHz ↔ 8 kHz resampler.

**Tier 3 — the proxy tier, Kamailio and OpenSIPS with rtpengine.** A B2BUA
terminates the media; a proxy does not. This tier puts a registrar-proxy in the
signalling path via Record-Route and rtpengine on the media path as a pure
relay, so all four AMR framings cross a middlebox that rewrites addresses and
ports and nothing else. rtpengine's own totals report zero transcoded media,
which is the assertion: our payloads arrive verbatim.

The lab is built to fail closed. A `rtpengine_manage` that does not succeed
returns 503 rather than passing the SDP through, because the alternative is the
endpoints negotiating directly and every media assertion passing vacuously —
which is exactly what an earlier version of this lab did before the guard
existed. Kamailio runs over UDP and TLS with SDES-SRTP; OpenSIPS runs UDP only,
having no TLS image yet.

This tier is lab evidence, not release-gate evidence: it does not run TCP or
both adjacency orders, and it is not bound into the four-peer attestation.

The framing column is not a preference, it is what each PBX can actually carry
end to end:

- **Asterisk** answers whichever framing we offer, on both legs, so the
  octet-aligned offer is honoured in both directions. (Verified separately:
  offering bandwidth-efficient instead yields byte-identical decoded audio, so
  the framing is not what distinguishes these rows — the PBX is.)
- **FreeSWITCH** *relays* AMR between the two legs of a bridged call without
  re-framing the payloads, and its outbound leg always offers `octet-align=0`.
  An octet-aligned inbound leg therefore leaves both endpoints reading the
  framing they did not agree to. The two legs only agree if ours is
  bandwidth-efficient as well, which is what the `amrnb_be` / `amrwb_be`
  profiles offer (PT 106 and 104 rather than 107 and 105).

  `mod_amr`'s `force-oa` does not fix this and is deliberately left at 0:
  setting it to 1 makes FreeSWITCH answer `octet-align=1` to an offer that
  asked for bandwidth-efficient, which RFC 4867 §8.3.1 does not permit, and
  breaks the inbound leg instead of aligning the outbound one.

  On the *transcoding* path, `mod_amr` has a second framing defect, measured
  directly: offered octet-aligned AMR (PT 107, `octet-align=1`), it negotiates
  correctly and then instantiates its **Bandwidth Efficient** decoder anyway —
  `Codec AMR / Bandwidth Efficient decoder error!` on every frame. So the
  bandwidth-efficient pairings are FreeSWITCH's transcode defaults too.

That split is better coverage than either alone: Asterisk exercises the
octet-aligned path and FreeSWITCH the bandwidth-efficient one, whose payload
boundaries do not fall on octets — in both tiers.

**The audio gate measures quality, not just pitch.** The first tone check was
a single Goertzel dominance ratio, and it could not discriminate: measured
against real captures, 1-bit squaring passed at ratio 441, 100× attenuation at
6820, half the frames zeroed at 922 — and a genuinely degraded capture passed
at 237×. The gate now requires, continuously for one second at the leg's own
rate: the far end's tone dominant, per-window fundamental-vs-residual SNR
≥ 15 dB (a true-dB figure — injected noise reads back within 1 dB), and every
20 ms frame above a quarter of the sent RMS. Each clause exists because a
measured failure defeated the others, and each is pinned by a test, including
the wideband-read-as-narrowband case (a clean tone an octave low) that nothing
else notices.

**The one real audio defect found was ours, and not in the codec.** The
harness's tone sender paced with a trailing `sleep(20 ms)`, making the true
send period 20 ms plus work plus scheduler slop — measured 21.5 ms/frame via
the Asterisk relay, 24 ms under load. FreeSWITCH re-clocks media to a true
20 ms; starved by a 24 ms source it stretches, which surfaced as level swings
and a 0.75 Hz-flat tone offset. (The "−12.6 dB" first measured was itself
partly metrology: a whole-file Goertzel at exactly 440.00 Hz cancels a
0.75 Hz-offset tone. Real coherent damage was ~2 dB plus the modulation.)
With the sender on a proper interval the same cells measure +26 to +30 dB.
Every audio measurement made through a PBX before that fix was confounded.

Environment defects found and fixed along the way, none of them AMR's: the
`rvoip_*` FreeSWITCH profiles pinned `inbound-codec-prefs` to `G729,PCMU,PCMA`,
so an accepted AMR call was bridged out as G.729 and refused; the container
advertised its docker-internal address in SDP unless started through
`scripts/up.sh`, so RTP went to an unroutable host; and for the transcode tier,
FreeSWITCH gained `rvoip_udp_xcode`/`rvoip_tls_srtp_xcode` profile twins
(5064/5065) with `disable-transcoding=false`, early codec negotiation (late
negotiation makes its bridge offer the B-leg only the A-leg's codec), and a
dialplan export of the full codec list to the originated leg. The relay
profiles are byte-for-byte untouched, so the tier-1 evidence stands. All of it
lives in `~/Developer/freeswitch/docker-entrypoint.sh`.

Neither reference is committed. `tools/build-amr-reference.sh`,
`build-amrnb-reference.sh` and the two `*-encoder-reference.sh` scripts fetch
and build them; only generated vectors, PCM, bitstreams and traces are checked
in, because they are output rather than source.

## Oracle qualification, measured rather than assumed

Phase 0 of the plan asked which oracles are bit-exact with the normative
references, per path. It went unmeasured for a long time, and risk R8 stayed
open on the strength of `vo-amrwbenc` being VisualOn-derived rather than
reference-derived. Building the encoder ground truth made the measurement free:
both encoders had by then been run over the same deterministic signal, so the
fixtures only had to be compared.

| Path | Apache-2.0 oracle | Agrees with the normative reference? |
|---|---|---|
| AMR-NB encode | `opencore-amr` | **Yes, all eight rates** |
| AMR-WB encode | `vo-amrwbenc` | **Seven of nine.** Not at 12.65 or 14.25 kbit/s |

The narrowband result is the more useful one: an AMR-NB encode fixture derived
from `opencore-amr` carries the same authority as one generated by running the
3GPP reference, without inheriting the redistribution question the plan leaves
open as IP-2b. The wideband result makes R8 real but narrow — a wideband
encoder fixture from that library is ground truth everywhere except those two
adjacent mid-range rates, where it is a lead rather than an answer. The
decoders are unaffected: both were validated against the 3GPP references
directly.

`src/codecs/amr/qualification.rs` pins this as a test, and pins the divergent
set *exactly* rather than bounding it — a rate that starts or stops agreeing
changes what the fixtures are worth and should be read before it is accepted.

## The lessons that cost the most

**1. An oracle that shares your assumption verifies nothing.** This fired three
times. The VAD flag, 23.85's high-band gain index, and AMR-NB's `packed_size`
convention were each wrong in the Rust *and* in the oracle written to check it,
so the test passed. The defence is a conservation law the shared assumption
cannot satisfy — bit counts, permutation checks, cross-table agreement — plus
comparing against something a genuinely independent implementation produced.
The `.amr` fixtures come from opencore-amr and vo-amrwbenc for exactly this
reason.

**2. Diff intermediates, do not reason from output.** A full session spent
reasoning about output PCM produced one speculative lead and no fixes.
Instrumenting the reference and diffing per-stage intermediates found every
remaining bug in a single pass. There are now four instruments, one per codec
path, and each asserts that the instrumented build still reproduces the
committed output byte for byte — so a trace point that changes behaviour rather
than observing it fails loudly:

| Instrument | Covers |
|---|---|
| `tools/trace-amr-reference.sh` | AMR-WB decoder |
| `tools/trace-amrnb-reference.sh` | AMR-NB decoder |
| `tools/trace-amrwb-encoder.sh` | AMR-WB encoder |
| `tools/trace-amrnb-encoder.sh` | AMR-NB encoder |

The Rust side emits the same names. For narrowband:

```sh
tools/trace-amrnb-reference.sh 4
AMR_TRACE_MODE=4 cargo test -p rvoip-codec-core --all-features \
    nb_dump_trace -- --ignored --nocapture
```

**3. A comparison that never happened looks exactly like one that passed.**
This is the same failure as lesson 1 from the other side, and it fired twice in
one session in two different languages. The C instrumentation wrote its trace
to stderr, where the reference's own newline-free progress output merged into
the following line and `grep '^T '` silently dropped it — fifty rows of the
encoder's 16 kHz input, gone. The Rust dump then lost its first row to
`cargo test --nocapture` leaving the test-name line open, which made the very
first pitch lag look wrong when it was correct, and later swallowed the score of
the one rate that was actually failing.

Both are fixed — the C traces have their own file, the Rust dumps start with a
newline — and the trace scripts now report the least frequent trace name, so a
row lost to any future interleaving shows up as an uneven count. The general
form: **a fixture reader that yields nothing must fail, not pass.**
`nb/vectors.rs` panics on a missing or empty section for this reason, and every
bit-exact test asserts how many cases it compared.

**4. Read the reference raw.** Both references interleave instrumentation
counters (`test(); move16();`) on the same line as real assignments, so any
filter that strips those lines strips real code. That removed four lines from
one function, including two shifts, before it was noticed.

**5. The reference declares the same table name twice, more than once.**
`mean_lsf` and `dico1..3_lsf` differ between `q_plsf_3.tab` and `q_plsf_5.tab`;
`inter_6` differs between `pred_lt.c` and `inter_36.tab` — 61 taps for the
adaptive codebook against 25 for the encoder's pitch search, differing from the
first coefficient. The table generator emitted the wrong `inter_6` for a while
under a doc comment describing the other one. It now asserts, for each such
pair, that they still differ; and the module indexing the 61-tap table fails the
*build* rather than a test if a regenerated table changes length.

## AMR-NB: what must not be shared with wideband

The two codecs look like relatives and are not interchangeable anywhere it
matters. Each of these would compile, run, and produce speech-shaped output:

| Thing | Narrowband | Wideband |
|---|---|---|
| Spectral representation | **LSP/LSF** | **ISP/ISF** |
| `F2(z)` factor in the LP conversion | `(1 − z⁻¹)`, subtract `f2[i-1]` | `(1 − z⁻²)`, subtract `f2[i-2]` |
| Final shift in that conversion | 13 | 12 |
| Trailing-coefficient scaling | none | scales by the last ISP |
| Cosine table | 65 entries, top 8 bits | 129 entries, top 9 bits |
| Spacing enforcement | all `M` coefficients | stops one short |
| MA prediction factor | per coefficient | one scalar |
| Interpolation weights | uniform ¼, ½, ¾, 1 | `{0.45, 0.8, 0.96, 1.0}` |
| Adaptive-codebook filter | ⅙ resolution | ¼ resolution |
| `log2` / `pow2` / `sqrt` tables | its own | its own, and G.729 has a third set |
| Output convention | **13-bit** (`& 0xfff8`) | **14-bit** (`& 0xfffC`) |
| Decoder post-filter | **yes** | none |
| `packed_size` in the reference | includes the ToC byte | excludes it |

`nb/lsp.rs` carries a test asserting the two LP conversions **disagree** on the
same input, so a later attempt to unify them fails loudly rather than silently.

## What the narrowband decoder assembly taught

Seven of the eight rates were bit-exact on the first assembly, which is not
what the wideband experience predicted — that one had every stage exact in
isolation and scored 1–3% assembled. The difference is that the three hazards
wideband found the hard way were written into `nb/decoder.rs`'s header before a
line of it ran:

- **Two excitations, and they diverge.** What goes back into the
  adaptive-codebook history is not what the synthesis filter consumes.
- **The previous lag is read on both sides of one write.** Bad-frame graceful
  degradation increments it, and 10.2's sharpening attenuation reads the
  incremented value.
- **The overflow flag is cleared per synthesis call, not per frame.**

The eighth rate, 4.75 kbit/s, failed for its own reason: it transmits one gain
index per *pair* of subframes, on the even one, and the odd subframe re-decodes
that same index against the other half of the table entry. The index has to
survive the two parameters the lag and the codebook consume in between.

## Remaining work, in order

1. ~~**DTX, comfort noise and homing.**~~ Done, both variants, both directions.

   The ground truth landed first, as it did for the encoders:
   `tools/build-amr-dtx-fixtures.sh` produces a 150-frame signal with real
   silence in it, encoded with `-dtx` at all seventeen rates across both
   variants, plus `_mute` variants whose SID updates are dropped so the
   `DTX_MUTE` fade is reachable at all. Five assertions keep it from being
   vacuous, the sharpest being that VAD1 and VAD2 choose different frame types
   on 21 of the 150 frames — established *before* the VAD1 port was written,
   because otherwise nothing would tell the two detectors apart.

   Everything this entry once listed as remaining has landed: the wideband
   decoder side (`rx_dtx_handler`, `dtx_dec` and the `DTX_MUTE` fade), homing
   for both variants, and narrowband — including VAD1, whose long pole was that
   it has no directly observable output at all, and VAD2 alongside it.

   The normative DTX vectors this entry used to describe as unread are now
   read: the wideband `testv/tst_md.cod` and `.out` (80 speech, 1 SID_FIRST, 15
   SID_UPDATE, 104 NO_DATA at 12.65 kbit/s), and the narrowband `spch_dos.inp`,
   `spch_dos.cod` and `spch_dos.out` — 425 frames encoded with `-dtx` across all
   eight rates, which is the reference's own installation check
   (`amr_chk.csh`). Both appear in the table above and in the conformance
   section below. They were the ground truth for the VAD ports, which otherwise
   have *no* directly observable output: the narrowband VAD decision appears
   nowhere in the bitstream, only in which frames become SID or NO_DATA.

2. ~~**Transcoding.**~~ Done — six pairs, tested by property.

3. ~~**Interop and performance.**~~ Done — see the table at the top and the
   interop section. Both PBXes, both variants, tone verified; benchmarks per
   rate with a real-time-factor gate.

4. ~~**The relay path's exit criterion.**~~ Met in full: rvoip as the
   relaying B2BUA (Asterisk OA + FreeSWITCH BE, UDP and TLS+SRTP), the
   mid-call mode switch observed on the wire, and the proxy tier — Kamailio
   and OpenSIPS with rtpengine relaying all four AMR framings verbatim.

5. ~~**Live DTX and CMR.**~~ Both now reach the wire. CMR emission closed with
   `request_peer_codec_mode` (see the exit criterion above). **DTX closed
   2026-08-12**: `Config::amr_dtx` -> `MediaAdapter::set_amr_dtx` ->
   `MediaConfig::with_amr_dtx` on the same commit as the negotiated codec ->
   `resolve_codec` -> the codec's own DTX switch. Sender-side policy only:
   RFC 4867 defines no fmtp for DTX, so nothing is negotiated and the peer
   needs no matching setting.

   Verified on the wire against Asterisk with `PBX_AMR_DTX=1` and a two-second
   silent window in the middle of the call (`amr_call`, octet-aligned, mode 7).
   Speech frames are 33-octet payloads throughout; during the silence the
   stream becomes:

   - `f0 44 2b 07 83 68 0e` — CMR 15, **FT 8 (SID_UPDATE)**, Q=1, five octets
     of comfort-noise parameters. 12 of them.
   - `f0 7c` — CMR 15, **FT 15 (NO_DATA)**, Q=1. 74 of them.

   One SID every eighth frame with NO_DATA between, which is the TS 26.093
   update cadence, and Asterisk relayed them to the far end unchanged. The
   same cell without `PBX_AMR_DTX=1` emits 87-byte frames and nothing else,
   so the switch is non-vacuous in both positions.

   Finding worth keeping: the first wiring set the flag in the harness's
   `session_config`, which returns early for every non-TLS transport — so it
   reached TLS cells only and every UDP run silently behaved as though DTX
   were off, while the diagnostics said it had been requested. What caught it
   was a new `codec generation built` log line naming the flags each codec was
   actually constructed with; that line is now permanent, because every AMR
   field in it has been silently wrong at some point on this branch.

6. ~~**Rates beyond the top of each variant.**~~ Closed, in both senses — see
   [Per-rate attestation]#per-rate-attestation below.

   Between our own endpoints, `every_amr_narrowband_mode_carries_audio_in_a_live_call`
   and its wideband twin walk the peer down through all 8 and all 9 modes with
   codec mode requests, reading back the mode of frames actually decoded from
   the peer. Against a third party, all 17 modes were run individually against
   Asterisk, each pinned by an RFC 4867 `mode-set` in the offer.

7. ~~**A soak test**, and a fuzz target for the encoders.~~ Both done.
   `soak.rs` holds `#[ignore]`d long-run encode/decode tests scaled by
   `RVOIP_AMR_SOAK_SECS`, driven by `tools/run-amr-soak.sh`; the encoders now
   have `fuzz/fuzz_targets/amr_encode.rs` beside the decoders' target, and both
   run from `tools/run-amr-fuzz.sh`.

8. ~~**AMR through `rvoip-core`'s media graph.**~~ Done. UCTP publishing,
   graph recording and MOQT fan-out can observe an AMR call.

   It was never an AMR gap: the codec built fine from a name and an fmtp all
   along, and what was missing was a negotiated payload type at the graph's
   entry points — which no transport reported, for any codec. `CodecInfo` now
   carries one, the SIP adapter and the WebRTC SDP parser report it, and the
   graph keys on it. The design question it turned on — whether AMR gets a
   conventional payload type the way Opus got 111 — was **settled: it does
   not**, because one AMR session routinely negotiates two payload types at
   once that differ only in `octet-align`.

   Two things came out of it that were not about AMR. The graph's admission
   check now also builds the codec, since a resolvable key stopped implying a
   buildable one. And the QUIC and WebTransport pumps had been stamping
   **Opus's payload type on any codec they could not name** — a well-formed
   datagram that lies about its contents, which a receiver cannot detect.

   The full reasoning is in
   [`MEDIA_GRAPH_CODECS.md`]../../../foundation/rvoip-core/docs/MEDIA_GRAPH_CODECS.md.

   The only item above still open is **6**, and only its third-party half: an
   evidence gap, not a code gap. No rate is unimplemented and no feature is
   blocked.

## Conformance against the normative sequences

**All nine TS 26.173 wideband vectors pass in both directions.** The encoder
reproduces `tst_m0.cod` .. `tst_m8.cod` bit for bit from `tst.inp`, and the
decoder reproduces `tst_m0.out` .. `tst_m8.out` sample for sample from those —
200 frames each, including 23.85 kbit/s, which no amount of ACELP work would
have reached without DTX and homing.

The decoder side needed the driver's own two-state homing protocol, and it
starts *homed*: `reset_flag_old` is initialised to 1, so a sequence opening
with a homing frame is answered with `0x0008` directly rather than decoded.
Starting from the other state emits silence where the vector has 8. `cargo test -- --ignored conformance` runs
them; they panic rather than skip when the sequences are absent, because a
conformance test that quietly passes having found nothing is worse than none.

The sequences are 3GPP copyright and stay out of the tree, on the same rule as
the reference implementations: only generated output is committed. The vectors
are read in the ETSI serial form they ship in rather than converted first — a
converter is one more thing that could be wrong in the same direction as the
code under test.

The normative *DTX* vector, `tst_md.cod` from `dtx.inp`, is covered on the
encode side too: 80 speech frames, a SID_FIRST, 15 SID_UPDATEs and 104 NO_DATA,
every transmit type and every payload matching. `tst.inp` never goes quiet
enough to emit a SID, so without this the DTX path would only ever have been
exercised through its effect on mode 8's gain.

**Everything passes now.** `tst_md.cod` against `tst_md.out` was the last
holdout — one LSB out from sample 16 of frame 80, the first `SID_FIRST` — and
it was two defects rather than one: an excitation snapshot taken per subframe
in four different exponents, and a `CN_dithering` written from a summary
instead of from the C. Both are described above. All six sequences now compare
with zero tolerance.

Mutation-checked: turning DTX off fails mode 8 at frame 0.

## Per-rate attestation

Every AMR mode, run individually against a live third-party PBX.

**Peer:** Asterisk 20.20.1 (aarch64, Linux, built 2026-08-11), `chan_pjsip`
with `codec_amr`. **Run:** 2026-08-13, `amr_call` scenario, `endpoint` API,
UDP, one cell per mode.

Each cell pins one rate the standard way — `Config::amr_mode_set` puts an
RFC 4867 `mode-set` in the INVITE naming exactly that mode, and the set is
bi-directional, so it governs what Asterisk sends as well as what we send.
The lab reaches it through `PBX_AMR_MODE_SET=<mode>`.

| Variant | Modes | UDP | TLS + SDES-SRTP |
|---|---|---|---|
| AMR-NB | 0, 1, 2, 3, 4, 5, 6, 7 | all PASS | all PASS |
| AMR-WB | 0, 1, 2, 3, 4, 5, 6, 7, 8 | all PASS | all PASS |

Thirty-four cells: every mode of both variants, over both transports.

**Two independent facts are recorded per cell, because the interesting failure
passes the obvious check.** A cell pinned to one rate and a cell that ignored
the pin both produce clean audio, so "the call passed" attests to nothing about
the rate.

- *The rate was actually in force.* `codec generation built` now logs
  `mode_set=` alongside the codec it constructed, so each cell's log shows the
  mode the codec was built with rather than the environment variable that was
  meant to cause it. Every cell logged the mode it was pinned to: mode 0 logged
  `mode_set="0"`, mode 7 logged `mode_set="7"`, and so on across both variants.
- *The audio survived at that rate.* The analyser's tone check ran per cell —
  the far tone dominating the near one, with a 1 s window above 15 dB SNR. The
  weakest margin across all 17 was 8,508× (AMR-WB mode 0, the 6.6 kbit/s rate,
  where a lower margin is expected); the strongest was 116,348×.

These rows are a release gate, not a one-off run. `interop.amr-rate-sweep`
aggregates four cells — narrowband and wideband over each transport — in the
`remote-release` profile, with its own Asterisk lab up/down chain so a failing
sweep still tears the lab down. The gate fails, rather than reporting, when a
cell's codec was not built at the mode it pinned.

Reproduce locally with the same tool the gate runs:

```bash
for transport in UDP TLS; do
  for profile in amrnb amrwb; do
    crates/sip/rvoip-sip/examples/pbx/rate-sweep.sh --profile "$profile" --transport "$transport"
  done
done
```

It fails the run if any cell's `built_mode_set` disagrees with the mode it
pinned, so a sweep cannot quietly attest to a rate it did not test, and it
clears each cell's directory first — a cell that never executes otherwise
leaves the previous run's logs in place, and reading those reports a rate as
attested when nothing ran. This tool did exactly that on its first attempt.

**What this does and does not say.** A third-party implementation decoded our
AMR and encoded audio we decoded, at each of the seventeen rates, one rate at
a time. It is one peer, one version, one topology, and it is not a carrier
certification.

**The TLS half needed a port-allocation fix first**, and it is worth recording
because it made the suite order-dependent rather than broken. The Kamailio lab
listened on host 5072/5073 and OpenSIPS on 5074 — which are this suite's own
endpoint ports for users 1002 and 1003. With a proxy lab up, the Asterisk TLS
callee could not bind, never registered, and every TLS cell failed with a 404
that named nothing about ports; whichever started first won. The labs moved to
5066/5067 and 5068, below the 5070+ endpoint block, and
`lab_daemon_ports_stay_clear_of_the_endpoint_block` now fails if a daemon is
ever allocated back into it. The TLS rows above were then run with the Kamailio
lab deliberately left running, which is the combination that used to fail.

## Where AMR flows, and the one place it does not

Everything above is about whether AMR *codes* correctly. Whether it reaches
every path media travels in this workspace was a separate question, and for a
while the answer was no. It is now yes, with one boundary worth stating.

**AMR crosses `rvoip-core`'s media graph** — the one-source-to-many fan-out
behind UCTP publishing, recording and MOQT fan-out — behind rvoip-core's own
`amr-nb`/`amr-wb` features. Frames go through in both directions, decoded and
tone-verified rather than counted, and a source whose packet time is not 20 ms
is re-framed rather than refused.

Getting there was not about constructing the codec: `AudioCodecSpec::build`
always built either variant from a name and an fmtp. It was that the graph
derived a payload type from a codec *name*, and AMR has no such number — one
session routinely negotiates the same variant under two payload types
differing only in `octet-align` (`amr_call_integration.rs` uses 106 and 107),
and the key the graph computes is stamped onto the frames it emits. The
decision stands: **AMR gets no conventional payload type; dynamic codecs key on
the negotiated one**, which `CodecInfo` now carries.

**AMR without a reported payload type is still refused**, and that is the
correct answer rather than a leftover — there is no number to label its frames
with. UCTP negotiates codecs by name and reports none, so AMR over QUIC or
WebTransport depends on frames carrying their own label; graph-transcoded
frames do, and one that does not is dropped rather than sent under a
fabricated number.

The decision and its evidence are in
[`crates/foundation/rvoip-core/docs/MEDIA_GRAPH_CODECS.md`](../../../foundation/rvoip-core/docs/MEDIA_GRAPH_CODECS.md).

## What is *not* claimed

Two different claims live in this document and they should not be conflated.

**Bit-exactness** means agreement with the 3GPP reference implementations over
the committed fixtures — 50 frames of one deterministic signal per rate, plus
25 frames of a second for the decoders. Those fixtures are ours; we generated
them.

**Conformance** means the normative sequences that ship with the reference
distributions, which the section above records as passing: all nine TS 26.173
wideband vectors in both directions, the wideband DTX vectors, and the
narrowband `spch_dos` set at 425 frames. Those we did not choose.

What is still *not* claimed is certification. TS 26.074 and TS 26.174 define the
conformance process, and passing the sequences a reference distribution ships is
not the same as being certified against that process by anyone but ourselves. No
3GPP material — neither the reference C nor the specifications — is in this tree;
it is fetched to work against and never redistributed, so every fixture and trace
committed here is generated output. The distinction is worth keeping, because
this repo's own G.711 tests already disclaim evidence from files that are not
present, and the same discipline applies here.

## The normative encoder vectors are DTX-on, and mode 8 needs it

Found while specifying DTX, and it changes what conformance will cost.

TS 26.173 ships nine encoder conformance vectors, and `testv/test_enc.bat`
produces every one of them with `-dtx`. Measured against our own build of the
reference:

| | Reproducible without `-dtx`? |
|---|---|
| `tst_m0.cod` .. `tst_m7.cod` | **Yes**, byte for byte |
| `tst_m8.cod` (23.85 kbit/s) | **No** — 266 of 192 000 bytes differ |

`tst.inp` never goes quiet enough to emit a SID at any rate, so for eight of
the nine the DTX flag changes nothing. Mode 8 is different because it is the
only rate whose high-band correction gain depends on `dtxHangoverCount`: with
DTX off the counter never leaves `DTX_HANG_CONST`, `gain_alpha` is pinned at
32767, and that is exactly what `wb/enc/encoder.rs` hardcodes today. The
differing frames are precisely those where the counter is below 7, and every
differing bit is inside the four 4-bit gain fields.

Two consequences:

1. **Eight of the nine normative vectors are within reach now**, without DTX.
   That is a stronger claim than agreement with our own fixtures and it costs
   only a fetch — the vectors are 3GPP-copyrighted and stay out of the tree, so
   the test is opt-in, `#[ignore]`d, and panics rather than skips when the
   environment variable naming them is unset.
2. **The ninth is the acceptance test for the DTX hangover counter.** Not a
   proxy for it: the same counter, read by the same expression, over 200 frames
   of the spec's own input.

`tst.inp` also opens with two encoder homing frames (all `0x0008`), each of
which drives `Reset_encoder` and re-arms the hangover — so reproducing
`tst_m8.cod` needs homing as well as DTX.

## Where these tests run

Until 2026-08-10, nowhere. `codec-core` defaults to `["g711"]` and the PR
shards build every crate with its default features, so
`cargo test -p rvoip-codec-core` ran **104 tests where `--all-features` runs
740** — every AMR test on this branch, plus G.729 and Opus, compiled out on
every pull request. media-core had the same hole, 290 against 316, and 14 of
its 26 missing tests are the RFC 4867 AMR payload-format ones.

The `codec-features` specialty gate now runs both crates at `--all-features`,
tests and Clippy, on any change under `crates/media/codec-core/**`,
media-core's codec and payload trees, or `Cargo.lock` — and unconditionally on
Main. It is defined in `scripts/ci/run_checks.py` and `scripts/ci/policy.json`,
and `scripts/ci/test_run_checks.py` asserts both that the gate passes
`--all-features` everywhere and that the shards do not, so the gate cannot
quietly become a duplicate of work already done.

## Detailed history


Living tracker for the AMR-NB / AMR-WB work. The plan is in
[`AMR_IMPLEMENTATION_PLAN.md`](AMR_IMPLEMENTATION_PLAN.md); this file records
where we actually are.

**Branch:** `feat/amr-codecs`
**Last updated:** 2026-08-13

---

## At a glance

| Phase | Scope | Status |
|---|---|---|
| 0 | Foundations: types, feature flags, ADR, oracle qualification | 🟢 **Complete** |
| 1 | RFC 4867 payload format + AMR file storage format | 🟢 **Complete** |
| 2 | SDP negotiation + relay path | 🟡 **Negotiation done** — relay path outstanding |
| 3 | DSP layer + oracle harness | 🟢 **Complete**, all four codec paths |
| 4 | AMR-WB decoder, fixed point | 🟢 **Bit-exact, all nine rates**, plus concealment |
| 5 | **AMR-WB encoder — the HD-voice milestone** | 🟢 **Byte-identical, all nine rates** |
| 6 | AMR-NB decoder, fixed point | 🟢 **Bit-exact, all eight rates**, plus concealment |
| 7 | AMR-NB encoder, fixed point | 🟢 **Byte-identical, all eight rates** |
| 8 | Transcoding, interop, performance, hardening | 🟢 **Transcoding, both PBXes, and a real-time-factor gate**; soak and encoder fuzzing not started |

Of the codec proper, nothing is missing: DTX, comfort noise, concealment and
homing are all in and checked against the normative sequences. The relay-path
exit criterion is met in full (rvoip as the relaying B2BUA, the mid-call mode
switch on the wire, and the Kamailio/OpenSIPS+rtpengine proxy tier). What is
left around the codec: a soak test and a fuzz target for the encoders.

---

## Phase 3 — the codec kernel

### The basic operators already existed

Phase 3 was planned to open with writing `common/basicop.rs`, the ETSI basic
operators, exhaustively tested, with nothing else starting until it was green —
budgeted as the foundation everything else is debugged against.

**It was already there.** The G.729A port carries a complete ETSI basic-operator
set, and AMR specifies its arithmetic against the same ITU-T/3GPP library: `add`,
`sub`, `mult`, `mult_r`, `l_mult`, `l_mac`, `l_msu`, `l_add`, `l_sub`, `shl`,
`shr`, `l_shl`, `l_shr`, `l_shr_r`, `round`, `norm_s`, `norm_l`, `div_s`,
`abs_s`, `l_abs`, `negate`, `extract_h/l`, `l_deposit_h/l`, `mac_r`, `msu_r`,
plus the extended-precision `_c` and `_ns` variants. Faithful to the reference,
including the `MIN_16 → MAX_16` quirk in `abs_s`/`negate` and the overflow/carry
flags — and validated by that codec reaching bit-exactness.

So the work was not writing them but **promoting them**: they lived in
`codecs::g729::impls::dsp`, gated behind the `g729` feature. They now live in
`crate::fixed_point`, shared. G.729 reaches them under the old name through a
re-export, so its ~318 call sites were untouched.

Two things were deliberately *not* shared:

- **`pow2`, `log2`, `inv_sqrt`** moved to `codecs::g729::impls::math` instead.
  They are not basic operators — each reads a G.729 Annex A lookup table. AMR
  specifies the same three functions over its own tables (TS 26.073
  `oper_32b.c`), and whether those tables are numerically identical is an open
  question. Sharing the code before answering it would be an assumption dressed
  as reuse.
- **The lint exemption** was carried across rather than widened. The G.729
  subtree has a narrowly enumerated `allow` list with a written rationale —
  deliberate wrapping casts, reference-style names. The same list now sits on
  `fixed_point` with the same reasoning, and `fixed_point` is `pub(crate)`
  because it is an implementation detail, not API this crate wants to commit to.

Verified: G.729's 26 tests still pass, so the move did not disturb a bit-exact
codec; the feature matrix builds in all five combinations, including AMR without
G.729 and vice versa.

### The oracle is running

**It never needed Docker.** The blocker was assumed to be container egress, but
the harness only ever needed source and a C compiler — and macOS has clang. The
sources come from the **Debian archive**, which is reachable, rather than
SourceForge (whose download mirrors are not) or GitHub (also not).

`tools/build-amr-oracle.sh` fetches and statically builds all three
Apache-2.0 libraries, then regenerates the vectors:

| Library | Covers |
|---|---|
| `opencore-amr` 0.1.6 | AMR-NB encode/decode, AMR-WB decode |
| `vo-amrwbenc` 0.1.3 | AMR-WB encode |

**It validates the whole mode table — all 17 modes.** The reference encoders
were run at every mode of both variants, and every frame size is exactly
`octet_aligned_bytes() + 1` (the extra octet being the storage ToC):

| AMR-WB | 18 | 24 | 33 | 37 | 41 | 47 | 51 | 59 | 61 |
|---|---|---|---|---|---|---|---|---|---|
| **AMR-NB** | 13 | 14 | 16 | 18 | 20 | 21 | 27 | 32 | |

Independent confirmation of a table that until now rested on RFC 4867 plus
arithmetic.

**It also settles the 6.70 / 7.40 question raised at the top of the plan.**
Several secondary sources, Wikipedia's AMR page among them, transpose those two
frame sizes. RFC 4867 says 6.70 carries 134 bits (17 octets) and 7.40 carries
148 (19), and the reference encoder's file lengths agree — mode 3 is the smaller
file. Had they been transposed, the sizes would be the other way round. There is
a test asserting exactly that, so the question cannot quietly reopen.

425 reference frames are checked in at `src/codecs/amr/testdata/` (72 KB), with
tests asserting that we read every mode correctly, that our frame sizes predict
the file lengths exactly, that re-writing reproduces the files byte for byte,
and that every frame survives both RFC 4867 framings. The generator also decodes
each file back through `opencore-amrwb` before accepting it, so the fixtures are
self-consistent independently of anything here.

The script reproduces the checked-in fixtures bit-identically from a clean
workdir, so a future regeneration that changes them means something changed for
a reason worth understanding.

**Nothing from the oracle is linked into the shipped crate.** It emits data;
the data is committed; the test suite reads only that. A normal `cargo test`
needs neither the libraries nor a C toolchain — the property that lets the
crate stay pure Rust while still being developed against a real reference.

### The spec was never actually blocked

An earlier revision of this section recorded Phase 3 as blocked on TS 26.190
being unreachable. **That was wrong, and the mistake is worth keeping visible.**
`3gpp.org` and `etsi.org` return 403 to `curl`'s default user agent and 200 to a
browser one — bot filtering, not an egress block. They were lumped in with hosts
that genuinely fail at the connection layer (`arib.or.jp`, `tech-invite.com`,
which return `EADDRNOTAVAIL` exactly like `github.com`) and the whole set was
called blocked.

The distinction is diagnosable in one command: a 403 means the connection
succeeded and the server refused; a connection-layer failure means it never got
that far. Two different problems, two different fixes.

TS 26.190 v19.0.0 is now available from ETSI. Extracting it also needed `pypdf`
rather than raw stream decompression — it uses subset fonts with custom
encodings, unlike TS 26.201, so naive extraction yields binary noise that looks
like a failed download.

### The float modules were removed

`wb/lp/{window,levinson,isp}.rs` are gone. They had **no callers** — nothing
outside their own directory referenced them, `AmrCodec` never reached them, and
`encode`/`decode` still return `FeatureNotEnabled`. Dead code that could be
mistaken for the codec.

Evidence that float has no role here at all:

- **TS 26.173, the normative reference**: `typedef short Word16; typedef long
  Word32;` — pure fixed point.
- **Zero files** across `opencore-amr` and `vo-amrwbenc` mention `float` or
  `double`.
- Every AMR implementation in this project's own lab — FreeSWITCH's `mod_amr`
  and `mod_amrwb`, Asterisk's `codec_amr`, the oracle libraries — is fixed-point,
  because they all link those same libraries.

The float specs (TS 26.104 / 26.204) exist for research; they do not appear in
telephony, which is why they were also dropped from the oracle roster.

What the removed work actually produced is kept: the window formula verified
against `ham_wind.tab` to within 1 LSB, and the noise-floor placement
discrepancy. Both are recorded above. The code itself was not the codec and
could not have become it.

### Superseded: first DSP as floating-point reference models

`codecs/amr/wb/lp/window.rs` implements TS 26.190 §5.2.1 — the asymmetric
analysis window (L1=256, L2=128, 384 samples), autocorrelation, 60 Hz lag
windowing and the 1.0001 white-noise correction. Floating point for now; the
fixed-point form will be checked against it.

**The oracle earned its keep immediately.** The window formula came through a
garbled PDF extraction and had to be reconstructed. Computing it from the
reconstructed formula and comparing against `vo-amrwbenc`'s `ham_wind.tab`:
all 384 values agree to within 1 LSB in Q15, none differing by more than 1. The
reading was right, and now it is *known* to be right rather than assumed —
without copying the table.

It also surfaced a real discrepancy worth recording. The reference's lag window
values differ from ours by a consistent 1e-4, because `lag_wind.tab` folds the
white-noise correction into the lag window (its own comment says "noise floor =
1.0001 = (0.9999 on r[1]..r[16])") while TS 26.190 places it on `r(0)`. The two
differ only by an overall scale on the autocorrelation sequence, which
Levinson-Durbin is invariant to, so the predictor is identical. We follow the
spec's placement; a test pins ours against the reference's documented values
with the factor restored, so the equivalence is asserted rather than assumed.

### Levinson-Durbin

`codecs/amr/wb/lp/levinson.rs` implements §5.2.2 — the order-16 recursion
solving the Yule-Walker system, returning predictor coefficients, reflection
coefficients and residual energy.

The recursion equations came through the PDF extraction scrambled, but unlike
the window this needed no reconstruction: the spec defers the algorithm to a
standard reference, so it is the textbook recursion. What is AMR-specific is the
fixed-point formulation, which will be written against this float version.

Validated by **an independent solve rather than a restatement**: the tests
solve the same system by Gaussian elimination — O(M³), no knowledge of the
Toeplitz structure — and require agreement. Also checked: the normal equations
are satisfied directly, every reflection coefficient lies inside the unit circle
(the stability condition that makes this recursion worth using over a general
solve), residual energy never increases with order, and a tone is orders of
magnitude more predictable than noise.

One test failure was the test's fault, and worth recording: the "white noise"
generator produced values in `0..2²⁴` minus 8192, so it carried a large DC
offset — and a constant is perfectly predictable, which made the noise look
highly structured. Centring it fixed the data, and the assertion was rewritten
to compare prediction gain between a tone and noise rather than test an absolute
threshold that depends on the analysis window's own spectral shape.

### LP to ISP conversion

`codecs/amr/wb/lp/isp.rs` implements §5.2.3: the sum and difference
polynomials, the removal of the roots at `z = ±1`, and the root search that
yields the 15 ISPs plus `a[16]`.

The coefficient recursions were **derived from the polynomial identities rather
than transcribed**, because the spec's recursion block did not survive PDF
extraction. `f'1(z) = A(z) + z⁻¹⁶A(z⁻¹)` gives `f'1[i] = a[i] + a[16-i]`
directly, and `f2 = f'2/(1-z⁻²)` gives `f2[i] = f'2[i] + f2[i-2]`. Both are
then checked numerically against the identities they came from, so the
derivation is verified rather than trusted.

**One deliberate divergence from the reference.** The reference alternates
between `f1` and `f2` while walking the grid, relying on the roots interlacing
so each switch lands in the next bracket. That is efficient but fragile: when
two roots fall inside one grid interval the search steps past one and then
fails to find the rest — which is exactly what happened here first. This model
scans each polynomial over the whole grid independently, so no bracketed root
can be lost, and the interlacing is then *asserted* rather than assumed. The
fixed-point version will need the reference's cheaper approach; having this one
to check against is the point.

### First fixed-point DSP: LP analysis front end

`codecs/amr/wb/lp/` now holds the real thing — TS 26.190 §5.2.1 in the
arithmetic that defines the codec, built on the ETSI operators in
`crate::fixed_point`.

- `tables.rs` — the analysis window (384 Q15 values) and lag window (16
  double-precision pairs), taken from TS 26.173 because they are normative
  constants. **The window is not computed from the §5.2.1 formula**: it is
  close, `round(w · 32767)` reproduces 377 of 384 entries, but seven differ by
  one LSB and one LSB through `mult_r` changes the output bits. A test checks
  the table against the formula to within 1 LSB, documenting the definition and
  catching transcription errors without pretending the formula is authoritative.
- `autocorr.rs` — windowing, the energy-estimate pre-shift, autocorrelation over
  17 lags in double-precision format, and lag windowing.
- `isp_to_lp.rs` — §5.2.4, ISPs back to predictor coefficients. **Decoder-side
  work**, which is why it lands before the encoder-only analysis stages: the
  decoder receives quantised ISFs and never analyses, so this is on the critical
  path to a working decoder while §5.2.2 and §5.2.3 are not.
- `isf.rs` — §5.2.5–§5.2.6, ISP↔ISF conversion and the four-subframe
  interpolation. The codec quantises in the ISF domain (ordered, bounded, local
  error) and computes in the ISP domain (what the polynomial arithmetic wants),
  converting once per frame each way. Interpolating ISPs rather than
  coefficients is what keeps every intermediate filter stable — ISPs stay
  ordered under a convex combination, and ordered ISPs *are* the minimum-phase
  condition.
- `isf_tables.rs` — the 129-entry cosine table and 128 `acos` slopes. **These
  tables are the transform**, not an approximation of it: both directions
  interpolate rather than evaluating a real trigonometric function, so a more
  accurate cosine gives different bits and fails conformance. The round trip is
  correspondingly not the identity, which is why each direction is checked
  against the reference separately rather than only checking that they compose.

- `isf_dequant.rs` + `isf_codebooks.rs` — §5.2.7/§6.1, indices to ISFs at both
  rates, with erasure concealment. Two-stage split VQ: splitting is what makes
  the codebooks tractable at all, since a joint 46-bit codebook over sixteen
  dimensions would need 7 × 10¹³ entries.

The dequantiser is **predictive, and therefore stateful** — a frame's output
depends on the previous frame's residual — which changes what a useful test
looks like. A single-frame vector would miss whether the state update is right,
and that is precisely the bug that makes a decoder drift away from its encoder
over seconds rather than failing outright. So the oracle runs a *sequence* from
the reset state, dumping output and residual after each frame, and the Rust
replays the whole sequence and checks both. The last frame is marked bad, so
concealment and its distinct state update are covered as well.

The interpolation weights are worth naming: `{0.45, 0.8, 0.96, 1.0}`, not
uniform quarters. The weighting is pushed hard toward the new frame because the
analysis window that produced it is itself concentrated at the end of the frame
— the "new" ISPs already describe the region the first subframe sits in.

Two details worth naming, both invisible in a float model:

- **The accumulator for `r[0]` starts at 1, not 0.** That is what stops a silent
  frame producing `r(0) = 0` and making Levinson-Durbin divide by zero. It is
  *not* the -40 dB noise floor, which lives in the lag window — a distinction
  the prose spec blurs.
- **One shared normalisation shift is applied to every lag.** Levinson-Durbin
  only cares about ratios, so a common shift buys precision without changing
  the answer; per-lag normalisation would corrupt the sequence.

Tests cover the properties that catch scaling mistakes: `r(0)` dominates every
other lag, `r(0)` lands in the top bits after normalisation, silence still gives
a usable `r(0)`, the lag window leaves `r[0]` untouched while shrinking the
rest, and doubling the input amplitude leaves the normalised sequence
essentially unchanged.

### Bit-exact against the normative reference

`tools/build-amr-reference.sh` fetches TS 26.173 and drives its functions
directly, dumping every intermediate of the LP analysis chain. The vectors are
checked in at `testdata/lp_stages_wb.txt`; the source is not, since 3GPP permits
in-house use but not redistribution.

**The fixed-point autocorrelation matches the reference exactly** — all 17 lags,
both halves of each double-precision pair, across every case. Two tests: one for
the whole windowing-and-accumulation path, and one feeding the reference's own
pre-lag values through `lag_window` in isolation so a failure there cannot be
blamed on the accumulation.

**§5.2.4 ISP→LP matches exactly too** — all 17 coefficients, every case, on the
first run. So do **§5.2.5 ISP→ISF, §5.2.6 ISF→ISP, and the subframe
interpolation** (4 × 17 coefficients per case), and **§5.2.7 ISF
dequantisation** at both rates across a six-frame sequence including an
erasure, checking the residual state as well as the output.

**TS 26.201 bitstream unpacking** closes the loop: real payload bytes → codec
bits → parameter indices, checked against every mode's first three frames.

That completes the decoder's spectral path: **payload → indices → ISFs → ISPs
→ interpolated → per-subframe LP coefficients, every stage bit-exact.**

The unpacking vectors deserve a note on method. A bit permutation that is
self-consistent but wrong round-trips perfectly against itself, so a synthetic
round trip would prove nothing. Instead the vectors come from feeding the
committed `.amr` fixtures — produced by **opencore-amr and vo-amrwbenc** —
through **TS 26.173's** own unsorter. Three independent implementations
agreeing on real bitstreams is a much stronger claim than any self-check.

Note the sorting tables ship inside TS 26.173 (`mime_io.tab`), so this needed
no source outside the tier-1 authority.

One test was written on a false assumption and removed: the first parameter
field is *not* the leading octet of the payload. `SORT_660` opens `0, 5, 6, 7`,
so the eight bits of the first ISF index arrive at payload positions 0, 31, 38,
32, 10, 1, 2, 3. The bit-exact test caught it — the guessed property failed
while the reference comparison passed.

### The full parameter walk, and what an oracle cannot catch

`wb/params.rs` reads a whole frame: VAD flag, ISF indices, and per subframe the
pitch lag, LTP filter bit, algebraic codebook pulses and gain index, plus the
high-band gains at 23.85. Bit-exact on the first three frames of all nine modes.

**The first version was wrong, and the way it was wrong is the lesson.** The VAD
flag is the first bit of a speech frame — `dec_main.c` reads it before the ISFs,
not after — so every field was shifted by one bit. The commit before this one
claimed "three independent implementations agree". That was overstated:
opencore-amr and vo-amrwbenc produced the *bitstream*, but the field offsets
came only from one reading of `dec_main.c`, copied into both the C oracle and
the Rust. They agreed perfectly because they shared the mistake.

**An oracle only checks what it does not share with you.** The generic defence
is a conservation law — something that must hold regardless of whether the
shared assumption is right. Here it is bit count: a field-offset error shifts
later fields rather than overrunning, so leftover bits are the only signal.
Every mode had exactly one bit left over, which is the signature of a one-bit
shift at the front. Now every mode consumes its frame exactly — zero left for
modes 0–7, sixteen at 23.85 for the high-band gains — and that check is a
permanent test.

`read_isf_indices` was removed from the bitstream module rather than fixed: it
read from the frame start, so any future caller would have walked straight back
into this bug.

### Algebraic codebook

`wb/codebook.rs` implements §6.2 — pulse indices to the 64-sample innovation
vector, all seven widths from 12 bits (two pulses) to 88 (twenty-four).
Bit-exact on all 108 subframes of the fixtures: 9 modes × 3 frames × 4
subframes, every sample.

The interesting part is that the per-track codes are *combinatorial*. Two
pulses fit in `2N+1` bits because the pair is unordered — the state freed by not
distinguishing `(a, b)` from `(b, a)` carries a sign instead. The wider decoders
are built recursively on that, splitting on a few leading bits that say how the
pulses distribute between two half-ranges.

The bite check here was chosen to match the failure mode: shifting one pulse by
a single sample, which is the classic wrong-track bug. The excitation stays
sparse and plausible while the pulses sit in the wrong places, so a
count-the-pulses test would pass and only a sample-by-sample comparison fails.

### Gains, and the AMR-WB math primitives

`wb/gain.rs` implements §6.3 and `wb/math.rs` the primitives it needs — isqrt,
pow2, log2, normalised dot product, median-of-five. Bit-exact on all 108
subframes, replayed from reset because the code gain is predicted from the last
four subframe energies.

Two design points worth recording:

- **The math primitives are AMR-WB's own, deliberately not shared with G.729.**
  Both codecs have tables for the same functions with different values. Sharing
  them would give a codec that sounds nearly right and is not conformant.
- **The code gain is normalised by the innovation's own energy.** Without that,
  a subframe whose pulses happened to land constructively would be louder than
  one that did not, for the same transmitted index. The normalisation makes the
  index mean intended loudness rather than an artefact of pulse placement.

Three mistakes in this stage, all in work from the same sitting:

- **The grep filter that strips the reference's instrumentation counters also
  strips real assignments** that share a line with them. That silently removed
  four lines from `Isqrt_n`, including two shifts. Reading the source raw is the
  only safe way; the filtered view is for orientation, never for transcription.
- The `log2`/`pow2` round trip was 2% off until the missing `& 0x7fff` mask on
  the interpolation fraction was restored — same cause.
- A test divided by `1i32 << 31`, which is `i32::MIN`, so a *correct* `isqrt`
  looked sign-flipped. The function was right; the test's scaling was negative.

### Long-term prediction

`wb/ltp.rs` implements §6.1/§6.4 — the adaptive codebook with quarter-sample
interpolation, the optional LTP low-pass, preemphasis by the excitation's tilt,
and pitch sharpening. Bit-exact over all 24 combinations of six lags and four
fractions.

**The adaptive codebook must write in place, and this is not a detail.** There
is no stored codebook: the vector is the excitation from one pitch period ago.
When the lag is shorter than a subframe — common, since lags start at 34
against a 64-sample subframe — the filter reads samples it wrote *earlier in
the same subframe*. That self-reference is how a short lag makes a waveform
repeat within the subframe.

My first API took an immutable history and wrote to a separate output slice,
which cannot express that. It read off the end of the history and panicked,
which pointed straight at the design error — but only by luck of the buffer
length. One element longer and it would have produced plausible, wrong samples
for every lag below 64, and the bit-exact test would have been the only thing
standing between that and shipping.

### Low-band synthesis

`wb/synthesis.rs` implements §6.6–§6.8 — LP synthesis, de-emphasis, 50 Hz
high-pass. Excitation in, 12.8 kHz speech out, bit-exact over a four-block
sequence replayed from reset.

**Two filters keep their recursive state in double precision, for the same
reason.** `1/A(z)` is marginally stable by construction, and the 50 Hz
high-pass at 12.8 kHz has poles very close to the unit circle. In a recursive
filter, rounding error is fed back rather than discarded, so sixteen bits of
state would let a sharp formant drift. That is why the synthesis filter hands
its output onward as a `(high, low)` pair rather than a single `Word16` — the
split is structural, not a rounding refinement.

The excitation's per-subframe scaling is undone by shifting `a[0]` rather than
the signal, so putting it back costs no precision.

### Excitation assembly and upsampling

`wb/excitation.rs` implements §6.5 and §6.9 — summing the two excitation
contributions under the decoder's adaptive scaling, and resampling 12.8 → 16
kHz. Bit-exact over a replayed four-subframe sequence.

**The adaptive scaling is structural, not an optimisation.** The excitation
buffer is held at a per-subframe shift, and the *whole* history is rescaled
when that shift moves — because the adaptive codebook reads the history, so
history and present must share a scale. Rescaling only the new subframe would
make pitch prediction read at the wrong loudness.

The shift is bounded by the **smallest** headroom across the last four
subframes, not the current one. A fresh decoder therefore records four zeroes
and cannot shift at all until four subframes have run. That looked like a bug
in a test I wrote and is in fact the point: the scaling must not open up on the
strength of one quiet subframe and then clip when the level returns.

The fixture also caught a real bug: the upsampling filter is centred on its
sample, so the read window starts `NB_COEF_UP - 1` taps earlier. I had omitted
the reference's `x = x - nb_coef + 1` back-step, which read past the buffer.

### Correction: the decoder is not as close to done as the per-stage list suggests

Assembling the decoder turned up **five stages I had not accounted for**, all
sitting between gain decoding and synthesis. An earlier summary here claimed
every decoder stage existed and only the wiring remained. That was wrong, and
the way it was wrong is worth recording: I had been working outward from the
data path in TS 26.190's section order, and these are *enhancement* stages that
the prose treats as refinements but `dec_main.c` runs unconditionally. Reading
per-stage left them invisible; only trying to wire the whole thing exposed them.

Still to implement:

| Stage | Where | Note |
|---|---|---|
| `voice_factor` | inline | Voicing measure driving the two enhancers and the next subframe's tilt |
| Phase dispersion | `ph_disp.c` | Spreads pulse energy in time at low rates; three dispersion levels by rate |
| Noise enhancer | inline | Moves the code gain toward a threshold on noisy, stable frames |
| Pitch enhancer | inline | HP-filters the innovation on voiced frames |
| Enhanced excitation | inline | **Synthesis consumes `exc2`, not the `exc` I assemble** — the two diverge |
| `Isf_Extrapolation` | `isfextrp.c` | The 6.60 kbit/s high-band branch |

The last row of that table is the one that would have bitten hardest: the
excitation fed to the synthesis filter is a separately enhanced copy, while the
one written back to the adaptive-codebook history is not. Wiring my current
modules together naively would have used one buffer for both and produced audio
that was recognisably speech and steadily wrong.

**End-to-end ground truth is now in place**, which is what will catch the rest.
`build-amr-reference.sh` builds the reference decoder and decodes every fixture
to 16 kHz PCM (`amrwb_mode*.pcm`, 25 frames each). Per-stage vectors cannot
reach the state coupling *between* stages; this can.

### Milestone: the wideband decoder reached bit-exactness

Every sample of every frame of every fixture, 6.60 through 23.85 kbit/s, is
identical to the TS 26.173 reference decoder. 8000/8000 per mode, worst error
zero. Asserted by `the_decoder_matches_the_reference_sample_for_sample`.

### How it got there, from 1–3%

Six defects, in the order they were found. Every one was found by **diffing
traced intermediates against the instrumented reference**, not by reading code
or reasoning from output PCM — a full turn spent on the latter produced one
speculative lead and no fixes.

| # | Defect | Effect |
|---|---|---|
| 1 | Comparing 16-bit output against 14-bit reference PCM (`decoder.c` masks with `0xfffC`) | The 1–3% figure itself |
| 2 | `Qsubfr`/`Q_old` seeded to 0 instead of `Q_MAX` | Mis-scaled the start of every stream |
| 3 | Phase dispersion given a rescaled gain, not the Q16 high half | Wrong dispersion state |
| 4 | 23.85's high-band gain parsed as a trailing block, not per subframe | 23.85 unusable |
| 5 | 6.60/8.85 pitch-sharpening blend (`agc2`) missing entirely | Both low rates ~14% |
| 6 | `Scale_sig` implemented as a truncating shift | **The entire residual** |

Number 6 is the one worth remembering. `Scale_sig` is not a shift: the reference
widens each sample to 32 bits, shifts, and *rounds* back, so scaling by −3 is
`floor((x+4)/8)`, not `floor(x/8)`. Those differ on half of all inputs. Because
the rescaled excitation feeds the voicing measure, the error reached the next
subframe's spectral tilt, both enhancers, and the low-rate sharpening. Fixing it
took modes from 71–81% to 100% in one step.

Number 4 is the one worth learning from: **my oracle shared the wrong
assumption**, so the parameter test passed while both were wrong. The
bit-conservation check could not catch it either — the bit *count* was
unchanged, only the assignment. This is the second time that trap fired (the
VAD flag was the first).

### The 6.60 high band

The last mode needed its own path: it extrapolates an order-20 predictor from
ISF *spacing* rather than borrowing the low band's order-16 filter. Wiring it
required generalising `isp_to_lp` to any even order — above 16 the reference
runs the same expansion four times smaller and shifts back, because twenty
accumulations overflow Q23 where sixteen do not — and an order-20 shaper over
the full memory at gamma 0.9.

One detail that would have been silently wrong: the high-band ISF interpolation
uses the plain complement `32767 - frac`, **not** the `+1` complement
`interpolate_isp` uses. Reusing that helper would have been off by one LSB per
subframe.

### Superseded: how the accuracy figure was corrected, in two steps

The two entries below are kept because the *reasoning* in them is still useful,
but both of their headline numbers are obsolete — the decoder is now bit-exact.
Read them as a record of how a wrong measurement was diagnosed, not as status.

#### Step 2 — the 1–3% figure was mostly a wrong yardstick

**TS 26.173's `decoder.c` masks its output with `0xfffC` before writing** — AMR-WB's
output is defined as 14-bit linear, so the low two bits are deleted. I was
comparing unmasked 16-bit output against 14-bit reference PCM. A decoder that was
substantially correct scored 1–3%.

With the mask applied and two real bugs fixed:

| Mode | Before | After | Worst \|delta\| |
|---|---|---|---|
| 6.60 | 1.2% | 15.2% | 220 |
| 8.85 | 1.4% | 13.1% | 220 |
| 12.65 | 1.5% | **80.7%** | 16 |
| 14.25 | 1.4% | **80.2%** | 24 |
| 15.85 | 1.3% | **76.5%** | 16 |
| 18.25 | 2.1% | **73.4%** | 12 |
| 19.85 | 2.1% | **78.0%** | 12 |
| 23.05 | 2.7% | **79.0%** | 8 |
| 23.85 | 0.5% | 1.1% | 25 640 |

The two real bugs, both found by tracing rather than reading:

- **`Qsubfr[0..3]` and `Q_old` initialise to `Q_MAX` (8), not zero.** The shift is
  bounded by the *minimum* of the four, so starting at zero pinned it to zero until
  four subframes had run — mis-scaling the start of every stream, and with it the
  synthesis filter's `a0`.
- **Phase dispersion takes the high half of the Q16 code gain** (`L_Extract`), not a
  rounded or rescaled version.

**Method that worked, after one that did not.** A full turn spent reasoning from
output PCM produced one speculative lead and no fixes. Instrumenting the reference
decoder with trace points and diffing intermediates found all three issues in a
single pass. That harness is now committed as `tools/instrument-amr-decoder.py` and
`tools/trace-amr-reference.sh`, and the Rust decoder emits the same names under
`cfg(test)`.

Verified equal to the reference for mode 12.65, frame 0, subframes 0 and 1: every
scalar (`T0`, `T0_frac`, `tilt_code`, `gain_pit`, `L_gain_code`, `Q_new`,
`gain_code`, `voice_fac`) and every vector (`pred`, `code`, `exc_total`,
`exc2_final`, `hfband`). The excitation chain is correct; the residual is
downstream.

**Remaining, in priority order:** 23.85 is badly broken (its transmitted high-band
gain path); 6.60 and 8.85 share a mode-specific bug (they take the
`nb_bits <= NBBITS_9k` branches — forced LTP filter, 6-bit gains, and a `pit_sharp`
post-processing step the Rust omits entirely); modes 12.65–23.05 have a small
residual worth 8–24 LSB.

#### Step 1 — the original measurement: fourteen exact stages, 1–3% right

The decoder is wired and runs on every mode without panicking, producing
speech-shaped output. Against the reference PCM:

| Mode | Exact samples | Worst \|delta\| |
|---|---|---|
| 6.60 | 1.2% | 8 493 |
| 8.85 | 1.4% | 15 713 |
| 12.65 | 1.5% | 6 972 |
| 14.25 | 1.4% | 7 011 |
| 15.85 | 1.3% | 7 899 |
| 18.25 | 2.1% | 6 351 |
| 19.85 | 2.1% | 5 673 |
| 23.05 | 2.7% | 7 336 |
| 23.85 | 0.5% | 28 241 |

**Every stage is bit-exact in isolation and the composition is not.** The
errors are therefore in the wiring: state carried between stages, order of
operations, or an interface I have matched to the wrong thing. This is exactly
the class of defect per-stage vectors cannot reach — which is why the
end-to-end ground truth was built first, and why the earlier claim that "only
the wiring remained" was worth distrusting even after the five missing stages
were found.

The suite now carries a **ratchet** test (fails on regression, floors set below
current values) and an exact-match test marked `#[ignore]` with the figure in
its reason. "Runs without panicking" is far too weak to be the only assertion,
and prose in this document is not a substitute for a failing test.

> *No longer true.* Both the ratchet and the `#[ignore]` were removed once the
> decoder became bit-exact: a floor on match percentage is meaningless at 100%,
> and an exact-match assertion supersedes it. The reasoning above still holds
> for any stage that is not yet exact — which is why the narrowband work uses
> the same approach.

One concrete bug the assembly did surface: the excitation buffer was one sample
too short. The adaptive codebook writes `L_SUBFR + 1` samples because the LTP
low-pass reads one ahead, and no per-stage test ever asked for that length.

**Debugging approach from here.** The per-stage vectors are still the lever:
instrument the assembled decoder to dump the same intermediates the oracle
already emits, run both on the same frame, and find the first stage whose
output diverges. The divergence point localises the wiring error far faster
than comparing PCM.

### High-band synthesis — the wideband half

`wb/highband.rs` implements §6.10, bit-exact over three blocks covering both
VAD weightings.

**Below 23.85 kbit/s not one bit of the payload describes this band.** It is
noise, scaled to the excitation's energy, levelled by the low band's spectral
tilt, shaped by a bandwidth-expanded LP filter, and band-limited. That is not a
shortcut: above about 6 kHz speech is mostly fricative energy with little
perceptually relevant fine structure, so the ear cares that the band is
*present* and at the right level far more than what is in it.

Three details, each of which is the reason for a whole step:

- **The noise is a defined sequence, not randomness.** Encoder and decoder must
  generate identical noise, so "random" means unpredictable to the ear and
  exactly reproducible to the codec.
- **The level follows spectral tilt** because a flat noise level would make
  vowels hiss. Voiced speech has a falling spectrum and gets far less.
- **The shaping filter is bandwidth-expanded**, widening the formants. Sharp
  resonances in a noise band sound like tones rather than like speech.

**Not covered:** the 6.60 kbit/s branch, which extrapolates a separate order-20
ISF set (`Isf_Extrapolation`) rather than reusing the low band's filter. Every
other mode uses the implemented path.

And one corrected premise: a median is **not** unchanged by an outlier —
replacing the smallest of five values with the largest shifts it up one rank.
The honest claim, which the test now makes, is that it moves far less than a
mean.

A bit-exact test can pass vacuously if the fixture reader silently returns
nothing, so the reader is checked too: corrupting one value in the dump by a
single LSB makes the corresponding test fail. Worth redoing whenever the dump
format changes, since a passing suite is otherwise indistinguishable from a
suite that compares nothing.

This is the only evidence that counts. Property tests can show output is
*plausible*; conformance means matching these integers.

The ISP→LP result also settled a question I could not settle by reading. The
reference's `Get_isp_pol` walks pointers rather than indices, and traced by
hand it appears to add the `f[i-2]` term twice — once from the pre-set
`*f = f[-2]` and once from the loop body's `L_add(*f, f[-2])`. I stopped
tracing, implemented it exactly as written, and let the vectors decide; they
came out bit-exact, so the apparent duplication is the algorithm. **With an
oracle in place, checking beats deriving.** The hour lost to hand-tracing this
before the harness existed is the argument for building the harness first.

Two things the harness needed, both worth recording for whoever runs it next:

- **ETSI serves 403 to curl's default user agent.** A browser one gets 200. Bot
  filtering, not an access restriction — the same trap that made me wrongly
  record the spec as unreachable earlier.
- **The reference's `typedefs.h` predates arm64** and stops with
  `#error "can't determine architecture; adapt typedefs.h to your platform"`.
  Its integer widths come from `limits.h` and are already right; only the
  platform/endianness block needs a branch, which is what the error asks for.
  The script adds it automatically.

A first attempt at these vectors was worthless and the reason is worth keeping.
Synthesising "ordered ISP-looking values" and feeding them to `Isp_Az` produced
saturated output — `a[1] = 32246` in Q12 is 7.9, and several coefficients came
out zero. Ordered values are not generally the roots of a minimum-phase filter.
The vectors now come from running the reference's own analysis chain, so the
ISPs are ones the codec could actually produce.

### Course correction: fixed point, and the reference is the definition

**AMR is defined in fixed-point arithmetic.** TS 26.190 §8.1: "The adaptive
multi-rate wideband speech codec is described in a bit-exact arithmetic to allow
easy type approval." The prose spec describes the algorithm; **TS 26.173, the
ANSI-C fixed-point source, defines it.** A floating-point AMR is a different
codec output — it will not pass conformance, and two endpoints running float and
fixed versions produce different bitstreams from identical audio.

The Phase 3 modules written so far are `f64` reference models, not the codec.
The plan (§6.4) had already rejected float-model-first as redundant once a real
oracle existed; building them anyway was a drift that should have been flagged
at the time.

**TS 26.173 is now obtained** (58 C files) and immediately overturned a decision:

> `lag_wind.c` loops `i = 1..M` and never touches `r[0]`. The noise-floor
> reciprocal is folded into the lag table, whose header reads "noise floor =
> 1.0001 = (0.9999 on r[1]..r[16])".

TS 26.190's prose says `r(0)` is multiplied by 1.0001. **The reference uses the
opposite placement**, and `window.rs` had followed the prose. Corrected, with a
test pinning it.

The two are equivalent up to a uniform scale, and Levinson-Durbin is
scale-invariant — but only in exact arithmetic. In fixed point the sequence is
normalised and rounded at each step, so the scale changes intermediate values
and therefore the bits.

Generalising: several "improvements" over the reference are defects. The 60-step
bisection in `isp.rs` converges further than the reference's 4, which for a
bit-exact codec means producing the wrong ISPs. **The reference's imprecision is
part of the specification.**

### Floating-point references removed from the oracle roster

TS 26.104 and TS 26.204 are out. Not being bit-exact, they cannot confirm
bit-exactness, and a disagreement would not identify which side is wrong — a
reference that can only mislead is a liability. FFmpeg stays, scoped to interop
rather than bit-exactness, for the same reason.

### Previously recorded as blocked (resolved)

The next piece of Phase 3 is the LP-analysis chain — order-16 autocorrelation
with lag windowing, Levinson-Durbin, A(z)↔ISP conversion, interpolation. **This
cannot be written bit-exactly without TS 26.190.** The algorithm *structure* is
well known and available from secondary sources, but the parts that decide
bit-exactness are normative data in the spec:

- the LP analysis window (shape, length, exact coefficients),
- the lag window / bandwidth-expansion values,
- the Q-format of each intermediate,
- the per-mode bit allocation.

Writing the structure with plausible-looking tables would produce a codec that
compiles, passes its own tests, sounds approximately right, and is not
bit-exact — the exact failure mode the plan was built to avoid. It is worse
than not writing it, because it looks finished.

**Every spec source is unreachable from this environment.** Checked:

| Host | |
|---|---|
| `arib.or.jp` (mirrored TS 26.201 successfully earlier today) | now unreachable |
| `3gpp.org`, `etsi.org` | 403 |
| `portal.3gpp.org`, `tech-invite.com`, `qtc.jp` | unreachable |

Egress tightened during the session: the ARIB mirror that supplied the TS 26.201
class A table earlier stopped responding.

### To resume, one of these

1. **Supply TS 26.190** (and ideally TS 26.201 for the bit ordering, TS 26.192/3/4
   for CNG/DTX/VAD). Downloadable from `3gpp.org` in a normal browser. This is
   the intended path and keeps the from-spec implementation the plan chose.
2. **Authorise taking the tables and algorithms from `opencore-amr`**, which is
   already built locally and is Apache-2.0. Lawful — plan §6.5 records this as
   the preferred fallback — but it is a **licensing decision, not a technical
   one**: parts of `codec-core` would become Apache-2.0-derived rather than MIT,
   requiring `THIRD_PARTY_NOTICES.md` entries and attribution. Deliberately not
   taken unilaterally.

> **RESOLVED.** Both tier-1 references are fetched and building —
> TS 26.173 by `tools/build-amr-reference.sh` and TS 26.073 by
> `tools/build-amrnb-reference.sh`. The blocker was never egress: ETSI serves
> 403 to curl's default user agent and 200 to a browser one, which is bot
> filtering rather than an access restriction. Nothing was taken from
> `opencore-amr`, so no licensing decision was needed and `codec-core` stays
> MIT; opencore is used only to *produce fixtures*, which is what makes the
> three-way comparison meaningful.
> **Superseded layout.** This planned a shared `common/dsp/`. That is not what
> was built, and deliberately so: the two variants turned out to share far less
> than the plan assumed (see *AMR-NB: what must not be shared with wideband*
> above). What is genuinely common lives in `fixed_point/`; everything
> spectral is per-variant. Actual state of the items listed here:
>
> - Autocorrelation and lag windowing — ✅ `wb/lp/autocorr.rs`
> - ISP→LP — ✅ `wb/lp/isp_to_lp.rs`; LSP→LP — ✅ `nb/lsp.rs` (a *different*
>   algorithm, not the same one at order 10)
> - ISF↔ISP and subframe interpolation — ✅ `wb/lp/isf.rs`; LSF↔LSP — ✅ `nb/lsp.rs`
> - Synthesis filter — ✅ `wb/synthesis.rs`
> - Levinson-Durbin, LP→ISP, residual filter — ❌ encoder-side, not started
> - Bit handling — ✅ `amr/bits.rs`, `wb/bitstream.rs`, `nb/bitstream.rs`
> - Homing (EHF/DHF) — ❌ not started

---

## Phase 2 — SDP negotiation and relay

### Done

| Item | Where |
|---|---|
| RFC 4867 §8.1 fmtp parsing and emission | `src/codecs/amr/sdp.rs` |
| RFC 4867 §8.3.1 offer/answer rules | `src/codecs/amr/sdp.rs` |
| `ModeChangePolicy``mode-change-period` / `mode-change-neighbor` | `src/codecs/amr/rate.rs` |
| `CmrDamper` — CMR-interval damping | `src/codecs/amr/rate.rs` |
| Dynamic PT resolution from `a=rtpmap` instead of assumed Opus | `rvoip-sip/src/adapters/media_adapter.rs` |
| AMR payload types, rtpmap and fmtp in offers | `rvoip-sip/src/adapters/media_adapter.rs` |
| Negotiated `a=fmtp` carried to the media layer | `NegotiatedConfig::negotiated_fmtp`, and into `MediaConfig` as of 2026-08-10 |
| `AmrPayloadFormat::from_negotiated` — written, **not yet called** | `media-core/src/rtp_processing/payload/amr.rs` |

### A phase 0 mistake, corrected

`AmrModeSet::intersect` was documented as implementing offer/answer
negotiation. **It does not, and building negotiation on it would have been a
compliance bug.** RFC 4867 §8.3.1:

> "If a mode set was supplied in the offer, the answerer SHALL return the
> mode-set unmodified or reject the payload type."

`mode-set` is **match-or-reject, and bi-directional** — it binds media sent
*and* received by both parties. An answerer that narrows it is non-compliant,
and the peer would keep sending modes we implied were acceptable. `intersect`
survives as a plain set operation with corrected docs; `is_superset_of` is the
test the rule actually needs.

Two further rules that are easy to get wrong, both now implemented and tested:

- `octet-align`, `crc`, `robust-sorting`, `interleaving` and `channels` must be
  **echoed verbatim** by the answerer. Each combination is a distinct bit
  pattern, so changing one yields a stream the offerer cannot parse. Endpoints
  supporting several should offer them as separate payload types.
- `mode-change-period` and `mode-change-capability` are a declarative pair:
  requiring `period=2` of a peer is only permitted if that peer declared
  `capability=2` or `period=2` itself.

### The dynamic payload-type blocker is gone

`media_adapter.rs` hardcoded the entire 96–127 range to Opus, so any AMR offer
on a dynamic PT was rejected. Dynamic payload types now dispatch on the
`a=rtpmap` encoding name. Widening the range did **not** make it a wildcard —
an unknown encoding is still rejected, and there is a test pinning that.

Our own offers use four payload types, one per transport configuration, per the
RFC's "separate payload types" guidance:

| PT | Codec | Framing | fmtp |
|---|---|---|---|
| 104 | AMR-WB | bandwidth-efficient | *(none — it is the default)* |
| 105 | AMR-WB | octet-aligned | `octet-align=1` |
| 106 | AMR-NB | bandwidth-efficient | *(none)* |
| 107 | AMR-NB | octet-aligned | `octet-align=1` |

96–98 are H.264/VP8/VP9 and 111 is Opus in this stack, so these were chosen to
avoid collisions. A *peer's* AMR payload type is identified from its rtpmap, not
from these numbers — they are only our own assignments.

### Signalling reaches the media layer — corrected 2026-08-10

**This section was written as "Signalling now reaches the wire" and overclaimed.**
What it describes was built and tested; what it did not check is whether anything
called it. Two separate consumers of the negotiated fmtp were left unwired:

- `MediaConfig::with_negotiated_fmtp` had **zero callers**.
  `apply_negotiated_media_config` took six arguments, none of them the fmtp, so
  the string died at the SIP/media boundary and the relay's framing guard
  compared `""` against `""` on every call. Fixed, with an end-to-end test that
  negotiates real SDP and asserts the value in media-core's own config — plus
  the half an insert-only builder gets wrong, that a renegotiation carrying no
  fmtp must *clear* the previous one rather than leave it behind.
- `AmrPayloadFormat::from_negotiated` still has **zero production callers**; all
  of them are in its own test module. The payload layer therefore still assumes
  RFC 4867 defaults. Harmless for a transparent relay, which never unpacks a
  frame, and a real gap the moment media-core terminates AMR.

The lesson is the branch's own recurring one, in a new place: a test named
`negotiated_fmtp_is_carried_verbatim_to_the_media_layer` asserted only that the
SDP parser could read an fmtp line. It stopped one call short of the boundary it
named, and reading the test list was enough to believe the path was covered.

What follows is accurate about the mechanism.

`NegotiatedConfig` gained `negotiated_fmtp: Option<String>` — the raw `a=fmtp`
parameters agreed for the negotiated payload type. It is deliberately
**unparsed**: interpreting format parameters is the codec layer's job, and
keeping the string opaque means the signalling layer needs no AMR knowledge.
The field is generic rather than AMR-specific, so other codecs can use it.

`AmrPayloadFormat::from_negotiated(pt, codec_name, fmtp)` closes the loop.
`None` for the fmtp is a positive statement — every RFC 4867 default applies —
not missing data.

Why this mattered enough to do before the relay: **using defaults instead of the
negotiated parameters is not a degraded mode, it is a broken one.**
`octet-align` selects the framing itself, so guessing it wrong yields a stream
the peer cannot parse at all. There is a test asserting the two framings produce
different bytes for identical content and that each rejects the other's.

Which SDP is authoritative differs by role, and both are handled: the UAC reads
the answer (what the peer will send), while the UAS reads the **offer**, since
RFC 4867 §8.3.1 requires the answerer to echo transport parameters unmodified —
reading back our own answer would be circular.

### Interop peers

`g729_call` in `rvoip-sip/examples/pbx` is the template for an AMR scenario; the
runner is `./run.sh --pbx asterisk|freeswitch|both --api all --scenario ...`.
Neither peer image shipped AMR, so both needed changes. **Everything below is
outside this repo** — in `~/Developer/freeswitch` and `~/Developer/asterisk` —
and each edited file has a timestamped `.pre-amr.*` backup, since neither
directory is version-controlled.

**FreeSWITCH — done and verified.** Its image already built FS 1.10.12 from
source, so this was three `-dev` packages in the build stage, three runtime
libraries, and two lines in `freeswitch-modules.conf`. First built as
`rvoip-freeswitch:amr`, a separate tag so the working `:local` image stayed
untouched; the AMR changes are now in `:local` as well, which is the tag
`docker-compose.yml` and `scripts/up.sh` actually run. Verified: `mod_amr.so`
and `mod_amrwb.so` present, linking `libopencore-amrnb`, `libopencore-amrwb` and
`libvo-amrwbenc`, zero unresolved symbols.

**Start it with `scripts/up.sh`, not `docker compose up`.** Only `up.sh` passes
`FS_EXTERNAL_SIP_IP`/`FS_EXTERNAL_RTP_IP` from the colima VM address; without
them the entrypoint falls back to the container's own address, FreeSWITCH
advertises `172.21.0.2` in SDP, and every RTP packet from a macOS-hosted client
fails with `No route to host`. SIP still registers and the call still connects,
so it presents as a codec problem rather than a routing one.

**One trap worth recording:** FreeSWITCH has *two* module lists. `modules.conf`
decides what gets **compiled**; `autoload_configs/modules.conf.xml` decides what
gets **loaded**. Adding the codecs to the first is not enough — and this image's
`docker-entrypoint.sh` overwrites the second with its own explicit list, so the
sample config's AMR entries were being discarded. Only caught by starting the
container and running `show codec`; the build looked perfectly healthy.

With both fixed, FreeSWITCH registers four codecs:

```
AMR / Bandwidth Efficient      mod_amr
AMR / Octet Aligned            mod_amr
AMR-WB / Bandwidth Efficient   mod_amrwb
AMR-WB / Octet Aligned         mod_amrwb
```

That is a real implementation independently arriving at the same shape as our
four offered payload types (104–107): one per transport configuration, because
the framings are not interchangeable.

`mode-set-overwrite=0` mirrors the offered mode-set, which is the RFC 4867
§8.3.1-compliant behaviour our negotiation implements.

`force-oa` reads as the knob that would let both framings be exercised against a
real peer. It is not, and it was measured rather than assumed: with `force-oa=1`
FreeSWITCH answered `octet-align=1` to our PT 106 offer, which asked for
bandwidth-efficient. That is not a legal answer, and it breaks the leg it
touches. It is left at 0 — see the interop section for what does work.

**Asterisk — built, and calls placed through it.** AMR is not a loadable module
for Asterisk; it is a source patch, and the packaged Alpine Asterisk cannot take
it. `Dockerfile.amr` (separate file and tag) builds Asterisk 20 from source and
applies the patches from `traud/asterisk-amr`, with `config-amr/` carrying
`allow = amrwb` / `allow = amr`.

Three things had to be fixed before it built and ran, none of them in the
patches: `make third-party` fails under `-j` with no diagnostic and has to be
serialised to `-j1`; the patch set omits `codecs/ex_amr.h`, which is fetched
from upstream; and `astdatadir` pointed at Alpine's `/usr/share/asterisk`, which
produced only `Stasis initialization failed. ASTERISK EXITING!` until it was
pointed at `/var/lib/asterisk`.

The patches document Asterisk 13/16 support, which looked like a serious
forward-port risk across four major versions. **It is not.** Dry-run against
Asterisk 20.20.1: every hunk applies, two with fuzz 1. They are purely additive
— appending to alphabetically-ordered registration lists — and the APIs they
touch (`ast_codec`, `CODEC_REGISTER_AND_CACHE`, `set_next_mime_type`,
`add_static_payload`) have been stable since 13. Verifying this took two minutes
and converted an open-ended risk into a known quantity; worth doing before a
long build rather than after.

To rebuild:

```sh
cd ~/Developer/asterisk && docker build -f Dockerfile.amr -t rvoip-asterisk:amr .
```

Note the two PBXes both bind 5060 and cannot run at once; `docker compose stop`
one before starting the other.

The Dockerfile fails loudly rather than silently producing an AMR-less image: it
asserts `configure` detected all three libraries, and that `codec_amr.so` and
`res_format_attr_amr.so` both exist, before the runtime stage.

### First evidence against a real implementation

FreeSWITCH 1.10.12 was started with AMR and asked to originate an AMR-WB call.
The `a=fmtp` it emitted:

```
a=rtpmap:102 AMR-WB/16000
a=fmtp:102 octet-align=0; mode-set=8; max-red=0; mode-change-capability=2
```

That line is now a checked-in fixture in `sdp.rs`, with tests asserting we parse
it and answer it compliantly. **All four passed first run, with no changes to
the implementation** — which is the point of testing against something other
than our own reading of the RFC.

Three things it independently confirms:

- **The payload type is 102** — neither of the two we offer for AMR-WB. Dynamic
  payload types genuinely must be resolved from the `a=rtpmap` encoding name,
  which is the refactor in `989d97eb`.
- **`mode-set=8` alone is a real, common configuration** (a gateway pinned to
  23.85 kbit/s). Our answer returns it unmodified rather than widening it to the
  nine modes we support — the §8.3.1 rule that phase 0 had wrong.
- **`mode-change-capability=2`** appears in the wild, so the declarative pair we
  implemented is not a theoretical corner.

### And the framing, against real RTP

A live AMR-WB call was then established with FreeSWITCH and its RTP captured.
50 payloads are checked in at `src/codecs/amr/testdata/`, with tests asserting:

- every payload is **61 octets** — 4-bit CMR + 6-bit ToC + 477 speech bits =
  487 bits, exactly what our mode table predicts, arrived at independently;
- each parses as one AMR-WB mode 8 speech frame with the Q bit set;
- **our packetizer reproduces FreeSWITCH's exact octets** from the parsed form,
  byte for byte — the strongest agreement available short of the 3GPP vectors;
- parsing them as octet-aligned fails for all 50, which is the interop failure
  mode this format is prone to.

**A first attempt at this produced a worthless fixture, and the way it was
caught is worth recording.** Using FreeSWITCH's `&echo` gave 50 payloads that
looked perfect — until a check showed all 50 were byte-identical to what we had
sent. FreeSWITCH was passing them through, not transcoding, so the "captured"
bytes were our own returning. It would have been a test of our packetizer
wearing a disguise. Bridging the call into a conference forces a mix in linear
PCM and therefore a real encode; the second capture has 50 distinct payloads.
`real_payloads_carry_genuine_encoder_output` now guards the fixture against that
mistake recurring.

Note what the first attempt *did* legitimately establish: FreeSWITCH parsed our
hand-built RFC 4867 payload, matched it to AMR-WB mode 8 and relayed it, which
is real validation of the packetizer.

Still open: this exercises framing frame-by-frame, not the full relay path with
rvoip bridging two live legs.

### Remaining for Phase 2

- [x] **Pass-through/relay path — already existed.** `relay/controller/bridge.rs`
      forwards RTP payload bytes without inspecting them, so AMR relays through
      it with no codec kernel. What it lacked was a correctness check: it
      compared payload types only, and two AMR legs can share a payload type
      while disagreeing on framing. That now returns `BridgeError::FormatMismatch`
      rather than relaying unparseable audio.
- [ ] `amr_call` scenario in `rvoip-sip/examples/pbx`, modelled on `g729_call`.
      **It cannot be audio-verifying the way `g729_call` is** — that scenario
      pushes tones through rvoip's own codec, which for AMR does not exist. The
      relay topology is what makes audio verification possible without one:
      peer → rvoip (frames only) → peer, with the PBXes doing the codec work.
- [x] CMR **emission** exists: `request_peer_codec_mode` on the coordinator
      stamps a CMR on the next outgoing payload (once — repeating it is what
      `CmrDamper` will be for), `peer_codec_mode` reads back the mode the
      peer is actually sending, and the adapter-level round trip is pinned by
      `a_requested_mode_change_crosses_the_wire_and_moves_the_peer`.
      `CmrDamper` **now has its caller** (2026-08-12): with
      `Config::amr_auto_cmr` set, the decode path feeds every arriving
      frame-block to the damper, and once per five-second interval the mode it
      names is routed across the encoder/decoder seam and stamped on the next
      payload. Off by default — a badly damped requester oscillates the peer's
      rate, which is worse than never asking — and an explicit
      `request_peer_codec_mode` always outranks it, which is pinned by a test.

      What this is **not**: the damper implements rtpengine's up-shift policy,
      asking for a mode the peer is *not* using. A loss-driven *down*-shift —
      telling a peer to slow down because our receive path is losing packets —
      needs receiver statistics the codec object never sees, and remains
      unimplemented.
- **Exit criterion:**
  - [x] AMR-WB call completed with **rvoip as the relaying B2BUA** against
        Asterisk (octet-aligned) and FreeSWITCH (bandwidth-efficient), UDP and
        TLS+SRTP, via the `b2bua_call` harness scenario. rvoip terminates both
        legs and bridges their payloads; the quality gate confirms the caller
        recovers the target's tone and vice versa, and a forced codec mismatch
        on one leg fails the bridge (the cell is not vacuous). This is rvoip in
        the middle, not an endpoint through the PBX's own bridge.
  - [x] **Mid-call mode switch observed on the wire** (`PBX_AMR_MODE_SWITCH=1`):
        after the quality floor is secured, the caller emits CMR 0 and the far
        endpoint's encoder drops from mode 8 (23.85) to mode 0 (6.60),
        observed by the caller's own decoder — through Asterisk's relay, and
        through the full chain Asterisk → rvoip bridge → Asterisk in
        `b2bua_call`. Non-vacuous both ways: the peer must be *at* the top
        mode before the request and at mode 0 after. On FreeSWITCH the same
        run fails deterministically and correctly: FS answers `mode-set=8`,
        making any CMR unsatisfiable on that leg, and our endpoint declines
        it per RFC 4867 §3.4.1 — the negative path, exercised live.
  - [x] **Kamailio+rtpengine and OpenSIPS+rtpengine** (2026-08-12): committed
        labs under `infra/release-runners/pbx/{kamailio,opensips}` — a
        registrar-proxy in the signaling path (Record-Route) with rtpengine
        relaying media in userspace (`table=-1`) and **no transcode flags**.
        `amr_call` sweeps all four framings (`amrnb amrwb amrnb_be amrwb_be`)
        against each proxy, tone-verified at both ends under the quality
        gate. Passthrough is proven three ways: the cell pcap shows the AMR
        PT and `a=fmtp` (octet-align, mode-set) crossing the relay unchanged
        with only address/port rewritten; the rtpengine log carries zero
        transcoding lines; and both endpoints latch on rtpengine's ports, so
        the media demonstrably traversed it. The configs **fail closed** — a
        dead relay 503s the INVITE instead of relaying SDP untouched, which
        is what made the first (vacuous) green run detectable and is now the
        guard against it recurring.

        Getting here surfaced a real stack bug the B2BUA labs could never
        see: neither our UAC nor our UAS ever **learned the route set** (RFC
        3261 §12.1.1/§12.1.2), so every in-dialog request (BYE, re-INVITE)
        bypassed record-routing proxies and went straight to the peer
        Contact. Fixed in `sip-dialog` (UAC learns from the dialog-forming
        2xx's Record-Route reversed, UAS from the request's in order, both
        preserving URI parameters; `create_response` now echoes
        Record-Route), pinned by unit tests and by the wire: the BYE now
        carries the Route header through the proxy and rtpengine receives
        one `delete` per call teardown.

---

## Phase 1 — RFC 4867 payload format

### Done

| Item | Where |
|---|---|
| MSB-first bit reader/writer (bandwidth-efficient framing is not octet-aligned) | `src/codecs/amr/bits.rs` |
| Bandwidth-efficient **and** octet-aligned framing | `src/codecs/amr/payload.rs` |
| CMR, ToC chains, F/FT/Q bits, multi-frame packets | `src/codecs/amr/payload.rs` |
| `NO_DATA` (FT 15) and `SPEECH_LOST` (FT 14, WB only) | `src/codecs/amr/payload.rs` |
| Reserved-FT rejection (RFC 4867: discard the packet) | `src/codecs/amr/mode.rs` |
| AMR file storage format, whole-file and incremental readers | `src/codecs/amr/storage.rs` |
| Frame CRC, robust sorting, interleaving fields | `src/codecs/amr/payload.rs` |
| AMR-WB class A bit table (TS 26.201 Table 2) | `src/codecs/amr/mode.rs` |
| `PayloadFormat` adapter for the existing pipeline | `media-core/src/rtp_processing/payload/amr.rs` |
| `amr_unpack` fuzz target + seed corpus | `crates/media/fuzz/` |

**Verification:** 77 AMR tests in codec-core (230 total) plus 9 in media-core,
and **20 million fuzz iterations with zero crashes**. Notable coverage:

- Round-trip over **every** (variant × mode × framing) combination, and every
  frame count from 1 to the 32-frame limit.
- Truncation rejected at **every byte prefix** of a valid payload.
- Both framings asserted byte-for-byte against the RFC bit diagrams, and
  asserted *not* to be interoperable — decoding one as the other must fail
  rather than yield plausible garbage. That is the most frequently reported AMR
  interop bug, so it gets an explicit test.
- Cross-variant frames rejected on pack: NB and WB frame sizes collide, so
  packing one into the other's stream would silently corrupt the payload.
- Storage frames feed the payload packer without conversion, which is what will
  let 3GPP conformance vectors drive the payload tests directly.
- The CRC is cross-checked against a second, independently written
  implementation of the RFC's prose description, over every mode and several
  data patterns — the two agree.
- The CRC is shown to detect damage to a class A bit and to *ignore* damage to a
  class B bit, which is the behaviour unequal error protection is for.
- Robust sorting is round-tripped with frames of mixed lengths, including
  zero-length ones; short frames dropping out of later rounds is the part of
  §4.4.3 easiest to get wrong.
- A `cargo fuzz` target (`amr_unpack`) sweeps all framing/extension
  combinations, asserting not just no-panic but that anything which unpacks
  re-packs and re-parses to the same packet.

### The three optional octet-aligned extensions — now implemented

| Extension | State |
|---|---|
| Frame CRC (§4.4.2.1) | Implemented for **both** variants |
| Robust sorting (§4.4.3) | Implemented |
| Interleaving (§4.4.1) | ILL/ILP carried; **receive-side reassembly implemented**, transmit-side interleaving not |
| `max-red` redundancy (§3.5, §4.3) | Implemented — `codecs/amr/redundancy.rs` |
| IF1 + IF2 (TS 26.101 / 26.201) | 🟢 **Both formats, both variants**, against the specs' own tables and worked examples |

**VAD2 is ported and bit-exact** (2026-08-12) — `nb/enc/vad2.rs`, 300
half-frames of the committed DTX input matching TS 26.073's own `vad2()` on
every field.

Worth stating precisely, because the name invites a wrong assumption:
**VAD2 is narrowband-only.** AMR-NB defines two detectors and ships both
(`vad1.c`, `vad2.c`, with `vadname.c` reporting which was compiled); AMR-WB
defines one (`wb_vad.c`), which `wb/enc/vad.rs` already implements bit-exactly.
There is no "VAD option 2" for wideband and inventing one would be inventing
spec.

It shares nothing with VAD1 but its output type. VAD1 works from the encoder's
own analysis — LP residual, open-loop lags, tone flag. VAD2 does its own signal
analysis: pre-emphasis, a 128-point real FFT, energy summed into sixteen
non-uniform channels, and an SNR/hangover state machine over those. It also
consumes 80 samples per call, so a 20 ms frame is two calls and the frame
decision is their OR.

Verification follows the VAD1 pattern for the same reason — the decision
appears nowhere in the bitstream, and here a wrong half-frame can additionally
be masked by its partner. `tools/nb_vad2_probe.c` dumps the reference's whole
state per half-frame (sixteen channel energies, sixteen noise estimates, the
long-term dB array, and every counter) and the test compares all of it;
`tools/trace-amrnb-vad2.sh` regenerates the trace, reproducibly.

The test was checked by mutation rather than assumed: perturbing the
pre-emphasis factor fails at half-frame 0 channel 1, and perturbing one entry
of the hangover table fails at half-frame 244 on the counters — two
independent subsystems. A third mutation (`CEE_SM_FAC` by one LSB) is
genuinely absorbed, because `mult(18022, 16384)` and `mult(18023, 16384)` both
truncate to 9011; that is arithmetic, not a weak test.

**IF2 for both variants** (2026-08-12) — `codecs/amr/interface_format.rs`,
built from TS 26.101 Annex A and TS 26.201 Annex A after those specs were
fetched. They are fetched for design and never redistributed, exactly as the
reference C is.

The two formats agree on almost nothing, which is the whole story here:

| | narrowband (26.101) | wideband (26.201) |
|---|---|---|
| Frame Type | four **LSBs** of octet 1 | four **MSBs** of octet 1 |
| Frame Quality Indicator | **absent** | 1 bit, after the frame type |
| Bit packing | **LSB-first** in each octet | **MSB-first** |
| SID Core Frame | 39 bits | 40 bits |

**The first attempt at this was wrong in both variants, and Wireshark called
it correct.** Narrowband was written with the wideband convention and the
dissector rejected it outright — that much was caught. But the wideband frames
it *accepted* were also wrong: they omitted the FQI bit, so every frame was
one bit short and 6.60 kbit/s was a whole octet short of the spec's 18. The
dissector reads the mode from the header nibble and never checks the length,
so it reported all nine modes happily. An oracle that answers a narrower
question than the one being asked will confirm a broken implementation, and
this is what that looks like.

What settles it now is the specs' own numbers: every frame length is asserted
against Table A.1b on both sides, and the worked 6.70 kbit/s (26.101 A.1a) and
8.85 kbit/s (26.201 A.1a) examples are asserted bit by bit — `d(0)` at bit 5 of
octet 1 for narrowband, `d(0)` at bit 3 after the FQI for wideband. Wireshark
still agrees, now for all eight narrowband and all nine wideband modes, but it
is the corroboration rather than the authority.

**IF1 followed** (same day): the generic frame format of both specs' §4 —
frame type, FQI, mode indication, mode request, an 8-bit codec CRC over the
class A bits, then the Core Frame, MSB-first throughout. The two variants
split the auxiliary octets differently (narrowband packs MI into octet 1 and
MR into octet 2 with five spare bits; wideband spares out octet 1 and gives MI
and MR four bits each in octet 2), and FT 14/15 are header-only — four bits
for narrowband, five for wideband, another place the two disagree.

The codec CRC is `G(x) = x^8+x^6+x^5+x^4+1` — the exact bit-reversal of RFC
4867's payload CRC polynomial, so nothing is shared and each carries its own
hand-worked vectors. A CRC mismatch on unpack is reported, not refused:
26.101 Table 1c maps it to `SPEECH_BAD`, whose bits may still assist
concealment. Coverage is asserted precisely — a flipped class A bit fails the
CRC, a flipped class B bit does not, which is the CRC doing what the spec
sized it for.

One extraction hazard recorded for the next reader: flattening the specs'
docx tables to text interleaves cells, and 26.201's worked example then
*appears* to show frame type 3 for 12.65 kbit/s. The normative tables (1a, 7,
A.1b) and the example's own 253-bit core all say frame type 2, and Wireshark
reads the nibble the same way. Checked against the tables, not the flattened
prose.

**Interleaving** (2026-08-12) gained its receive half:
`codecs/amr/interleave.rs` reassembles a group's frame-blocks from the ILL/ILP
fields the parser has always carried, reporting positions that never arrived as
lost so concealment answers for them. A payload at index `ILP` of a group of
`ILL + 1` carries the blocks at `ILP`, `ILP + (ILL+1)`, … — a receiver that
ignores this decodes 20 ms blocks in shuffled order, which sounds broken but
parses perfectly, so nothing upstream would report it.

The buffer holds exactly one group and is bounded by the field widths (16
packets × 32 frame-blocks). A peer that never completes a group cannot make it
grow: the group flushes when the next one starts, and its gaps are reported
rather than waited for.

**This does not make interleaving usable end to end, and the session is still
refused.** RFC 4867 §8.1 makes fmtp declarative — a peer naming `interleaving`
is asking to *receive* interleaved payloads, which obliges our transmit side,
and we do not interleave on transmit. What closed is the parse-only gap on the
direction we can control; `AmrAdapter::new` still declines the negotiation, now
saying which direction is missing.

**`max-red` redundancy** (2026-08-12) is a scheduler and a dedup filter, both
in `redundancy.rs`. Redundancy in RFC 4867 is not a separate mechanism: it is
the multi-frame payload used deliberately, re-sending recent frames beside the
new one, so the scheduler's whole job is choosing the frame list and the
timestamp. The rule that is easy to invert and expensive to debug — §4.3
orders frames **oldest first** and stamps the payload with the *oldest*
frame's timestamp — is stated in the module header and pinned by a test,
because getting it backwards shifts audio by the redundancy depth rather than
failing to parse.

Depth is bounded by what the peer's `max-red` permits and a too-deep request
is **refused rather than clamped**: a caller that thinks it has three-deep
protection and silently got one is worse off than one told no. The dedup side
is wrapping-aware — a 32-bit timestamp at 8 kHz wraps every six days, and a
naive `>` comparison would drop every frame for an epoch after the wrap.

We advertise `max-red=0`, so nothing turns this on by itself; the receive path
handles a peer's multi-frame payloads either way, since a peer may bundle for
its own reasons.

**The WB CRC blocker is gone.** An earlier revision recorded that RFC 4867
defers the AMR-WB class A counts to TS 26.201 and that we did not have them.
They were extracted from TS 26.201 Table 2 and are now in `mode.rs`:

| Mode | 6.60 | 8.85 | 12.65 | 14.25 | 15.85 | 18.25 | 19.85 | 23.05 | 23.85 |
|---|---|---|---|---|---|---|---|---|---|
| Class A | 54 | 64 | 72 | 72 | 72 | 72 | 72 | 72 | 72 |

Cross-check: class A + B + C from TS 26.201 equals the frame total from RFC
4867 for all nine modes — two independent sources agreeing.

TS 26.201 also settles *which* bits the CRC covers: "when the AMR-WB codec mode
is 6.60, then the Class A bits are d(0)..d(53)". Class A bits are the frame's
**leading** bits in importance order, so the CRC is computed over the first
`class_a_bits()` bits of the payload. `class_a_bits()` consequently returns
`usize` rather than `Option<usize>`.

**Interleaving is carried, not applied.** ILL/ILP are emitted and parsed and
exposed on `AmrPacket`, but reassembling frame-block order means holding frames
from up to `ill + 1` packets and emitting them out of arrival order. That is
jitter-buffer work; doing it in the payload format would duplicate reordering
logic that layer already owns. Documented on `AmrInterleaving`.

### One RFC ambiguity resolved by choice

RFC 4867 §4.4.2.1 says the CRC list follows the table of contents but does not
say whether frames carrying no data get a CRC entry. We follow TS 26.201 —
"When Frame Type Index of table 1a is 14 or 15, the CRC field is not included"
— so `NO_DATA` and `SPEECH_LOST` contribute no CRC octet. **If a peer disagrees,
every CRC after the first no-data frame will misalign.** It is the one place in
Phase 1 where we guessed, and it was measured to be **externally unsettleable
for now**: Wireshark 4.6's AMR dissector has no CRC mode at all (its
`amr.encoding.version` preference offers only octet_aligned / bw_efficient /
IF1 / IF2 — RFC 3267's CRC variant never made it in), FreeSWITCH's mod_amr does
not negotiate CRC, and neither reference tree ships CRC framing code. The CRC
*arithmetic* itself is now triple-checked — the module implementation, an
independently written prose implementation, and hand-worked register traces
(`crc_matches_hand_worked_vectors`) — so what remains open is only the
no-data-octet framing choice, to be confirmed if a CRC-negotiating peer ever
appears.

### Remaining for Phase 1

- [ ] Byte-for-byte comparison against captured real-world AMR pcaps via the
      Wireshark dissector. **Needs sample captures, which we do not have.**
      Deferred to Phase 8 interop, where live traffic is available anyway.

---

## Phase 0 — Foundations

### Done

| Item | Where |
|---|---|
| `AmrVariant`, `AmrMode`, `AmrFrameType`, `AmrModeSet` | `src/codecs/amr/mode.rs` |
| RFC 4867 frame-size / class-A / bit-rate tables, with tests asserting each value | `src/codecs/amr/mode.rs` |
| `mode-set` intersection with offer/answer semantics | `src/codecs/amr/mode.rs` |
| `CodecType::AmrNb` / `AmrWb` and all `match` arms | `src/types.rs`, `src/utils/validation.rs` |
| `AmrParameters` mirroring the RFC 4867 SDP attributes | `src/types.rs` |
| `VariableRateCodec`, `CodedFrame`, `FrameKind` | `src/types.rs` |
| `AmrCodec` stub implementing both traits | `src/codecs/amr/mod.rs` |
| Factory / registry / capabilities wiring | `src/codecs/mod.rs`, `src/lib.rs` |
| Feature chain `rvoip``rvoip-sip``media-core``codec-core` | four `Cargo.toml`s |
| ADR 001 for the trait design | [`ADR_001_VARIABLE_RATE_CODEC.md`]ADR_001_VARIABLE_RATE_CODEC.md |

**Verification**

- 21 AMR tests pass; 174 codec-core unit tests + 9 doc-tests pass with
  `--all-features`.
- Feature matrix compiles: default (no AMR), `amr-nb` alone, `amr-wb` alone,
  `--no-default-features`, and `--all-features`.
- Downstream crates build with the feature on: `rvoip-media-core`,
  `rvoip-sip`, and the `rvoip` facade, each with `--features amr`.
- Clippy clean for new code under `pedantic` + `nursery`. Two warnings remain,
  both pre-existing const-eval lints on assertions this work did not touch
  (`g711/tests/itu_validation_tests.rs:27`, `lib.rs:262`).

```bash
cargo test -p rvoip-codec-core --all-features
cargo check -p rvoip --features amr
```

### Outstanding — needs someone other than the compiler

| Item | Blocks | Owner |
|---|---|---|
| ~~**IP-1** — AMR-WB/G.722.2 essential patents~~ | ~~Phase 3+~~ |**CLEARED 2026-08-09** — no blockers |
| ~~**Oracle qualification**~~ | ~~Phase 3~~ |**DONE** — both tier-1 references build; every stage is verified against them |
| ~~**Spec acquisition**~~ | ~~Phase 3~~ |**DONE** — TS 26.173 and 26.073 fetched by script |
| ~~**IP-2a** — may 3GPP test sequences (TS 26.074 / 26.174) be vendored?~~ | ~~Conformance testing only~~ |**DECIDED 2026-08-12 — no** |
| **IP-2b** — may vectors *generated by running* the reference be committed? | Nothing — already done | ⚠️ **assumed yes** |
| **Staffing** — is a second engineer available? | Pulls the encoder milestone in | ❗ unassigned |

**IP-1 is cleared and the oracles are qualified.** Everything that was gating
DSP work is resolved.

**IP-2b needs a decision after the fact, not before.** Generated vectors *are*
committed — `lp_stages_wb.txt`, `stages_nb.txt` and the `.pcm` ground truth are
all output from running the 3GPP references. That was judged to be output
rather than redistribution of the source, which is never committed. If that
reading is wrong, the fixtures must be regenerated on demand in CI instead;
both build scripts already reproduce them bit-identically from a clean fetch,
so the change would be mechanical.

**IP-2a is decided: no 3GPP material enters this repository, ever.** That is a
standing rule, not a judgement about these particular files, and it closes the
question rather than deferring it — vendoring the TS 26.074/26.174 sequences
would be redistribution of 3GPP copyright material, which is exactly what the
whole reference-handling posture exists to avoid.

The conformance suites therefore run **fetch-on-demand only**, and nothing
about that is a compromise:

- `tools/build-amr-reference.sh` and `tools/build-amrnb-encoder-reference.sh`
  fetch and build the references into a scratch directory. Nothing they fetch
  is committed.
- `codecs/amr/conformance.rs` reads the sequences from the directories those
  scripts populate, named by `RVOIP_AMRWB_REFERENCE` / `RVOIP_AMRNB_REFERENCE`.
  When the sequences are absent the tests **panic rather than skip** — a
  conformance test that quietly passes because it found nothing to compare is
  worse than no test at all.
- A fresh machine needs exactly one thing to run them: network access to the
  reference sources, then `tools/run-amr-conformance.sh`, whose six-pass
  contract is unchanged.

What is committed is *generated output* — PCM ground truth, per-stage traces,
bitstreams — under the IP-2b reading below. Independently of all of it, the
committed fixtures come from opencore-amr and vo-amrwbenc, which is a stronger
check than the 3GPP sequences would be anyway: an independent implementation
agreeing is worth more than the reference agreeing with itself.

Note the environment currently has a **selective egress allowlist**:
`raw.githubusercontent.com` is reachable but `github.com` is not, and Docker
containers have no outbound network at all. Oracle qualification needs to fetch
and build `opencore-amr`, `vo-amrwbenc` and the 3GPP reference, so it needs
either that egress restored or the sources staged by hand.

---

## Decisions taken

| # | Decision | Recorded in |
|---|---|---|
| 1 | Pure Rust in the shipped crate; no FFI backend at any point | Plan §6.1 |
| 2 | Transcoding required from day one; relay is a by-product | Plan §1.2 |
| 3 | Five-oracle roster, out-of-tree, vector-generating | Plan §1.2.1 |
| 4 | **WB first**, NB second | Plan §1.2 |
| 5 | `VariableRateCodec` as a new trait, not a widened `AudioCodec` | ADR 001 |
| 6 | `mode_set` as a `u16` bitmask, not `Vec<u8>` | ADR 001 |
| 7 | Stub codec errors loudly rather than returning silence | ADR 001 |
| 8 | **No 3GPP material in the repository** — references and test sequences are fetched on demand, only generated output is committed | IP-2a, 2026-08-12 |

---

## Open questions

**Q1 — Where does the RFC 4867 packetizer live?** *(decided: option (c))*

**Fully resolved, and the follow-up dissolved.** The packetizer lives in
`codec-core` beside the codec (`src/codecs/amr/payload.rs`): RFC 4867 framing is
codec framing rather than transport, and it needs the mode tables intimately.

The worry about a dependency edge turned out to be unfounded — the payload
formats do **not** live in `rtp-core` any more. They were moved to
`media-core/src/rtp_processing/payload/`, and `media-core` already depends on
both `rtp-core` and `codec-core`. So the `PayloadFormat` adapter sits there
alongside `g711.rs` and `opus.rs` with **no new dependency edge and no
duplicated tables**.

Original framing of the question, retained for context:

`rtp-core` does **not** depend on `codec-core`; both are consumed by
`media-core`. But the packetizer needs the frame-size tables that now live in
`codecs::amr::mode`. Options:

- **(a)** Add `codec-core` as a dependency of `rtp-core` — new edge in the
  dependency graph, likely unwanted.
- **(b)** Duplicate the ~20 constants in `rtp-core`. Small, but two sources of
  truth for numbers we just carefully verified.
- **(c)** Put the packetizer in `codec-core` beside the codec, and let
  `media-core` wire the two together. Diverges from the existing convention that
  payload formats live in `rtp-core` (`g711.rs`, `opus.rs`, `vp8.rs`).
- **(d)** Extract shared AMR constants into a small crate both depend on.

Chose **(c)**.

**Q2 — Crate placement.** Keep AMR inside `rvoip-codec-core`, or split into a
standalone publishable crate? A pure-Rust AMR crate would be the first in the
Rust ecosystem. Note `amr` is taken on crates.io by an unrelated GPL-3.0 project.
No urgency; revisit once there is a working kernel.

**Q3 — AMR-WB class A bit counts.** *(resolved)* Extracted from TS 26.201
Table 2 and cross-checked against the RFC 4867 frame totals. `class_a_bits()`
now returns `usize` for both variants and WB CRC works. See the Phase 1 section.

---

## Changelog

### 2026-08-10 — All four codec paths bit-exact

Both encoders now produce a byte-identical bitstream at every rate — wideband
nine, narrowband eight, 50 frames each — and both are reachable through
`AmrCodec` alongside the decoders. With the decoders finished earlier the same
day, every path this codec has is exact against its normative reference.

The encoders were built the same way as the decoders and for the same reason:
ground truth first, stages second, assembly last. Committed input PCM and
reference bitstreams at every rate, an instrumented reference encoder emitting
per-stage traces, then one module per stage group each verified against those
traces, then the frame loop. Both assemblies matched on close to the first run,
which is what the decoders' hazards bought — every composition trap the
wideband decoder found the hard way was written into the assembly brief before
a line of it existed.

Also this session: oracle qualification finally measured, the wideband erasure
path closed, a decoder fuzz target, and a latent `prev_bfi` frame-versus-
subframe divergence found and fixed before it could fire.

**Lesson 3 fired twice more and is now the one to expect.** A trace row that is
absent, or that silently duplicates another, reads exactly like a passing
comparison. Two rows were lost to output interleaving, one row was a
byte-identical copy of its neighbour under a different name, and one vacuity
check passed because a single sample moved by 8 is below what 4.75 kbit/s can
resolve. Every one was caught by a count assertion or a mutation check rather
than by reading the code.

### 2026-08-10 — Both decoders bit-exact, and reachable

The narrowband decoder joined the wideband one: all eight rates, every sample
of every frame identical to TS 26.073's own decoder. Seven were exact on the
first assembly, because the three composition hazards the wideband port found
the hard way were written into the module header before any of it ran. The
eighth, 4.75 kbit/s, failed on its shared two-subframe gain index.

Both are now reachable through `AmrCodec`, and the API path is separately
asserted bit-exact — per-stage exactness cannot see a wiring layer that resets
state between frames.

Also this session:

- **Oracle qualification, finally measured.** `opencore-amr` reproduces
  TS 26.073 exactly at all eight narrowband rates; `vo-amrwbenc` reproduces
  TS 26.173 at seven of nine wideband rates and not at 12.65 or 14.25. Risk R8
  is real but narrow, and it is now a test rather than an open question.
- **Encoder ground truth for both variants**, which did not exist in any form:
  committed input PCM, the reference bitstream at every rate, and per-stage
  traces from instrumented reference encoders.
- **The wrong `inter_6`.** The table generator had been emitting the encoder's
  25-tap pitch-search filter under a doc comment describing the decoder's
  61-tap adaptive-codebook filter. Both are now generated under names that say
  which is which.
- **Two trace harnesses silently dropped rows**, in C and in Rust, for the same
  reason — output merging into a line a filter then discarded. Recorded above
  as lesson 3, because it is the same failure as an oracle sharing your
  assumption, seen from the other side.

### 2026-08-09 — Spec obtained; first DSP written

TS 26.190 was never unreachable. `3gpp.org` and `etsi.org` were rejecting
curl's user agent with 403; the previous entry conflated that with hosts that
fail at the connection layer and declared the whole set blocked.

With the spec in hand, wrote the LP analysis front end (§5.2.1). The oracle
confirmed the reconstructed window formula against `ham_wind.tab` — 384 values,
all within 1 LSB — and surfaced that the reference folds the white-noise
correction into its lag window where the spec puts it on r(0).

### Superseded: 2026-08-09 — Phase 3 blocked on spec access

The LP-analysis chain cannot be written bit-exactly without TS 26.190's
normative windows, lag-window values and Q-formats. Every 3GPP spec mirror is
unreachable or 403 from this environment, including the ARIB one that served
TS 26.201 earlier the same day.

Stopped rather than writing structurally-correct DSP with invented tables — that
produces something that looks finished and is not bit-exact, which is worse than
an obvious gap. Two ways forward are recorded above; one of them is a licensing
decision that is not mine to make.

### 2026-08-09 — The oracle is running, and it confirmed the mode table

Built `opencore-amr` and `vo-amrwbenc` natively with clang. The supposed
blocker was container networking, but the harness only ever needed source and a
compiler; sources come from the Debian archive, which is reachable.

The reference encoder's frame sizes match our mode table at all nine AMR-WB
modes. 225 reference frames checked in, with the build script reproducing them
bit-identically.

### 2026-08-09 — Phase 3 opened; the basic operators were already written

The ETSI basic-operator set Phase 3 was going to start by writing already
existed in the G.729A port, validated by that codec being bit-exact. Promoted
from `codecs::g729::impls::dsp` to a shared `crate::fixed_point`; G.729 reaches
it under the old name via a re-export so none of its ~318 call sites changed.

Table-driven transcendentals were deliberately left behind with G.729 rather
than shared, because AMR supplies its own tables and their equality is unproven.

### 2026-08-09 — Framing validated against real FreeSWITCH RTP

Captured 50 AMR-WB payloads from a live call and checked them in. Our
packetizer reproduces them byte for byte.

The first capture attempt was invalid — `&echo` passes through rather than
transcoding, so the payloads were our own bytes returning. Caught by comparing
them against what we sent. A conference bridge forces a real encode; there is
now a test guarding the fixture against that mistake.

### 2026-08-09 — IP-1 cleared

Counsel confirmed no patent blockers. Phase 3 (the codec kernel) is unblocked
for the first time — every phase to date has been protocol and plumbing
precisely because this gate was open.

### 2026-08-09 — Negotiation validated against FreeSWITCH

Captured a live AMR-WB offer from FreeSWITCH 1.10.12 and pinned it as a test
fixture. Parser and answerer both handled it correctly on the first run.

Also fixed a trap in the FreeSWITCH image: the codecs were compiled but never
loaded, because the entrypoint overwrites the runtime module list. The build
looked entirely healthy — only starting the container and running `show codec`
revealed it.

### 2026-08-09 — Interop peers, and the relay that already existed

FreeSWITCH AMR built and verified. Asterisk prepared; its build is blocked on
container networking, not on anything in the patch — which dry-runs cleanly
against Asterisk 20 despite targeting 13/16.

The relay path turned out to be largely a discovery rather than a build: the
transparent bridge is codec-agnostic. The work was closing a hole in its
compatibility check, where matching payload types were treated as sufficient.

### 2026-08-09 — Negotiated parameters reach the media layer

`NegotiatedConfig::negotiated_fmtp` plus `AmrPayloadFormat::from_negotiated`.
The signalling layer carries the fmtp string without interpreting it; the codec
layer parses it.

**Corrected 2026-08-10:** this claimed to close the gap and did not. Neither
consumer was called — `MediaConfig::with_negotiated_fmtp` had zero callers, and
`AmrPayloadFormat::from_negotiated` still has none outside its own tests. The
first is now wired end to end; the second is not. See "Signalling reaches the
media layer" above.

### 2026-08-08 — Phase 2 negotiation layer

SDP parameters, offer/answer, rate adaptation, and the dynamic payload-type
refactor. The relay path itself is still outstanding — see above.

The significant find was that phase 0's `mode-set` intersection was wrong:
RFC 4867 requires match-or-reject, not narrowing. Caught by reading §8.3.1
directly rather than trusting the summary that led to the original code.

### 2026-08-08 — Phase 1 complete

Closed out the remainder: frame CRC (both variants), robust sorting,
interleaving fields, the `PayloadFormat` adapter, and a fuzz target.

- The AMR-WB CRC blocker was resolved by extracting TS 26.201 Table 2. Class A
  + B + C from that table equals the RFC 4867 frame total for all nine modes,
  which is a genuine two-source cross-check rather than an assumption.
- TS 26.201 also confirmed class A bits are the frame's *leading* bits
  ("d(0)..d(53)" for mode 6.60), which is what makes a CRC over the first N
  bits correct.
- The `PayloadFormat` adapter needed no new dependency edge after all: payload
  formats had already been moved out of `rtp-core` into `media-core`, which
  depends on both crates. Q1's follow-up dissolved.
- A test failure caught a real convention issue: a frame is `mode.bits()` bits,
  not `octet_aligned_bytes()` bytes, so trailing bits of the final octet are
  padding and do not survive a round trip. Now documented on `pack` and pinned
  by a test rather than left to be rediscovered.

### 2026-08-08 — Phase 1 core complete

RFC 4867 payload format (both framings) and the AMR file storage format, with
62 AMR tests. Three optional octet-aligned extensions deferred; see above.

Worth flagging:

- The two framings differ in size for the same content — an AMR-WB mode 0 frame
  is 18 octets bandwidth-efficient and 19 octet-aligned. That saving is the
  point of the framing, and it is exactly why the two are not interoperable.
  There is an explicit test asserting cross-decoding fails.
- `unpack` rejects a whole trailing octet beyond what the ToC accounts for.
  Sub-octet padding is legitimate; a full octet means the sender's frame sizes
  disagree with ours, which is better caught at the boundary than delivered to
  the decoder as misaligned bits.
- The ToC chain is bounded at 32 frames (640 ms). RFC 4867 sets no hard limit —
  it is governed by `maxptime` — but an unbounded F-bit chain in a hostile
  payload would otherwise loop until the buffer ran out.

### 2026-08-08 — Phase 0 code complete

Types, modes, mode-set negotiation, the `VariableRateCodec` trait, the stub
codec, and the feature chain across four crates. ADR 001 recorded.

Two things worth flagging from the implementation:

- `AmrParameters::mode_set` began as `Vec<u8>` and forced
  `CodecConfig::with_parameters` to lose `const fn`. Switched to a `u16`
  bitmask, which is `Copy`, makes duplicates unrepresentable, and turns
  negotiation into a bitwise AND.
- `AmrMode` carries its variant rather than being a bare index. NB mode 0 is
  4.75 kbit/s and WB mode 0 is 6.60 kbit/s, and several frame sizes collide
  across variants — this makes the confusion unrepresentable rather than
  something to catch in review.

### 2026-08-08 — Plan finalised

Four revisions on `feat/amr-codecs` as decisions landed: pure-Rust +
transcoding-first, then the Apache-2.0 oracle + WB-first, then the 3GPP
reference as primary oracle, then the five-oracle roster + interop matrix.