@e04/ft8ts 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,10 +1,62 @@
1
1
  # ft8ts
2
2
 
3
+ [![Tests](https://github.com/e04/ft8ts/actions/workflows/test.yml/badge.svg)](https://github.com/e04/ft8ts/actions/workflows/test.yml)
4
+
3
5
  FT8 encoder and decoder in TypeScript. A port of the Fortran implementation from [WSJT-X](https://wsjt.sourceforge.io/wsjtx.html) v2.7.0.
4
6
 
5
7
  ## Overview
6
8
 
7
- FT8 is a digital amateur radio mode designed for weak-signal communication. This library provides pure TypeScript implementations of both encoding and decoding, suitable for use in Node.js or the browser.
9
+ FT8 is a digital amateur radio mode designed for weak-signal communication, developed by Joe Taylor (K1JT) and Steve Franke (K9AN).
10
+
11
+ This library provides pure TypeScript implementations of both encoding and decoding, suitable for use in Node.js or the browser.
12
+
13
+ ## Demo
14
+
15
+ ### Browser
16
+
17
+ https://e04.github.io/ft8ts/example/browser/index.html
18
+
19
+ ### CLI
20
+
21
+ #### Encode
22
+
23
+ ```bash
24
+ npx tsx example/generate-ft8-wav.ts "CQ JK1IFA PM95" [--out output.wav] [--df 1000]
25
+ ```
26
+
27
+ #### Decode
28
+
29
+ ```bash
30
+ npx tsx example/decode-ft8-wav.ts ./src/__test__/190227_155815.wav [--low 200] [--high 3000] [--depth 2]
31
+ ```
32
+
33
+ ## Benchmark
34
+
35
+ The benchmark below was compiled with reference to [Comparing PyFT8 with WSJT-x and FT8_lib](https://www.reddit.com/r/amateurradio/comments/1qt27ss/comparing_pyft8_with_wsjtx_and_ft8_lib/).
36
+
37
+ | Call a | Call b | Message | WSJT-x(default) | WSJT-x (fast) | [PyFT8](https://github.com/G1OJS/PyFT8) | [ft8_lib](https://github.com/kgoba/ft8_lib) | ft8ts (depth=1) | ft8ts (depth=2) | ft8ts (depth=3) |
38
+ | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
39
+ | W1FC | F5BZB | -8 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
40
+ | WM3PEN | EA6VQ | -9 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
41
+ | CQ | F5RXL | IN94 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
42
+ | N1JFU | EA6EE | R-07 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
43
+ | A92EE | F5PSR | -14 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
44
+ | K1BZM | EA3GP | -9 | ☑️ | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
45
+ | W0RSJ | EA3BMU | RR73 | ☑️ | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
46
+ | K1JT | HA0DU | KN07 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
47
+ | W1DIG | SV9CVY | -14 | ☑️ | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
48
+ | K1JT | EA3AGB | -15 | ☑️ | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
49
+ | XE2X | HA2NP | RR73 | ☑️ | ☑️ | ☑️ | ☑️ | | | ☑️ |
50
+ | N1PJT | HB9CQK | -10 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
51
+ | K1BZM | EA3CJ | JN01 | ☑️ | ☑️ | | | | | |
52
+ | KD2UGC | F6GCP | R-23 | ☑️ | ☑️ | | | | | |
53
+ | WA2FZW | DL5AXX | RR73 | ☑️ | | | | | | |
54
+ | N1API | HA6FQ | -23 | ☑️ | | | | | ☑️ | ☑️ |
55
+ | N1API | F2VX | 73 | ☑️ | | | | | | |
56
+ | K1JT | HA5WA | 73 | ☑️ | | | | | ☑️ | ☑️ |
57
+ | CQ | EA2BFM | IN83 | ☑️ | | | | | | |
58
+
59
+ At its maximum depth mode (Depth 3), it successfully decodes 14 messages, outperforming both `PyFT8` (12) and `FT8_lib` (8), and matching the total message count of `WSJT-x FAST mode`.
8
60
 
9
61
  ## Installation
10
62
 
@@ -15,7 +67,7 @@ FT8 is a digital amateur radio mode designed for weak-signal communication. This
15
67
  ### API
16
68
 
17
69
  ```typescript
18
- import { encodeFT8, decodeFT8 } from "@e04/ft8ts";
70
+ import { encodeFT8, decodeFT8, HashCallBook } from "@e04/ft8ts";
19
71
 
20
72
  // Encode a message to audio samples (Float32Array)
21
73
  const samples = encodeFT8("CQ JK1IFA PM95", {
@@ -23,11 +75,17 @@ const samples = encodeFT8("CQ JK1IFA PM95", {
23
75
  baseFrequency: 1000,
24
76
  });
25
77
 
78
+ // Create a HashCallBook to resolve hashed callsigns.
79
+ // Reuse the same instance across multiple decode calls so that
80
+ // callsigns learned from earlier frames can resolve hashes in later ones.
81
+ const book = new HashCallBook();
82
+
26
83
  // Decode audio samples to messages
27
84
  const decoded = decodeFT8(samples, 12000, {
28
85
  freqLow: 200,
29
86
  freqHigh: 3000,
30
87
  depth: 2,
88
+ hashCallBook: book,
31
89
  });
32
90
 
33
91
  for (const d of decoded) {
@@ -44,15 +102,11 @@ for (const d of decoded) {
44
102
  | `syncMin` | 1.2 | Minimum sync threshold |
45
103
  | `depth` | 2 | Decoding depth: 1=fast BP only, 2=BP+OSD, 3=deep |
46
104
  | `maxCandidates` | 300 | Maximum candidates to process |
105
+ | `hashCallBook` | — | `HashCallBook` instance for resolving hashed callsigns |
47
106
 
48
- ### CLI (WAV file decoding)
49
-
50
- ```bash
51
- npx tsx example/decode-ft8-wav.ts recording.wav [--low 200] [--high 3000] [--depth 2]
52
- ```
53
-
54
- ### Browser Demo
107
+ ## ToDo
55
108
 
109
+ - [ ] FT4 Support
56
110
 
57
111
  ## Build
58
112
 
@@ -67,3 +121,9 @@ GPL-3.0
67
121
  ## References
68
122
 
69
123
  - [WSJT-X](https://wsjt.sourceforge.io/wsjtx.html) — Original Fortran implementation (v2.7.0), licensed under [GPL v3](https://www.gnu.org/licenses/gpl-3.0.html)
124
+
125
+ ## Related Projects
126
+
127
+ - **[PyFT8](https://github.com/G1OJS/PyFT8)** — Python implementation.
128
+ - **[ft8_lib](https://github.com/kgoba/ft8_lib)** — C++ implementation.
129
+ - **[ft8js](https://github.com/e04/ft8js)** - My previous experimental project using WebAssembly (WASM) with ft8_lib.
package/dist/ft8ts.cjs CHANGED
@@ -568,6 +568,10 @@ function fftComplex(re, im, inverse) {
568
568
  const n = re.length;
569
569
  if (n <= 1)
570
570
  return;
571
+ if ((n & (n - 1)) !== 0) {
572
+ bluestein(re, im, inverse);
573
+ return;
574
+ }
571
575
  // Bit-reversal permutation
572
576
  let j = 0;
573
577
  for (let i = 0; i < n; i++) {
@@ -586,7 +590,7 @@ function fftComplex(re, im, inverse) {
586
590
  }
587
591
  j += m;
588
592
  }
589
- const sign = -1;
593
+ const sign = inverse ? 1 : -1;
590
594
  for (let size = 2; size <= n; size <<= 1) {
591
595
  const halfsize = size >> 1;
592
596
  const step = (sign * Math.PI) / halfsize;
@@ -610,6 +614,53 @@ function fftComplex(re, im, inverse) {
610
614
  }
611
615
  }
612
616
  }
617
+ if (inverse) {
618
+ for (let i = 0; i < n; i++) {
619
+ re[i] /= n;
620
+ im[i] /= n;
621
+ }
622
+ }
623
+ }
624
+ function bluestein(re, im, inverse) {
625
+ const n = re.length;
626
+ const m = nextPow2(n * 2 - 1);
627
+ const s = inverse ? 1 : -1;
628
+ const aRe = new Float64Array(m);
629
+ const aIm = new Float64Array(m);
630
+ const bRe = new Float64Array(m);
631
+ const bIm = new Float64Array(m);
632
+ for (let i = 0; i < n; i++) {
633
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
634
+ const cosA = Math.cos(angle);
635
+ const sinA = Math.sin(angle);
636
+ aRe[i] = re[i] * cosA - im[i] * sinA;
637
+ aIm[i] = re[i] * sinA + im[i] * cosA;
638
+ bRe[i] = cosA;
639
+ bIm[i] = -sinA;
640
+ }
641
+ for (let i = 1; i < n; i++) {
642
+ bRe[m - i] = bRe[i];
643
+ bIm[m - i] = bIm[i];
644
+ }
645
+ fftComplex(aRe, aIm, false);
646
+ fftComplex(bRe, bIm, false);
647
+ for (let i = 0; i < m; i++) {
648
+ const r = aRe[i] * bRe[i] - aIm[i] * bIm[i];
649
+ const iIm = aRe[i] * bIm[i] + aIm[i] * bRe[i];
650
+ aRe[i] = r;
651
+ aIm[i] = iIm;
652
+ }
653
+ fftComplex(aRe, aIm, true);
654
+ const scale = inverse ? 1 / n : 1;
655
+ for (let i = 0; i < n; i++) {
656
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
657
+ const cosA = Math.cos(angle);
658
+ const sinA = Math.sin(angle);
659
+ const r = aRe[i] * cosA - aIm[i] * sinA;
660
+ const iIm = aRe[i] * sinA + aIm[i] * cosA;
661
+ re[i] = r * scale;
662
+ im[i] = iIm * scale;
663
+ }
613
664
  }
614
665
  /** Next power of 2 >= n */
615
666
  function nextPow2(n) {
@@ -635,7 +686,7 @@ function bitsToUint(bits, start, len) {
635
686
  }
636
687
  return val;
637
688
  }
638
- function unpack28(n28) {
689
+ function unpack28(n28, book) {
639
690
  if (n28 < 0 || n28 >= 268435456)
640
691
  return { call: "", success: false };
641
692
  if (n28 === 0)
@@ -649,7 +700,6 @@ function unpack28(n28) {
649
700
  return { call: `CQ ${nqsy.toString().padStart(3, "0")}`, success: true };
650
701
  }
651
702
  if (n28 >= 1003 && n28 < NTOKENS) {
652
- // CQ with 4-letter directed call
653
703
  let m = n28 - 1003;
654
704
  let chars = "";
655
705
  for (let i = 3; i >= 0; i--) {
@@ -663,7 +713,10 @@ function unpack28(n28) {
663
713
  return { call: "CQ", success: true };
664
714
  }
665
715
  if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
666
- // Hashed call we don't have a hash table, so show <...>
716
+ const n22 = n28 - NTOKENS;
717
+ const resolved = book?.lookup22(n22);
718
+ if (resolved)
719
+ return { call: `<${resolved}>`, success: true };
667
720
  return { call: "<...>", success: true };
668
721
  }
669
722
  // Standard callsign
@@ -743,8 +796,11 @@ function unpackText77(bits71) {
743
796
  }
744
797
  /**
745
798
  * Unpack a 77-bit FT8 message into a human-readable string.
799
+ *
800
+ * When a {@link HashCallBook} is provided, hashed callsigns are resolved from
801
+ * the book, and newly decoded standard callsigns are saved into it.
746
802
  */
747
- function unpack77(bits77) {
803
+ function unpack77(bits77, book) {
748
804
  const n3 = bitsToUint(bits77, 71, 3);
749
805
  const i3 = bitsToUint(bits77, 74, 3);
750
806
  if (i3 === 0 && n3 === 0) {
@@ -762,8 +818,8 @@ function unpack77(bits77) {
762
818
  const ipb = bits77[57];
763
819
  const ir = bits77[58];
764
820
  const igrid4 = bitsToUint(bits77, 59, 15);
765
- const { call: call1, success: ok1 } = unpack28(n28a);
766
- const { call: call2Raw, success: ok2 } = unpack28(n28b);
821
+ const { call: call1, success: ok1 } = unpack28(n28a, book);
822
+ const { call: call2Raw, success: ok2 } = unpack28(n28b, book);
767
823
  if (!ok1 || !ok2)
768
824
  return { msg: "", success: false };
769
825
  let c1 = call1;
@@ -781,6 +837,9 @@ function unpack77(bits77) {
781
837
  c2 += "/R";
782
838
  if (ipb === 1 && i3 === 2 && c2.length >= 3)
783
839
  c2 += "/P";
840
+ // Save the "from" call (call_2) into the hash book
841
+ if (book && c2.length >= 3)
842
+ book.save(c2);
784
843
  }
785
844
  if (igrid4 <= MAXGRID4) {
786
845
  const { grid, success: gridOk } = toGrid4(igrid4);
@@ -813,6 +872,7 @@ function unpack77(bits77) {
813
872
  }
814
873
  if (i3 === 4) {
815
874
  // Type 4: One nonstandard call
875
+ const n12 = bitsToUint(bits77, 0, 12);
816
876
  let n58 = 0n;
817
877
  for (let i = 0; i < 58; i++) {
818
878
  n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
@@ -820,7 +880,6 @@ function unpack77(bits77) {
820
880
  const iflip = bits77[70];
821
881
  const nrpt = bitsToUint(bits77, 71, 2);
822
882
  const icq = bits77[73];
823
- // Decode n58 to 11-char string using C38 alphabet
824
883
  const c11chars = [];
825
884
  let remain = n58;
826
885
  for (let i = 10; i >= 0; i--) {
@@ -829,12 +888,15 @@ function unpack77(bits77) {
829
888
  c11chars.unshift(C38[j] ?? " ");
830
889
  }
831
890
  const c11 = c11chars.join("").trim();
832
- const call3 = "<...>"; // We don't have a hash table for n12
891
+ const resolved = book?.lookup12(n12);
892
+ const call3 = resolved ? `<${resolved}>` : "<...>";
833
893
  let call1;
834
894
  let call2;
835
895
  if (iflip === 0) {
836
896
  call1 = call3;
837
897
  call2 = c11;
898
+ if (book)
899
+ book.save(c11);
838
900
  }
839
901
  else {
840
902
  call1 = c11;
@@ -869,6 +931,7 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
869
931
  const syncmin = options.syncMin ?? 1.2;
870
932
  const depth = options.depth ?? 2;
871
933
  const maxCandidates = options.maxCandidates ?? 300;
934
+ const book = options.hashCallBook;
872
935
  // Resample to 12000 Hz if needed
873
936
  let dd;
874
937
  if (sampleRate === SAMPLE_RATE) {
@@ -880,12 +943,20 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
880
943
  else {
881
944
  dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
882
945
  }
946
+ // Compute huge FFT for downsampling caching
947
+ const NFFT1_LONG = 192000;
948
+ const cxRe = new Float64Array(NFFT1_LONG);
949
+ const cxIm = new Float64Array(NFFT1_LONG);
950
+ for (let i = 0; i < NMAX; i++) {
951
+ cxRe[i] = dd[i] ?? 0;
952
+ }
953
+ fftComplex(cxRe, cxIm, false);
883
954
  // Compute spectrogram and find sync candidates
884
955
  const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
885
956
  const decoded = [];
886
957
  const seenMessages = new Set();
887
958
  for (const cand of candidates) {
888
- const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
959
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
889
960
  if (!result)
890
961
  continue;
891
962
  if (seenMessages.has(result.msg))
@@ -921,7 +992,7 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
921
992
  for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
922
993
  xRe[i] = fac * dd[ia + i];
923
994
  }
924
- fftComplex(xRe, xIm);
995
+ fftComplex(xRe, xIm, false);
925
996
  for (let i = 0; i < halfSize; i++) {
926
997
  const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
927
998
  s[i * NHSYM + j] = power;
@@ -1069,17 +1140,16 @@ function computeBaseline(savg, nfa, nfb, df, nh1) {
1069
1140
  }
1070
1141
  return sbase;
1071
1142
  }
1072
- function ft8b(dd0, f1, xdt, _sbase, depth) {
1143
+ function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
1073
1144
  const NFFT2 = 3200;
1074
1145
  const NP2 = 2812;
1075
- const NFFT1_LONG = 192000;
1076
1146
  const fs2 = SAMPLE_RATE / NDOWN;
1077
1147
  const dt2 = 1.0 / fs2;
1078
1148
  const twopi = 2 * Math.PI;
1079
1149
  // Downsample: mix to baseband and filter
1080
1150
  const cd0Re = new Float64Array(NFFT2);
1081
1151
  const cd0Im = new Float64Array(NFFT2);
1082
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1152
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1083
1153
  // Find best time offset
1084
1154
  const i0 = Math.round((xdt + 0.5) * fs2);
1085
1155
  let smax = 0;
@@ -1113,7 +1183,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1113
1183
  }
1114
1184
  // Apply frequency correction and re-downsample
1115
1185
  f1 += delfbest;
1116
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1186
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1117
1187
  // Refine time offset
1118
1188
  const ss = new Float64Array(9);
1119
1189
  for (let idt = -4; idt <= 4; idt++) {
@@ -1145,7 +1215,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1145
1215
  symbIm[j] = cd0Im[i1 + j];
1146
1216
  }
1147
1217
  }
1148
- fftComplex(symbRe, symbIm);
1218
+ fftComplex(symbRe, symbIm, false);
1149
1219
  for (let tone = 0; tone < 8; tone++) {
1150
1220
  const re = symbRe[tone] / 1000;
1151
1221
  const im = symbIm[tone] / 1000;
@@ -1278,7 +1348,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1278
1348
  if (i3v === 0 && n3v === 2)
1279
1349
  return null;
1280
1350
  // Unpack
1281
- const { msg, success } = unpack77(message77);
1351
+ const { msg, success } = unpack77(message77, book);
1282
1352
  if (!success || msg.trim().length === 0)
1283
1353
  return null;
1284
1354
  // Estimate SNR
@@ -1319,44 +1389,75 @@ function getTones$1(cw) {
1319
1389
  return tones;
1320
1390
  }
1321
1391
  /**
1322
- * Mix f0 to baseband and decimate by NDOWN (60x).
1323
- * Time-domain approach: mix down, low-pass filter via moving average, decimate.
1324
- * Output: complex baseband signal at 200 Hz sample rate (32 samples/symbol).
1392
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1393
+ * Identical to Fortran ft8_downsample.
1325
1394
  */
1326
- function ft8Downsample(dd, f0, outRe, outIm, _nfft1Long, nfft2) {
1327
- const twopi = 2 * Math.PI;
1328
- const len = Math.min(dd.length, NMAX);
1329
- const dphi = (twopi * f0) / SAMPLE_RATE;
1330
- // Mix to baseband
1331
- const mixRe = new Float64Array(len);
1332
- const mixIm = new Float64Array(len);
1333
- let phi = 0;
1334
- for (let i = 0; i < len; i++) {
1335
- mixRe[i] = dd[i] * Math.cos(phi);
1336
- mixIm[i] = -dd[i] * Math.sin(phi);
1337
- phi += dphi;
1338
- if (phi > twopi)
1339
- phi -= twopi;
1340
- }
1341
- // Low-pass filter: simple moving-average with window = NDOWN
1342
- // then decimate by NDOWN to get 200 Hz sample rate
1343
- const outLen = Math.min(nfft2, Math.floor(len / NDOWN));
1344
- outRe.fill(0);
1345
- outIm.fill(0);
1346
- // Running sum filter
1347
- const halfWin = NDOWN >> 1;
1348
- for (let k = 0; k < outLen; k++) {
1349
- const center = k * NDOWN + halfWin;
1350
- let sumRe = 0, sumIm = 0;
1351
- const start = Math.max(0, center - halfWin);
1352
- const end = Math.min(len, center + halfWin);
1353
- for (let j = start; j < end; j++) {
1354
- sumRe += mixRe[j];
1355
- sumIm += mixIm[j];
1356
- }
1357
- const n = end - start;
1358
- outRe[k] = sumRe / n;
1359
- outIm[k] = sumIm / n;
1395
+ function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
1396
+ const NFFT1 = 192000;
1397
+ const NFFT2 = 3200;
1398
+ const df = 12000.0 / NFFT1;
1399
+ // NSPS is imported, should be 1920
1400
+ const baud = 12000.0 / NSPS; // 6.25
1401
+ const i0 = Math.round(f0 / df);
1402
+ const ft = f0 + 8.5 * baud;
1403
+ const it = Math.min(Math.round(ft / df), NFFT1 / 2);
1404
+ const fb = f0 - 1.5 * baud;
1405
+ const ib = Math.max(1, Math.round(fb / df));
1406
+ c1Re.fill(0);
1407
+ c1Im.fill(0);
1408
+ let k = 0;
1409
+ for (let i = ib; i <= it; i++) {
1410
+ if (k >= NFFT2)
1411
+ break;
1412
+ c1Re[k] = cxRe[i] ?? 0;
1413
+ c1Im[k] = cxIm[i] ?? 0;
1414
+ k++;
1415
+ }
1416
+ // Taper
1417
+ const pi = Math.PI;
1418
+ const taper = new Float64Array(101);
1419
+ for (let i = 0; i <= 100; i++) {
1420
+ taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
1421
+ }
1422
+ for (let i = 0; i <= 100; i++) {
1423
+ if (i >= NFFT2)
1424
+ break;
1425
+ const tap = taper[100 - i];
1426
+ c1Re[i] = c1Re[i] * tap;
1427
+ c1Im[i] = c1Im[i] * tap;
1428
+ }
1429
+ const endTap = k - 1;
1430
+ for (let i = 0; i <= 100; i++) {
1431
+ const idx = endTap - 100 + i;
1432
+ if (idx >= 0 && idx < NFFT2) {
1433
+ const tap = taper[i];
1434
+ c1Re[idx] = c1Re[idx] * tap;
1435
+ c1Im[idx] = c1Im[idx] * tap;
1436
+ }
1437
+ }
1438
+ // CSHIFT
1439
+ const shift = i0 - ib;
1440
+ const tempRe = new Float64Array(NFFT2);
1441
+ const tempIm = new Float64Array(NFFT2);
1442
+ for (let i = 0; i < NFFT2; i++) {
1443
+ let srcIdx = (i + shift) % NFFT2;
1444
+ if (srcIdx < 0)
1445
+ srcIdx += NFFT2;
1446
+ tempRe[i] = c1Re[srcIdx];
1447
+ tempIm[i] = c1Im[srcIdx];
1448
+ }
1449
+ for (let i = 0; i < NFFT2; i++) {
1450
+ c1Re[i] = tempRe[i];
1451
+ c1Im[i] = tempIm[i];
1452
+ }
1453
+ // iFFT
1454
+ fftComplex(c1Re, c1Im, true);
1455
+ // Scale
1456
+ // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
1457
+ const scale = Math.sqrt(NFFT2 / NFFT1);
1458
+ for (let i = 0; i < NFFT2; i++) {
1459
+ c1Re[i] = c1Re[i] * scale;
1460
+ c1Im[i] = c1Im[i] * scale;
1360
1461
  }
1361
1462
  }
1362
1463
  function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
@@ -2114,6 +2215,113 @@ function encode(msg, options = {}) {
2114
2215
  return generateFT8Waveform(encodeMessage(msg), options);
2115
2216
  }
2116
2217
 
2218
+ /**
2219
+ * Hash call table – TypeScript port of the hash call storage from packjt77.f90
2220
+ *
2221
+ * In FT8, nonstandard callsigns are transmitted as hashes (10-, 12-, or 22-bit).
2222
+ * When a full callsign is decoded from a standard message, it is stored in this
2223
+ * table so that future hashed references to it can be resolved.
2224
+ *
2225
+ * Mirrors Fortran: save_hash_call, hash10, hash12, hash22, ihashcall
2226
+ */
2227
+ const MAGIC = 47055833459n;
2228
+ const MAX_HASH22_ENTRIES = 1000;
2229
+ function ihashcall(c0, m) {
2230
+ const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
2231
+ let n8 = 0n;
2232
+ for (let i = 0; i < 11; i++) {
2233
+ const j = C38.indexOf(s[i] ?? " ");
2234
+ n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
2235
+ }
2236
+ const prod = BigInt.asUintN(64, MAGIC * n8);
2237
+ return Number(prod >> BigInt(64 - m)) & ((1 << m) - 1);
2238
+ }
2239
+ /**
2240
+ * Maintains a callsign ↔ hash lookup table for resolving hashed FT8 callsigns.
2241
+ *
2242
+ * Usage:
2243
+ * ```ts
2244
+ * const book = new HashCallBook();
2245
+ * const decoded = decodeFT8(samples, sampleRate, { hashCallBook: book });
2246
+ * // `book` now contains callsigns learned from decoded messages.
2247
+ * // Subsequent calls reuse the same book to resolve hashed callsigns:
2248
+ * const decoded2 = decodeFT8(samples2, sampleRate, { hashCallBook: book });
2249
+ * ```
2250
+ *
2251
+ * You can also pre-populate the book with known callsigns:
2252
+ * ```ts
2253
+ * book.save("W9XYZ");
2254
+ * book.save("PJ4/K1ABC");
2255
+ * ```
2256
+ */
2257
+ class HashCallBook {
2258
+ calls10 = new Map();
2259
+ calls12 = new Map();
2260
+ hash22Entries = [];
2261
+ /**
2262
+ * Store a callsign in all three hash tables (10, 12, 22-bit).
2263
+ * Strips angle brackets if present. Ignores `<...>` and blank/short strings.
2264
+ */
2265
+ save(callsign) {
2266
+ let cw = callsign.trim().toUpperCase();
2267
+ if (cw === "" || cw === "<...>")
2268
+ return;
2269
+ if (cw.startsWith("<"))
2270
+ cw = cw.slice(1);
2271
+ const gt = cw.indexOf(">");
2272
+ if (gt >= 0)
2273
+ cw = cw.slice(0, gt);
2274
+ cw = cw.trim();
2275
+ if (cw.length < 3)
2276
+ return;
2277
+ const n10 = ihashcall(cw, 10);
2278
+ if (n10 >= 0 && n10 <= 1023)
2279
+ this.calls10.set(n10, cw);
2280
+ const n12 = ihashcall(cw, 12);
2281
+ if (n12 >= 0 && n12 <= 4095)
2282
+ this.calls12.set(n12, cw);
2283
+ const n22 = ihashcall(cw, 22);
2284
+ const existing = this.hash22Entries.findIndex((e) => e.hash === n22);
2285
+ if (existing >= 0) {
2286
+ this.hash22Entries[existing].call = cw;
2287
+ }
2288
+ else {
2289
+ if (this.hash22Entries.length >= MAX_HASH22_ENTRIES) {
2290
+ this.hash22Entries.pop();
2291
+ }
2292
+ this.hash22Entries.unshift({ hash: n22, call: cw });
2293
+ }
2294
+ }
2295
+ /** Look up a callsign by its 10-bit hash. Returns `null` if not found. */
2296
+ lookup10(n10) {
2297
+ if (n10 < 0 || n10 > 1023)
2298
+ return null;
2299
+ return this.calls10.get(n10) ?? null;
2300
+ }
2301
+ /** Look up a callsign by its 12-bit hash. Returns `null` if not found. */
2302
+ lookup12(n12) {
2303
+ if (n12 < 0 || n12 > 4095)
2304
+ return null;
2305
+ return this.calls12.get(n12) ?? null;
2306
+ }
2307
+ /** Look up a callsign by its 22-bit hash. Returns `null` if not found. */
2308
+ lookup22(n22) {
2309
+ const entry = this.hash22Entries.find((e) => e.hash === n22);
2310
+ return entry?.call ?? null;
2311
+ }
2312
+ /** Number of entries in the 22-bit hash table. */
2313
+ get size() {
2314
+ return this.hash22Entries.length;
2315
+ }
2316
+ /** Remove all stored entries. */
2317
+ clear() {
2318
+ this.calls10.clear();
2319
+ this.calls12.clear();
2320
+ this.hash22Entries.length = 0;
2321
+ }
2322
+ }
2323
+
2324
+ exports.HashCallBook = HashCallBook;
2117
2325
  exports.decodeFT8 = decode;
2118
2326
  exports.encodeFT8 = encode;
2119
2327
  //# sourceMappingURL=ft8ts.cjs.map