@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/dist/ft8ts.mjs CHANGED
@@ -566,6 +566,10 @@ function fftComplex(re, im, inverse) {
566
566
  const n = re.length;
567
567
  if (n <= 1)
568
568
  return;
569
+ if ((n & (n - 1)) !== 0) {
570
+ bluestein(re, im, inverse);
571
+ return;
572
+ }
569
573
  // Bit-reversal permutation
570
574
  let j = 0;
571
575
  for (let i = 0; i < n; i++) {
@@ -584,7 +588,7 @@ function fftComplex(re, im, inverse) {
584
588
  }
585
589
  j += m;
586
590
  }
587
- const sign = -1;
591
+ const sign = inverse ? 1 : -1;
588
592
  for (let size = 2; size <= n; size <<= 1) {
589
593
  const halfsize = size >> 1;
590
594
  const step = (sign * Math.PI) / halfsize;
@@ -608,6 +612,53 @@ function fftComplex(re, im, inverse) {
608
612
  }
609
613
  }
610
614
  }
615
+ if (inverse) {
616
+ for (let i = 0; i < n; i++) {
617
+ re[i] /= n;
618
+ im[i] /= n;
619
+ }
620
+ }
621
+ }
622
+ function bluestein(re, im, inverse) {
623
+ const n = re.length;
624
+ const m = nextPow2(n * 2 - 1);
625
+ const s = inverse ? 1 : -1;
626
+ const aRe = new Float64Array(m);
627
+ const aIm = new Float64Array(m);
628
+ const bRe = new Float64Array(m);
629
+ const bIm = new Float64Array(m);
630
+ for (let i = 0; i < n; i++) {
631
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
632
+ const cosA = Math.cos(angle);
633
+ const sinA = Math.sin(angle);
634
+ aRe[i] = re[i] * cosA - im[i] * sinA;
635
+ aIm[i] = re[i] * sinA + im[i] * cosA;
636
+ bRe[i] = cosA;
637
+ bIm[i] = -sinA;
638
+ }
639
+ for (let i = 1; i < n; i++) {
640
+ bRe[m - i] = bRe[i];
641
+ bIm[m - i] = bIm[i];
642
+ }
643
+ fftComplex(aRe, aIm, false);
644
+ fftComplex(bRe, bIm, false);
645
+ for (let i = 0; i < m; i++) {
646
+ const r = aRe[i] * bRe[i] - aIm[i] * bIm[i];
647
+ const iIm = aRe[i] * bIm[i] + aIm[i] * bRe[i];
648
+ aRe[i] = r;
649
+ aIm[i] = iIm;
650
+ }
651
+ fftComplex(aRe, aIm, true);
652
+ const scale = inverse ? 1 / n : 1;
653
+ for (let i = 0; i < n; i++) {
654
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
655
+ const cosA = Math.cos(angle);
656
+ const sinA = Math.sin(angle);
657
+ const r = aRe[i] * cosA - aIm[i] * sinA;
658
+ const iIm = aRe[i] * sinA + aIm[i] * cosA;
659
+ re[i] = r * scale;
660
+ im[i] = iIm * scale;
661
+ }
611
662
  }
612
663
  /** Next power of 2 >= n */
613
664
  function nextPow2(n) {
@@ -633,7 +684,7 @@ function bitsToUint(bits, start, len) {
633
684
  }
634
685
  return val;
635
686
  }
