@e04/ft8ts 0.0.1 → 0.0.2

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,11 +1,61 @@
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
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.
8
10
 
11
+ ## Demo
12
+
13
+ ### Browser
14
+
15
+ https://e04.github.io/ft8ts/example/browser/index.html
16
+
17
+ ### CLI
18
+
19
+ #### Encode
20
+
21
+ ```bash
22
+ npx tsx example/generate-ft8-wav.ts "CQ JK1IFA PM95" [--out output.wav] [--df 1000]
23
+ ```
24
+
25
+ #### Decode
26
+
27
+ ```bash
28
+ npx tsx example/decode-ft8-wav.ts ./src/__test__/190227_155815.wav [--low 200] [--high 3000] [--depth 2]
29
+ ```
30
+
31
+ ## Benchmark
32
+
33
+ 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
+
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 | | | | | | |
56
+
57
+ 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
+
9
59
  ## Installation
10
60
 
11
61
  `npm i @e04/ft8ts`
@@ -45,14 +95,9 @@ for (const d of decoded) {
45
95
  | `depth` | 2 | Decoding depth: 1=fast BP only, 2=BP+OSD, 3=deep |
46
96
  | `maxCandidates` | 300 | Maximum candidates to process |
47
97
 
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
98
+ ## ToDo
55
99
 
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 <...>.
56
101
 
57
102
  ## Build
58
103
 
@@ -67,3 +112,9 @@ GPL-3.0
67
112
  ## References
68
113
 
69
114
  - [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)
115
+
116
+ ## Related Projects
117
+
118
+ - **[PyFT8](https://github.com/G1OJS/PyFT8)** — Python implementation.
119
+ - **[ft8_lib](https://github.com/kgoba/ft8_lib)** — C++ implementation.
120
+ - **[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) {
@@ -880,12 +931,20 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
880
931
  else {
881
932
  dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
882
933
  }
934
+ // Compute huge FFT for downsampling caching
935
+ const NFFT1_LONG = 192000;
936
+ const cxRe = new Float64Array(NFFT1_LONG);
937
+ const cxIm = new Float64Array(NFFT1_LONG);
938
+ for (let i = 0; i < NMAX; i++) {
939
+ cxRe[i] = dd[i] ?? 0;
940
+ }
941
+ fftComplex(cxRe, cxIm, false);
883
942
  // Compute spectrogram and find sync candidates
884
943
  const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
885
944
  const decoded = [];
886
945
  const seenMessages = new Set();
887
946
  for (const cand of candidates) {
888
- const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
947
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth);
889
948
  if (!result)
890
949
  continue;
891
950
  if (seenMessages.has(result.msg))
@@ -921,7 +980,7 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
921
980
  for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
922
981
  xRe[i] = fac * dd[ia + i];
923
982
  }
924
- fftComplex(xRe, xIm);
983
+ fftComplex(xRe, xIm, false);
925
984
  for (let i = 0; i < halfSize; i++) {
926
985
  const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
927
986
  s[i * NHSYM + j] = power;
@@ -1069,17 +1128,16 @@ function computeBaseline(savg, nfa, nfb, df, nh1) {
1069
1128
  }
1070
1129
  return sbase;
1071
1130
  }
1072
- function ft8b(dd0, f1, xdt, _sbase, depth) {
1131
+ function ft8b(dd0, cxRe, cxIm, f1, xdt, _sbase, depth) {
1073
1132
  const NFFT2 = 3200;
1074
1133
  const NP2 = 2812;
1075
- const NFFT1_LONG = 192000;
1076
1134
  const fs2 = SAMPLE_RATE / NDOWN;
1077
1135
  const dt2 = 1.0 / fs2;
1078
1136
  const twopi = 2 * Math.PI;
1079
1137
  // Downsample: mix to baseband and filter
1080
1138
  const cd0Re = new Float64Array(NFFT2);
1081
1139
  const cd0Im = new Float64Array(NFFT2);
1082
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1140
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1083
1141
  // Find best time offset
1084
1142
  const i0 = Math.round((xdt + 0.5) * fs2);
1085
1143
  let smax = 0;
@@ -1113,7 +1171,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1113
1171
  }
1114
1172
  // Apply frequency correction and re-downsample
1115
1173
  f1 += delfbest;
1116
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1174
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1117
1175
  // Refine time offset
1118
1176
  const ss = new Float64Array(9);
1119
1177
  for (let idt = -4; idt <= 4; idt++) {
@@ -1145,7 +1203,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1145
1203
  symbIm[j] = cd0Im[i1 + j];
1146
1204
  }
1147
1205
  }
1148
- fftComplex(symbRe, symbIm);
1206
+ fftComplex(symbRe, symbIm, false);
1149
1207
  for (let tone = 0; tone < 8; tone++) {
1150
1208
  const re = symbRe[tone] / 1000;
1151
1209
  const im = symbIm[tone] / 1000;
@@ -1319,44 +1377,75 @@ function getTones$1(cw) {
1319
1377
  return tones;
1320
1378
  }
1321
1379
  /**
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).
1380
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1381
+ * Identical to Fortran ft8_downsample.
1325
1382
  */
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;
1383
+ function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
1384
+ const NFFT1 = 192000;
1385
+ const NFFT2 = 3200;
1386
+ const df = 12000.0 / NFFT1;
1387
+ // NSPS is imported, should be 1920
1388
+ const baud = 12000.0 / NSPS; // 6.25
1389
+ const i0 = Math.round(f0 / df);
1390
+ const ft = f0 + 8.5 * baud;
1391
+ const it = Math.min(Math.round(ft / df), NFFT1 / 2);
1392
+ const fb = f0 - 1.5 * baud;
1393
+ const ib = Math.max(1, Math.round(fb / df));
1394
+ c1Re.fill(0);
1395
+ c1Im.fill(0);
1396
+ let k = 0;
1397
+ for (let i = ib; i <= it; i++) {
1398
+ if (k >= NFFT2)
1399
+ break;
1400
+ c1Re[k] = cxRe[i] ?? 0;
1401
+ c1Im[k] = cxIm[i] ?? 0;
1402
+ k++;
1403
+ }
1404
+ // Taper
1405
+ const pi = Math.PI;
1406
+ const taper = new Float64Array(101);
1407
+ for (let i = 0; i <= 100; i++) {
1408
+ taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
1409
+ }
1410
+ for (let i = 0; i <= 100; i++) {
1411
+ if (i >= NFFT2)
1412
+ break;
1413
+ const tap = taper[100 - i];
1414
+ c1Re[i] = c1Re[i] * tap;
1415
+ c1Im[i] = c1Im[i] * tap;
1416
+ }
1417
+ const endTap = k - 1;
1418
+ for (let i = 0; i <= 100; i++) {
1419
+ const idx = endTap - 100 + i;
1420
+ if (idx >= 0 && idx < NFFT2) {
1421
+ const tap = taper[i];
1422
+ c1Re[idx] = c1Re[idx] * tap;
1423
+ c1Im[idx] = c1Im[idx] * tap;
1424
+ }
1425
+ }
1426
+ // CSHIFT
1427
+ const shift = i0 - ib;
1428
+ const tempRe = new Float64Array(NFFT2);
1429
+ const tempIm = new Float64Array(NFFT2);
1430
+ for (let i = 0; i < NFFT2; i++) {
1431
+ let srcIdx = (i + shift) % NFFT2;
1432
+ if (srcIdx < 0)
1433
+ srcIdx += NFFT2;
1434
+ tempRe[i] = c1Re[srcIdx];
1435
+ tempIm[i] = c1Im[srcIdx];
1436
+ }
1437
+ for (let i = 0; i < NFFT2; i++) {
1438
+ c1Re[i] = tempRe[i];
1439
+ c1Im[i] = tempIm[i];
1440
+ }
1441
+ // iFFT
1442
+ fftComplex(c1Re, c1Im, true);
1443
+ // Scale
1444
+ // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
1445
+ const scale = Math.sqrt(NFFT2 / NFFT1);
1446
+ for (let i = 0; i < NFFT2; i++) {
1447
+ c1Re[i] = c1Re[i] * scale;
1448
+ c1Im[i] = c1Im[i] * scale;
1360
1449
  }
1361
1450
  }
1362
1451
  function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {