@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 +58 -7
- package/dist/ft8ts.cjs +134 -45
- package/dist/ft8ts.cjs.map +1 -1
- package/dist/ft8ts.mjs +134 -45
- package/dist/ft8ts.mjs.map +1 -1
- package/package.json +1 -1
- package/src/ft8/decode.ts +87 -44
- package/src/util/fft.ts +56 -0
- package/src/__test__/190227_155815.wav +0 -0
- package/src/__test__/decode.test.ts +0 -117
- package/src/__test__/encode.test.ts +0 -52
- package/src/__test__/test_vectors.ts +0 -221
- package/src/__test__/wav.test.ts +0 -45
- package/src/__test__/waveform.test.ts +0 -28
package/README.md
CHANGED
|
@@ -1,11 +1,61 @@
|
|
|
1
1
|
# ft8ts
|
|
2
2
|
|
|
3
|
+
[](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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
1327
|
-
const
|
|
1328
|
-
const
|
|
1329
|
-
const
|
|
1330
|
-
//
|
|
1331
|
-
const
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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) {
|