@e04/ft8ts 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,19 +2,10 @@
2
2
  import { writeFileSync, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
 
5
+ /** Shared constants used by FT8, FT4, pack77, etc. */
5
6
  const SAMPLE_RATE$1 = 12_000;
6
- const NSPS = 1920;
7
- const NFFT1 = 2 * NSPS; // 3840
8
- const NSTEP = NSPS / 4; // 480
9
- const NMAX = 15 * SAMPLE_RATE$1; // 180000
10
- const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
11
- const NDOWN = 60;
12
- const NN = 79;
13
- const KK = 91;
7
+ /** LDPC(174,91) code (shared by FT8 and FT4). */
14
8
  const N_LDPC = 174;
15
- const M_LDPC = N_LDPC - KK; // 83
16
- const icos7 = [3, 1, 4, 0, 6, 5, 2];
17
- const graymap = [0, 1, 3, 2, 5, 6, 4, 7];
18
9
  const gHex = [
19
10
  "8329ce11bf31eaf509f27fc",
20
11
  "761c264e25c259335493132",
@@ -239,6 +230,8 @@ for (let i = 0; i < 83; i++) {
239
230
  * LDPC (174,91) Belief Propagation decoder for FT8.
240
231
  * Port of bpdecode174_91.f90 and decode174_91.f90.
241
232
  */
233
+ const KK = 91;
234
+ const M_LDPC = N_LDPC - KK; // 83
242
235
  function platanh(x) {
243
236
  if (x > 0.9999999)
244
237
  return 18.71;
@@ -387,36 +380,48 @@ function osdDecode174_91(llr, apmask, norder) {
387
380
  const N = N_LDPC;
388
381
  const K = KK;
389
382
  const gen = getGenerator();
383
+ const absllr = new Float64Array(N);
384
+ for (let i = 0; i < N; i++)
385
+ absllr[i] = Math.abs(llr[i]);
390
386
  // Sort by reliability (descending)
391
- const indices = Array.from({ length: N }, (_, i) => i);
392
- indices.sort((a, b) => Math.abs(llr[b]) - Math.abs(llr[a]));
387
+ const indices = new Array(N);
388
+ for (let i = 0; i < N; i++)
389
+ indices[i] = i;
390
+ indices.sort((a, b) => absllr[b] - absllr[a]);
393
391
  // Reorder generator matrix columns
394
392
  const genmrb = new Uint8Array(K * N);
395
- for (let i = 0; i < N; i++) {
396
- for (let k = 0; k < K; k++) {
397
- genmrb[k * N + i] = gen[k * N + indices[i]];
393
+ for (let k = 0; k < K; k++) {
394
+ const row = k * N;
395
+ for (let i = 0; i < N; i++) {
396
+ genmrb[row + i] = gen[row + indices[i]];
398
397
  }
399
398
  }
400
399
  // Gaussian elimination to get systematic form on the K most-reliable bits
400
+ const maxPivotCol = Math.min(K + 20, N);
401
401
  for (let id = 0; id < K; id++) {
402
402
  let found = false;
403
- for (let icol = id; icol < Math.min(K + 20, N); icol++) {
404
- if (genmrb[id * N + icol] === 1) {
403
+ const idRow = id * N;
404
+ for (let icol = id; icol < maxPivotCol; icol++) {
405
+ if (genmrb[idRow + icol] === 1) {
405
406
  if (icol !== id) {
406
407
  // Swap columns
407
408
  for (let k = 0; k < K; k++) {
408
- const tmp = genmrb[k * N + id];
409
- genmrb[k * N + id] = genmrb[k * N + icol];
410
- genmrb[k * N + icol] = tmp;
409
+ const row = k * N;
410
+ const tmp = genmrb[row + id];
411
+ genmrb[row + id] = genmrb[row + icol];
412
+ genmrb[row + icol] = tmp;
411
413
  }
412
414
  const tmp = indices[id];
413
415
  indices[id] = indices[icol];
414
416
  indices[icol] = tmp;
415
417
  }
416
418
  for (let ii = 0; ii < K; ii++) {
417
- if (ii !== id && genmrb[ii * N + id] === 1) {
419
+ if (ii === id)
420
+ continue;
421
+ const iiRow = ii * N;
422
+ if (genmrb[iiRow + id] === 1) {
418
423
  for (let c = 0; c < N; c++) {
419
- genmrb[ii * N + c] ^= genmrb[id * N + c];
424
+ genmrb[iiRow + c] ^= genmrb[idRow + c];
420
425
  }
421
426
  }
422
427
  }
@@ -430,80 +435,82 @@ function osdDecode174_91(llr, apmask, norder) {
430
435
  // Hard decisions on reordered received word
431
436
  const hdec = new Int8Array(N);
432
437
  for (let i = 0; i < N; i++) {
433
- hdec[i] = llr[indices[i]] >= 0 ? 1 : 0;
438
+ const idx = indices[i];
439
+ hdec[i] = llr[idx] >= 0 ? 1 : 0;
434
440
  }
435
441
  const absrx = new Float64Array(N);
436
442
  for (let i = 0; i < N; i++) {
437
- absrx[i] = Math.abs(llr[indices[i]]);
443
+ absrx[i] = absllr[indices[i]];
438
444
  }
439
- // Transpose of reordered gen matrix
440
- const g2 = new Uint8Array(N * K);
445
+ // Encode hard decision on MRB (c0): xor selected rows of genmrb.
446
+ const c0 = new Int8Array(N);
441
447
  for (let i = 0; i < K; i++) {
448
+ if (hdec[i] !== 1)
449
+ continue;
450
+ const row = i * N;
442
451
  for (let j = 0; j < N; j++) {
443
- g2[j * K + i] = genmrb[i * N + j];
444
- }
445
- }
446
- function mrbencode(me) {
447
- const codeword = new Int8Array(N);
448
- for (let i = 0; i < K; i++) {
449
- if (me[i] === 1) {
450
- for (let j = 0; j < N; j++) {
451
- codeword[j] ^= g2[j * K + i];
452
- }
453
- }
452
+ c0[j] ^= genmrb[row + j];
454
453
  }
455
- return codeword;
456
454
  }
457
- const m0 = hdec.slice(0, K);
458
- const c0 = mrbencode(m0);
459
- const bestCw = new Int8Array(c0);
460
455
  let dmin = 0;
461
456
  for (let i = 0; i < N; i++) {
462
457
  const x = c0[i] ^ hdec[i];
463
458
  dmin += x * absrx[i];
464
459
  }
460
+ let bestFlip1 = -1;
461
+ let bestFlip2 = -1;
465
462
  // Order-1: flip single bits in the info portion
466
463
  for (let i1 = K - 1; i1 >= 0; i1--) {
467
464
  if (apmask[indices[i1]] === 1)
468
465
  continue;
469
- const me = new Int8Array(m0);
470
- me[i1] ^= 1;
471
- const ce = mrbencode(me);
466
+ const row1 = i1 * N;
472
467
  let dd = 0;
473
468
  for (let j = 0; j < N; j++) {
474
- const x = ce[j] ^ hdec[j];
469
+ const x = c0[j] ^ genmrb[row1 + j] ^ hdec[j];
475
470
  dd += x * absrx[j];
476
471
  }
477
472
  if (dd < dmin) {
478
473
  dmin = dd;
479
- bestCw.set(ce);
474
+ bestFlip1 = i1;
475
+ bestFlip2 = -1;
480
476
  }
481
477
  }
482
478
  // Order-2: flip pairs of least-reliable info bits (limited search)
483
479
  if (norder >= 2) {
484
- const ntry = Math.min(40, K);
485
- for (let i1 = K - 1; i1 >= K - ntry; i1--) {
480
+ const ntry = Math.min(64, K);
481
+ const iMin = Math.max(0, K - ntry);
482
+ for (let i1 = K - 1; i1 >= iMin; i1--) {
486
483
  if (apmask[indices[i1]] === 1)
487
484
  continue;
488
- for (let i2 = i1 - 1; i2 >= K - ntry; i2--) {
485
+ const row1 = i1 * N;
486
+ for (let i2 = i1 - 1; i2 >= iMin; i2--) {
489
487
  if (apmask[indices[i2]] === 1)
490
488
  continue;
491
- const me = new Int8Array(m0);
492
- me[i1] ^= 1;
493
- me[i2] ^= 1;
494
- const ce = mrbencode(me);
489
+ const row2 = i2 * N;
495
490
  let dd = 0;
496
491
  for (let j = 0; j < N; j++) {
497
- const x = ce[j] ^ hdec[j];
492
+ const x = c0[j] ^ genmrb[row1 + j] ^ genmrb[row2 + j] ^ hdec[j];
498
493
  dd += x * absrx[j];
499
494
  }
500
495
  if (dd < dmin) {
501
496
  dmin = dd;
502
- bestCw.set(ce);
497
+ bestFlip1 = i1;
498
+ bestFlip2 = i2;
503
499
  }
504
500
  }
505
501
  }
506
502
  }
503
+ const bestCw = new Int8Array(c0);
504
+ if (bestFlip1 >= 0) {
505
+ const row1 = bestFlip1 * N;
506
+ for (let j = 0; j < N; j++)
507
+ bestCw[j] ^= genmrb[row1 + j];
508
+ if (bestFlip2 >= 0) {
509
+ const row2 = bestFlip2 * N;
510
+ for (let j = 0; j < N; j++)
511
+ bestCw[j] ^= genmrb[row2 + j];
512
+ }
513
+ }
507
514
  // Reorder codeword back to original order
508
515
  const finalCw = new Int8Array(N);
509
516
  for (let i = 0; i < N; i++) {
@@ -514,14 +521,12 @@ function osdDecode174_91(llr, apmask, norder) {
514
521
  return null;
515
522
  // Compute dmin in original order
516
523
  let dminOrig = 0;
517
- const hdecOrig = new Int8Array(N);
518
- for (let i = 0; i < N; i++)
519
- hdecOrig[i] = llr[i] >= 0 ? 1 : 0;
520
524
  let nhe = 0;
521
525
  for (let i = 0; i < N; i++) {
522
- const x = finalCw[i] ^ hdecOrig[i];
526
+ const hard = llr[i] >= 0 ? 1 : 0;
527
+ const x = finalCw[i] ^ hard;
523
528
  nhe += x;
524
- dminOrig += x * Math.abs(llr[i]);
529
+ dminOrig += x * absllr[i];
525
530
  }
526
531
  return {
527
532
  message91: bits91,
@@ -566,6 +571,8 @@ function getGenerator() {
566
571
  * Radix-2 Cooley-Tukey FFT for FT8 decoding.
567
572
  * Supports real-to-complex, complex-to-complex, and inverse transforms.
568
573
  */
574
+ const RADIX2_PLAN_CACHE = new Map();
575
+ const BLUESTEIN_PLAN_CACHE = new Map();
569
576
  function fftComplex(re, im, inverse) {
570
577
  const n = re.length;
571
578
  if (n <= 1)
@@ -574,9 +581,10 @@ function fftComplex(re, im, inverse) {
574
581
  bluestein(re, im, inverse);
575
582
  return;
576
583
  }
584
+ const { bitReversed } = getRadix2Plan(n);
577
585
  // Bit-reversal permutation
578
- let j = 0;
579
586
  for (let i = 0; i < n; i++) {
587
+ const j = bitReversed[i];
580
588
  if (j > i) {
581
589
  let tmp = re[i];
582
590
  re[i] = re[j];
@@ -585,12 +593,6 @@ function fftComplex(re, im, inverse) {
585
593
  im[i] = im[j];
586
594
  im[j] = tmp;
587
595
  }
588
- let m = n >> 1;
589
- while (m >= 1 && j >= m) {
590
- j -= m;
591
- m >>= 1;
592
- }
593
- j += m;
594
596
  }
595
597
  const sign = inverse ? 1 : -1;
596
598
  for (let size = 2; size <= n; size <<= 1) {
@@ -617,53 +619,98 @@ function fftComplex(re, im, inverse) {
617
619
  }
618
620
  }
619
621
  if (inverse) {
622
+ const scale = 1 / n;
620
623
  for (let i = 0; i < n; i++) {
621
- re[i] /= n;
622
- im[i] /= n;
624
+ re[i] = re[i] * scale;
625
+ im[i] = im[i] * scale;
623
626
  }
624
627
  }
625
628
  }
626
629
  function bluestein(re, im, inverse) {
627
630
  const n = re.length;
628
- const m = nextPow2(n * 2 - 1);
629
- const s = inverse ? 1 : -1;
630
- const aRe = new Float64Array(m);
631
- const aIm = new Float64Array(m);
632
- const bRe = new Float64Array(m);
633
- const bIm = new Float64Array(m);
631
+ const { m, chirpRe, chirpIm, bFftRe, bFftIm, aRe, aIm } = getBluesteinPlan(n, inverse);
632
+ aRe.fill(0);
633
+ aIm.fill(0);
634
634
  for (let i = 0; i < n; i++) {
635
- const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
636
- const cosA = Math.cos(angle);
637
- const sinA = Math.sin(angle);
638
- aRe[i] = re[i] * cosA - im[i] * sinA;
639
- aIm[i] = re[i] * sinA + im[i] * cosA;
640
- bRe[i] = cosA;
641
- bIm[i] = -sinA;
642
- }
643
- for (let i = 1; i < n; i++) {
644
- bRe[m - i] = bRe[i];
645
- bIm[m - i] = bIm[i];
635
+ const cosA = chirpRe[i];
636
+ const sinA = chirpIm[i];
637
+ const inRe = re[i];
638
+ const inIm = im[i];
639
+ aRe[i] = inRe * cosA - inIm * sinA;
640
+ aIm[i] = inRe * sinA + inIm * cosA;
646
641
  }
647
642
  fftComplex(aRe, aIm, false);
648
- fftComplex(bRe, bIm, false);
649
643
  for (let i = 0; i < m; i++) {
650
- const r = aRe[i] * bRe[i] - aIm[i] * bIm[i];
651
- const iIm = aRe[i] * bIm[i] + aIm[i] * bRe[i];
652
- aRe[i] = r;
653
- aIm[i] = iIm;
644
+ const ar = aRe[i];
645
+ const ai = aIm[i];
646
+ const br = bFftRe[i];
647
+ const bi = bFftIm[i];
648
+ aRe[i] = ar * br - ai * bi;
649
+ aIm[i] = ar * bi + ai * br;
654
650
  }
655
651
  fftComplex(aRe, aIm, true);
656
652
  const scale = inverse ? 1 / n : 1;
657
653
  for (let i = 0; i < n; i++) {
658
- const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
659
- const cosA = Math.cos(angle);
660
- const sinA = Math.sin(angle);
654
+ const cosA = chirpRe[i];
655
+ const sinA = chirpIm[i];
661
656
  const r = aRe[i] * cosA - aIm[i] * sinA;
662
657
  const iIm = aRe[i] * sinA + aIm[i] * cosA;
663
658
  re[i] = r * scale;
664
659
  im[i] = iIm * scale;
665
660
  }
666
661
  }
662
+ function getRadix2Plan(n) {
663
+ let plan = RADIX2_PLAN_CACHE.get(n);
664
+ if (plan)
665
+ return plan;
666
+ const bits = 31 - Math.clz32(n);
667
+ const bitReversed = new Uint32Array(n);
668
+ for (let i = 1; i < n; i++) {
669
+ bitReversed[i] = (bitReversed[i >> 1] >> 1) | ((i & 1) << (bits - 1));
670
+ }
671
+ plan = { bitReversed };
672
+ RADIX2_PLAN_CACHE.set(n, plan);
673
+ return plan;
674
+ }
675
+ function getBluesteinPlan(n, inverse) {
676
+ const key = `${n}:${inverse ? 1 : 0}`;
677
+ const cached = BLUESTEIN_PLAN_CACHE.get(key);
678
+ if (cached)
679
+ return cached;
680
+ const m = nextPow2(n * 2 - 1);
681
+ const s = inverse ? 1 : -1;
682
+ const chirpRe = new Float64Array(n);
683
+ const chirpIm = new Float64Array(n);
684
+ for (let i = 0; i < n; i++) {
685
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
686
+ chirpRe[i] = Math.cos(angle);
687
+ chirpIm[i] = Math.sin(angle);
688
+ }
689
+ const bFftRe = new Float64Array(m);
690
+ const bFftIm = new Float64Array(m);
691
+ for (let i = 0; i < n; i++) {
692
+ const cosA = chirpRe[i];
693
+ const sinA = chirpIm[i];
694
+ bFftRe[i] = cosA;
695
+ bFftIm[i] = -sinA;
696
+ }
697
+ for (let i = 1; i < n; i++) {
698
+ bFftRe[m - i] = bFftRe[i];
699
+ bFftIm[m - i] = bFftIm[i];
700
+ }
701
+ fftComplex(bFftRe, bFftIm, false);
702
+ const plan = {
703
+ m,
704
+ chirpRe,
705
+ chirpIm,
706
+ bFftRe,
707
+ bFftIm,
708
+ aRe: new Float64Array(m),
709
+ aIm: new Float64Array(m),
710
+ };
711
+ BLUESTEIN_PLAN_CACHE.set(key, plan);
712
+ return plan;
713
+ }
667
714
  /** Next power of 2 >= n */
668
715
  function nextPow2(n) {
669
716
  let v = 1;
@@ -923,607 +970,789 @@ function unpack77(bits77, book) {
923
970
  return { msg: "", success: false };
924
971
  }
925
972
 
973
+ /** FT4-specific constants (lib/ft4/ft4_params.f90). */
974
+ const GRAYMAP = [0, 1, 3, 2];
975
+
976
+ // Message scrambling vector (rvec) from WSJT-X.
977
+ const RVEC = [
978
+ 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1,
979
+ 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0,
980
+ 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1,
981
+ ];
982
+ function xorWithScrambler(bits77) {
983
+ const out = new Array(77);
984
+ for (let i = 0; i < 77; i++) {
985
+ out[i] = ((bits77[i] ?? 0) + RVEC[i]) & 1;
986
+ }
987
+ return out;
988
+ }
989
+
990
+ const COSTAS_A = [0, 1, 3, 2];
991
+ const COSTAS_B = [1, 0, 2, 3];
992
+ const COSTAS_C = [2, 3, 1, 0];
993
+ const COSTAS_D = [3, 2, 0, 1];
994
+ const NSPS$1 = 576;
995
+ const NFFT1$1 = 4 * NSPS$1; // 2304
996
+ const NH1 = NFFT1$1 / 2; // 1152
997
+ const NMAX$1 = 21 * 3456; // 72576
998
+ const NHSYM$1 = Math.floor((NMAX$1 - NFFT1$1) / NSPS$1); // 122
999
+ const NDOWN$1 = 18;
1000
+ const NN$1 = 103;
1001
+ const NFFT2$1 = NMAX$1 / NDOWN$1; // 4032
1002
+ const NSS = NSPS$1 / NDOWN$1; // 32
1003
+ const FS2$1 = SAMPLE_RATE$1 / NDOWN$1; // 666.67 Hz
1004
+ const MAX_FREQ = 4910;
1005
+ const SYNC_PASS_MIN = 1.2;
1006
+ const TWO_PI$2 = 2 * Math.PI;
1007
+ const HARD_SYNC_PATTERNS = [
1008
+ { offset: 0, bits: [0, 0, 0, 1, 1, 0, 1, 1] },
1009
+ { offset: 66, bits: [0, 1, 0, 0, 1, 1, 1, 0] },
1010
+ { offset: 132, bits: [1, 1, 1, 0, 0, 1, 0, 0] },
1011
+ { offset: 198, bits: [1, 0, 1, 1, 0, 0, 0, 1] },
1012
+ ];
1013
+ const COSTAS_BLOCKS$1 = 4;
1014
+ const FT4_SYNC_STRIDE = 33 * NSS;
1015
+ const FT4_MAX_TWEAK = 16;
1016
+ const LDPC_BITS = 174;
1017
+ const BITMETRIC_LEN = 2 * NN$1;
1018
+ const FRAME_LEN = NN$1 * NSS;
1019
+ const NUTTALL_WINDOW = makeNuttallWindow(NFFT1$1);
1020
+ const DOWNSAMPLE_CTX = createDownsampleContext();
1021
+ const TWEAKED_SYNC_TEMPLATES = createTweakedSyncTemplates();
926
1022
  /**
927
- * Decode all FT8 signals in an audio buffer.
928
- * Input: mono audio samples at `sampleRate` Hz, duration ~15s.
1023
+ * Decode all FT4 signals in a buffer.
1024
+ * Input: mono audio samples at `sampleRate` Hz, duration ~6s.
929
1025
  */
930
- function decode(samples, options = {}) {
1026
+ function decode$1(samples, options = {}) {
931
1027
  const sampleRate = options.sampleRate ?? SAMPLE_RATE$1;
932
- const nfa = options.freqLow ?? 200;
933
- const nfb = options.freqHigh ?? 3000;
934
- const syncmin = options.syncMin ?? 1.2;
1028
+ const freqLow = options.freqLow ?? 200;
1029
+ const freqHigh = options.freqHigh ?? 3000;
1030
+ const syncMin = options.syncMin ?? 1.2;
935
1031
  const depth = options.depth ?? 2;
936
- const maxCandidates = options.maxCandidates ?? 300;
1032
+ const maxCandidates = options.maxCandidates ?? 100;
937
1033
  const book = options.hashCallBook;
938
- // Resample to 12000 Hz if needed
939
- let dd;
940
- if (sampleRate === SAMPLE_RATE$1) {
941
- dd = new Float64Array(NMAX);
942
- const len = Math.min(samples.length, NMAX);
943
- for (let i = 0; i < len; i++)
944
- dd[i] = samples[i];
945
- }
946
- else {
947
- dd = resample(samples, sampleRate, SAMPLE_RATE$1, NMAX);
948
- }
949
- // Compute huge FFT for downsampling caching
950
- const NFFT1_LONG = 192000;
951
- const cxRe = new Float64Array(NFFT1_LONG);
952
- const cxIm = new Float64Array(NFFT1_LONG);
953
- for (let i = 0; i < NMAX; i++) {
1034
+ const dd = sampleRate === SAMPLE_RATE$1
1035
+ ? copySamplesToDecodeWindow$1(samples)
1036
+ : resample$1(samples, sampleRate, SAMPLE_RATE$1, NMAX$1);
1037
+ const cxRe = new Float64Array(NMAX$1);
1038
+ const cxIm = new Float64Array(NMAX$1);
1039
+ for (let i = 0; i < NMAX$1; i++)
954
1040
  cxRe[i] = dd[i] ?? 0;
955
- }
956
1041
  fftComplex(cxRe, cxIm, false);
957
- // Compute spectrogram and find sync candidates
958
- const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
1042
+ const candidates = getCandidates4(dd, freqLow, freqHigh, syncMin, maxCandidates);
1043
+ if (candidates.length === 0)
1044
+ return [];
1045
+ const workspace = createDecodeWorkspace$1();
959
1046
  const decoded = [];
960
1047
  const seenMessages = new Set();
961
- for (const cand of candidates) {
962
- const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
963
- if (!result)
1048
+ for (const candidate of candidates) {
1049
+ const one = decodeCandidate(candidate, cxRe, cxIm, depth, book, workspace);
1050
+ if (!one)
964
1051
  continue;
965
- if (seenMessages.has(result.msg))
1052
+ if (seenMessages.has(one.msg))
966
1053
  continue;
967
- seenMessages.add(result.msg);
968
- decoded.push({
969
- freq: result.freq,
970
- dt: result.dt - 0.5,
971
- snr: result.snr,
972
- msg: result.msg,
973
- sync: cand.sync,
974
- });
1054
+ seenMessages.add(one.msg);
1055
+ decoded.push(one);
975
1056
  }
976
1057
  return decoded;
977
1058
  }
978
- function sync8(dd, nfa, nfb, syncmin, maxcand) {
979
- const JZ = 62;
980
- // Fortran uses NFFT1=3840 for the spectrogram FFT; we need a power of 2
981
- const fftSize = nextPow2(NFFT1); // 4096
982
- const halfSize = fftSize / 2; // 2048
983
- const tstep = NSTEP / SAMPLE_RATE$1;
984
- const df = SAMPLE_RATE$1 / fftSize;
985
- const fac = 1.0 / 300.0;
986
- // Compute symbol spectra, stepping by NSTEP
987
- const s = new Float64Array(halfSize * NHSYM);
988
- const savg = new Float64Array(halfSize);
989
- const xRe = new Float64Array(fftSize);
990
- const xIm = new Float64Array(fftSize);
991
- for (let j = 0; j < NHSYM; j++) {
992
- const ia = j * NSTEP;
993
- xRe.fill(0);
994
- xIm.fill(0);
995
- for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
996
- xRe[i] = fac * dd[ia + i];
997
- }
998
- fftComplex(xRe, xIm, false);
999
- for (let i = 0; i < halfSize; i++) {
1000
- const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
1001
- s[i * NHSYM + j] = power;
1002
- savg[i] = (savg[i] ?? 0) + power;
1003
- }
1059
+ function createDecodeWorkspace$1() {
1060
+ return {
1061
+ coarseRe: new Float64Array(NFFT2$1),
1062
+ coarseIm: new Float64Array(NFFT2$1),
1063
+ fineRe: new Float64Array(NFFT2$1),
1064
+ fineIm: new Float64Array(NFFT2$1),
1065
+ frameRe: new Float64Array(FRAME_LEN),
1066
+ frameIm: new Float64Array(FRAME_LEN),
1067
+ symbRe: new Float64Array(NSS),
1068
+ symbIm: new Float64Array(NSS),
1069
+ csRe: new Float64Array(4 * NN$1),
1070
+ csIm: new Float64Array(4 * NN$1),
1071
+ s4: new Float64Array(4 * NN$1),
1072
+ s2: new Float64Array(1 << 8),
1073
+ bitmetrics1: new Float64Array(BITMETRIC_LEN),
1074
+ bitmetrics2: new Float64Array(BITMETRIC_LEN),
1075
+ bitmetrics3: new Float64Array(BITMETRIC_LEN),
1076
+ llra: new Float64Array(LDPC_BITS),
1077
+ llrb: new Float64Array(LDPC_BITS),
1078
+ llrc: new Float64Array(LDPC_BITS),
1079
+ llr: new Float64Array(LDPC_BITS),
1080
+ apmask: new Int8Array(LDPC_BITS),
1081
+ };
1082
+ }
1083
+ function copySamplesToDecodeWindow$1(samples) {
1084
+ const out = new Float64Array(NMAX$1);
1085
+ const len = Math.min(samples.length, NMAX$1);
1086
+ for (let i = 0; i < len; i++)
1087
+ out[i] = samples[i];
1088
+ return out;
1089
+ }
1090
+ function decodeCandidate(candidate, cxRe, cxIm, depth, book, workspace) {
1091
+ ft4Downsample(cxRe, cxIm, candidate.freq, DOWNSAMPLE_CTX, workspace.coarseRe, workspace.coarseIm);
1092
+ normalizeComplexPower(workspace.coarseRe, workspace.coarseIm, NMAX$1 / NDOWN$1);
1093
+ for (let segment = 1; segment <= 3; segment++) {
1094
+ const coarse = findBestSyncLocation(workspace.coarseRe, workspace.coarseIm, segment);
1095
+ if (coarse.smax < SYNC_PASS_MIN)
1096
+ continue;
1097
+ const f1 = candidate.freq + coarse.idfbest;
1098
+ if (f1 <= 10 || f1 >= 4990)
1099
+ continue;
1100
+ ft4Downsample(cxRe, cxIm, f1, DOWNSAMPLE_CTX, workspace.fineRe, workspace.fineIm);
1101
+ normalizeComplexPower(workspace.fineRe, workspace.fineIm, NSS * NN$1);
1102
+ extractFrame(workspace.fineRe, workspace.fineIm, coarse.ibest, workspace.frameRe, workspace.frameIm);
1103
+ const badsync = buildBitMetrics$1(workspace.frameRe, workspace.frameIm, workspace);
1104
+ if (badsync)
1105
+ continue;
1106
+ if (!passesHardSyncQuality(workspace.bitmetrics1))
1107
+ continue;
1108
+ buildLlrs(workspace);
1109
+ const result = tryDecodePasses$1(workspace, depth);
1110
+ if (!result)
1111
+ continue;
1112
+ const message77Scrambled = result.message91.slice(0, 77);
1113
+ if (!hasNonZeroBit(message77Scrambled))
1114
+ continue;
1115
+ const message77 = xorWithScrambler(message77Scrambled);
1116
+ const { msg, success } = unpack77(message77, book);
1117
+ if (!success || msg.trim().length === 0)
1118
+ continue;
1119
+ return {
1120
+ freq: f1,
1121
+ dt: coarse.ibest / FS2$1 - 0.5,
1122
+ snr: toFt4Snr(candidate.sync - 1.0),
1123
+ msg,
1124
+ sync: coarse.smax,
1125
+ };
1004
1126
  }
1005
- // Compute baseline
1006
- const sbase = computeBaseline(savg, nfa, nfb, df, halfSize);
1007
- const ia = Math.max(1, Math.round(nfa / df));
1008
- const ib = Math.min(halfSize - 14, Math.round(nfb / df));
1009
- const nssy = Math.floor(NSPS / NSTEP);
1010
- const nfos = Math.round(SAMPLE_RATE$1 / NSPS / df); // ~2 bins per tone spacing
1011
- const jstrt = Math.round(0.5 / tstep);
1012
- // 2D sync correlation
1013
- const sync2d = new Float64Array((ib - ia + 1) * (2 * JZ + 1));
1014
- const width = 2 * JZ + 1;
1015
- for (let i = ia; i <= ib; i++) {
1016
- for (let jj = -JZ; jj <= JZ; jj++) {
1017
- let ta = 0, tb = 0, tc = 0;
1018
- let t0a = 0, t0b = 0, t0c = 0;
1019
- for (let n = 0; n < 7; n++) {
1020
- const m = jj + jstrt + nssy * n;
1021
- const iCostas = i + nfos * icos7[n];
1022
- if (m >= 0 && m < NHSYM && iCostas < halfSize) {
1023
- ta += s[iCostas * NHSYM + m];
1024
- for (let tone = 0; tone <= 6; tone++) {
1025
- const idx = i + nfos * tone;
1026
- if (idx < halfSize)
1027
- t0a += s[idx * NHSYM + m];
1028
- }
1029
- }
1030
- const m36 = m + nssy * 36;
1031
- if (m36 >= 0 && m36 < NHSYM && iCostas < halfSize) {
1032
- tb += s[iCostas * NHSYM + m36];
1033
- for (let tone = 0; tone <= 6; tone++) {
1034
- const idx = i + nfos * tone;
1035
- if (idx < halfSize)
1036
- t0b += s[idx * NHSYM + m36];
1037
- }
1038
- }
1039
- const m72 = m + nssy * 72;
1040
- if (m72 >= 0 && m72 < NHSYM && iCostas < halfSize) {
1041
- tc += s[iCostas * NHSYM + m72];
1042
- for (let tone = 0; tone <= 6; tone++) {
1043
- const idx = i + nfos * tone;
1044
- if (idx < halfSize)
1045
- t0c += s[idx * NHSYM + m72];
1046
- }
1047
- }
1127
+ return null;
1128
+ }
1129
+ function findBestSyncLocation(cdRe, cdIm, segment) {
1130
+ let ibest = -1;
1131
+ let idfbest = 0;
1132
+ let smax = -99;
1133
+ for (let isync = 1; isync <= 2; isync++) {
1134
+ let idfmin;
1135
+ let idfmax;
1136
+ let idfstp;
1137
+ let ibmin;
1138
+ let ibmax;
1139
+ let ibstp;
1140
+ if (isync === 1) {
1141
+ idfmin = -12;
1142
+ idfmax = 12;
1143
+ idfstp = 3;
1144
+ ibmin = -344;
1145
+ ibmax = 1012;
1146
+ if (segment === 1) {
1147
+ ibmin = 108;
1148
+ ibmax = 560;
1048
1149
  }
1049
- const t = ta + tb + tc;
1050
- const t0total = t0a + t0b + t0c;
1051
- const t0 = (t0total - t) / 6.0;
1052
- const syncVal = t0 > 0 ? t / t0 : 0;
1053
- const tbc = tb + tc;
1054
- const t0bc = t0b + t0c;
1055
- const t0bc2 = (t0bc - tbc) / 6.0;
1056
- const syncBc = t0bc2 > 0 ? tbc / t0bc2 : 0;
1057
- sync2d[(i - ia) * width + (jj + JZ)] = Math.max(syncVal, syncBc);
1058
- }
1059
- }
1060
- // Find peaks
1061
- const candidates0 = [];
1062
- const mlag = 10;
1063
- for (let i = ia; i <= ib; i++) {
1064
- let bestSync = -1;
1065
- let bestJ = 0;
1066
- for (let j = -mlag; j <= mlag; j++) {
1067
- const v = sync2d[(i - ia) * width + (j + JZ)];
1068
- if (v > bestSync) {
1069
- bestSync = v;
1070
- bestJ = j;
1150
+ else if (segment === 2) {
1151
+ ibmin = 560;
1152
+ ibmax = 1012;
1071
1153
  }
1072
- }
1073
- // Also check wider range
1074
- let bestSync2 = -1;
1075
- let bestJ2 = 0;
1076
- for (let j = -JZ; j <= JZ; j++) {
1077
- const v = sync2d[(i - ia) * width + (j + JZ)];
1078
- if (v > bestSync2) {
1079
- bestSync2 = v;
1080
- bestJ2 = j;
1154
+ else {
1155
+ ibmin = -344;
1156
+ ibmax = 108;
1081
1157
  }
1158
+ ibstp = 4;
1082
1159
  }
1083
- if (bestSync >= syncmin) {
1084
- candidates0.push({
1085
- freq: i * df,
1086
- dt: (bestJ - 0.5) * tstep,
1087
- sync: bestSync,
1088
- });
1089
- }
1090
- if (Math.abs(bestJ2 - bestJ) > 0 && bestSync2 >= syncmin) {
1091
- candidates0.push({
1092
- freq: i * df,
1093
- dt: (bestJ2 - 0.5) * tstep,
1094
- sync: bestSync2,
1095
- });
1096
- }
1097
- }
1098
- // Compute baseline normalization for sync values
1099
- const syncValues = candidates0.map((c) => c.sync);
1100
- syncValues.sort((a, b) => a - b);
1101
- const pctileIdx = Math.max(0, Math.round(0.4 * syncValues.length) - 1);
1102
- const base = syncValues[pctileIdx] ?? 1;
1103
- if (base > 0) {
1104
- for (const c of candidates0)
1105
- c.sync /= base;
1106
- }
1107
- // Remove near-duplicate candidates
1108
- for (let i = 0; i < candidates0.length; i++) {
1109
- for (let j = 0; j < i; j++) {
1110
- const fdiff = Math.abs(candidates0[i].freq - candidates0[j].freq);
1111
- const tdiff = Math.abs(candidates0[i].dt - candidates0[j].dt);
1112
- if (fdiff < 4.0 && tdiff < 0.04) {
1113
- if (candidates0[i].sync >= candidates0[j].sync) {
1114
- candidates0[j].sync = 0;
1115
- }
1116
- else {
1117
- candidates0[i].sync = 0;
1160
+ else {
1161
+ idfmin = idfbest - 4;
1162
+ idfmax = idfbest + 4;
1163
+ idfstp = 1;
1164
+ ibmin = ibest - 5;
1165
+ ibmax = ibest + 5;
1166
+ ibstp = 1;
1167
+ }
1168
+ for (let idf = idfmin; idf <= idfmax; idf += idfstp) {
1169
+ const templates = TWEAKED_SYNC_TEMPLATES.get(idf);
1170
+ if (!templates)
1171
+ continue;
1172
+ for (let istart = ibmin; istart <= ibmax; istart += ibstp) {
1173
+ const sync = sync4d(cdRe, cdIm, istart, templates);
1174
+ if (sync > smax) {
1175
+ smax = sync;
1176
+ ibest = istart;
1177
+ idfbest = idf;
1118
1178
  }
1119
1179
  }
1120
1180
  }
1121
1181
  }
1122
- // Sort by sync descending, take top maxcand
1123
- const filtered = candidates0.filter((c) => c.sync >= syncmin);
1124
- filtered.sort((a, b) => b.sync - a.sync);
1125
- return { candidates: filtered.slice(0, maxcand), sbase };
1182
+ return { ibest, idfbest, smax };
1126
1183
  }
1127
- function computeBaseline(savg, nfa, nfb, df, nh1) {
1128
- const sbase = new Float64Array(nh1);
1129
- const ia = Math.max(1, Math.round(nfa / df));
1130
- const ib = Math.min(nh1 - 1, Math.round(nfb / df));
1131
- // Smooth the spectrum to get baseline
1132
- const window = 50; // bins
1133
- for (let i = 0; i < nh1; i++) {
1184
+ function getCandidates4(dd, freqLow, freqHigh, syncMin, maxCandidates) {
1185
+ const df = SAMPLE_RATE$1 / NFFT1$1;
1186
+ const fac = 1 / 300;
1187
+ const savg = new Float64Array(NH1);
1188
+ const s = new Float64Array(NH1 * NHSYM$1);
1189
+ const savsm = new Float64Array(NH1);
1190
+ const xRe = new Float64Array(NFFT1$1);
1191
+ const xIm = new Float64Array(NFFT1$1);
1192
+ for (let j = 0; j < NHSYM$1; j++) {
1193
+ const ia = j * NSPS$1;
1194
+ const ib = ia + NFFT1$1;
1195
+ if (ib > NMAX$1)
1196
+ break;
1197
+ xIm.fill(0);
1198
+ for (let i = 0; i < NFFT1$1; i++)
1199
+ xRe[i] = fac * dd[ia + i] * NUTTALL_WINDOW[i];
1200
+ fftComplex(xRe, xIm, false);
1201
+ for (let bin = 1; bin <= NH1; bin++) {
1202
+ const idx = bin - 1;
1203
+ const re = xRe[bin] ?? 0;
1204
+ const im = xIm[bin] ?? 0;
1205
+ const power = re * re + im * im;
1206
+ s[idx * NHSYM$1 + j] = power;
1207
+ savg[idx] = (savg[idx] ?? 0) + power;
1208
+ }
1209
+ }
1210
+ for (let i = 0; i < NH1; i++)
1211
+ savg[i] = (savg[i] ?? 0) / NHSYM$1;
1212
+ for (let i = 7; i < NH1 - 7; i++) {
1134
1213
  let sum = 0;
1135
- let count = 0;
1136
- const lo = Math.max(ia, i - window);
1137
- const hi = Math.min(ib, i + window);
1138
- for (let j = lo; j <= hi; j++) {
1214
+ for (let j = i - 7; j <= i + 7; j++)
1139
1215
  sum += savg[j];
1140
- count++;
1216
+ savsm[i] = sum / 15;
1217
+ }
1218
+ let nfa = Math.round(freqLow / df);
1219
+ if (nfa < Math.round(200 / df))
1220
+ nfa = Math.round(200 / df);
1221
+ let nfb = Math.round(freqHigh / df);
1222
+ if (nfb > Math.round(MAX_FREQ / df))
1223
+ nfb = Math.round(MAX_FREQ / df);
1224
+ const sbase = ft4Baseline(savg, nfa, nfb, df);
1225
+ for (let bin = nfa; bin <= nfb; bin++) {
1226
+ if ((sbase[bin - 1] ?? 0) <= 0)
1227
+ return [];
1228
+ }
1229
+ for (let bin = nfa; bin <= nfb; bin++) {
1230
+ const idx = bin - 1;
1231
+ savsm[idx] = (savsm[idx] ?? 0) / sbase[idx];
1232
+ }
1233
+ const fOffset = (-1.5 * SAMPLE_RATE$1) / NSPS$1;
1234
+ const candidates = [];
1235
+ for (let i = nfa + 1; i <= nfb - 1; i++) {
1236
+ const left = savsm[i - 2] ?? 0;
1237
+ const center = savsm[i - 1] ?? 0;
1238
+ const right = savsm[i] ?? 0;
1239
+ if (center >= left && center >= right && center >= syncMin) {
1240
+ const den = left - 2 * center + right;
1241
+ const del = den !== 0 ? (0.5 * (left - right)) / den : 0;
1242
+ const fpeak = (i + del) * df + fOffset;
1243
+ if (fpeak < 200 || fpeak > MAX_FREQ)
1244
+ continue;
1245
+ const speak = center - 0.25 * (left - right) * del;
1246
+ candidates.push({ freq: fpeak, sync: speak });
1141
1247
  }
1142
- sbase[i] = count > 0 ? 10 * Math.log10(Math.max(1e-30, sum / count)) : 0;
1143
1248
  }
1144
- return sbase;
1249
+ candidates.sort((a, b) => b.sync - a.sync);
1250
+ return candidates.slice(0, maxCandidates);
1145
1251
  }
1146
- function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
1147
- const NFFT2 = 3200;
1148
- const NP2 = 2812;
1149
- const fs2 = SAMPLE_RATE$1 / NDOWN;
1150
- const dt2 = 1.0 / fs2;
1151
- const twopi = 2 * Math.PI;
1152
- // Downsample: mix to baseband and filter
1153
- const cd0Re = new Float64Array(NFFT2);
1154
- const cd0Im = new Float64Array(NFFT2);
1155
- ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1156
- // Find best time offset
1157
- const i0 = Math.round((xdt + 0.5) * fs2);
1158
- let smax = 0;
1159
- let ibest = i0;
1160
- for (let idt = i0 - 10; idt <= i0 + 10; idt++) {
1161
- const sync = sync8d(cd0Re, cd0Im, idt, null, null, false);
1162
- if (sync > smax) {
1163
- smax = sync;
1164
- ibest = idt;
1165
- }
1252
+ function makeNuttallWindow(n) {
1253
+ const out = new Float64Array(n);
1254
+ const a0 = 0.3635819;
1255
+ const a1 = -0.4891775;
1256
+ const a2 = 0.1365995;
1257
+ const a3 = -0.0106411;
1258
+ for (let i = 0; i < n; i++) {
1259
+ out[i] =
1260
+ a0 +
1261
+ a1 * Math.cos((2 * Math.PI * i) / n) +
1262
+ a2 * Math.cos((4 * Math.PI * i) / n) +
1263
+ a3 * Math.cos((6 * Math.PI * i) / n);
1166
1264
  }
1167
- // Fine frequency search
1168
- smax = 0;
1169
- let delfbest = 0;
1170
- for (let ifr = -5; ifr <= 5; ifr++) {
1171
- const delf = ifr * 0.5;
1172
- const dphi = twopi * delf * dt2;
1173
- const twkRe = new Float64Array(32);
1174
- const twkIm = new Float64Array(32);
1175
- let phi = 0;
1176
- for (let i = 0; i < 32; i++) {
1177
- twkRe[i] = Math.cos(phi);
1178
- twkIm[i] = Math.sin(phi);
1179
- phi = (phi + dphi) % twopi;
1180
- }
1181
- const sync = sync8d(cd0Re, cd0Im, ibest, twkRe, twkIm, true);
1182
- if (sync > smax) {
1183
- smax = sync;
1184
- delfbest = delf;
1265
+ return out;
1266
+ }
1267
+ function ft4Baseline(savg, nfa, nfb, df) {
1268
+ const sbase = new Float64Array(NH1);
1269
+ sbase.fill(1);
1270
+ const ia = Math.max(Math.round(200 / df), nfa);
1271
+ const ib = Math.min(NH1, nfb);
1272
+ if (ib <= ia)
1273
+ return sbase;
1274
+ const sDb = new Float64Array(NH1);
1275
+ for (let i = ia; i <= ib; i++)
1276
+ sDb[i - 1] = 10 * Math.log10(Math.max(1e-30, savg[i - 1]));
1277
+ const nseg = 10;
1278
+ const npct = 10;
1279
+ const nlen = Math.max(1, Math.trunc((ib - ia + 1) / nseg));
1280
+ const i0 = Math.trunc((ib - ia + 1) / 2);
1281
+ const x = [];
1282
+ const y = [];
1283
+ for (let seg = 0; seg < nseg; seg++) {
1284
+ const ja = ia + seg * nlen;
1285
+ if (ja > ib)
1286
+ break;
1287
+ const jb = Math.min(ib, ja + nlen - 1);
1288
+ const vals = [];
1289
+ for (let i = ja; i <= jb; i++)
1290
+ vals.push(sDb[i - 1]);
1291
+ const base = percentile(vals, npct);
1292
+ for (let i = ja; i <= jb; i++) {
1293
+ const v = sDb[i - 1];
1294
+ if (v <= base) {
1295
+ x.push(i - i0);
1296
+ y.push(v);
1297
+ }
1185
1298
  }
1186
1299
  }
1187
- // Apply frequency correction and re-downsample
1188
- f1 += delfbest;
1189
- ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1190
- // Refine time offset
1191
- const ss = new Float64Array(9);
1192
- for (let idt = -4; idt <= 4; idt++) {
1193
- ss[idt + 4] = sync8d(cd0Re, cd0Im, ibest + idt, null, null, false);
1194
- }
1195
- let maxss = -1;
1196
- let maxIdx = 4;
1197
- for (let i = 0; i < 9; i++) {
1198
- if (ss[i] > maxss) {
1199
- maxss = ss[i];
1200
- maxIdx = i;
1300
+ const coeff = x.length >= 5 ? polyfitLeastSquares(x, y, 4) : null;
1301
+ if (coeff) {
1302
+ for (let i = ia; i <= ib; i++) {
1303
+ const t = i - i0;
1304
+ const db = coeff[0] + t * (coeff[1] + t * (coeff[2] + t * (coeff[3] + t * coeff[4]))) + 0.65;
1305
+ sbase[i - 1] = 10 ** (db / 10);
1201
1306
  }
1202
1307
  }
1203
- ibest = ibest + maxIdx - 4;
1204
- xdt = (ibest - 1) * dt2;
1205
- // Extract 8-tone soft symbols for each of NN=79 symbols
1206
- const s8 = new Float64Array(8 * NN);
1207
- const csRe = new Float64Array(8 * NN);
1208
- const csIm = new Float64Array(8 * NN);
1209
- const symbRe = new Float64Array(32);
1210
- const symbIm = new Float64Array(32);
1211
- for (let k = 0; k < NN; k++) {
1212
- const i1 = ibest + k * 32;
1213
- symbRe.fill(0);
1214
- symbIm.fill(0);
1215
- if (i1 >= 0 && i1 + 31 < NP2) {
1216
- for (let j = 0; j < 32; j++) {
1217
- symbRe[j] = cd0Re[i1 + j];
1218
- symbIm[j] = cd0Im[i1 + j];
1308
+ else {
1309
+ const halfWindow = 25;
1310
+ for (let i = ia; i <= ib; i++) {
1311
+ const lo = Math.max(ia, i - halfWindow);
1312
+ const hi = Math.min(ib, i + halfWindow);
1313
+ let sum = 0;
1314
+ let count = 0;
1315
+ for (let j = lo; j <= hi; j++) {
1316
+ sum += savg[j - 1];
1317
+ count++;
1219
1318
  }
1220
- }
1221
- fftComplex(symbRe, symbIm, false);
1222
- for (let tone = 0; tone < 8; tone++) {
1223
- const re = symbRe[tone] / 1000;
1224
- const im = symbIm[tone] / 1000;
1225
- csRe[tone * NN + k] = re;
1226
- csIm[tone * NN + k] = im;
1227
- s8[tone * NN + k] = Math.sqrt(re * re + im * im);
1319
+ sbase[i - 1] = count > 0 ? sum / count : 1;
1228
1320
  }
1229
1321
  }
1230
- // Sync quality check
1231
- let nsync = 0;
1232
- for (let k = 0; k < 7; k++) {
1233
- for (const offset of [0, 36, 72]) {
1234
- let maxTone = 0;
1235
- let maxVal = -1;
1236
- for (let t = 0; t < 8; t++) {
1237
- const v = s8[t * NN + k + offset];
1238
- if (v > maxVal) {
1239
- maxVal = v;
1240
- maxTone = t;
1241
- }
1322
+ return sbase;
1323
+ }
1324
+ function percentile(values, pct) {
1325
+ if (values.length === 0)
1326
+ return 0;
1327
+ const sorted = [...values].sort((a, b) => a - b);
1328
+ const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((pct / 100) * (sorted.length - 1))));
1329
+ return sorted[idx];
1330
+ }
1331
+ function polyfitLeastSquares(x, y, degree) {
1332
+ const n = degree + 1;
1333
+ const mat = Array.from({ length: n }, () => new Float64Array(n + 1));
1334
+ const xPows = new Float64Array(2 * degree + 1);
1335
+ for (let p = 0; p <= 2 * degree; p++) {
1336
+ let sum = 0;
1337
+ for (let i = 0; i < x.length; i++)
1338
+ sum += x[i] ** p;
1339
+ xPows[p] = sum;
1340
+ }
1341
+ for (let row = 0; row < n; row++) {
1342
+ for (let col = 0; col < n; col++)
1343
+ mat[row][col] = xPows[row + col];
1344
+ let rhs = 0;
1345
+ for (let i = 0; i < x.length; i++)
1346
+ rhs += y[i] * x[i] ** row;
1347
+ mat[row][n] = rhs;
1348
+ }
1349
+ for (let col = 0; col < n; col++) {
1350
+ let pivot = col;
1351
+ let maxAbs = Math.abs(mat[col][col]);
1352
+ for (let row = col + 1; row < n; row++) {
1353
+ const a = Math.abs(mat[row][col]);
1354
+ if (a > maxAbs) {
1355
+ maxAbs = a;
1356
+ pivot = row;
1242
1357
  }
1243
- if (maxTone === icos7[k])
1244
- nsync++;
1245
1358
  }
1246
- }
1247
- if (nsync <= 6)
1248
- return null;
1249
- // Compute soft bit metrics for multiple nsym values (1, 2, 3)
1250
- // and a normalized version, matching the Fortran ft8b passes 1-4
1251
- const bmeta = new Float64Array(N_LDPC); // nsym=1
1252
- const bmetb = new Float64Array(N_LDPC); // nsym=2
1253
- const bmetc = new Float64Array(N_LDPC); // nsym=3
1254
- const bmetd = new Float64Array(N_LDPC); // nsym=1 normalized
1255
- for (let nsym = 1; nsym <= 3; nsym++) {
1256
- const nt = 1 << (3 * nsym); // 8, 64, 512
1257
- const ibmax = nsym === 1 ? 2 : nsym === 2 ? 5 : 8;
1258
- for (let ihalf = 1; ihalf <= 2; ihalf++) {
1259
- for (let k = 1; k <= 29; k += nsym) {
1260
- const ks = ihalf === 1 ? k + 7 : k + 43;
1261
- const s2 = new Float64Array(nt);
1262
- for (let i = 0; i < nt; i++) {
1263
- const i1 = Math.floor(i / 64);
1264
- const i2 = Math.floor((i & 63) / 8);
1265
- const i3 = i & 7;
1266
- if (nsym === 1) {
1267
- const re = csRe[graymap[i3] * NN + ks - 1];
1268
- const im = csIm[graymap[i3] * NN + ks - 1];
1269
- s2[i] = Math.sqrt(re * re + im * im);
1270
- }
1271
- else if (nsym === 2) {
1272
- const sRe = csRe[graymap[i2] * NN + ks - 1] + csRe[graymap[i3] * NN + ks];
1273
- const sIm = csIm[graymap[i2] * NN + ks - 1] + csIm[graymap[i3] * NN + ks];
1274
- s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
1275
- }
1276
- else {
1277
- const sRe = csRe[graymap[i1] * NN + ks - 1] +
1278
- csRe[graymap[i2] * NN + ks] +
1279
- csRe[graymap[i3] * NN + ks + 1];
1280
- const sIm = csIm[graymap[i1] * NN + ks - 1] +
1281
- csIm[graymap[i2] * NN + ks] +
1282
- csIm[graymap[i3] * NN + ks + 1];
1283
- s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
1284
- }
1285
- }
1286
- // Fortran: i32 = 1 + (k-1)*3 + (ihalf-1)*87 (1-based)
1287
- const i32 = 1 + (k - 1) * 3 + (ihalf - 1) * 87;
1288
- for (let ib = 0; ib <= ibmax; ib++) {
1289
- // max of s2 where bit (ibmax-ib) of index is 1
1290
- let max1 = -1e30, max0 = -1e30;
1291
- for (let i = 0; i < nt; i++) {
1292
- const bitSet = (i & (1 << (ibmax - ib))) !== 0;
1293
- if (bitSet) {
1294
- if (s2[i] > max1)
1295
- max1 = s2[i];
1296
- }
1297
- else {
1298
- if (s2[i] > max0)
1299
- max0 = s2[i];
1300
- }
1301
- }
1302
- const idx = i32 + ib - 1; // Convert to 0-based
1303
- if (idx >= 0 && idx < N_LDPC) {
1304
- const bm = max1 - max0;
1305
- if (nsym === 1) {
1306
- bmeta[idx] = bm;
1307
- const den = Math.max(max1, max0);
1308
- bmetd[idx] = den > 0 ? bm / den : 0;
1309
- }
1310
- else if (nsym === 2) {
1311
- bmetb[idx] = bm;
1312
- }
1313
- else {
1314
- bmetc[idx] = bm;
1315
- }
1316
- }
1317
- }
1318
- }
1359
+ if (maxAbs < 1e-12)
1360
+ return null;
1361
+ if (pivot !== col) {
1362
+ const tmp = mat[col];
1363
+ mat[col] = mat[pivot];
1364
+ mat[pivot] = tmp;
1365
+ }
1366
+ const pivotVal = mat[col][col];
1367
+ for (let c = col; c <= n; c++)
1368
+ mat[col][c] = mat[col][c] / pivotVal;
1369
+ for (let row = 0; row < n; row++) {
1370
+ if (row === col)
1371
+ continue;
1372
+ const factor = mat[row][col];
1373
+ if (factor === 0)
1374
+ continue;
1375
+ for (let c = col; c <= n; c++)
1376
+ mat[row][c] = mat[row][c] - factor * mat[col][c];
1319
1377
  }
1320
1378
  }
1321
- normalizeBmet(bmeta);
1322
- normalizeBmet(bmetb);
1323
- normalizeBmet(bmetc);
1324
- normalizeBmet(bmetd);
1325
- const bmetrics = [bmeta, bmetb, bmetc, bmetd];
1326
- const scalefac = 2.83;
1327
- const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
1328
- const apmask = new Int8Array(N_LDPC);
1329
- // Try 4 passes with different soft-symbol metrics (matching Fortran)
1330
- let result = null;
1331
- for (let ipass = 0; ipass < 4; ipass++) {
1332
- const llr = new Float64Array(N_LDPC);
1333
- for (let i = 0; i < N_LDPC; i++)
1334
- llr[i] = scalefac * bmetrics[ipass][i];
1335
- result = decode174_91(llr, apmask, maxosd);
1336
- if (result && result.nharderrors >= 0 && result.nharderrors <= 36)
1337
- break;
1338
- result = null;
1379
+ const coeff = new Array(n);
1380
+ for (let i = 0; i < n; i++)
1381
+ coeff[i] = mat[i][n];
1382
+ return coeff;
1383
+ }
1384
+ function createDownsampleContext() {
1385
+ const df = SAMPLE_RATE$1 / NMAX$1;
1386
+ const baud = SAMPLE_RATE$1 / NSPS$1;
1387
+ const bwTransition = 0.5 * baud;
1388
+ const bwFlat = 4 * baud;
1389
+ const iwt = Math.max(1, Math.trunc(bwTransition / df));
1390
+ const iwf = Math.max(1, Math.trunc(bwFlat / df));
1391
+ const iws = Math.trunc(baud / df);
1392
+ const raw = new Float64Array(NFFT2$1);
1393
+ for (let i = 0; i < iwt && i < raw.length; i++) {
1394
+ raw[i] = 0.5 * (1 + Math.cos((Math.PI * (iwt - 1 - i)) / iwt));
1395
+ }
1396
+ for (let i = iwt; i < iwt + iwf && i < raw.length; i++)
1397
+ raw[i] = 1;
1398
+ for (let i = iwt + iwf; i < 2 * iwt + iwf && i < raw.length; i++) {
1399
+ raw[i] = 0.5 * (1 + Math.cos((Math.PI * (i - (iwt + iwf))) / iwt));
1400
+ }
1401
+ const window = new Float64Array(NFFT2$1);
1402
+ for (let i = 0; i < NFFT2$1; i++) {
1403
+ const src = (i + iws) % NFFT2$1;
1404
+ window[i] = raw[src];
1405
+ }
1406
+ return { df, window };
1407
+ }
1408
+ function ft4Downsample(cxRe, cxIm, f0, ctx, outRe, outIm) {
1409
+ outRe.fill(0);
1410
+ outIm.fill(0);
1411
+ const i0 = Math.round(f0 / ctx.df);
1412
+ if (i0 >= 0 && i0 <= NMAX$1 / 2) {
1413
+ outRe[0] = cxRe[i0] ?? 0;
1414
+ outIm[0] = cxIm[i0] ?? 0;
1415
+ }
1416
+ for (let i = 1; i <= NFFT2$1 / 2; i++) {
1417
+ const hi = i0 + i;
1418
+ if (hi >= 0 && hi <= NMAX$1 / 2) {
1419
+ outRe[i] = cxRe[hi] ?? 0;
1420
+ outIm[i] = cxIm[hi] ?? 0;
1421
+ }
1422
+ const lo = i0 - i;
1423
+ if (lo >= 0 && lo <= NMAX$1 / 2) {
1424
+ const idx = NFFT2$1 - i;
1425
+ outRe[idx] = cxRe[lo] ?? 0;
1426
+ outIm[idx] = cxIm[lo] ?? 0;
1427
+ }
1428
+ }
1429
+ const scale = 1 / NFFT2$1;
1430
+ for (let i = 0; i < NFFT2$1; i++) {
1431
+ const w = (ctx.window[i] ?? 0) * scale;
1432
+ outRe[i] = outRe[i] * w;
1433
+ outIm[i] = outIm[i] * w;
1434
+ }
1435
+ fftComplex(outRe, outIm, true);
1436
+ }
1437
+ function normalizeComplexPower(re, im, denom) {
1438
+ let sum = 0;
1439
+ for (let i = 0; i < re.length; i++)
1440
+ sum += re[i] * re[i] + im[i] * im[i];
1441
+ if (sum <= 0)
1442
+ return;
1443
+ const scale = 1 / Math.sqrt(sum / denom);
1444
+ for (let i = 0; i < re.length; i++) {
1445
+ re[i] = re[i] * scale;
1446
+ im[i] = im[i] * scale;
1339
1447
  }
1340
- if (!result || result.nharderrors < 0 || result.nharderrors > 36)
1341
- return null;
1342
- // Check for all-zero codeword
1343
- if (result.cw.every((b) => b === 0))
1344
- return null;
1345
- const message77 = result.message91.slice(0, 77);
1346
- // Validate message type
1347
- const n3v = (message77[71] << 2) | (message77[72] << 1) | message77[73];
1348
- const i3v = (message77[74] << 2) | (message77[75] << 1) | message77[76];
1349
- if (i3v > 5 || (i3v === 0 && n3v > 6))
1350
- return null;
1351
- if (i3v === 0 && n3v === 2)
1352
- return null;
1353
- // Unpack
1354
- const { msg, success } = unpack77(message77, book);
1355
- if (!success || msg.trim().length === 0)
1356
- return null;
1357
- // Estimate SNR
1358
- let xsig = 0;
1359
- let xnoi = 0;
1360
- const itone = getTones$1(result.cw);
1361
- for (let i = 0; i < 79; i++) {
1362
- xsig += s8[itone[i] * NN + i] ** 2;
1363
- const ios = (itone[i] + 4) % 7;
1364
- xnoi += s8[ios * NN + i] ** 2;
1448
+ }
1449
+ function extractFrame(cbRe, cbIm, ibest, outRe, outIm) {
1450
+ for (let i = 0; i < outRe.length; i++) {
1451
+ const src = ibest + i;
1452
+ if (src >= 0 && src < cbRe.length) {
1453
+ outRe[i] = cbRe[src];
1454
+ outIm[i] = cbIm[src];
1455
+ }
1456
+ else {
1457
+ outRe[i] = 0;
1458
+ outIm[i] = 0;
1459
+ }
1365
1460
  }
1366
- let snr = 0.001;
1367
- const arg = xsig / Math.max(xnoi, 1e-30) - 1.0;
1368
- if (arg > 0.1)
1369
- snr = arg;
1370
- snr = 10 * Math.log10(snr) - 27.0;
1371
- if (snr < -24)
1372
- snr = -24;
1373
- return { msg, freq: f1, dt: xdt, snr };
1374
1461
  }
1375
- function getTones$1(cw) {
1376
- const tones = new Array(79).fill(0);
1377
- for (let i = 0; i < 7; i++)
1378
- tones[i] = icos7[i];
1379
- for (let i = 0; i < 7; i++)
1380
- tones[36 + i] = icos7[i];
1381
- for (let i = 0; i < 7; i++)
1382
- tones[72 + i] = icos7[i];
1383
- let k = 7;
1384
- for (let j = 1; j <= 58; j++) {
1385
- const i = (j - 1) * 3;
1386
- if (j === 30)
1387
- k += 7;
1388
- const indx = cw[i] * 4 + cw[i + 1] * 2 + cw[i + 2];
1389
- tones[k] = graymap[indx];
1390
- k++;
1462
+ function createTweakedSyncTemplates() {
1463
+ const base = createBaseSyncTemplates();
1464
+ const fsample = FS2$1 / 2;
1465
+ const out = new Map();
1466
+ for (let idf = -FT4_MAX_TWEAK; idf <= FT4_MAX_TWEAK; idf++) {
1467
+ const tweak = createFrequencyTweak(idf, 2 * NSS, fsample);
1468
+ out.set(idf, [
1469
+ applyTweak(base[0], tweak),
1470
+ applyTweak(base[1], tweak),
1471
+ applyTweak(base[2], tweak),
1472
+ applyTweak(base[3], tweak),
1473
+ ]);
1391
1474
  }
1392
- return tones;
1475
+ return out;
1393
1476
  }
1394
- /**
1395
- * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1396
- * Identical to Fortran ft8_downsample.
1397
- */
1398
- function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
1399
- const NFFT1 = 192000;
1400
- const NFFT2 = 3200;
1401
- const df = 12000.0 / NFFT1;
1402
- // NSPS is imported, should be 1920
1403
- const baud = 12000.0 / NSPS; // 6.25
1404
- const i0 = Math.round(f0 / df);
1405
- const ft = f0 + 8.5 * baud;
1406
- const it = Math.min(Math.round(ft / df), NFFT1 / 2);
1407
- const fb = f0 - 1.5 * baud;
1408
- const ib = Math.max(1, Math.round(fb / df));
1409
- c1Re.fill(0);
1410
- c1Im.fill(0);
1477
+ function createBaseSyncTemplates() {
1478
+ return [
1479
+ buildSyncTemplate(COSTAS_A),
1480
+ buildSyncTemplate(COSTAS_B),
1481
+ buildSyncTemplate(COSTAS_C),
1482
+ buildSyncTemplate(COSTAS_D),
1483
+ ];
1484
+ }
1485
+ function buildSyncTemplate(tones) {
1486
+ const re = new Float64Array(2 * NSS);
1487
+ const im = new Float64Array(2 * NSS);
1411
1488
  let k = 0;
1412
- for (let i = ib; i <= it; i++) {
1413
- if (k >= NFFT2)
1414
- break;
1415
- c1Re[k] = cxRe[i] ?? 0;
1416
- c1Im[k] = cxIm[i] ?? 0;
1417
- k++;
1418
- }
1419
- // Taper
1420
- const pi = Math.PI;
1421
- const taper = new Float64Array(101);
1422
- for (let i = 0; i <= 100; i++) {
1423
- taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
1424
- }
1425
- for (let i = 0; i <= 100; i++) {
1426
- if (i >= NFFT2)
1427
- break;
1428
- const tap = taper[100 - i];
1429
- c1Re[i] = c1Re[i] * tap;
1430
- c1Im[i] = c1Im[i] * tap;
1431
- }
1432
- const endTap = k - 1;
1433
- for (let i = 0; i <= 100; i++) {
1434
- const idx = endTap - 100 + i;
1435
- if (idx >= 0 && idx < NFFT2) {
1436
- const tap = taper[i];
1437
- c1Re[idx] = c1Re[idx] * tap;
1438
- c1Im[idx] = c1Im[idx] * tap;
1489
+ let phi = 0;
1490
+ for (const tone of tones) {
1491
+ const dphi = (TWO_PI$2 * tone * 2) / NSS;
1492
+ for (let j = 0; j < NSS / 2; j++) {
1493
+ re[k] = Math.cos(phi);
1494
+ im[k] = Math.sin(phi);
1495
+ phi = (phi + dphi) % TWO_PI$2;
1496
+ k++;
1439
1497
  }
1440
1498
  }
1441
- // CSHIFT
1442
- const shift = i0 - ib;
1443
- const tempRe = new Float64Array(NFFT2);
1444
- const tempIm = new Float64Array(NFFT2);
1445
- for (let i = 0; i < NFFT2; i++) {
1446
- let srcIdx = (i + shift) % NFFT2;
1447
- if (srcIdx < 0)
1448
- srcIdx += NFFT2;
1449
- tempRe[i] = c1Re[srcIdx];
1450
- tempIm[i] = c1Im[srcIdx];
1451
- }
1452
- for (let i = 0; i < NFFT2; i++) {
1453
- c1Re[i] = tempRe[i];
1454
- c1Im[i] = tempIm[i];
1455
- }
1456
- // iFFT
1457
- fftComplex(c1Re, c1Im, true);
1458
- // Scale
1459
- // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
1460
- const scale = Math.sqrt(NFFT2 / NFFT1);
1461
- for (let i = 0; i < NFFT2; i++) {
1462
- c1Re[i] = c1Re[i] * scale;
1463
- c1Im[i] = c1Im[i] * scale;
1499
+ return { re, im };
1500
+ }
1501
+ function createFrequencyTweak(idf, npts, fsample) {
1502
+ const re = new Float64Array(npts);
1503
+ const im = new Float64Array(npts);
1504
+ const dphi = (TWO_PI$2 * idf) / fsample;
1505
+ const stepRe = Math.cos(dphi);
1506
+ const stepIm = Math.sin(dphi);
1507
+ let wRe = 1;
1508
+ let wIm = 0;
1509
+ for (let i = 0; i < npts; i++) {
1510
+ const newRe = wRe * stepRe - wIm * stepIm;
1511
+ const newIm = wRe * stepIm + wIm * stepRe;
1512
+ wRe = newRe;
1513
+ wIm = newIm;
1514
+ re[i] = wRe;
1515
+ im[i] = wIm;
1516
+ }
1517
+ return { re, im };
1518
+ }
1519
+ function applyTweak(template, tweak) {
1520
+ const re = new Float64Array(template.re.length);
1521
+ const im = new Float64Array(template.im.length);
1522
+ for (let i = 0; i < template.re.length; i++) {
1523
+ const sr = template.re[i];
1524
+ const si = template.im[i];
1525
+ const tr = tweak.re[i];
1526
+ const ti = tweak.im[i];
1527
+ re[i] = tr * sr - ti * si;
1528
+ im[i] = tr * si + ti * sr;
1529
+ }
1530
+ return { re, im };
1531
+ }
1532
+ function sync4d(cdRe, cdIm, i0, templates) {
1533
+ let sync = 0;
1534
+ for (let i = 0; i < COSTAS_BLOCKS$1; i++) {
1535
+ const start = i0 + i * FT4_SYNC_STRIDE;
1536
+ const z = correlateStride2(cdRe, cdIm, start, templates[i].re, templates[i].im);
1537
+ if (z.count <= 16)
1538
+ continue;
1539
+ sync += Math.hypot(z.re, z.im) / (2 * NSS);
1464
1540
  }
1541
+ return sync;
1465
1542
  }
1466
- function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
1467
- const NP2 = 2812;
1468
- const twopi = 2 * Math.PI;
1469
- // Precompute Costas sync waveforms
1470
- const csyncRe = new Float64Array(7 * 32);
1471
- const csyncIm = new Float64Array(7 * 32);
1472
- for (let i = 0; i < 7; i++) {
1473
- let phi = 0;
1474
- const dphi = (twopi * icos7[i]) / 32;
1475
- for (let j = 0; j < 32; j++) {
1476
- csyncRe[i * 32 + j] = Math.cos(phi);
1477
- csyncIm[i * 32 + j] = Math.sin(phi);
1478
- phi = (phi + dphi) % twopi;
1543
+ function correlateStride2(cdRe, cdIm, start, templateRe, templateIm) {
1544
+ let zRe = 0;
1545
+ let zIm = 0;
1546
+ let count = 0;
1547
+ for (let i = 0; i < templateRe.length; i++) {
1548
+ const idx = start + 2 * i;
1549
+ if (idx < 0 || idx >= cdRe.length)
1550
+ continue;
1551
+ const sRe = templateRe[i];
1552
+ const sIm = templateIm[i];
1553
+ const dRe = cdRe[idx];
1554
+ const dIm = cdIm[idx];
1555
+ zRe += dRe * sRe + dIm * sIm;
1556
+ zIm += dIm * sRe - dRe * sIm;
1557
+ count++;
1558
+ }
1559
+ return { re: zRe, im: zIm, count };
1560
+ }
1561
+ function buildBitMetrics$1(cdRe, cdIm, workspace) {
1562
+ const { csRe, csIm, s4, symbRe, symbIm, bitmetrics1, bitmetrics2, bitmetrics3, s2 } = workspace;
1563
+ for (let k = 0; k < NN$1; k++) {
1564
+ const i1 = k * NSS;
1565
+ for (let i = 0; i < NSS; i++) {
1566
+ symbRe[i] = cdRe[i1 + i];
1567
+ symbIm[i] = cdIm[i1 + i];
1568
+ }
1569
+ fftComplex(symbRe, symbIm, false);
1570
+ for (let tone = 0; tone < 4; tone++) {
1571
+ const idx = tone * NN$1 + k;
1572
+ const re = symbRe[tone];
1573
+ const im = symbIm[tone];
1574
+ csRe[idx] = re;
1575
+ csIm[idx] = im;
1576
+ s4[idx] = Math.hypot(re, im);
1479
1577
  }
1480
1578
  }
1481
- let sync = 0;
1482
- for (let i = 0; i < 7; i++) {
1483
- const i1 = i0 + i * 32;
1484
- const i2 = i1 + 36 * 32;
1485
- const i3 = i1 + 72 * 32;
1486
- for (const iStart of [i1, i2, i3]) {
1487
- let zRe = 0, zIm = 0;
1488
- if (iStart >= 0 && iStart + 31 < NP2) {
1489
- for (let j = 0; j < 32; j++) {
1490
- let sRe = csyncRe[i * 32 + j];
1491
- let sIm = csyncIm[i * 32 + j];
1492
- if (useTwk && twkRe && twkIm) {
1493
- const tRe = twkRe[j] * sRe - twkIm[j] * sIm;
1494
- const tIm = twkRe[j] * sIm + twkIm[j] * sRe;
1495
- sRe = tRe;
1496
- sIm = tIm;
1579
+ let nsync = 0;
1580
+ for (let k = 0; k < 4; k++) {
1581
+ if (maxTone(s4, k) === COSTAS_A[k])
1582
+ nsync++;
1583
+ if (maxTone(s4, 33 + k) === COSTAS_B[k])
1584
+ nsync++;
1585
+ if (maxTone(s4, 66 + k) === COSTAS_C[k])
1586
+ nsync++;
1587
+ if (maxTone(s4, 99 + k) === COSTAS_D[k])
1588
+ nsync++;
1589
+ }
1590
+ bitmetrics1.fill(0);
1591
+ bitmetrics2.fill(0);
1592
+ bitmetrics3.fill(0);
1593
+ if (nsync < 6)
1594
+ return true;
1595
+ for (let nseq = 1; nseq <= 3; nseq++) {
1596
+ const nsym = nseq === 1 ? 1 : nseq === 2 ? 2 : 4;
1597
+ const nt = 1 << (2 * nsym);
1598
+ const ibmax = nseq === 1 ? 1 : nseq === 2 ? 3 : 7;
1599
+ for (let ks = 1; ks <= NN$1 - nsym + 1; ks += nsym) {
1600
+ for (let i = 0; i < nt; i++) {
1601
+ const i1 = Math.floor(i / 64);
1602
+ const i2 = Math.floor((i & 63) / 16);
1603
+ const i3 = Math.floor((i & 15) / 4);
1604
+ const i4 = i & 3;
1605
+ if (nsym === 1) {
1606
+ const t = GRAYMAP[i4];
1607
+ const idx = t * NN$1 + (ks - 1);
1608
+ s2[i] = Math.hypot(csRe[idx], csIm[idx]);
1609
+ }
1610
+ else if (nsym === 2) {
1611
+ const t3 = GRAYMAP[i3];
1612
+ const t4 = GRAYMAP[i4];
1613
+ const iA = t3 * NN$1 + (ks - 1);
1614
+ const iB = t4 * NN$1 + ks;
1615
+ const re = csRe[iA] + csRe[iB];
1616
+ const im = csIm[iA] + csIm[iB];
1617
+ s2[i] = Math.hypot(re, im);
1618
+ }
1619
+ else {
1620
+ const t1 = GRAYMAP[i1];
1621
+ const t2 = GRAYMAP[i2];
1622
+ const t3 = GRAYMAP[i3];
1623
+ const t4 = GRAYMAP[i4];
1624
+ const iA = t1 * NN$1 + (ks - 1);
1625
+ const iB = t2 * NN$1 + ks;
1626
+ const iC = t3 * NN$1 + (ks + 1);
1627
+ const iD = t4 * NN$1 + (ks + 2);
1628
+ const re = csRe[iA] + csRe[iB] + csRe[iC] + csRe[iD];
1629
+ const im = csIm[iA] + csIm[iB] + csIm[iC] + csIm[iD];
1630
+ s2[i] = Math.hypot(re, im);
1631
+ }
1632
+ }
1633
+ const ipt = 1 + (ks - 1) * 2;
1634
+ for (let ib = 0; ib <= ibmax; ib++) {
1635
+ const mask = 1 << (ibmax - ib);
1636
+ let max1 = -1e30;
1637
+ let max0 = -1e30;
1638
+ for (let i = 0; i < nt; i++) {
1639
+ const v = s2[i];
1640
+ if ((i & mask) !== 0) {
1641
+ if (v > max1)
1642
+ max1 = v;
1497
1643
  }
1498
- // Conjugate multiply: cd0 * conj(csync)
1499
- const dRe = cd0Re[iStart + j];
1500
- const dIm = cd0Im[iStart + j];
1501
- zRe += dRe * sRe + dIm * sIm;
1502
- zIm += dIm * sRe - dRe * sIm;
1644
+ else if (v > max0) {
1645
+ max0 = v;
1646
+ }
1647
+ }
1648
+ const idx = ipt + ib;
1649
+ if (idx > BITMETRIC_LEN)
1650
+ continue;
1651
+ const bm = max1 - max0;
1652
+ if (nseq === 1) {
1653
+ bitmetrics1[idx - 1] = bm;
1654
+ }
1655
+ else if (nseq === 2) {
1656
+ bitmetrics2[idx - 1] = bm;
1657
+ }
1658
+ else {
1659
+ bitmetrics3[idx - 1] = bm;
1503
1660
  }
1504
1661
  }
1505
- sync += zRe * zRe + zIm * zIm;
1506
1662
  }
1507
1663
  }
1508
- return sync;
1664
+ bitmetrics2[208] = bitmetrics1[208];
1665
+ bitmetrics2[209] = bitmetrics1[209];
1666
+ bitmetrics3[208] = bitmetrics1[208];
1667
+ bitmetrics3[209] = bitmetrics1[209];
1668
+ normalizeBitMetrics(bitmetrics1);
1669
+ normalizeBitMetrics(bitmetrics2);
1670
+ normalizeBitMetrics(bitmetrics3);
1671
+ return false;
1509
1672
  }
1510
- function normalizeBmet(bmet) {
1511
- const n = bmet.length;
1512
- let sum = 0, sum2 = 0;
1513
- for (let i = 0; i < n; i++) {
1673
+ function maxTone(s4, symbolIndex) {
1674
+ let bestTone = 0;
1675
+ let bestValue = -1;
1676
+ for (let tone = 0; tone < 4; tone++) {
1677
+ const v = s4[tone * NN$1 + symbolIndex];
1678
+ if (v > bestValue) {
1679
+ bestValue = v;
1680
+ bestTone = tone;
1681
+ }
1682
+ }
1683
+ return bestTone;
1684
+ }
1685
+ function normalizeBitMetrics(bmet) {
1686
+ let sum = 0;
1687
+ let sum2 = 0;
1688
+ for (let i = 0; i < bmet.length; i++) {
1514
1689
  sum += bmet[i];
1515
1690
  sum2 += bmet[i] * bmet[i];
1516
1691
  }
1517
- const avg = sum / n;
1518
- const avg2 = sum2 / n;
1692
+ const avg = sum / bmet.length;
1693
+ const avg2 = sum2 / bmet.length;
1519
1694
  const variance = avg2 - avg * avg;
1520
1695
  const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
1521
- if (sigma > 0) {
1522
- for (let i = 0; i < n; i++)
1523
- bmet[i] = bmet[i] / sigma;
1696
+ if (sigma <= 0)
1697
+ return;
1698
+ for (let i = 0; i < bmet.length; i++)
1699
+ bmet[i] = bmet[i] / sigma;
1700
+ }
1701
+ function passesHardSyncQuality(bitmetrics1) {
1702
+ const hard = new Uint8Array(bitmetrics1.length);
1703
+ for (let i = 0; i < bitmetrics1.length; i++)
1704
+ hard[i] = bitmetrics1[i] >= 0 ? 1 : 0;
1705
+ let score = 0;
1706
+ for (const pattern of HARD_SYNC_PATTERNS) {
1707
+ for (let i = 0; i < pattern.bits.length; i++) {
1708
+ if (hard[pattern.offset + i] === pattern.bits[i])
1709
+ score++;
1710
+ }
1711
+ }
1712
+ return score >= 10;
1713
+ }
1714
+ function buildLlrs(workspace) {
1715
+ const { bitmetrics1, bitmetrics2, bitmetrics3, llra, llrb, llrc } = workspace;
1716
+ for (let i = 0; i < 58; i++) {
1717
+ llra[i] = bitmetrics1[8 + i];
1718
+ llra[58 + i] = bitmetrics1[74 + i];
1719
+ llra[116 + i] = bitmetrics1[140 + i];
1720
+ llrb[i] = bitmetrics2[8 + i];
1721
+ llrb[58 + i] = bitmetrics2[74 + i];
1722
+ llrb[116 + i] = bitmetrics2[140 + i];
1723
+ llrc[i] = bitmetrics3[8 + i];
1724
+ llrc[58 + i] = bitmetrics3[74 + i];
1725
+ llrc[116 + i] = bitmetrics3[140 + i];
1524
1726
  }
1525
1727
  }
1526
- function resample(input, fromRate, toRate, outLen) {
1728
+ function tryDecodePasses$1(workspace, depth) {
1729
+ const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
1730
+ const scalefac = 2.83;
1731
+ const sources = [workspace.llra, workspace.llrb, workspace.llrc];
1732
+ workspace.apmask.fill(0);
1733
+ for (const src of sources) {
1734
+ for (let i = 0; i < LDPC_BITS; i++)
1735
+ workspace.llr[i] = scalefac * src[i];
1736
+ const result = decode174_91(workspace.llr, workspace.apmask, maxosd);
1737
+ if (result)
1738
+ return result;
1739
+ }
1740
+ return null;
1741
+ }
1742
+ function hasNonZeroBit(bits) {
1743
+ for (const bit of bits) {
1744
+ if (bit !== 0)
1745
+ return true;
1746
+ }
1747
+ return false;
1748
+ }
1749
+ function toFt4Snr(syncMinusOne) {
1750
+ if (syncMinusOne > 0) {
1751
+ return Math.round(Math.max(-21, 10 * Math.log10(syncMinusOne) - 14.8));
1752
+ }
1753
+ return -21;
1754
+ }
1755
+ function resample$1(input, fromRate, toRate, outLen) {
1527
1756
  const out = new Float64Array(outLen);
1528
1757
  const ratio = fromRate / toRate;
1529
1758
  for (let i = 0; i < outLen; i++) {
@@ -2046,10 +2275,10 @@ function packFreeText(msg) {
2046
2275
  return bits; // 77 bits
2047
2276
  }
2048
2277
 
2049
- const TWO_PI = 2 * Math.PI;
2050
- const DEFAULT_SAMPLE_RATE = 12_000;
2051
- const DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
2052
- const DEFAULT_BT = 2.0;
2278
+ const TWO_PI$1 = 2 * Math.PI;
2279
+ const FT8_DEFAULT_SAMPLE_RATE = 12_000;
2280
+ const FT8_DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
2281
+ const FT8_DEFAULT_BT = 2.0;
2053
2282
  const MODULATION_INDEX = 1.0;
2054
2283
  function assertPositiveFinite(value, name) {
2055
2284
  if (!Number.isFinite(value) || value <= 0) {
@@ -2072,33 +2301,36 @@ function gfskPulse(bt, tt) {
2072
2301
  const scale = Math.PI * Math.sqrt(2 / Math.log(2)) * bt;
2073
2302
  return 0.5 * (erfApprox(scale * (tt + 0.5)) - erfApprox(scale * (tt - 0.5)));
2074
2303
  }
2075
- function generateFT8Waveform(tones, options = {}) {
2076
- // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
2304
+ function generateGfskWaveform(tones, options, defaults, shape) {
2077
2305
  const nsym = tones.length;
2078
2306
  if (nsym === 0) {
2079
2307
  return new Float32Array(0);
2080
2308
  }
2081
- const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
2082
- const nsps = options.samplesPerSymbol ?? DEFAULT_SAMPLES_PER_SYMBOL;
2083
- const bt = options.bt ?? DEFAULT_BT;
2309
+ const sampleRate = options.sampleRate ?? defaults.sampleRate;
2310
+ const nsps = options.samplesPerSymbol ?? defaults.samplesPerSymbol;
2311
+ const bt = options.bt ?? defaults.bt;
2084
2312
  const f0 = options.baseFrequency ?? 0;
2313
+ const initialPhase = options.initialPhase ?? 0;
2085
2314
  assertPositiveFinite(sampleRate, "sampleRate");
2086
2315
  assertPositiveFinite(nsps, "samplesPerSymbol");
2087
2316
  assertPositiveFinite(bt, "bt");
2088
2317
  if (!Number.isFinite(f0)) {
2089
2318
  throw new Error("baseFrequency must be finite");
2090
2319
  }
2320
+ if (!Number.isFinite(initialPhase)) {
2321
+ throw new Error("initialPhase must be finite");
2322
+ }
2091
2323
  if (!Number.isInteger(nsps)) {
2092
2324
  throw new Error("samplesPerSymbol must be an integer");
2093
2325
  }
2094
- const nwave = nsym * nsps;
2326
+ const nwave = (nsym) * nsps;
2095
2327
  const pulse = new Float64Array(3 * nsps);
2096
2328
  for (let i = 0; i < pulse.length; i++) {
2097
2329
  const tt = (i + 1 - 1.5 * nsps) / nsps;
2098
2330
  pulse[i] = gfskPulse(bt, tt);
2099
2331
  }
2100
2332
  const dphi = new Float64Array((nsym + 2) * nsps);
2101
- const dphiPeak = (TWO_PI * MODULATION_INDEX) / nsps;
2333
+ const dphiPeak = (TWO_PI$1 * MODULATION_INDEX) / nsps;
2102
2334
  for (let j = 0; j < nsym; j++) {
2103
2335
  const tone = tones[j];
2104
2336
  const ib = j * nsps;
@@ -2113,33 +2345,52 @@ function generateFT8Waveform(tones, options = {}) {
2113
2345
  dphi[i] += dphiPeak * firstTone * pulse[nsps + i];
2114
2346
  dphi[tailBase + i] += dphiPeak * lastTone * pulse[i];
2115
2347
  }
2116
- const carrierDphi = (TWO_PI * f0) / sampleRate;
2348
+ const carrierDphi = (TWO_PI$1 * f0) / sampleRate;
2117
2349
  for (let i = 0; i < dphi.length; i++) {
2118
2350
  dphi[i] += carrierDphi;
2119
2351
  }
2120
2352
  const wave = new Float32Array(nwave);
2121
- let phi = 0;
2353
+ let phi = initialPhase % TWO_PI$1;
2354
+ if (phi < 0)
2355
+ phi += TWO_PI$1;
2356
+ const phaseStart = nsps;
2122
2357
  for (let k = 0; k < nwave; k++) {
2123
- const j = nsps + k; // skip the leading dummy symbol
2358
+ const j = phaseStart + k;
2124
2359
  wave[k] = Math.sin(phi);
2125
2360
  phi += dphi[j];
2126
- phi %= TWO_PI;
2361
+ phi %= TWO_PI$1;
2127
2362
  if (phi < 0) {
2128
- phi += TWO_PI;
2363
+ phi += TWO_PI$1;
2129
2364
  }
2130
2365
  }
2131
- const nramp = Math.round(nsps / 8);
2132
- for (let i = 0; i < nramp; i++) {
2133
- const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
2134
- wave[i] *= up;
2135
- }
2136
- const tailStart = nwave - nramp;
2137
- for (let i = 0; i < nramp; i++) {
2138
- const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
2139
- wave[tailStart + i] *= down;
2366
+ {
2367
+ const nramp = Math.round(nsps / 8);
2368
+ for (let i = 0; i < nramp; i++) {
2369
+ const up = (1 - Math.cos((TWO_PI$1 * i) / (2 * nramp))) / 2;
2370
+ wave[i] *= up;
2371
+ }
2372
+ const tailStart = nwave - nramp;
2373
+ for (let i = 0; i < nramp; i++) {
2374
+ const down = (1 + Math.cos((TWO_PI$1 * i) / (2 * nramp))) / 2;
2375
+ wave[tailStart + i] *= down;
2376
+ }
2140
2377
  }
2141
2378
  return wave;
2142
2379
  }
2380
+ function generateFT8Waveform(tones, options = {}) {
2381
+ // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
2382
+ return generateGfskWaveform(tones, options, {
2383
+ sampleRate: FT8_DEFAULT_SAMPLE_RATE,
2384
+ samplesPerSymbol: FT8_DEFAULT_SAMPLES_PER_SYMBOL,
2385
+ bt: FT8_DEFAULT_BT,
2386
+ });
2387
+ }
2388
+
2389
+ /** FT8-specific constants (lib/ft8/ft8_params.f90). */
2390
+ /** 7-symbol Costas array for sync. */
2391
+ const COSTAS = [3, 1, 4, 0, 6, 5, 2];
2392
+ /** 8-tone Gray mapping. */
2393
+ const GRAY_MAP = [0, 1, 3, 2, 5, 6, 4, 7];
2143
2394
 
2144
2395
  function generateLdpcGMatrix() {
2145
2396
  const K = 91;
@@ -2190,21 +2441,21 @@ function encode174_91(msg77) {
2190
2441
  }
2191
2442
  return codeword;
2192
2443
  }
2193
- function getTones(codeword) {
2444
+ function getTones$1(codeword) {
2194
2445
  const tones = new Array(79).fill(0);
2195
2446
  for (let i = 0; i < 7; i++)
2196
- tones[i] = icos7[i];
2447
+ tones[i] = COSTAS[i];
2197
2448
  for (let i = 0; i < 7; i++)
2198
- tones[36 + i] = icos7[i];
2449
+ tones[36 + i] = COSTAS[i];
2199
2450
  for (let i = 0; i < 7; i++)
2200
- tones[72 + i] = icos7[i];
2451
+ tones[72 + i] = COSTAS[i];
2201
2452
  let k = 7;
2202
2453
  for (let j = 1; j <= 58; j++) {
2203
2454
  const i = j * 3 - 3; // codeword is 0-indexed in JS, but the loop was j=1 to 58
2204
2455
  if (j === 30)
2205
2456
  k += 7;
2206
2457
  const indx = codeword[i] * 4 + codeword[i + 1] * 2 + codeword[i + 2];
2207
- tones[k] = graymap[indx];
2458
+ tones[k] = GRAY_MAP[indx];
2208
2459
  k++;
2209
2460
  }
2210
2461
  return tones;
@@ -2212,54 +2463,830 @@ function getTones(codeword) {
2212
2463
  function encodeMessage(msg) {
2213
2464
  const bits77 = pack77(msg);
2214
2465
  const codeword = encode174_91(bits77);
2215
- return getTones(codeword);
2466
+ return getTones$1(codeword);
2216
2467
  }
2217
2468
  function encode(msg, options = {}) {
2218
2469
  return generateFT8Waveform(encodeMessage(msg), options);
2219
2470
  }
2220
2471
 
2221
- /// <reference types="node" />
2472
+ const NSPS = 1920;
2473
+ const NFFT1 = 2 * NSPS; // 3840
2474
+ const NSTEP = NSPS / 4; // 480
2475
+ const NMAX = 15 * 12_000; // 180000
2476
+ const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
2477
+ const NDOWN = 60;
2478
+ const NN = 79;
2479
+ const NFFT1_LONG = 192000;
2480
+ const NFFT2 = 3200;
2481
+ const NP2 = 2812;
2482
+ const COSTAS_BLOCKS = 7;
2483
+ const COSTAS_SYMBOL_LEN = 32;
2484
+ const SYNC_TIME_SHIFTS = [0, 36, 72];
2485
+ const TAPER_SIZE = 101;
2486
+ const TAPER_LAST = TAPER_SIZE - 1;
2487
+ const TWO_PI = 2 * Math.PI;
2488
+ const MAX_DECODE_PASSES_DEPTH3 = 2;
2489
+ const SUBTRACTION_GAIN = 0.95;
2490
+ const SUBTRACTION_PHASE_SHIFT = Math.PI / 2;
2491
+ const MIN_SUBTRACTION_SNR = -22;
2492
+ const FS2 = SAMPLE_RATE$1 / NDOWN;
2493
+ const DT2 = 1.0 / FS2;
2494
+ const DOWNSAMPLE_DF = SAMPLE_RATE$1 / NFFT1_LONG;
2495
+ const DOWNSAMPLE_BAUD = SAMPLE_RATE$1 / NSPS;
2496
+ const DOWNSAMPLE_SCALE = Math.sqrt(NFFT2 / NFFT1_LONG);
2497
+ const TAPER = buildTaper(TAPER_SIZE);
2498
+ const COSTAS_SYNC = buildCostasSyncTemplates();
2499
+ const FREQ_SHIFT_SYNC = buildFrequencyShiftSyncTemplates();
2222
2500
  /**
2223
- * Parse a WAV file buffer into sample rate and normalized float samples.
2224
- * Supports PCM format 1, 8/16/32-bit samples.
2501
+ * Decode all FT8 signals in an audio buffer.
2502
+ * Input: mono audio samples at `sampleRate` Hz, duration ~15s.
2225
2503
  */
2226
- function parseWavBuffer(buf) {
2227
- if (buf.length < 44)
2228
- throw new Error("File too small for WAV");
2229
- const riff = buf.toString("ascii", 0, 4);
2230
- const wave = buf.toString("ascii", 8, 12);
2231
- if (riff !== "RIFF" || wave !== "WAVE")
2232
- throw new Error("Not a WAV file");
2233
- let offset = 12;
2234
- let fmtFound = false;
2235
- let sampleRate = 0;
2236
- let bitsPerSample = 0;
2237
- let numChannels = 1;
2238
- let audioFormat = 0;
2239
- let dataOffset = 0;
2240
- let dataSize = 0;
2241
- while (offset < buf.length - 8) {
2242
- const chunkId = buf.toString("ascii", offset, offset + 4);
2243
- const chunkSize = buf.readUInt32LE(offset + 4);
2244
- offset += 8;
2245
- if (chunkId === "fmt ") {
2246
- audioFormat = buf.readUInt16LE(offset);
2247
- numChannels = buf.readUInt16LE(offset + 2);
2248
- sampleRate = buf.readUInt32LE(offset + 4);
2249
- bitsPerSample = buf.readUInt16LE(offset + 14);
2250
- fmtFound = true;
2251
- }
2252
- else if (chunkId === "data") {
2253
- dataOffset = offset;
2254
- dataSize = chunkSize;
2255
- break;
2256
- }
2257
- offset += chunkSize;
2258
- }
2259
- if (!fmtFound)
2260
- throw new Error("No fmt chunk found");
2261
- if (audioFormat !== 1)
2262
- throw new Error(`Unsupported audio format: ${audioFormat} (only PCM=1)`);
2504
+ function decode(samples, options = {}) {
2505
+ const sampleRate = options.sampleRate ?? SAMPLE_RATE$1;
2506
+ const nfa = options.freqLow ?? 200;
2507
+ const nfb = options.freqHigh ?? 3000;
2508
+ const syncmin = options.syncMin ?? 1.2;
2509
+ const depth = options.depth ?? 2;
2510
+ const maxCandidates = options.maxCandidates ?? 300;
2511
+ const book = options.hashCallBook;
2512
+ const dd = sampleRate === SAMPLE_RATE$1
2513
+ ? copySamplesToDecodeWindow(samples)
2514
+ : resample(samples, sampleRate, SAMPLE_RATE$1, NMAX);
2515
+ const residual = new Float64Array(dd);
2516
+ const cxRe = new Float64Array(NFFT1_LONG);
2517
+ const cxIm = new Float64Array(NFFT1_LONG);
2518
+ const workspace = createDecodeWorkspace();
2519
+ const toneCache = new Map();
2520
+ const decoded = [];
2521
+ const seenMessages = new Set();
2522
+ const maxPasses = depth >= 3 ? MAX_DECODE_PASSES_DEPTH3 : 1;
2523
+ for (let pass = 0; pass < maxPasses; pass++) {
2524
+ cxRe.fill(0);
2525
+ cxIm.fill(0);
2526
+ cxRe.set(residual);
2527
+ fftComplex(cxRe, cxIm, false);
2528
+ const { candidates, sbase } = sync8(residual, nfa, nfb, syncmin, maxCandidates);
2529
+ const coarseFrequencyUses = countCandidateFrequencies(candidates);
2530
+ const coarseDownsampleCache = new Map();
2531
+ let decodedInPass = 0;
2532
+ for (const cand of candidates) {
2533
+ const result = ft8b(residual, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book, workspace, coarseDownsampleCache, coarseFrequencyUses);
2534
+ if (!result)
2535
+ continue;
2536
+ const messageKey = normalizeMessageKey(result.msg);
2537
+ if (seenMessages.has(messageKey))
2538
+ continue;
2539
+ seenMessages.add(messageKey);
2540
+ decoded.push({
2541
+ freq: result.freq,
2542
+ dt: result.dt - 0.5,
2543
+ snr: result.snr,
2544
+ msg: result.msg,
2545
+ sync: cand.sync,
2546
+ });
2547
+ decodedInPass++;
2548
+ if (pass + 1 < maxPasses) {
2549
+ subtractDecodedSignal(residual, result, toneCache);
2550
+ }
2551
+ }
2552
+ if (decodedInPass === 0)
2553
+ break;
2554
+ }
2555
+ return decoded;
2556
+ }
2557
+ function normalizeMessageKey(msg) {
2558
+ return msg.trim().replace(/\s+/g, " ").toUpperCase();
2559
+ }
2560
+ function countCandidateFrequencies(candidates) {
2561
+ const counts = new Map();
2562
+ for (const c of candidates) {
2563
+ counts.set(c.freq, (counts.get(c.freq) ?? 0) + 1);
2564
+ }
2565
+ return counts;
2566
+ }
2567
+ function createDecodeWorkspace() {
2568
+ return {
2569
+ cd0Re: new Float64Array(NFFT2),
2570
+ cd0Im: new Float64Array(NFFT2),
2571
+ shiftRe: new Float64Array(NFFT2),
2572
+ shiftIm: new Float64Array(NFFT2),
2573
+ s8: new Float64Array(8 * NN),
2574
+ csRe: new Float64Array(8 * NN),
2575
+ csIm: new Float64Array(8 * NN),
2576
+ symbRe: new Float64Array(COSTAS_SYMBOL_LEN),
2577
+ symbIm: new Float64Array(COSTAS_SYMBOL_LEN),
2578
+ s2: new Float64Array(1 << 9),
2579
+ bmeta: new Float64Array(N_LDPC),
2580
+ bmetb: new Float64Array(N_LDPC),
2581
+ bmetc: new Float64Array(N_LDPC),
2582
+ bmetd: new Float64Array(N_LDPC),
2583
+ llr: new Float64Array(N_LDPC),
2584
+ apmask: new Int8Array(N_LDPC),
2585
+ ss: new Float64Array(9),
2586
+ };
2587
+ }
2588
+ function copySamplesToDecodeWindow(samples) {
2589
+ const out = new Float64Array(NMAX);
2590
+ const len = Math.min(samples.length, NMAX);
2591
+ for (let i = 0; i < len; i++)
2592
+ out[i] = samples[i];
2593
+ return out;
2594
+ }
2595
+ function sync8(dd, nfa, nfb, syncmin, maxcand) {
2596
+ const JZ = 62;
2597
+ const fftSize = nextPow2(NFFT1); // 4096
2598
+ const halfSize = fftSize / 2;
2599
+ const tstep = NSTEP / SAMPLE_RATE$1;
2600
+ const df = SAMPLE_RATE$1 / fftSize;
2601
+ const fac = 1.0 / 300.0;
2602
+ const s = new Float64Array(halfSize * NHSYM);
2603
+ const savg = new Float64Array(halfSize);
2604
+ const xRe = new Float64Array(fftSize);
2605
+ const xIm = new Float64Array(fftSize);
2606
+ for (let j = 0; j < NHSYM; j++) {
2607
+ const ia = j * NSTEP;
2608
+ xRe.fill(0);
2609
+ xIm.fill(0);
2610
+ for (let i = 0; i < NSPS && ia + i < dd.length; i++)
2611
+ xRe[i] = fac * dd[ia + i];
2612
+ fftComplex(xRe, xIm, false);
2613
+ for (let i = 0; i < halfSize; i++) {
2614
+ const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
2615
+ s[i * NHSYM + j] = power;
2616
+ savg[i] = savg[i] + power;
2617
+ }
2618
+ }
2619
+ const sbase = computeBaseline(savg, nfa, nfb, df, halfSize);
2620
+ const ia = Math.max(1, Math.round(nfa / df));
2621
+ const ib = Math.min(halfSize - 14, Math.round(nfb / df));
2622
+ const nssy = Math.floor(NSPS / NSTEP);
2623
+ const nfos = Math.round(SAMPLE_RATE$1 / NSPS / df);
2624
+ const jstrt = Math.round(0.5 / tstep);
2625
+ const width = 2 * JZ + 1;
2626
+ const sync2d = new Float64Array((ib - ia + 1) * width);
2627
+ for (let i = ia; i <= ib; i++) {
2628
+ for (let jj = -JZ; jj <= JZ; jj++) {
2629
+ let ta = 0;
2630
+ let tb = 0;
2631
+ let tc = 0;
2632
+ let t0a = 0;
2633
+ let t0b = 0;
2634
+ let t0c = 0;
2635
+ for (let n = 0; n < COSTAS_BLOCKS; n++) {
2636
+ const m = jj + jstrt + nssy * n;
2637
+ const iCostas = i + nfos * COSTAS[n];
2638
+ if (m >= 0 && m < NHSYM && iCostas < halfSize) {
2639
+ ta += s[iCostas * NHSYM + m];
2640
+ for (let tone = 0; tone <= 6; tone++) {
2641
+ const idx = i + nfos * tone;
2642
+ if (idx < halfSize)
2643
+ t0a += s[idx * NHSYM + m];
2644
+ }
2645
+ }
2646
+ const m36 = m + nssy * 36;
2647
+ if (m36 >= 0 && m36 < NHSYM && iCostas < halfSize) {
2648
+ tb += s[iCostas * NHSYM + m36];
2649
+ for (let tone = 0; tone <= 6; tone++) {
2650
+ const idx = i + nfos * tone;
2651
+ if (idx < halfSize)
2652
+ t0b += s[idx * NHSYM + m36];
2653
+ }
2654
+ }
2655
+ const m72 = m + nssy * 72;
2656
+ if (m72 >= 0 && m72 < NHSYM && iCostas < halfSize) {
2657
+ tc += s[iCostas * NHSYM + m72];
2658
+ for (let tone = 0; tone <= 6; tone++) {
2659
+ const idx = i + nfos * tone;
2660
+ if (idx < halfSize)
2661
+ t0c += s[idx * NHSYM + m72];
2662
+ }
2663
+ }
2664
+ }
2665
+ const t = ta + tb + tc;
2666
+ const t0 = (t0a + t0b + t0c - t) / 6.0;
2667
+ const syncVal = t0 > 0 ? t / t0 : 0;
2668
+ const tbc = tb + tc;
2669
+ const t0bc = (t0b + t0c - tbc) / 6.0;
2670
+ const syncBc = t0bc > 0 ? tbc / t0bc : 0;
2671
+ sync2d[(i - ia) * width + (jj + JZ)] = Math.max(syncVal, syncBc);
2672
+ }
2673
+ }
2674
+ const candidates0 = [];
2675
+ const mlag = 10;
2676
+ for (let i = ia; i <= ib; i++) {
2677
+ let bestSync = -1;
2678
+ let bestJ = 0;
2679
+ for (let j = -mlag; j <= mlag; j++) {
2680
+ const v = sync2d[(i - ia) * width + (j + JZ)];
2681
+ if (v > bestSync) {
2682
+ bestSync = v;
2683
+ bestJ = j;
2684
+ }
2685
+ }
2686
+ let bestSync2 = -1;
2687
+ let bestJ2 = 0;
2688
+ for (let j = -JZ; j <= JZ; j++) {
2689
+ const v = sync2d[(i - ia) * width + (j + JZ)];
2690
+ if (v > bestSync2) {
2691
+ bestSync2 = v;
2692
+ bestJ2 = j;
2693
+ }
2694
+ }
2695
+ if (bestSync >= syncmin) {
2696
+ candidates0.push({
2697
+ freq: i * df,
2698
+ dt: (bestJ - 0.5) * tstep,
2699
+ sync: bestSync,
2700
+ });
2701
+ }
2702
+ if (bestJ2 !== bestJ && bestSync2 >= syncmin) {
2703
+ candidates0.push({
2704
+ freq: i * df,
2705
+ dt: (bestJ2 - 0.5) * tstep,
2706
+ sync: bestSync2,
2707
+ });
2708
+ }
2709
+ }
2710
+ const syncValues = candidates0.map((c) => c.sync);
2711
+ syncValues.sort((a, b) => a - b);
2712
+ const pctileIdx = Math.max(0, Math.round(0.4 * syncValues.length) - 1);
2713
+ const base = syncValues[pctileIdx] ?? 1;
2714
+ if (base > 0) {
2715
+ for (const c of candidates0)
2716
+ c.sync /= base;
2717
+ }
2718
+ for (let i = 0; i < candidates0.length; i++) {
2719
+ for (let j = 0; j < i; j++) {
2720
+ const fdiff = Math.abs(candidates0[i].freq - candidates0[j].freq);
2721
+ const tdiff = Math.abs(candidates0[i].dt - candidates0[j].dt);
2722
+ if (fdiff < 4.0 && tdiff < 0.04) {
2723
+ if (candidates0[i].sync >= candidates0[j].sync) {
2724
+ candidates0[j].sync = 0;
2725
+ }
2726
+ else {
2727
+ candidates0[i].sync = 0;
2728
+ }
2729
+ }
2730
+ }
2731
+ }
2732
+ const filtered = candidates0.filter((c) => c.sync >= syncmin);
2733
+ filtered.sort((a, b) => b.sync - a.sync);
2734
+ return { candidates: filtered.slice(0, maxcand), sbase };
2735
+ }
2736
+ function computeBaseline(savg, nfa, nfb, df, nh1) {
2737
+ const sbase = new Float64Array(nh1);
2738
+ const ia = Math.max(1, Math.round(nfa / df));
2739
+ const ib = Math.min(nh1 - 1, Math.round(nfb / df));
2740
+ const window = 50;
2741
+ for (let i = 0; i < nh1; i++) {
2742
+ let sum = 0;
2743
+ let count = 0;
2744
+ const lo = Math.max(ia, i - window);
2745
+ const hi = Math.min(ib, i + window);
2746
+ for (let j = lo; j <= hi; j++) {
2747
+ sum += savg[j];
2748
+ count++;
2749
+ }
2750
+ sbase[i] = count > 0 ? 10 * Math.log10(Math.max(1e-30, sum / count)) : 0;
2751
+ }
2752
+ return sbase;
2753
+ }
2754
+ function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book, workspace, coarseDownsampleCache, coarseFrequencyUses) {
2755
+ loadCoarseDownsample(cxRe, cxIm, f1, workspace, coarseDownsampleCache, coarseFrequencyUses);
2756
+ let ibest = findBestTimeOffset(workspace.cd0Re, workspace.cd0Im, xdt);
2757
+ const delfbest = findBestFrequencyShift(workspace.cd0Re, workspace.cd0Im, ibest);
2758
+ f1 += delfbest;
2759
+ ft8Downsample(cxRe, cxIm, f1, workspace);
2760
+ ibest = refineTimeOffset(workspace.cd0Re, workspace.cd0Im, ibest, workspace.ss);
2761
+ xdt = (ibest - 1) * DT2;
2762
+ extractSoftSymbols(workspace.cd0Re, workspace.cd0Im, ibest, workspace);
2763
+ const minCostasHits = depth >= 3 ? 6 : 7;
2764
+ if (!passesSyncGate(workspace.s8, minCostasHits))
2765
+ return null;
2766
+ buildBitMetrics(workspace);
2767
+ const result = tryDecodePasses(workspace, depth);
2768
+ if (!result)
2769
+ return null;
2770
+ if (result.cw.every((b) => b === 0))
2771
+ return null;
2772
+ const message77 = result.message91.slice(0, 77);
2773
+ if (!isValidMessageType(message77))
2774
+ return null;
2775
+ const { msg, success } = unpack77(message77, book);
2776
+ if (!success || msg.trim().length === 0)
2777
+ return null;
2778
+ const snr = estimateSnr(workspace.s8, result.cw);
2779
+ return { msg, freq: f1, dt: xdt, snr };
2780
+ }
2781
+ function loadCoarseDownsample(cxRe, cxIm, f0, workspace, coarseDownsampleCache, coarseFrequencyUses) {
2782
+ const cached = coarseDownsampleCache.get(f0);
2783
+ if (cached) {
2784
+ workspace.cd0Re.set(cached.re);
2785
+ workspace.cd0Im.set(cached.im);
2786
+ }
2787
+ else {
2788
+ ft8Downsample(cxRe, cxIm, f0, workspace);
2789
+ const uses = coarseFrequencyUses.get(f0) ?? 0;
2790
+ if (uses > 1) {
2791
+ coarseDownsampleCache.set(f0, {
2792
+ re: new Float64Array(workspace.cd0Re),
2793
+ im: new Float64Array(workspace.cd0Im),
2794
+ });
2795
+ }
2796
+ }
2797
+ const remaining = (coarseFrequencyUses.get(f0) ?? 1) - 1;
2798
+ if (remaining <= 0) {
2799
+ coarseFrequencyUses.delete(f0);
2800
+ coarseDownsampleCache.delete(f0);
2801
+ }
2802
+ else {
2803
+ coarseFrequencyUses.set(f0, remaining);
2804
+ }
2805
+ }
2806
+ function findBestTimeOffset(cd0Re, cd0Im, xdt) {
2807
+ const i0 = Math.round((xdt + 0.5) * FS2);
2808
+ let smax = 0;
2809
+ let ibest = i0;
2810
+ for (let idt = i0 - 10; idt <= i0 + 10; idt++) {
2811
+ const sync = sync8d(cd0Re, cd0Im, idt, COSTAS_SYNC.re, COSTAS_SYNC.im);
2812
+ if (sync > smax) {
2813
+ smax = sync;
2814
+ ibest = idt;
2815
+ }
2816
+ }
2817
+ return ibest;
2818
+ }
2819
+ function findBestFrequencyShift(cd0Re, cd0Im, ibest) {
2820
+ let smax = 0;
2821
+ let delfbest = 0;
2822
+ for (const tpl of FREQ_SHIFT_SYNC) {
2823
+ const sync = sync8d(cd0Re, cd0Im, ibest, tpl.re, tpl.im);
2824
+ if (sync > smax) {
2825
+ smax = sync;
2826
+ delfbest = tpl.delf;
2827
+ }
2828
+ }
2829
+ return delfbest;
2830
+ }
2831
+ function refineTimeOffset(cd0Re, cd0Im, ibest, ss) {
2832
+ for (let idt = -4; idt <= 4; idt++) {
2833
+ ss[idt + 4] = sync8d(cd0Re, cd0Im, ibest + idt, COSTAS_SYNC.re, COSTAS_SYNC.im);
2834
+ }
2835
+ let maxss = -1;
2836
+ let maxIdx = 4;
2837
+ for (let i = 0; i < 9; i++) {
2838
+ if (ss[i] > maxss) {
2839
+ maxss = ss[i];
2840
+ maxIdx = i;
2841
+ }
2842
+ }
2843
+ return ibest + maxIdx - 4;
2844
+ }
2845
+ function extractSoftSymbols(cd0Re, cd0Im, ibest, workspace) {
2846
+ const { s8, csRe, csIm, symbRe, symbIm } = workspace;
2847
+ for (let k = 0; k < NN; k++) {
2848
+ const i1 = ibest + k * COSTAS_SYMBOL_LEN;
2849
+ symbRe.fill(0);
2850
+ symbIm.fill(0);
2851
+ if (i1 >= 0 && i1 + COSTAS_SYMBOL_LEN - 1 < NP2) {
2852
+ for (let j = 0; j < COSTAS_SYMBOL_LEN; j++) {
2853
+ symbRe[j] = cd0Re[i1 + j];
2854
+ symbIm[j] = cd0Im[i1 + j];
2855
+ }
2856
+ }
2857
+ fftComplex(symbRe, symbIm, false);
2858
+ for (let tone = 0; tone < 8; tone++) {
2859
+ const re = symbRe[tone] / 1000;
2860
+ const im = symbIm[tone] / 1000;
2861
+ const idx = tone * NN + k;
2862
+ csRe[idx] = re;
2863
+ csIm[idx] = im;
2864
+ s8[idx] = Math.sqrt(re * re + im * im);
2865
+ }
2866
+ }
2867
+ }
2868
+ function passesSyncGate(s8, minCostasHits) {
2869
+ let nsync = 0;
2870
+ for (let k = 0; k < COSTAS_BLOCKS; k++) {
2871
+ for (const offset of SYNC_TIME_SHIFTS) {
2872
+ let maxTone = 0;
2873
+ let maxVal = -1;
2874
+ for (let t = 0; t < 8; t++) {
2875
+ const v = s8[t * NN + k + offset];
2876
+ if (v > maxVal) {
2877
+ maxVal = v;
2878
+ maxTone = t;
2879
+ }
2880
+ }
2881
+ if (maxTone === COSTAS[k])
2882
+ nsync++;
2883
+ }
2884
+ }
2885
+ return nsync >= minCostasHits;
2886
+ }
2887
+ function buildBitMetrics(workspace) {
2888
+ const { csRe, csIm, bmeta, bmetb, bmetc, bmetd, s2 } = workspace;
2889
+ bmeta.fill(0);
2890
+ bmetb.fill(0);
2891
+ bmetc.fill(0);
2892
+ bmetd.fill(0);
2893
+ for (let nsym = 1; nsym <= 3; nsym++) {
2894
+ const nt = 1 << (3 * nsym);
2895
+ const ibmax = nsym === 1 ? 2 : nsym === 2 ? 5 : 8;
2896
+ for (let ihalf = 1; ihalf <= 2; ihalf++) {
2897
+ for (let k = 1; k <= 29; k += nsym) {
2898
+ const ks = ihalf === 1 ? k + 7 : k + 43;
2899
+ for (let i = 0; i < nt; i++) {
2900
+ const i1 = Math.floor(i / 64);
2901
+ const i2 = Math.floor((i & 63) / 8);
2902
+ const i3 = i & 7;
2903
+ if (nsym === 1) {
2904
+ const re = csRe[GRAY_MAP[i3] * NN + ks - 1];
2905
+ const im = csIm[GRAY_MAP[i3] * NN + ks - 1];
2906
+ s2[i] = Math.sqrt(re * re + im * im);
2907
+ }
2908
+ else if (nsym === 2) {
2909
+ const sRe = csRe[GRAY_MAP[i2] * NN + ks - 1] + csRe[GRAY_MAP[i3] * NN + ks];
2910
+ const sIm = csIm[GRAY_MAP[i2] * NN + ks - 1] + csIm[GRAY_MAP[i3] * NN + ks];
2911
+ s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
2912
+ }
2913
+ else {
2914
+ const sRe = csRe[GRAY_MAP[i1] * NN + ks - 1] +
2915
+ csRe[GRAY_MAP[i2] * NN + ks] +
2916
+ csRe[GRAY_MAP[i3] * NN + ks + 1];
2917
+ const sIm = csIm[GRAY_MAP[i1] * NN + ks - 1] +
2918
+ csIm[GRAY_MAP[i2] * NN + ks] +
2919
+ csIm[GRAY_MAP[i3] * NN + ks + 1];
2920
+ s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
2921
+ }
2922
+ }
2923
+ const i32 = 1 + (k - 1) * 3 + (ihalf - 1) * 87;
2924
+ for (let ib = 0; ib <= ibmax; ib++) {
2925
+ let max1 = -1e30;
2926
+ let max0 = -1e30;
2927
+ for (let i = 0; i < nt; i++) {
2928
+ const bitSet = (i & (1 << (ibmax - ib))) !== 0;
2929
+ if (bitSet) {
2930
+ if (s2[i] > max1)
2931
+ max1 = s2[i];
2932
+ }
2933
+ else {
2934
+ if (s2[i] > max0)
2935
+ max0 = s2[i];
2936
+ }
2937
+ }
2938
+ const idx = i32 + ib - 1;
2939
+ if (idx < 0 || idx >= N_LDPC)
2940
+ continue;
2941
+ const bm = max1 - max0;
2942
+ if (nsym === 1) {
2943
+ bmeta[idx] = bm;
2944
+ const den = Math.max(max1, max0);
2945
+ bmetd[idx] = den > 0 ? bm / den : 0;
2946
+ }
2947
+ else if (nsym === 2) {
2948
+ bmetb[idx] = bm;
2949
+ }
2950
+ else {
2951
+ bmetc[idx] = bm;
2952
+ }
2953
+ }
2954
+ }
2955
+ }
2956
+ }
2957
+ normalizeBmet(bmeta);
2958
+ normalizeBmet(bmetb);
2959
+ normalizeBmet(bmetc);
2960
+ normalizeBmet(bmetd);
2961
+ }
2962
+ function tryDecodePasses(workspace, depth) {
2963
+ const scalefac = 2.83;
2964
+ const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
2965
+ const bmetrics = [workspace.bmeta, workspace.bmetb, workspace.bmetc, workspace.bmetd];
2966
+ workspace.apmask.fill(0);
2967
+ for (let ipass = 0; ipass < 4; ipass++) {
2968
+ const metric = bmetrics[ipass];
2969
+ for (let i = 0; i < N_LDPC; i++)
2970
+ workspace.llr[i] = scalefac * metric[i];
2971
+ const result = decode174_91(workspace.llr, workspace.apmask, maxosd);
2972
+ if (result && result.nharderrors >= 0 && result.nharderrors <= 36)
2973
+ return result;
2974
+ }
2975
+ return null;
2976
+ }
2977
+ function isValidMessageType(message77) {
2978
+ const n3v = (message77[71] << 2) | (message77[72] << 1) | message77[73];
2979
+ const i3v = (message77[74] << 2) | (message77[75] << 1) | message77[76];
2980
+ if (i3v > 5 || (i3v === 0 && n3v > 6))
2981
+ return false;
2982
+ if (i3v === 0 && n3v === 2)
2983
+ return false;
2984
+ return true;
2985
+ }
2986
+ function estimateSnr(s8, cw) {
2987
+ let xsig = 0;
2988
+ let xnoi = 0;
2989
+ const itone = getTones(cw);
2990
+ for (let i = 0; i < 79; i++) {
2991
+ xsig += s8[itone[i] * NN + i] ** 2;
2992
+ const ios = (itone[i] + 4) % 7;
2993
+ xnoi += s8[ios * NN + i] ** 2;
2994
+ }
2995
+ let snr = 0.001;
2996
+ const arg = xsig / Math.max(xnoi, 1e-30) - 1.0;
2997
+ if (arg > 0.1)
2998
+ snr = arg;
2999
+ snr = 10 * Math.log10(snr) - 27.0;
3000
+ return snr < -24 ? -24 : snr;
3001
+ }
3002
+ function getTones(cw) {
3003
+ const tones = new Array(79).fill(0);
3004
+ for (let i = 0; i < 7; i++)
3005
+ tones[i] = COSTAS[i];
3006
+ for (let i = 0; i < 7; i++)
3007
+ tones[36 + i] = COSTAS[i];
3008
+ for (let i = 0; i < 7; i++)
3009
+ tones[72 + i] = COSTAS[i];
3010
+ let k = 7;
3011
+ for (let j = 1; j <= 58; j++) {
3012
+ const i = (j - 1) * 3;
3013
+ if (j === 30)
3014
+ k += 7;
3015
+ const indx = cw[i] * 4 + cw[i + 1] * 2 + cw[i + 2];
3016
+ tones[k] = GRAY_MAP[indx];
3017
+ k++;
3018
+ }
3019
+ return tones;
3020
+ }
3021
+ /**
3022
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
3023
+ * Identical to Fortran ft8_downsample.
3024
+ */
3025
+ function ft8Downsample(cxRe, cxIm, f0, workspace) {
3026
+ const { cd0Re, cd0Im, shiftRe, shiftIm } = workspace;
3027
+ const df = DOWNSAMPLE_DF;
3028
+ const baud = DOWNSAMPLE_BAUD;
3029
+ const i0 = Math.round(f0 / df);
3030
+ const ft = f0 + 8.5 * baud;
3031
+ const it = Math.min(Math.round(ft / df), NFFT1_LONG / 2);
3032
+ const fb = f0 - 1.5 * baud;
3033
+ const ib = Math.max(1, Math.round(fb / df));
3034
+ cd0Re.fill(0);
3035
+ cd0Im.fill(0);
3036
+ let k = 0;
3037
+ for (let i = ib; i <= it; i++) {
3038
+ if (k >= NFFT2)
3039
+ break;
3040
+ cd0Re[k] = cxRe[i];
3041
+ cd0Im[k] = cxIm[i];
3042
+ k++;
3043
+ }
3044
+ for (let i = 0; i <= TAPER_LAST; i++) {
3045
+ if (i >= NFFT2)
3046
+ break;
3047
+ const tap = TAPER[TAPER_LAST - i];
3048
+ cd0Re[i] = cd0Re[i] * tap;
3049
+ cd0Im[i] = cd0Im[i] * tap;
3050
+ }
3051
+ const endTap = k - 1;
3052
+ for (let i = 0; i <= TAPER_LAST; i++) {
3053
+ const idx = endTap - TAPER_LAST + i;
3054
+ if (idx >= 0 && idx < NFFT2) {
3055
+ const tap = TAPER[i];
3056
+ cd0Re[idx] = cd0Re[idx] * tap;
3057
+ cd0Im[idx] = cd0Im[idx] * tap;
3058
+ }
3059
+ }
3060
+ const shift = i0 - ib;
3061
+ for (let i = 0; i < NFFT2; i++) {
3062
+ let srcIdx = (i + shift) % NFFT2;
3063
+ if (srcIdx < 0)
3064
+ srcIdx += NFFT2;
3065
+ shiftRe[i] = cd0Re[srcIdx];
3066
+ shiftIm[i] = cd0Im[srcIdx];
3067
+ }
3068
+ for (let i = 0; i < NFFT2; i++) {
3069
+ cd0Re[i] = shiftRe[i];
3070
+ cd0Im[i] = shiftIm[i];
3071
+ }
3072
+ fftComplex(cd0Re, cd0Im, true);
3073
+ for (let i = 0; i < NFFT2; i++) {
3074
+ cd0Re[i] = cd0Re[i] * DOWNSAMPLE_SCALE;
3075
+ cd0Im[i] = cd0Im[i] * DOWNSAMPLE_SCALE;
3076
+ }
3077
+ }
3078
+ function sync8d(cd0Re, cd0Im, i0, syncRe, syncIm) {
3079
+ let sync = 0;
3080
+ const stride = 36 * COSTAS_SYMBOL_LEN;
3081
+ for (let i = 0; i < COSTAS_BLOCKS; i++) {
3082
+ const base = i * COSTAS_SYMBOL_LEN;
3083
+ let iStart = i0 + i * COSTAS_SYMBOL_LEN;
3084
+ for (let block = 0; block < 3; block++, iStart += stride) {
3085
+ if (iStart < 0 || iStart + COSTAS_SYMBOL_LEN - 1 >= NP2)
3086
+ continue;
3087
+ let zRe = 0;
3088
+ let zIm = 0;
3089
+ for (let j = 0; j < COSTAS_SYMBOL_LEN; j++) {
3090
+ const sRe = syncRe[base + j];
3091
+ const sIm = syncIm[base + j];
3092
+ const dRe = cd0Re[iStart + j];
3093
+ const dIm = cd0Im[iStart + j];
3094
+ zRe += dRe * sRe + dIm * sIm;
3095
+ zIm += dIm * sRe - dRe * sIm;
3096
+ }
3097
+ sync += zRe * zRe + zIm * zIm;
3098
+ }
3099
+ }
3100
+ return sync;
3101
+ }
3102
+ function normalizeBmet(bmet) {
3103
+ const n = bmet.length;
3104
+ let sum = 0;
3105
+ let sum2 = 0;
3106
+ for (let i = 0; i < n; i++) {
3107
+ sum += bmet[i];
3108
+ sum2 += bmet[i] * bmet[i];
3109
+ }
3110
+ const avg = sum / n;
3111
+ const avg2 = sum2 / n;
3112
+ const variance = avg2 - avg * avg;
3113
+ const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
3114
+ if (sigma > 0) {
3115
+ for (let i = 0; i < n; i++)
3116
+ bmet[i] = bmet[i] / sigma;
3117
+ }
3118
+ }
3119
+ function resample(input, fromRate, toRate, outLen) {
3120
+ const out = new Float64Array(outLen);
3121
+ const ratio = fromRate / toRate;
3122
+ for (let i = 0; i < outLen; i++) {
3123
+ const srcIdx = i * ratio;
3124
+ const lo = Math.floor(srcIdx);
3125
+ const frac = srcIdx - lo;
3126
+ const v0 = lo < input.length ? (input[lo] ?? 0) : 0;
3127
+ const v1 = lo + 1 < input.length ? (input[lo + 1] ?? 0) : 0;
3128
+ out[i] = v0 * (1 - frac) + v1 * frac;
3129
+ }
3130
+ return out;
3131
+ }
3132
+ function subtractDecodedSignal(residual, result, toneCache) {
3133
+ if (result.snr < MIN_SUBTRACTION_SNR)
3134
+ return;
3135
+ const msgKey = normalizeMessageKey(result.msg);
3136
+ let tones = toneCache.get(msgKey);
3137
+ if (!tones) {
3138
+ try {
3139
+ tones = encodeMessage(result.msg);
3140
+ }
3141
+ catch {
3142
+ return;
3143
+ }
3144
+ toneCache.set(msgKey, tones);
3145
+ }
3146
+ const waveI = generateFT8Waveform(tones, {
3147
+ sampleRate: SAMPLE_RATE$1,
3148
+ samplesPerSymbol: NSPS,
3149
+ baseFrequency: result.freq,
3150
+ initialPhase: 0,
3151
+ });
3152
+ const waveQ = generateFT8Waveform(tones, {
3153
+ sampleRate: SAMPLE_RATE$1,
3154
+ samplesPerSymbol: NSPS,
3155
+ baseFrequency: result.freq,
3156
+ initialPhase: SUBTRACTION_PHASE_SHIFT,
3157
+ });
3158
+ const start = Math.round(result.dt * SAMPLE_RATE$1);
3159
+ let srcStart = start;
3160
+ let tplStart = 0;
3161
+ if (srcStart < 0) {
3162
+ tplStart = -srcStart;
3163
+ srcStart = 0;
3164
+ }
3165
+ const maxLen = Math.min(residual.length - srcStart, waveI.length - tplStart, waveQ.length - tplStart);
3166
+ if (maxLen <= 0)
3167
+ return;
3168
+ let sii = 0;
3169
+ let sqq = 0;
3170
+ let siq = 0;
3171
+ let sri = 0;
3172
+ let srq = 0;
3173
+ for (let i = 0; i < maxLen; i++) {
3174
+ const wi = waveI[tplStart + i];
3175
+ const wq = waveQ[tplStart + i];
3176
+ const rv = residual[srcStart + i];
3177
+ sii += wi * wi;
3178
+ sqq += wq * wq;
3179
+ siq += wi * wq;
3180
+ sri += rv * wi;
3181
+ srq += rv * wq;
3182
+ }
3183
+ const det = sii * sqq - siq * siq;
3184
+ if (det <= 1e-9)
3185
+ return;
3186
+ const ampI = (sri * sqq - srq * siq) / det;
3187
+ const ampQ = (srq * sii - sri * siq) / det;
3188
+ for (let i = 0; i < maxLen; i++) {
3189
+ const wi = waveI[tplStart + i];
3190
+ const wq = waveQ[tplStart + i];
3191
+ const idx = srcStart + i;
3192
+ residual[idx] = residual[idx] - SUBTRACTION_GAIN * (ampI * wi + ampQ * wq);
3193
+ }
3194
+ }
3195
+ function buildTaper(size) {
3196
+ const taper = new Float64Array(size);
3197
+ const last = size - 1;
3198
+ for (let i = 0; i < size; i++)
3199
+ taper[i] = 0.5 * (1.0 + Math.cos((i * Math.PI) / last));
3200
+ return taper;
3201
+ }
3202
+ function buildCostasSyncTemplates() {
3203
+ const re = new Float64Array(COSTAS_BLOCKS * COSTAS_SYMBOL_LEN);
3204
+ const im = new Float64Array(COSTAS_BLOCKS * COSTAS_SYMBOL_LEN);
3205
+ for (let i = 0; i < COSTAS_BLOCKS; i++) {
3206
+ let phi = 0;
3207
+ const dphi = (TWO_PI * COSTAS[i]) / COSTAS_SYMBOL_LEN;
3208
+ for (let j = 0; j < COSTAS_SYMBOL_LEN; j++) {
3209
+ re[i * COSTAS_SYMBOL_LEN + j] = Math.cos(phi);
3210
+ im[i * COSTAS_SYMBOL_LEN + j] = Math.sin(phi);
3211
+ phi = (phi + dphi) % TWO_PI;
3212
+ }
3213
+ }
3214
+ return { re, im };
3215
+ }
3216
+ function buildFrequencyShiftSyncTemplates() {
3217
+ const templates = [];
3218
+ for (let ifr = -5; ifr <= 5; ifr++) {
3219
+ const delf = ifr * 0.5;
3220
+ const dphi = TWO_PI * delf * DT2;
3221
+ const twkRe = new Float64Array(COSTAS_SYMBOL_LEN);
3222
+ const twkIm = new Float64Array(COSTAS_SYMBOL_LEN);
3223
+ let phi = 0;
3224
+ for (let j = 0; j < COSTAS_SYMBOL_LEN; j++) {
3225
+ twkRe[j] = Math.cos(phi);
3226
+ twkIm[j] = Math.sin(phi);
3227
+ phi = (phi + dphi) % TWO_PI;
3228
+ }
3229
+ const re = new Float64Array(COSTAS_BLOCKS * COSTAS_SYMBOL_LEN);
3230
+ const im = new Float64Array(COSTAS_BLOCKS * COSTAS_SYMBOL_LEN);
3231
+ for (let i = 0; i < COSTAS_BLOCKS; i++) {
3232
+ const base = i * COSTAS_SYMBOL_LEN;
3233
+ for (let j = 0; j < COSTAS_SYMBOL_LEN; j++) {
3234
+ const idx = base + j;
3235
+ const csRe = COSTAS_SYNC.re[idx];
3236
+ const csIm = COSTAS_SYNC.im[idx];
3237
+ const tRe = twkRe[j] * csRe - twkIm[j] * csIm;
3238
+ const tIm = twkRe[j] * csIm + twkIm[j] * csRe;
3239
+ re[idx] = tRe;
3240
+ im[idx] = tIm;
3241
+ }
3242
+ }
3243
+ templates.push({ delf, re, im });
3244
+ }
3245
+ return templates;
3246
+ }
3247
+
3248
+ /// <reference types="node" />
3249
+ /**
3250
+ * Parse a WAV file buffer into sample rate and normalized float samples.
3251
+ * Supports PCM format 1, 8/16/32-bit samples.
3252
+ */
3253
+ function parseWavBuffer(buf) {
3254
+ if (buf.length < 44)
3255
+ throw new Error("File too small for WAV");
3256
+ const riff = buf.toString("ascii", 0, 4);
3257
+ const wave = buf.toString("ascii", 8, 12);
3258
+ if (riff !== "RIFF" || wave !== "WAVE")
3259
+ throw new Error("Not a WAV file");
3260
+ let offset = 12;
3261
+ let fmtFound = false;
3262
+ let sampleRate = 0;
3263
+ let bitsPerSample = 0;
3264
+ let numChannels = 1;
3265
+ let audioFormat = 0;
3266
+ let dataOffset = 0;
3267
+ let dataSize = 0;
3268
+ while (offset < buf.length - 8) {
3269
+ const chunkId = buf.toString("ascii", offset, offset + 4);
3270
+ const chunkSize = buf.readUInt32LE(offset + 4);
3271
+ offset += 8;
3272
+ if (chunkId === "fmt ") {
3273
+ audioFormat = buf.readUInt16LE(offset);
3274
+ numChannels = buf.readUInt16LE(offset + 2);
3275
+ sampleRate = buf.readUInt32LE(offset + 4);
3276
+ bitsPerSample = buf.readUInt16LE(offset + 14);
3277
+ fmtFound = true;
3278
+ }
3279
+ else if (chunkId === "data") {
3280
+ dataOffset = offset;
3281
+ dataSize = chunkSize;
3282
+ break;
3283
+ }
3284
+ offset += chunkSize;
3285
+ }
3286
+ if (!fmtFound)
3287
+ throw new Error("No fmt chunk found");
3288
+ if (audioFormat !== 1)
3289
+ throw new Error(`Unsupported audio format: ${audioFormat} (only PCM=1)`);
2263
3290
  if (dataOffset === 0)
2264
3291
  throw new Error("No data chunk found");
2265
3292
  const bytesPerSample = bitsPerSample / 8;
@@ -2341,9 +3368,11 @@ Usage:
2341
3368
  ft8ts encode "<message>" [options]
2342
3369
 
2343
3370
  Decode options:
3371
+ --mode <ft8|ft4> Mode: ft8 (default) or ft4
2344
3372
  --low <hz> Lower frequency bound (default: 200)
2345
3373
  --high <hz> Upper frequency bound (default: 3000)
2346
3374
  --depth <1|2|3> Decoding depth (default: 2)
3375
+ --max-candidates <n> Max candidate signals to decode (default: 300)
2347
3376
 
2348
3377
  Encode options:
2349
3378
  --out <file> Output WAV file (default: output.wav)
@@ -2364,9 +3393,19 @@ function runDecode(argv) {
2364
3393
  }
2365
3394
  const wavFile = argv[0];
2366
3395
  const options = {};
3396
+ let mode = "ft8";
2367
3397
  for (let i = 1; i < argv.length; i++) {
2368
3398
  const arg = argv[i];
2369
- if (arg === "--low") {
3399
+ if (arg === "--mode") {
3400
+ const value = argv[++i];
3401
+ if (value === "ft8" || value === "ft4") {
3402
+ mode = value;
3403
+ }
3404
+ else {
3405
+ throw new Error(`Invalid --mode: ${value ?? "(missing)"}. Use ft8 or ft4`);
3406
+ }
3407
+ }
3408
+ else if (arg === "--low") {
2370
3409
  options.freqLow = Number(argv[++i]);
2371
3410
  }
2372
3411
  else if (arg === "--high") {
@@ -2375,6 +3414,9 @@ function runDecode(argv) {
2375
3414
  else if (arg === "--depth") {
2376
3415
  options.depth = Number(argv[++i]);
2377
3416
  }
3417
+ else if (arg === "--max-candidates") {
3418
+ options.maxCandidates = Number(argv[++i]);
3419
+ }
2378
3420
  else {
2379
3421
  throw new Error(`Unknown argument: ${arg}`);
2380
3422
  }
@@ -2384,7 +3426,9 @@ function runDecode(argv) {
2384
3426
  const { sampleRate, samples } = parseWavBuffer(readFileSync(filePath));
2385
3427
  console.log(`WAV: ${sampleRate} Hz, ${samples.length} samples, ${(samples.length / sampleRate).toFixed(1)}s`);
2386
3428
  const startTime = performance.now();
2387
- const decoded = decode(samples, { ...options, sampleRate });
3429
+ const decoded = mode === "ft4"
3430
+ ? decode$1(samples, { ...options, sampleRate })
3431
+ : decode(samples, { ...options, sampleRate });
2388
3432
  const elapsed = performance.now() - startTime;
2389
3433
  console.log(`\nDecoded ${decoded.length} messages in ${(elapsed / 1000).toFixed(2)}s:\n`);
2390
3434
  console.log(" dt snr freq message");