@e04/ft8ts 0.0.2 → 0.0.6

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
@@ -6,7 +6,9 @@ FT8 encoder and decoder in TypeScript. A port of the Fortran implementation from
6
6
 
7
7
  ## Overview
8
8
 
9
- 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.
10
12
 
11
13
  ## Demo
12
14
 
@@ -32,27 +34,27 @@ npx tsx example/decode-ft8-wav.ts ./src/__test__/190227_155815.wav [--low 200] [
32
34
 
33
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/).
34
36
 
35
- | Call a | Call b | Message | 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) |
36
- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
37
- | W1FC | F5BZB | -8 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
38
- | WM3PEN | EA6VQ | -9 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
39
- | CQ | F5RXL | IN94 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
40
- | N1JFU | EA6EE | R-07 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
41
- | A92EE | F5PSR | -14 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
42
- | K1BZM | EA3GP | -9 | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
43
- | W0RSJ | EA3BMU | RR73 | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
44
- | K1JT | HA0DU | KN07 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
45
- | W1DIG | SV9CVY | -14 | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
46
- | K1JT | EA3AGB | -15 | ☑️ | ☑️ | | ☑️ | ☑️ | ☑️ |
47
- | XE2X | HA2NP | RR73 | ☑️ | ☑️ | ☑️ | | | ☑️ |
48
- | N1PJT | HB9CQK | -10 | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ |
49
- | K1BZM | EA3CJ | JN01 | ☑️ | | | | | |
50
- | KD2UGC | F6GCP | R-23 | ☑️ | | | | | |
51
- | WA2FZW | DL5AXX | RR73 | | | | | | |
52
- | N1API | HA6FQ | -23 | | | | | ☑️ | ☑️ |
53
- | N1API | F2VX | 73 | | | | | | |
54
- | K1JT | HA5WA | 73 | | | | | ☑️ | ☑️ |
55
- | CQ | EA2BFM | IN83 | | | | | | |
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 | ☑️ | | | | | | |
56
58
 
57
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`.
58
60
 
@@ -65,7 +67,7 @@ At its maximum depth mode (Depth 3), it successfully decodes 14 messages, outper
65
67
  ### API
66
68
 
67
69
  ```typescript
68
- import { encodeFT8, decodeFT8 } from "@e04/ft8ts";
70
+ import { encodeFT8, decodeFT8, HashCallBook } from "@e04/ft8ts";
69
71
 
70
72
  // Encode a message to audio samples (Float32Array)