636
- function unpack28(n28) {
687
+ function unpack28(n28, book) {
637
688
  if (n28 < 0 || n28 >= 268435456)
638
689
  return { call: "", success: false };
639
690
  if (n28 === 0)
@@ -647,7 +698,6 @@ function unpack28(n28) {
647
698
  return { call: `CQ ${nqsy.toString().padStart(3, "0")}`, success: true };
648
699
  }
649
700
  if (n28 >= 1003 && n28 < NTOKENS) {
650
- // CQ with 4-letter directed call
651
701
  let m = n28 - 1003;
652
702
  let chars = "";
653
703
  for (let i = 3; i >= 0; i--) {
@@ -661,7 +711,10 @@ function unpack28(n28) {
661
711
  return { call: "CQ", success: true };
662
712
  }
663
713
  if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
664
- // Hashed call we don't have a hash table, so show <...>
714
+ const n22 = n28 - NTOKENS;
715
+ const resolved = book?.lookup22(n22);
716
+ if (resolved)
717
+ return { call: `<${resolved}>`, success: true };
665
718
  return { call: "<...>", success: true };
666
719
  }
667
720
  // Standard callsign
@@ -741,8 +794,11 @@ function unpackText77(bits71) {
741
794
  }
742
795
  /**
743
796
  * Unpack a 77-bit FT8 message into a human-readable string.
797
+ *
798
+ * When a {@link HashCallBook} is provided, hashed callsigns are resolved from
799
+ * the book, and newly decoded standard callsigns are saved into it.
744
800
  */
745
- function unpack77(bits77) {
801
+ function unpack77(bits77, book) {
746
802
  const n3 = bitsToUint(bits77, 71, 3);
747
803
  const i3 = bitsToUint(bits77, 74, 3);
748
804
  if (i3 === 0 && n3 === 0) {
@@ -760,8 +816,8 @@ function unpack77(bits77) {
760
816
  const ipb = bits77[57];
761
817
  const ir = bits77[58];
762
818
  const igrid4 = bitsToUint(bits77, 59, 15);
763
- const { call: call1, success: ok1 } = unpack28(n28a);
764
- const { call: call2Raw, success: ok2 } = unpack28(n28b);
819
+ const { call: call1, success: ok1 } = unpack28(n28a, book);
820
+ const { call: call2Raw, success: ok2 } = unpack28(n28b, book);
765
821
  if (!ok1 || !ok2)
766
822
  return { msg: "", success: false };
767
823
  let c1 = call1;
@@ -779,6 +835,9 @@ function unpack77(bits77) {
779
835
  c2 += "/R";
780
836
  if (ipb === 1 && i3 === 2 && c2.length >= 3)
781
837
  c2 += "/P";
838
+ // Save the "from" call (call_2) into the hash book
839
+ if (book && c2.length >= 3)
840
+ book.save(c2);
782
841
  }
783
842
  if (igrid4 <= MAXGRID4) {
784
843
  const { grid, success: gridOk } = toGrid4(igrid4);
@@ -811,6 +870,7 @@ function unpack77(bits77) {
811
870
  }
812
871
  if (i3 === 4) {
813
872
  // Type 4: One nonstandard call
873
+ const n12 = bitsToUint(bits77, 0, 12);
814
874
  let n58 = 0n;
815
875
  for (let i = 0; i < 58; i++) {
816
876
  n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
@@ -818,7 +878,6 @@ function unpack77(bits77) {
818
878
  const iflip = bits77[70];
819
879
  const nrpt = bitsToUint(bits77, 71, 2);
820
880
  const icq = bits77[73];
821
- // Decode n58 to 11-char string using C38 alphabet
822
881
  const c11chars = [];
823
882
  let remain = n58;
824
883
  for (let i = 10; i >= 0; i--) {
@@ -827,12 +886,15 @@ function unpack77(bits77) {
827
886
  c11chars.unshift(C38[j] ?? " ");
828
887
  }
829
888
  const c11 = c11chars.join("").trim();
830
- const call3 = "<...>"; // We don't have a hash table for n12
889
+ const resolved = book?.lookup12(n12);
890
+ const call3 = resolved ? `<${resolved}>` : "<...>";
831
891
  let call1;
832
892
  let call2;
833
893
  if (iflip === 0) {
834
894
  call1 = call3;
835
895
  call2 = c11;
896
+ if (book)
897
+ book.save(c11);
836
898
  }
837
899
  else {
838
900
  call1 = c11;
@@ -867,6 +929,7 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
867
929
  const syncmin = options.syncMin ?? 1.2;
868
930
  const depth = options.depth ?? 2;
869
931
  const maxCandidates = options.maxCandidates ?? 300;
932
+ const book = options.hashCallBook;
870
933
  // Resample to 12000 Hz if needed
871
934
  let dd;
872
935
  if (sampleRate === SAMPLE_RATE) {
@@ -878,12 +941,20 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
878
941
  else {
879
942
  dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
880
943
  }
944
+ // Compute huge FFT for downsampling caching
945
+ const NFFT1_LONG = 192000;
946
+ const cxRe = new Float64Array(NFFT1_LONG);
947
+ const cxIm = new Float64Array(NFFT1_LONG);
948
+ for (let i = 0; i < NMAX; i++) {
949
+ cxRe[i] = dd[i] ?? 0;
950
+ }
951
+ fftComplex(cxRe, cxIm, false);
881
952
  // Compute spectrogram and find sync candidates
882
953
  const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
883
954
  const decoded = [];
884
955
  const seenMessages = new Set();
885
956
  for (const cand of candidates) {
886
- const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
957
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
887
958
  if (!result)
888
959
  continue;
889
960
  if (seenMessages.has(result.msg))
@@ -919,7 +990,7 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
919
990
  for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
920
991
  xRe[i] = fac * dd[ia + i];
921
992
  }
922
- fftComplex(xRe, xIm);
993
+ fftComplex(xRe, xIm, false);
923
994
  for (let i = 0; i < halfSize; i++) {
924
995
  const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
925
996
  s[i * NHSYM + j] = power;
@@ -1067,17 +1138,16 @@ function computeBaseline(savg, nfa, nfb, df, nh1) {
1067
1138
  }
1068
1139
  return sbase;
1069
1140
  }
1070
- function ft8b(dd0, f1, xdt, _sbase, depth) {
1141
+ function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
1071
1142
  const NFFT2 = 3200;
1072
1143
  const NP2 = 2812;
1073
- const NFFT1_LONG = 192000;
1074
1144
  const fs2 = SAMPLE_RATE / NDOWN;
1075
1145
  const dt2 = 1.0 / fs2;
1076
1146
  const twopi = 2 * Math.PI;
1077
1147
  // Downsample: mix to baseband and filter
1078
1148
  const cd0Re = new Float64Array(NFFT2);
1079
1149
  const cd0Im = new Float64Array(NFFT2);
1080
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1150
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1081
1151
  // Find best time offset
1082
1152
  const i0 = Math.round((xdt + 0.5) * fs2);
1083
1153
  let smax = 0;
@@ -1111,7 +1181,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1111
1181
  }
1112
1182
  // Apply frequency correction and re-downsample
1113
1183
  f1 += delfbest;
1114
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1184
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1115
1185
  // Refine time offset
1116
1186
  const ss = new Float64Array(9);
1117
1187
  for (let idt = -4; idt <= 4; idt++) {
@@ -1143,7 +1213,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1143
1213
  symbIm[j] = cd0Im[i1 + j];
1144
1214
  }
1145
1215
  }
1146
- fftComplex(symbRe, symbIm);
1216
+ fftComplex(symbRe, symbIm, false);
1147
1217
  for (let tone = 0; tone < 8; tone++) {
1148
1218
  const re = symbRe[tone] / 1000;
1149
1219
  const im = symbIm[tone] / 1000;
@@ -1276,7 +1346,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1276
1346
  if (i3v === 0 && n3v === 2)
1277
1347
  return null;
1278
1348
  // Unpack
1279
- const { msg, success } = unpack77(message77);
1349
+ const { msg, success } = unpack77(message77, book);
1280
1350
  if (!success || msg.trim().length === 0)
1281
1351
  return null;
1282
1352
  // Estimate SNR
@@ -1317,44 +1387,75 @@ function getTones$1(cw) {
1317
1387
  return tones;
1318
1388
  }
1319
1389
  /**
1320
- * Mix f0 to baseband and decimate by NDOWN (60x).
1321
- * Time-domain approach: mix down, low-pass filter via moving average, decimate.
1322
- * Output: complex baseband signal at 200 Hz sample rate (32 samples/symbol).
1390
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1391
+ * Identical to Fortran ft8_downsample.
1323
1392
  */
1324
- function ft8Downsample(dd, f0, outRe, outIm, _nfft1Long, nfft2) {
1325
- const twopi = 2 * Math.PI;
1326
- const len = Math.min(dd.length, NMAX);
1327
- const dphi = (twopi * f0) / SAMPLE_RATE;
1328
- // Mix to baseband
1329
- const mixRe = new Float64Array(len);
1330
- const mixIm = new Float64Array(len);
1331
- let phi = 0;
1332
- for (let i = 0; i < len; i++) {
1333
- mixRe[i] = dd[i] * Math.cos(phi);
1334
- mixIm[i] = -dd[i] * Math.sin(phi);
1335
- phi += dphi;
1336
- if (phi > twopi)
1337
- phi -= twopi;
1338
- }
1339
- // Low-pass filter: simple moving-average with window = NDOWN
1340
- // then decimate by NDOWN to get 200 Hz sample rate
1341
- const outLen = Math.min(nfft2, Math.floor(len / NDOWN));
1342
- outRe.fill(0);
1343
- outIm.fill(0);
1344
- // Running sum filter
1345
- const halfWin = NDOWN >> 1;
1346
- for (let k = 0; k < outLen; k++) {
1347
- const center = k * NDOWN + halfWin;
1348
- let sumRe = 0, sumIm = 0;
1349
- const start = Math.max(0, center - halfWin);
1350
- const end = Math.min(len, center + halfWin);
1351
- for (let j = start; j < end; j++) {
1352
- sumRe += mixRe[j];
1353
- sumIm += mixIm[j];
1354
- }
1355
- const n = end - start;
1356
- outRe[k] = sumRe / n;
1357
- outIm[k] = sumIm / n;
1393
+ function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
1394
+ const NFFT1 = 192000;
1395
+ const NFFT2 = 3200;
1396
+ const df = 12000.0 / NFFT1;
1397
+ // NSPS is imported, should be 1920
1398
+ const baud = 12000.0 / NSPS; // 6.25
1399
+ const i0 = Math.round(f0 / df);
1400
+ const ft = f0 + 8.5 * baud;
1401
+ const it = Math.min(Math.round(ft / df), NFFT1 / 2);
1402
+ const fb = f0 - 1.5 * baud;
1403
+ const ib = Math.max(1, Math.round(fb / df));
1404
+ c1Re.fill(0);
1405
+ c1Im.fill(0);
1406
+ let k = 0;
1407
+ for (let i = ib; i <= it; i++) {
1408
+ if (k >= NFFT2)
1409
+ break;
1410
+ c1Re[k] = cxRe[i] ?? 0;
1411
+ c1Im[k] = cxIm[i] ?? 0;
1412
+ k++;
1413
+ }
1414
+ // Taper
1415
+ const pi = Math.PI;
1416
+ const taper = new Float64Array(101);
1417
+ for (let i = 0; i <= 100; i++) {
1418
+ taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
1419
+ }
1420
+ for (let i = 0; i <= 100; i++) {
1421
+ if (i >= NFFT2)
1422
+ break;
1423
+ const tap = taper[100 - i];
1424
+ c1Re[i] = c1Re[i] * tap;
1425
+ c1Im[i] = c1Im[i] * tap;
1426
+ }
1427
+ const endTap = k - 1;
1428
+ for (let i = 0; i <= 100; i++) {
1429
+ const idx = endTap - 100 + i;
1430
+ if (idx >= 0 && idx < NFFT2) {
1431
+ const tap = taper[i];
1432
+ c1Re[idx] = c1Re[idx] * tap;
1433
+ c1Im[idx] = c1Im[idx] * tap;
1434
+ }
1435
+ }
1436
+ // CSHIFT
1437
+ const shift = i0 - ib;
1438
+ const tempRe = new Float64Array(NFFT2);
1439
+ const tempIm = new Float64Array(NFFT2);
1440
+ for (let i = 0; i < NFFT2; i++) {
1441
+ let srcIdx = (i + shift) % NFFT2;
1442
+ if (srcIdx < 0)
1443
+ srcIdx += NFFT2;
1444
+ tempRe[i] = c1Re[srcIdx];
1445
+ tempIm[i] = c1Im[srcIdx];
1446
+ }
1447
+ for (let i = 0; i < NFFT2; i++) {
1448
+ c1Re[i] = tempRe[i];
1449
+ c1Im[i] = tempIm[i];
1450
+ }
1451
+ // iFFT
1452
+ fftComplex(c1Re, c1Im, true);
1453
+ // Scale
1454
+ // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
1455
+ const scale = Math.sqrt(NFFT2 / NFFT1);
1456
+ for (let i = 0; i < NFFT2; i++) {
1457
+ c1Re[i] = c1Re[i] * scale;
1458
+ c1Im[i] = c1Im[i] * scale;
1358
1459
  }
1359
1460
  }
1360
1461
  function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
@@ -2112,5 +2213,111 @@ function encode(msg, options = {}) {
2112
2213
  return generateFT8Waveform(encodeMessage(msg), options);
2113
2214
  }
2114
2215
 
2115
- export { decode as decodeFT8, encode as encodeFT8 };
2216
+ /**
2217
+ * Hash call table – TypeScript port of the hash call storage from packjt77.f90
2218
+ *
2219
+ * In FT8, nonstandard callsigns are transmitted as hashes (10-, 12-, or 22-bit).
2220
+ * When a full callsign is decoded from a standard message, it is stored in this
2221
+ * table so that future hashed references to it can be resolved.
2222
+ *
2223
+ * Mirrors Fortran: save_hash_call, hash10, hash12, hash22, ihashcall
2224
+ */
2225
+ const MAGIC = 47055833459n;
2226
+ const MAX_HASH22_ENTRIES = 1000;
2227
+ function ihashcall(c0, m) {
2228
+ const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
2229
+ let n8 = 0n;
2230
+ for (let i = 0; i < 11; i++) {
2231
+ const j = C38.indexOf(s[i] ?? " ");
2232
+ n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
2233
+ }
2234
+ const prod = BigInt.asUintN(64, MAGIC * n8);
2235
+ return Number(prod >> BigInt(64 - m)) & ((1 << m) - 1);
2236
+ }
2237
+ /**
2238
+ * Maintains a callsign ↔ hash lookup table for resolving hashed FT8 callsigns.
2239
+ *
2240
+ * Usage:
2241
+ * ```ts
2242
+ * const book = new HashCallBook();
2243
+ * const decoded = decodeFT8(samples, sampleRate, { hashCallBook: book });
2244
+ * // `book` now contains callsigns learned from decoded messages.
2245
+ * // Subsequent calls reuse the same book to resolve hashed callsigns:
2246
+ * const decoded2 = decodeFT8(samples2, sampleRate, { hashCallBook: book });
2247
+ * ```
2248
+ *
2249
+ * You can also pre-populate the book with known callsigns:
2250
+ * ```ts
2251
+ * book.save("W9XYZ");
2252
+ * book.save("PJ4/K1ABC");
2253
+ * ```
2254
+ */
2255
+ class HashCallBook {
2256
+ calls10 = new Map();
2257
+ calls12 = new Map();
2258
+ hash22Entries = [];
2259
+ /**
2260
+ * Store a callsign in all three hash tables (10, 12, 22-bit).
2261
+ * Strips angle brackets if present. Ignores `<...>` and blank/short strings.
2262
+ */
2263
+ save(callsign) {
2264
+ let cw = callsign.trim().toUpperCase();
2265
+ if (cw === "" || cw === "<...>")
2266
+ return;
2267
+ if (cw.startsWith("<"))
2268
+ cw = cw.slice(1);
2269
+ const gt = cw.indexOf(">");
2270
+ if (gt >= 0)
2271
+ cw = cw.slice(0, gt);
2272
+ cw = cw.trim();
2273
+ if (cw.length < 3)
2274
+ return;
2275
+ const n10 = ihashcall(cw, 10);
2276
+ if (n10 >= 0 && n10 <= 1023)
2277
+ this.calls10.set(n10, cw);
2278
+ const n12 = ihashcall(cw, 12);
2279
+ if (n12 >= 0 && n12 <= 4095)
2280
+ this.calls12.set(n12, cw);
2281
+ const n22 = ihashcall(cw, 22);
2282
+ const existing = this.hash22Entries.findIndex((e) => e.hash === n22);
2283
+ if (existing >= 0) {
2284
+ this.hash22Entries[existing].call = cw;
2285
+ }
2286
+ else {
2287
+ if (this.hash22Entries.length >= MAX_HASH22_ENTRIES) {
2288
+ this.hash22Entries.pop();
2289
+ }
2290
+ this.hash22Entries.unshift({ hash: n22, call: cw });
2291
+ }
2292
+ }
2293
+ /** Look up a callsign by its 10-bit hash. Returns `null` if not found. */
2294
+ lookup10(n10) {
2295
+ if (n10 < 0 || n10 > 1023)
2296
+ return null;
2297
+ return this.calls10.get(n10) ?? null;
2298
+ }
2299
+ /** Look up a callsign by its 12-bit hash. Returns `null` if not found. */
2300
+ lookup12(n12) {
2301
+ if (n12 < 0 || n12 > 4095)
2302
+ return null;
2303
+ return this.calls12.get(n12) ?? null;
2304
+ }
2305
+ /** Look up a callsign by its 22-bit hash. Returns `null` if not found. */
2306
+ lookup22(n22) {
2307
+ const entry = this.hash22Entries.find((e) => e.hash === n22);
2308
+ return entry?.call ?? null;
2309
+ }
2310
+ /** Number of entries in the 22-bit hash table. */
2311
+ get size() {
2312
+ return this.hash22Entries.length;
2313
+ }
2314
+ /** Remove all stored entries. */
2315
+ clear() {
2316
+ this.calls10.clear();
2317
+ this.calls12.clear();
2318
+ this.hash22Entries.length = 0;
2319
+ }
2320
+ }
2321
+
2322
+ export { HashCallBook, decode as decodeFT8, encode as encodeFT8 };
2116
2323
  //# sourceMappingURL=ft8ts.mjs.map