@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 +69 -9
- package/dist/ft8ts.cjs +262 -54
- package/dist/ft8ts.cjs.map +1 -1
- package/dist/ft8ts.d.ts +57 -1
- package/dist/ft8ts.mjs +262 -55
- package/dist/ft8ts.mjs.map +1 -1
- package/example/browser/index.html +4 -4
- package/package.json +51 -51
- package/src/ft8/decode.ts +101 -47
- package/src/index.ts +1 -0
- package/src/util/fft.ts +56 -0
- package/src/util/hashcall.ts +110 -0
- package/src/util/unpack_jt77.ts +17 -8
- 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,10 +1,62 @@
|
|
|
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
|
-
FT8 is a digital amateur radio mode designed for weak-signal communication
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|