71
73
  const samples = encodeFT8("CQ JK1IFA PM95", {
@@ -73,11 +75,17 @@ const samples = encodeFT8("CQ JK1IFA PM95", {
73
75
  baseFrequency: 1000,
74
76
  });
75
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
+
76
83
  // Decode audio samples to messages
77
84
  const decoded = decodeFT8(samples, 12000, {
78
85
  freqLow: 200,
79
86
  freqHigh: 3000,
80
87
  depth: 2,
88
+ hashCallBook: book,
81
89
  });
82
90
 
83
91
  for (const d of decoded) {
@@ -94,10 +102,11 @@ for (const d of decoded) {
94
102
  | `syncMin` | 1.2 | Minimum sync threshold |
95
103
  | `depth` | 2 | Decoding depth: 1=fast BP only, 2=BP+OSD, 3=deep |
96
104
  | `maxCandidates` | 300 | Maximum candidates to process |
105
+ | `hashCallBook` | — | `HashCallBook` instance for resolving hashed callsigns |
97
106
 
98
107
  ## ToDo
99
108
 
100
- - [ ] Add save_hash_call-style hash tables to the TypeScript port so that h10/h12/h22 hash references can be resolved to callsigns (e.g. <YW18FIFA>) instead of always showing <...>.
109
+ - [ ] FT4 Support
101
110
 
102
111
  ## Build
103
112
 
package/dist/ft8ts.cjs CHANGED
@@ -686,7 +686,7 @@ function bitsToUint(bits, start, len) {
686
686
  }
687
687
  return val;
688
688
  }
689
- function unpack28(n28) {
689
+ function unpack28(n28, book) {
690
690
  if (n28 < 0 || n28 >= 268435456)
691
691
  return { call: "", success: false };
692
692
  if (n28 === 0)
@@ -700,7 +700,6 @@ function unpack28(n28) {
700
700
  return { call: `CQ ${nqsy.toString().padStart(3, "0")}`, success: true };
701
701
  }
702
702
  if (n28 >= 1003 && n28 < NTOKENS) {
703
- // CQ with 4-letter directed call
704
703
  let m = n28 - 1003;
705
704
  let chars = "";
706
705
  for (let i = 3; i >= 0; i--) {
@@ -714,7 +713,10 @@ function unpack28(n28) {
714
713
  return { call: "CQ", success: true };
715
714
  }
716
715
  if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
717
- // 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 };
718
720
  return { call: "<...>", success: true };
719
721
  }
720
722
  // Standard callsign
@@ -794,8 +796,11 @@ function unpackText77(bits71) {
794
796
  }
795
797
  /**
796
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.
797
802
  */
798
- function unpack77(bits77) {
803
+ function unpack77(bits77, book) {
799
804
  const n3 = bitsToUint(bits77, 71, 3);
800
805
  const i3 = bitsToUint(bits77, 74, 3);
801
806
  if (i3 === 0 && n3 === 0) {
@@ -813,8 +818,8 @@ function unpack77(bits77) {
813
818
  const ipb = bits77[57];
814
819
  const ir = bits77[58];
815
820
  const igrid4 = bitsToUint(bits77, 59, 15);
816
- const { call: call1, success: ok1 } = unpack28(n28a);
817
- 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);
818
823
  if (!ok1 || !ok2)
819
824
  return { msg: "", success: false };
820
825
  let c1 = call1;
@@ -832,6 +837,9 @@ function unpack77(bits77) {
832
837
  c2 += "/R";
833
838
  if (ipb === 1 && i3 === 2 && c2.length >= 3)
834
839
  c2 += "/P";
840
+ // Save the "from" call (call_2) into the hash book
841
+ if (book && c2.length >= 3)
842
+ book.save(c2);
835
843
  }
836
844
  if (igrid4 <= MAXGRID4) {
837
845
  const { grid, success: gridOk } = toGrid4(igrid4);
@@ -864,6 +872,7 @@ function unpack77(bits77) {
864
872
  }
865
873
  if (i3 === 4) {
866
874
  // Type 4: One nonstandard call
875
+ const n12 = bitsToUint(bits77, 0, 12);
867
876
  let n58 = 0n;
868
877
  for (let i = 0; i < 58; i++) {
869
878
  n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
@@ -871,7 +880,6 @@ function unpack77(bits77) {
871
880
  const iflip = bits77[70];
872
881
  const nrpt = bitsToUint(bits77, 71, 2);
873
882
  const icq = bits77[73];
874
- // Decode n58 to 11-char string using C38 alphabet
875
883
  const c11chars = [];
876
884
  let remain = n58;
877
885
  for (let i = 10; i >= 0; i--) {
@@ -880,12 +888,15 @@ function unpack77(bits77) {
880
888
  c11chars.unshift(C38[j] ?? " ");
881
889
  }
882
890
  const c11 = c11chars.join("").trim();
883
- const call3 = "<...>"; // We don't have a hash table for n12
891
+ const resolved = book?.lookup12(n12);
892
+ const call3 = resolved ? `<${resolved}>` : "<...>";
884
893
  let call1;
885
894
  let call2;
886
895
  if (iflip === 0) {
887
896
  call1 = call3;
888
897
  call2 = c11;
898
+ if (book)
899
+ book.save(c11);
889
900
  }
890
901
  else {
891
902
  call1 = c11;
@@ -920,6 +931,7 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
920
931
  const syncmin = options.syncMin ?? 1.2;
921
932
  const depth = options.depth ?? 2;
922
933
  const maxCandidates = options.maxCandidates ?? 300;
934
+ const book = options.hashCallBook;
923
935
  // Resample to 12000 Hz if needed
924
936
  let dd;
925
937
  if (sampleRate === SAMPLE_RATE) {
@@ -944,7 +956,7 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
944
956
  const decoded = [];
945
957
  const seenMessages = new Set();
946
958
  for (const cand of candidates) {
947
- const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth);
959
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
948
960
  if (!result)
949
961
  continue;
950
962
  if (seenMessages.has(result.msg))
@@ -1128,7 +1140,7 @@ function computeBaseline(savg, nfa, nfb, df, nh1) {
1128
1140
  }
1129
1141
  return sbase;
1130
1142
  }
1131
- function ft8b(dd0, cxRe, cxIm, f1, xdt, _sbase, depth) {
1143
+ function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
1132
1144
  const NFFT2 = 3200;
1133
1145
  const NP2 = 2812;
1134
1146
  const fs2 = SAMPLE_RATE / NDOWN;
@@ -1336,7 +1348,7 @@ function ft8b(dd0, cxRe, cxIm, f1, xdt, _sbase, depth) {
1336
1348
  if (i3v === 0 && n3v === 2)
1337
1349
  return null;
1338
1350
  // Unpack
1339
- const { msg, success } = unpack77(message77);
1351
+ const { msg, success } = unpack77(message77, book);
1340
1352
  if (!success || msg.trim().length === 0)
1341
1353
  return null;
1342
1354
  // Estimate SNR
@@ -2203,6 +2215,113 @@ function encode(msg, options = {}) {
2203
2215
  return generateFT8Waveform(encodeMessage(msg), options);
2204
2216
  }
2205
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;
2206
2325
  exports.decodeFT8 = decode;
2207
2326
  exports.encodeFT8 = encode;
2208
2327
  //# sourceMappingURL=ft8ts.cjs.map