@e04/ft8ts 0.0.1
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/LICENSE +674 -0
- package/README.md +69 -0
- package/dist/ft8js.cjs +2119 -0
- package/dist/ft8js.cjs.map +1 -0
- package/dist/ft8js.mjs +2116 -0
- package/dist/ft8js.mjs.map +1 -0
- package/dist/ft8ts.cjs +2119 -0
- package/dist/ft8ts.cjs.map +1 -0
- package/dist/ft8ts.d.ts +36 -0
- package/dist/ft8ts.mjs +2116 -0
- package/dist/ft8ts.mjs.map +1 -0
- package/example/browser/index.html +251 -0
- package/example/decode-ft8-wav.ts +78 -0
- package/example/generate-ft8-wav.ts +82 -0
- package/package.json +53 -0
- package/src/__test__/190227_155815.wav +0 -0
- package/src/__test__/decode.test.ts +117 -0
- package/src/__test__/encode.test.ts +52 -0
- package/src/__test__/test_vectors.ts +221 -0
- package/src/__test__/wav.test.ts +45 -0
- package/src/__test__/waveform.test.ts +28 -0
- package/src/ft8/decode.ts +713 -0
- package/src/ft8/encode.ts +85 -0
- package/src/index.ts +2 -0
- package/src/util/constants.ts +118 -0
- package/src/util/crc.ts +39 -0
- package/src/util/decode174_91.ts +375 -0
- package/src/util/fft.ts +108 -0
- package/src/util/ldpc_tables.ts +91 -0
- package/src/util/pack_jt77.ts +531 -0
- package/src/util/unpack_jt77.ts +237 -0
- package/src/util/wav.ts +129 -0
- package/src/util/waveform.ts +120 -0
package/dist/ft8js.mjs
ADDED
|
@@ -0,0 +1,2116 @@
|
|
|
1
|
+
const SAMPLE_RATE = 12_000;
|
|
2
|
+
const NSPS = 1920;
|
|
3
|
+
const NFFT1 = 2 * NSPS; // 3840
|
|
4
|
+
const NSTEP = NSPS / 4; // 480
|
|
5
|
+
const NMAX = 15 * SAMPLE_RATE; // 180000
|
|
6
|
+
const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
|
|
7
|
+
const NDOWN = 60;
|
|
8
|
+
const NN = 79;
|
|
9
|
+
const KK = 91;
|
|
10
|
+
const N_LDPC = 174;
|
|
11
|
+
const M_LDPC = N_LDPC - KK; // 83
|
|
12
|
+
const icos7 = [3, 1, 4, 0, 6, 5, 2];
|
|
13
|
+
const graymap = [0, 1, 3, 2, 5, 6, 4, 7];
|
|
14
|
+
const gHex = [
|
|
15
|
+
"8329ce11bf31eaf509f27fc",
|
|
16
|
+
"761c264e25c259335493132",
|
|
17
|
+
"dc265902fb277c6410a1bdc",
|
|
18
|
+
"1b3f417858cd2dd33ec7f62",
|
|
19
|
+
"09fda4fee04195fd034783a",
|
|
20
|
+
"077cccc11b8873ed5c3d48a",
|
|
21
|
+
"29b62afe3ca036f4fe1a9da",
|
|
22
|
+
"6054faf5f35d96d3b0c8c3e",
|
|
23
|
+
"e20798e4310eed27884ae90",
|
|
24
|
+
"775c9c08e80e26ddae56318",
|
|
25
|
+
"b0b811028c2bf997213487c",
|
|
26
|
+
"18a0c9231fc60adf5c5ea32",
|
|
27
|
+
"76471e8302a0721e01b12b8",
|
|
28
|
+
"ffbccb80ca8341fafb47b2e",
|
|
29
|
+
"66a72a158f9325a2bf67170",
|
|
30
|
+
"c4243689fe85b1c51363a18",
|
|
31
|
+
"0dff739414d1a1b34b1c270",
|
|
32
|
+
"15b48830636c8b99894972e",
|
|
33
|
+
"29a89c0d3de81d665489b0e",
|
|
34
|
+
"4f126f37fa51cbe61bd6b94",
|
|
35
|
+
"99c47239d0d97d3c84e0940",
|
|
36
|
+
"1919b75119765621bb4f1e8",
|
|
37
|
+
"09db12d731faee0b86df6b8",
|
|
38
|
+
"488fc33df43fbdeea4eafb4",
|
|
39
|
+
"827423ee40b675f756eb5fe",
|
|
40
|
+
"abe197c484cb74757144a9a",
|
|
41
|
+
"2b500e4bc0ec5a6d2bdbdd0",
|
|
42
|
+
"c474aa53d70218761669360",
|
|
43
|
+
"8eba1a13db3390bd6718cec",
|
|
44
|
+
"753844673a27782cc42012e",
|
|
45
|
+
"06ff83a145c37035a5c1268",
|
|
46
|
+
"3b37417858cc2dd33ec3f62",
|
|
47
|
+
"9a4a5a28ee17ca9c324842c",
|
|
48
|
+
"bc29f465309c977e89610a4",
|
|
49
|
+
"2663ae6ddf8b5ce2bb29488",
|
|
50
|
+
"46f231efe457034c1814418",
|
|
51
|
+
"3fb2ce85abe9b0c72e06fbe",
|
|
52
|
+
"de87481f282c153971a0a2e",
|
|
53
|
+
"fcd7ccf23c69fa99bba1412",
|
|
54
|
+
"f0261447e9490ca8e474cec",
|
|
55
|
+
"4410115818196f95cdd7012",
|
|
56
|
+
"088fc31df4bfbde2a4eafb4",
|
|
57
|
+
"b8fef1b6307729fb0a078c0",
|
|
58
|
+
"5afea7acccb77bbc9d99a90",
|
|
59
|
+
"49a7016ac653f65ecdc9076",
|
|
60
|
+
"1944d085be4e7da8d6cc7d0",
|
|
61
|
+
"251f62adc4032f0ee714002",
|
|
62
|
+
"56471f8702a0721e00b12b8",
|
|
63
|
+
"2b8e4923f2dd51e2d537fa0",
|
|
64
|
+
"6b550a40a66f4755de95c26",
|
|
65
|
+
"a18ad28d4e27fe92a4f6c84",
|
|
66
|
+
"10c2e586388cb82a3d80758",
|
|
67
|
+
"ef34a41817ee02133db2eb0",
|
|
68
|
+
"7e9c0c54325a9c15836e000",
|
|
69
|
+
"3693e572d1fde4cdf079e86",
|
|
70
|
+
"bfb2cec5abe1b0c72e07fbe",
|
|
71
|
+
"7ee18230c583cccc57d4b08",
|
|
72
|
+
"a066cb2fedafc9f52664126",
|
|
73
|
+
"bb23725abc47cc5f4cc4cd2",
|
|
74
|
+
"ded9dba3bee40c59b5609b4",
|
|
75
|
+
"d9a7016ac653e6decdc9036",
|
|
76
|
+
"9ad46aed5f707f280ab5fc4",
|
|
77
|
+
"e5921c77822587316d7d3c2",
|
|
78
|
+
"4f14da8242a8b86dca73352",
|
|
79
|
+
"8b8b507ad467d4441df770e",
|
|
80
|
+
"22831c9cf1169467ad04b68",
|
|
81
|
+
"213b838fe2ae54c38ee7180",
|
|
82
|
+
"5d926b6dd71f085181a4e12",
|
|
83
|
+
"66ab79d4b29ee6e69509e56",
|
|
84
|
+
"958148682d748a38dd68baa",
|
|
85
|
+
"b8ce020cf069c32a723ab14",
|
|
86
|
+
"f4331d6d461607e95752746",
|
|
87
|
+
"6da23ba424b9596133cf9c8",
|
|
88
|
+
"a636bcbc7b30c5fbeae67fe",
|
|
89
|
+
"5cb0d86a07df654a9089a20",
|
|
90
|
+
"f11f106848780fc9ecdd80a",
|
|
91
|
+
"1fbb5364fb8d2c9d730d5ba",
|
|
92
|
+
"fcb86bc70a50c9d02a5d034",
|
|
93
|
+
"a534433029eac15f322e34c",
|
|
94
|
+
"c989d9c7c3d3b8c55d75130",
|
|
95
|
+
"7bb38b2f0186d46643ae962",
|
|
96
|
+
"2644ebadeb44b9467d1f42c",
|
|
97
|
+
"608cc857594bfbb55d69600",
|
|
98
|
+
];
|
|
99
|
+
const FTALPH = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?";
|
|
100
|
+
const A1 = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
101
|
+
const A2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
102
|
+
const A3 = "0123456789";
|
|
103
|
+
const A4 = " ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
104
|
+
const C38 = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/";
|
|
105
|
+
const NTOKENS = 2063592;
|
|
106
|
+
const MAX22 = 4194304; // 2^22
|
|
107
|
+
const MAX28 = 268435456; // 2^28
|
|
108
|
+
const MAXGRID4 = 32400;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* CRC-14 computation and checking, shared between encoder and decoder.
|
|
112
|
+
* Polynomial: 0x2757 (x^14 + x^13 + x^10 + x^9 + x^8 + x^6 + x^4 + x^2 + x + 1)
|
|
113
|
+
*/
|
|
114
|
+
function computeCRC14(msg77) {
|
|
115
|
+
const poly = 0x2757;
|
|
116
|
+
let crc = 0;
|
|
117
|
+
const bitArray = [...msg77, 0, 0, 0, ...new Array(16).fill(0)];
|
|
118
|
+
for (let bit = 0; bit < 96; bit++) {
|
|
119
|
+
const nextBit = bitArray[bit];
|
|
120
|
+
if ((crc & 0x2000) !== 0) {
|
|
121
|
+
crc = ((crc << 1) | nextBit) ^ poly;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
crc = (crc << 1) | nextBit;
|
|
125
|
+
}
|
|
126
|
+
crc &= 0x3fff;
|
|
127
|
+
}
|
|
128
|
+
return crc;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Check CRC-14 of a 91-bit decoded message (77 message + 14 CRC).
|
|
132
|
+
* Returns true if CRC is valid.
|
|
133
|
+
*/
|
|
134
|
+
function checkCRC14(bits91) {
|
|
135
|
+
const msg77 = bits91.slice(0, 77);
|
|
136
|
+
const receivedCRC = bitsToInt(bits91, 77, 14);
|
|
137
|
+
const computedCRC = computeCRC14(msg77);
|
|
138
|
+
return receivedCRC === computedCRC;
|
|
139
|
+
}
|
|
140
|
+
function bitsToInt(bits, offset, count) {
|
|
141
|
+
let val = 0;
|
|
142
|
+
for (let i = 0; i < count; i++) {
|
|
143
|
+
val = (val << 1) | (bits[offset + i] ?? 0);
|
|
144
|
+
}
|
|
145
|
+
return val;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* LDPC (174,91) parity check matrix data from ldpc_174_91_c_parity.f90
|
|
150
|
+
*
|
|
151
|
+
* Mn[j] = list of 3 check-node indices (1-based) for bit j (j=0..173)
|
|
152
|
+
* Nm[i] = list of variable-node indices (1-based) for check i (i=0..82), padded with 0
|
|
153
|
+
* nrw[i] = row weight for check i
|
|
154
|
+
* ncw = 3 (column weight – every bit participates in exactly 3 checks)
|
|
155
|
+
*/
|
|
156
|
+
// Mn: 174 rows, each with 3 check-node indices (1-based, from Fortran)
|
|
157
|
+
const MnFlat = [
|
|
158
|
+
16, 45, 73, 25, 51, 62, 33, 58, 78, 1, 44, 45, 2, 7, 61, 3, 6, 54, 4, 35, 48, 5, 13, 21, 8, 56,
|
|
159
|
+
79, 9, 64, 69, 10, 19, 66, 11, 36, 60, 12, 37, 58, 14, 32, 43, 15, 63, 80, 17, 28, 77, 18, 74, 83,
|
|
160
|
+
22, 53, 81, 23, 30, 34, 24, 31, 40, 26, 41, 76, 27, 57, 70, 29, 49, 65, 3, 38, 78, 5, 39, 82, 46,
|
|
161
|
+
50, 73, 51, 52, 74, 55, 71, 72, 44, 67, 72, 43, 68, 78, 1, 32, 59, 2, 6, 71, 4, 16, 54, 7, 65, 67,
|
|
162
|
+
8, 30, 42, 9, 22, 31, 10, 18, 76, 11, 23, 82, 12, 28, 61, 13, 52, 79, 14, 50, 51, 15, 81, 83, 17,
|
|
163
|
+
29, 60, 19, 33, 64, 20, 26, 73, 21, 34, 40, 24, 27, 77, 25, 55, 58, 35, 53, 66, 36, 48, 68, 37,
|
|
164
|
+
46, 75, 38, 45, 47, 39, 57, 69, 41, 56, 62, 20, 49, 53, 46, 52, 63, 45, 70, 75, 27, 35, 80, 1, 15,
|
|
165
|
+
30, 2, 68, 80, 3, 36, 51, 4, 28, 51, 5, 31, 56, 6, 20, 37, 7, 40, 82, 8, 60, 69, 9, 10, 49, 11,
|
|
166
|
+
44, 57, 12, 39, 59, 13, 24, 55, 14, 21, 65, 16, 71, 78, 17, 30, 76, 18, 25, 80, 19, 61, 83, 22,
|
|
167
|
+
38, 77, 23, 41, 50, 7, 26, 58, 29, 32, 81, 33, 40, 73, 18, 34, 48, 13, 42, 64, 5, 26, 43, 47, 69,
|
|
168
|
+
72, 54, 55, 70, 45, 62, 68, 10, 63, 67, 14, 66, 72, 22, 60, 74, 35, 39, 79, 1, 46, 64, 1, 24, 66,
|
|
169
|
+
2, 5, 70, 3, 31, 65, 4, 49, 58, 1, 4, 5, 6, 60, 67, 7, 32, 75, 8, 48, 82, 9, 35, 41, 10, 39, 62,
|
|
170
|
+
11, 14, 61, 12, 71, 74, 13, 23, 78, 11, 35, 55, 15, 16, 79, 7, 9, 16, 17, 54, 63, 18, 50, 57, 19,
|
|
171
|
+
30, 47, 20, 64, 80, 21, 28, 69, 22, 25, 43, 13, 22, 37, 2, 47, 51, 23, 54, 74, 26, 34, 72, 27, 36,
|
|
172
|
+
37, 21, 36, 63, 29, 40, 44, 19, 26, 57, 3, 46, 82, 14, 15, 58, 33, 52, 53, 30, 43, 52, 6, 9, 52,
|
|
173
|
+
27, 33, 65, 25, 69, 73, 38, 55, 83, 20, 39, 77, 18, 29, 56, 32, 48, 71, 42, 51, 59, 28, 44, 79,
|
|
174
|
+
34, 60, 62, 31, 45, 61, 46, 68, 77, 6, 24, 76, 8, 10, 78, 40, 41, 70, 17, 50, 53, 42, 66, 68, 4,
|
|
175
|
+
22, 72, 36, 64, 81, 13, 29, 47, 2, 8, 81, 56, 67, 73, 5, 38, 50, 12, 38, 64, 59, 72, 80, 3, 26,
|
|
176
|
+
79, 45, 76, 81, 1, 65, 74, 7, 18, 77, 11, 56, 59, 14, 39, 54, 16, 37, 66, 10, 28, 55, 15, 60, 70,
|
|
177
|
+
17, 25, 82, 20, 30, 31, 12, 67, 68, 23, 75, 80, 27, 32, 62, 24, 69, 75, 19, 21, 71, 34, 53, 61,
|
|
178
|
+
35, 46, 47, 33, 59, 76, 40, 43, 83, 41, 42, 63, 49, 75, 83, 20, 44, 48, 42, 49, 57,
|
|
179
|
+
];
|
|
180
|
+
// Nm: 83 rows, each with up to 7 variable-node indices (1-based, 0-padded)
|
|
181
|
+
const NmFlat = [
|
|
182
|
+
4, 31, 59, 91, 92, 96, 153, 5, 32, 60, 93, 115, 146, 0, 6, 24, 61, 94, 122, 151, 0, 7, 33, 62, 95,
|
|
183
|
+
96, 143, 0, 8, 25, 63, 83, 93, 96, 148, 6, 32, 64, 97, 126, 138, 0, 5, 34, 65, 78, 98, 107, 154,
|
|
184
|
+
9, 35, 66, 99, 139, 146, 0, 10, 36, 67, 100, 107, 126, 0, 11, 37, 67, 87, 101, 139, 158, 12, 38,
|
|
185
|
+
68, 102, 105, 155, 0, 13, 39, 69, 103, 149, 162, 0, 8, 40, 70, 82, 104, 114, 145, 14, 41, 71, 88,
|
|
186
|
+
102, 123, 156, 15, 42, 59, 106, 123, 159, 0, 1, 33, 72, 106, 107, 157, 0, 16, 43, 73, 108, 141,
|
|
187
|
+
160, 0, 17, 37, 74, 81, 109, 131, 154, 11, 44, 75, 110, 121, 166, 0, 45, 55, 64, 111, 130, 161,
|
|
188
|
+
173, 8, 46, 71, 112, 119, 166, 0, 18, 36, 76, 89, 113, 114, 143, 19, 38, 77, 104, 116, 163, 0, 20,
|
|
189
|
+
47, 70, 92, 138, 165, 0, 2, 48, 74, 113, 128, 160, 0, 21, 45, 78, 83, 117, 121, 151, 22, 47, 58,
|
|
190
|
+
118, 127, 164, 0, 16, 39, 62, 112, 134, 158, 0, 23, 43, 79, 120, 131, 145, 0, 19, 35, 59, 73, 110,
|
|
191
|
+
125, 161, 20, 36, 63, 94, 136, 161, 0, 14, 31, 79, 98, 132, 164, 0, 3, 44, 80, 124, 127, 169, 0,
|
|
192
|
+
19, 46, 81, 117, 135, 167, 0, 7, 49, 58, 90, 100, 105, 168, 12, 50, 61, 118, 119, 144, 0, 13, 51,
|
|
193
|
+
64, 114, 118, 157, 0, 24, 52, 76, 129, 148, 149, 0, 25, 53, 69, 90, 101, 130, 156, 20, 46, 65, 80,
|
|
194
|
+
120, 140, 170, 21, 54, 77, 100, 140, 171, 0, 35, 82, 133, 142, 171, 174, 0, 14, 30, 83, 113, 125,
|
|
195
|
+
170, 0, 4, 29, 68, 120, 134, 173, 0, 1, 4, 52, 57, 86, 136, 152, 26, 51, 56, 91, 122, 137, 168,
|
|
196
|
+
52, 84, 110, 115, 145, 168, 0, 7, 50, 81, 99, 132, 173, 0, 23, 55, 67, 95, 172, 174, 0, 26, 41,
|
|
197
|
+
77, 109, 141, 148, 0, 2, 27, 41, 61, 62, 115, 133, 27, 40, 56, 124, 125, 126, 0, 18, 49, 55, 124,
|
|
198
|
+
141, 167, 0, 6, 33, 85, 108, 116, 156, 0, 28, 48, 70, 85, 105, 129, 158, 9, 54, 63, 131, 147, 155,
|
|
199
|
+
0, 22, 53, 68, 109, 121, 174, 0, 3, 13, 48, 78, 95, 123, 0, 31, 69, 133, 150, 155, 169, 0, 12, 43,
|
|
200
|
+
66, 89, 97, 135, 159, 5, 39, 75, 102, 136, 167, 0, 2, 54, 86, 101, 135, 164, 0, 15, 56, 87, 108,
|
|
201
|
+
119, 171, 0, 10, 44, 82, 91, 111, 144, 149, 23, 34, 71, 94, 127, 153, 0, 11, 49, 88, 92, 142, 157,
|
|
202
|
+
0, 29, 34, 87, 97, 147, 162, 0, 30, 50, 60, 86, 137, 142, 162, 10, 53, 66, 84, 112, 128, 165, 22,
|
|
203
|
+
57, 85, 93, 140, 159, 0, 28, 32, 72, 103, 132, 166, 0, 28, 29, 84, 88, 117, 143, 150, 1, 26, 45,
|
|
204
|
+
80, 128, 147, 0, 17, 27, 89, 103, 116, 153, 0, 51, 57, 98, 163, 165, 172, 0, 21, 37, 73, 138, 152,
|
|
205
|
+
169, 0, 16, 47, 76, 130, 137, 154, 0, 3, 24, 30, 72, 104, 139, 0, 9, 40, 90, 106, 134, 151, 0, 15,
|
|
206
|
+
58, 60, 74, 111, 150, 163, 18, 42, 79, 144, 146, 152, 0, 25, 38, 65, 99, 122, 160, 0, 17, 42, 75,
|
|
207
|
+
129, 170, 172, 0,
|
|
208
|
+
];
|
|
209
|
+
const nrwData = [
|
|
210
|
+
7, 6, 6, 6, 7, 6, 7, 6, 6, 7, 6, 6, 7, 7, 6, 6, 6, 7, 6, 7, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6, 6,
|
|
211
|
+
6, 7, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6,
|
|
212
|
+
6, 7, 7, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6,
|
|
213
|
+
];
|
|
214
|
+
const ncw = 3;
|
|
215
|
+
/** Mn[j] = check indices (0-based) for bit j (0..173). Each entry has exactly 3 elements. */
|
|
216
|
+
const Mn = [];
|
|
217
|
+
for (let j = 0; j < 174; j++) {
|
|
218
|
+
Mn.push([MnFlat[j * 3] - 1, MnFlat[j * 3 + 1] - 1, MnFlat[j * 3 + 2] - 1]);
|
|
219
|
+
}
|
|
220
|
+
/** Nm[i] = bit indices (0-based) for check i (0..82). Variable length (nrw[i] elements). */
|
|
221
|
+
const Nm = [];
|
|
222
|
+
/** nrw[i] = row weight for check i */
|
|
223
|
+
const nrw = nrwData.slice();
|
|
224
|
+
for (let i = 0; i < 83; i++) {
|
|
225
|
+
const row = [];
|
|
226
|
+
for (let k = 0; k < 7; k++) {
|
|
227
|
+
const v = NmFlat[i * 7 + k];
|
|
228
|
+
if (v !== 0)
|
|
229
|
+
row.push(v - 1);
|
|
230
|
+
}
|
|
231
|
+
Nm.push(row);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* LDPC (174,91) Belief Propagation decoder for FT8.
|
|
236
|
+
* Port of bpdecode174_91.f90 and decode174_91.f90.
|
|
237
|
+
*/
|
|
238
|
+
function platanh(x) {
|
|
239
|
+
if (x > 0.9999999)
|
|
240
|
+
return 18.71;
|
|
241
|
+
if (x < -0.9999999)
|
|
242
|
+
return -18.71;
|
|
243
|
+
return 0.5 * Math.log((1 + x) / (1 - x));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* BP decoder for (174,91) LDPC code.
|
|
247
|
+
* llr: log-likelihood ratios (174 values, positive = bit more likely 0)
|
|
248
|
+
* apmask: AP mask (174 values, 1 = a priori bit, don't update from check messages)
|
|
249
|
+
* maxIterations: max BP iterations
|
|
250
|
+
* Returns null if decoding fails, otherwise { message91, cw, nharderrors }
|
|
251
|
+
*/
|
|
252
|
+
function bpDecode174_91(llr, apmask, maxIterations) {
|
|
253
|
+
const N = N_LDPC;
|
|
254
|
+
const M = M_LDPC;
|
|
255
|
+
const tov = new Float64Array(ncw * N);
|
|
256
|
+
const toc = new Float64Array(7 * M);
|
|
257
|
+
const tanhtoc = new Float64Array(7 * M);
|
|
258
|
+
const zn = new Float64Array(N);
|
|
259
|
+
const cw = new Int8Array(N);
|
|
260
|
+
// Initialize messages to checks
|
|
261
|
+
for (let j = 0; j < M; j++) {
|
|
262
|
+
const w = nrw[j];
|
|
263
|
+
for (let i = 0; i < w; i++) {
|
|
264
|
+
toc[i * M + j] = llr[Nm[j][i]];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
let nclast = 0;
|
|
268
|
+
let ncnt = 0;
|
|
269
|
+
for (let iter = 0; iter <= maxIterations; iter++) {
|
|
270
|
+
// Update bit LLRs
|
|
271
|
+
for (let i = 0; i < N; i++) {
|
|
272
|
+
if (apmask[i] !== 1) {
|
|
273
|
+
let sum = 0;
|
|
274
|
+
for (let k = 0; k < ncw; k++)
|
|
275
|
+
sum += tov[k * N + i];
|
|
276
|
+
zn[i] = llr[i] + sum;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
zn[i] = llr[i];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Hard decision
|
|
283
|
+
for (let i = 0; i < N; i++)
|
|
284
|
+
cw[i] = zn[i] > 0 ? 1 : 0;
|
|
285
|
+
// Check parity
|
|
286
|
+
let ncheck = 0;
|
|
287
|
+
for (let i = 0; i < M; i++) {
|
|
288
|
+
const w = nrw[i];
|
|
289
|
+
let s = 0;
|
|
290
|
+
for (let k = 0; k < w; k++)
|
|
291
|
+
s += cw[Nm[i][k]];
|
|
292
|
+
if (s % 2 !== 0)
|
|
293
|
+
ncheck++;
|
|
294
|
+
}
|
|
295
|
+
if (ncheck === 0) {
|
|
296
|
+
const bits91 = Array.from(cw.slice(0, KK));
|
|
297
|
+
if (checkCRC14(bits91)) {
|
|
298
|
+
let nharderrors = 0;
|
|
299
|
+
for (let i = 0; i < N; i++) {
|
|
300
|
+
if ((2 * cw[i] - 1) * llr[i] < 0)
|
|
301
|
+
nharderrors++;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
message91: bits91,
|
|
305
|
+
cw: Array.from(cw),
|
|
306
|
+
nharderrors,
|
|
307
|
+
dmin: 0,
|
|
308
|
+
ntype: 1,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Early stopping
|
|
313
|
+
if (iter > 0) {
|
|
314
|
+
const nd = ncheck - nclast;
|
|
315
|
+
if (nd < 0) {
|
|
316
|
+
ncnt = 0;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
ncnt++;
|
|
320
|
+
}
|
|
321
|
+
if (ncnt >= 5 && iter >= 10 && ncheck > 15)
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
nclast = ncheck;
|
|
325
|
+
// Send messages from bits to check nodes
|
|
326
|
+
for (let j = 0; j < M; j++) {
|
|
327
|
+
const w = nrw[j];
|
|
328
|
+
for (let i = 0; i < w; i++) {
|
|
329
|
+
const ibj = Nm[j][i];
|
|
330
|
+
let val = zn[ibj];
|
|
331
|
+
for (let kk = 0; kk < ncw; kk++) {
|
|
332
|
+
if (Mn[ibj][kk] === j) {
|
|
333
|
+
val -= tov[kk * N + ibj];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
toc[i * M + j] = val;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Send messages from check nodes to variable nodes
|
|
340
|
+
for (let i = 0; i < M; i++) {
|
|
341
|
+
for (let k = 0; k < 7; k++) {
|
|
342
|
+
tanhtoc[k * M + i] = Math.tanh(-toc[k * M + i] / 2);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (let j = 0; j < N; j++) {
|
|
346
|
+
for (let i = 0; i < ncw; i++) {
|
|
347
|
+
const ichk = Mn[j][i];
|
|
348
|
+
const w = nrw[ichk];
|
|
349
|
+
let Tmn = 1.0;
|
|
350
|
+
for (let k = 0; k < w; k++) {
|
|
351
|
+
if (Nm[ichk][k] !== j) {
|
|
352
|
+
Tmn *= tanhtoc[k * M + ichk];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
tov[i * N + j] = 2 * platanh(-Tmn);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Hybrid BP + OSD-like decoder for (174,91) code.
|
|
363
|
+
* Tries BP first, then falls back to OSD approach for deeper decoding.
|
|
364
|
+
*/
|
|
365
|
+
function decode174_91(llr, apmask, maxosd) {
|
|
366
|
+
const maxIterations = 30;
|
|
367
|
+
// Try BP decoding
|
|
368
|
+
const bpResult = bpDecode174_91(llr, apmask, maxIterations);
|
|
369
|
+
if (bpResult)
|
|
370
|
+
return bpResult;
|
|
371
|
+
// OSD-0 fallback: try hard-decision with bit flipping for most unreliable bits
|
|
372
|
+
if (maxosd >= 0) {
|
|
373
|
+
return osdDecode174_91(llr, apmask, maxosd >= 1 ? 2 : 1);
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Simplified OSD decoder for (174,91) code.
|
|
379
|
+
* Uses ordered statistics approach: sort bits by reliability,
|
|
380
|
+
* do Gaussian elimination, try flipping least reliable info bits.
|
|
381
|
+
*/
|
|
382
|
+
function osdDecode174_91(llr, apmask, norder) {
|
|
383
|
+
const N = N_LDPC;
|
|
384
|
+
const K = KK;
|
|
385
|
+
const gen = getGenerator();
|
|
386
|
+
// Sort by reliability (descending)
|
|
387
|
+
const indices = Array.from({ length: N }, (_, i) => i);
|
|
388
|
+
indices.sort((a, b) => Math.abs(llr[b]) - Math.abs(llr[a]));
|
|
389
|
+
// Reorder generator matrix columns
|
|
390
|
+
const genmrb = new Uint8Array(K * N);
|
|
391
|
+
for (let i = 0; i < N; i++) {
|
|
392
|
+
for (let k = 0; k < K; k++) {
|
|
393
|
+
genmrb[k * N + i] = gen[k * N + indices[i]];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Gaussian elimination to get systematic form on the K most-reliable bits
|
|
397
|
+
for (let id = 0; id < K; id++) {
|
|
398
|
+
let found = false;
|
|
399
|
+
for (let icol = id; icol < Math.min(K + 20, N); icol++) {
|
|
400
|
+
if (genmrb[id * N + icol] === 1) {
|
|
401
|
+
if (icol !== id) {
|
|
402
|
+
// Swap columns
|
|
403
|
+
for (let k = 0; k < K; k++) {
|
|
404
|
+
const tmp = genmrb[k * N + id];
|
|
405
|
+
genmrb[k * N + id] = genmrb[k * N + icol];
|
|
406
|
+
genmrb[k * N + icol] = tmp;
|
|
407
|
+
}
|
|
408
|
+
const tmp = indices[id];
|
|
409
|
+
indices[id] = indices[icol];
|
|
410
|
+
indices[icol] = tmp;
|
|
411
|
+
}
|
|
412
|
+
for (let ii = 0; ii < K; ii++) {
|
|
413
|
+
if (ii !== id && genmrb[ii * N + id] === 1) {
|
|
414
|
+
for (let c = 0; c < N; c++) {
|
|
415
|
+
genmrb[ii * N + c] ^= genmrb[id * N + c];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
found = true;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!found)
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
// Hard decisions on reordered received word
|
|
427
|
+
const hdec = new Int8Array(N);
|
|
428
|
+
for (let i = 0; i < N; i++) {
|
|
429
|
+
hdec[i] = llr[indices[i]] >= 0 ? 1 : 0;
|
|
430
|
+
}
|
|
431
|
+
const absrx = new Float64Array(N);
|
|
432
|
+
for (let i = 0; i < N; i++) {
|
|
433
|
+
absrx[i] = Math.abs(llr[indices[i]]);
|
|
434
|
+
}
|
|
435
|
+
// Transpose of reordered gen matrix
|
|
436
|
+
const g2 = new Uint8Array(N * K);
|
|
437
|
+
for (let i = 0; i < K; i++) {
|
|
438
|
+
for (let j = 0; j < N; j++) {
|
|
439
|
+
g2[j * K + i] = genmrb[i * N + j];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function mrbencode(me) {
|
|
443
|
+
const codeword = new Int8Array(N);
|
|
444
|
+
for (let i = 0; i < K; i++) {
|
|
445
|
+
if (me[i] === 1) {
|
|
446
|
+
for (let j = 0; j < N; j++) {
|
|
447
|
+
codeword[j] ^= g2[j * K + i];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return codeword;
|
|
452
|
+
}
|
|
453
|
+
const m0 = hdec.slice(0, K);
|
|
454
|
+
const c0 = mrbencode(m0);
|
|
455
|
+
const bestCw = new Int8Array(c0);
|
|
456
|
+
let dmin = 0;
|
|
457
|
+
for (let i = 0; i < N; i++) {
|
|
458
|
+
const x = c0[i] ^ hdec[i];
|
|
459
|
+
dmin += x * absrx[i];
|
|
460
|
+
}
|
|
461
|
+
// Order-1: flip single bits in the info portion
|
|
462
|
+
for (let i1 = K - 1; i1 >= 0; i1--) {
|
|
463
|
+
if (apmask[indices[i1]] === 1)
|
|
464
|
+
continue;
|
|
465
|
+
const me = new Int8Array(m0);
|
|
466
|
+
me[i1] ^= 1;
|
|
467
|
+
const ce = mrbencode(me);
|
|
468
|
+
let dd = 0;
|
|
469
|
+
for (let j = 0; j < N; j++) {
|
|
470
|
+
const x = ce[j] ^ hdec[j];
|
|
471
|
+
dd += x * absrx[j];
|
|
472
|
+
}
|
|
473
|
+
if (dd < dmin) {
|
|
474
|
+
dmin = dd;
|
|
475
|
+
bestCw.set(ce);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Order-2: flip pairs of least-reliable info bits (limited search)
|
|
479
|
+
if (norder >= 2) {
|
|
480
|
+
const ntry = Math.min(40, K);
|
|
481
|
+
for (let i1 = K - 1; i1 >= K - ntry; i1--) {
|
|
482
|
+
if (apmask[indices[i1]] === 1)
|
|
483
|
+
continue;
|
|
484
|
+
for (let i2 = i1 - 1; i2 >= K - ntry; i2--) {
|
|
485
|
+
if (apmask[indices[i2]] === 1)
|
|
486
|
+
continue;
|
|
487
|
+
const me = new Int8Array(m0);
|
|
488
|
+
me[i1] ^= 1;
|
|
489
|
+
me[i2] ^= 1;
|
|
490
|
+
const ce = mrbencode(me);
|
|
491
|
+
let dd = 0;
|
|
492
|
+
for (let j = 0; j < N; j++) {
|
|
493
|
+
const x = ce[j] ^ hdec[j];
|
|
494
|
+
dd += x * absrx[j];
|
|
495
|
+
}
|
|
496
|
+
if (dd < dmin) {
|
|
497
|
+
dmin = dd;
|
|
498
|
+
bestCw.set(ce);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Reorder codeword back to original order
|
|
504
|
+
const finalCw = new Int8Array(N);
|
|
505
|
+
for (let i = 0; i < N; i++) {
|
|
506
|
+
finalCw[indices[i]] = bestCw[i];
|
|
507
|
+
}
|
|
508
|
+
const bits91 = Array.from(finalCw.slice(0, KK));
|
|
509
|
+
if (!checkCRC14(bits91))
|
|
510
|
+
return null;
|
|
511
|
+
// Compute dmin in original order
|
|
512
|
+
let dminOrig = 0;
|
|
513
|
+
const hdecOrig = new Int8Array(N);
|
|
514
|
+
for (let i = 0; i < N; i++)
|
|
515
|
+
hdecOrig[i] = llr[i] >= 0 ? 1 : 0;
|
|
516
|
+
let nhe = 0;
|
|
517
|
+
for (let i = 0; i < N; i++) {
|
|
518
|
+
const x = finalCw[i] ^ hdecOrig[i];
|
|
519
|
+
nhe += x;
|
|
520
|
+
dminOrig += x * Math.abs(llr[i]);
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
message91: bits91,
|
|
524
|
+
cw: Array.from(finalCw),
|
|
525
|
+
nharderrors: nhe,
|
|
526
|
+
dmin: dminOrig,
|
|
527
|
+
ntype: 2,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
let _generator = null;
|
|
531
|
+
function getGenerator() {
|
|
532
|
+
if (_generator)
|
|
533
|
+
return _generator;
|
|
534
|
+
const K = KK;
|
|
535
|
+
const N = N_LDPC;
|
|
536
|
+
const M = M_LDPC;
|
|
537
|
+
// Build full generator matrix (K×N) where first K columns are identity
|
|
538
|
+
const gen = new Uint8Array(K * N);
|
|
539
|
+
for (let i = 0; i < K; i++)
|
|
540
|
+
gen[i * N + i] = 1;
|
|
541
|
+
// gHex encodes the M×K generator parity matrix
|
|
542
|
+
// gen_parity[m][k] = 1 means info bit k contributes to parity bit m
|
|
543
|
+
for (let m = 0; m < M; m++) {
|
|
544
|
+
const hexStr = gHex[m];
|
|
545
|
+
for (let j = 0; j < 23; j++) {
|
|
546
|
+
const val = parseInt(hexStr[j], 16);
|
|
547
|
+
const limit = j === 22 ? 3 : 4;
|
|
548
|
+
for (let jj = 1; jj <= limit; jj++) {
|
|
549
|
+
const col = j * 4 + jj - 1;
|
|
550
|
+
if (col < K && (val & (1 << (4 - jj))) !== 0) {
|
|
551
|
+
// For info bit `col`, parity bit `m` is set
|
|
552
|
+
gen[col * N + K + m] = 1;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
_generator = gen;
|
|
558
|
+
return gen;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Radix-2 Cooley-Tukey FFT for FT8 decoding.
|
|
563
|
+
* Supports real-to-complex, complex-to-complex, and inverse transforms.
|
|
564
|
+
*/
|
|
565
|
+
function fftComplex(re, im, inverse) {
|
|
566
|
+
const n = re.length;
|
|
567
|
+
if (n <= 1)
|
|
568
|
+
return;
|
|
569
|
+
// Bit-reversal permutation
|
|
570
|
+
let j = 0;
|
|
571
|
+
for (let i = 0; i < n; i++) {
|
|
572
|
+
if (j > i) {
|
|
573
|
+
let tmp = re[i];
|
|
574
|
+
re[i] = re[j];
|
|
575
|
+
re[j] = tmp;
|
|
576
|
+
tmp = im[i];
|
|
577
|
+
im[i] = im[j];
|
|
578
|
+
im[j] = tmp;
|
|
579
|
+
}
|
|
580
|
+
let m = n >> 1;
|
|
581
|
+
while (m >= 1 && j >= m) {
|
|
582
|
+
j -= m;
|
|
583
|
+
m >>= 1;
|
|
584
|
+
}
|
|
585
|
+
j += m;
|
|
586
|
+
}
|
|
587
|
+
const sign = -1;
|
|
588
|
+
for (let size = 2; size <= n; size <<= 1) {
|
|
589
|
+
const halfsize = size >> 1;
|
|
590
|
+
const step = (sign * Math.PI) / halfsize;
|
|
591
|
+
const wRe = Math.cos(step);
|
|
592
|
+
const wIm = Math.sin(step);
|
|
593
|
+
for (let i = 0; i < n; i += size) {
|
|
594
|
+
let curRe = 1;
|
|
595
|
+
let curIm = 0;
|
|
596
|
+
for (let k = 0; k < halfsize; k++) {
|
|
597
|
+
const evenIdx = i + k;
|
|
598
|
+
const oddIdx = i + k + halfsize;
|
|
599
|
+
const tRe = curRe * re[oddIdx] - curIm * im[oddIdx];
|
|
600
|
+
const tIm = curRe * im[oddIdx] + curIm * re[oddIdx];
|
|
601
|
+
re[oddIdx] = re[evenIdx] - tRe;
|
|
602
|
+
im[oddIdx] = im[evenIdx] - tIm;
|
|
603
|
+
re[evenIdx] = re[evenIdx] + tRe;
|
|
604
|
+
im[evenIdx] = im[evenIdx] + tIm;
|
|
605
|
+
const newCurRe = curRe * wRe - curIm * wIm;
|
|
606
|
+
curIm = curRe * wIm + curIm * wRe;
|
|
607
|
+
curRe = newCurRe;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/** Next power of 2 >= n */
|
|
613
|
+
function nextPow2(n) {
|
|
614
|
+
let v = 1;
|
|
615
|
+
while (v < n)
|
|
616
|
+
v <<= 1;
|
|
617
|
+
return v;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* FT8 message unpacking – TypeScript port of unpack77 from packjt77.f90
|
|
622
|
+
*
|
|
623
|
+
* Supported message types:
|
|
624
|
+
* Type 0.0 Free text
|
|
625
|
+
* Type 1 Standard (two callsigns + grid/report/RR73/73)
|
|
626
|
+
* Type 2 /P form for EU VHF contest
|
|
627
|
+
* Type 4 One nonstandard call and one hashed call
|
|
628
|
+
*/
|
|
629
|
+
function bitsToUint(bits, start, len) {
|
|
630
|
+
let val = 0;
|
|
631
|
+
for (let i = 0; i < len; i++) {
|
|
632
|
+
val = val * 2 + (bits[start + i] ?? 0);
|
|
633
|
+
}
|
|
634
|
+
return val;
|
|
635
|
+
}
|
|
636
|
+
function unpack28(n28) {
|
|
637
|
+
if (n28 < 0 || n28 >= 268435456)
|
|
638
|
+
return { call: "", success: false };
|
|
639
|
+
if (n28 === 0)
|
|
640
|
+
return { call: "DE", success: true };
|
|
641
|
+
if (n28 === 1)
|
|
642
|
+
return { call: "QRZ", success: true };
|
|
643
|
+
if (n28 === 2)
|
|
644
|
+
return { call: "CQ", success: true };
|
|
645
|
+
if (n28 >= 3 && n28 < 3 + 1000) {
|
|
646
|
+
const nqsy = n28 - 3;
|
|
647
|
+
return { call: `CQ ${nqsy.toString().padStart(3, "0")}`, success: true };
|
|
648
|
+
}
|
|
649
|
+
if (n28 >= 1003 && n28 < NTOKENS) {
|
|
650
|
+
// CQ with 4-letter directed call
|
|
651
|
+
let m = n28 - 1003;
|
|
652
|
+
let chars = "";
|
|
653
|
+
for (let i = 3; i >= 0; i--) {
|
|
654
|
+
const j = m % 27;
|
|
655
|
+
m = Math.floor(m / 27);
|
|
656
|
+
chars = (j === 0 ? " " : String.fromCharCode(64 + j)) + chars;
|
|
657
|
+
}
|
|
658
|
+
const directed = chars.trim();
|
|
659
|
+
if (directed.length > 0)
|
|
660
|
+
return { call: `CQ ${directed}`, success: true };
|
|
661
|
+
return { call: "CQ", success: true };
|
|
662
|
+
}
|
|
663
|
+
if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
|
|
664
|
+
// Hashed call – we don't have a hash table, so show <...>
|
|
665
|
+
return { call: "<...>", success: true };
|
|
666
|
+
}
|
|
667
|
+
// Standard callsign
|
|
668
|
+
let n = n28 - NTOKENS - MAX22;
|
|
669
|
+
if (n < 0)
|
|
670
|
+
return { call: "", success: false };
|
|
671
|
+
const i6 = n % 27;
|
|
672
|
+
n = Math.floor(n / 27);
|
|
673
|
+
const i5 = n % 27;
|
|
674
|
+
n = Math.floor(n / 27);
|
|
675
|
+
const i4 = n % 27;
|
|
676
|
+
n = Math.floor(n / 27);
|
|
677
|
+
const i3 = n % 10;
|
|
678
|
+
n = Math.floor(n / 10);
|
|
679
|
+
const i2 = n % 36;
|
|
680
|
+
n = Math.floor(n / 36);
|
|
681
|
+
const i1 = n;
|
|
682
|
+
if (i1 < 0 || i1 >= A1.length)
|
|
683
|
+
return { call: "", success: false };
|
|
684
|
+
if (i2 < 0 || i2 >= A2.length)
|
|
685
|
+
return { call: "", success: false };
|
|
686
|
+
if (i3 < 0 || i3 >= A3.length)
|
|
687
|
+
return { call: "", success: false };
|
|
688
|
+
if (i4 < 0 || i4 >= A4.length)
|
|
689
|
+
return { call: "", success: false };
|
|
690
|
+
if (i5 < 0 || i5 >= A4.length)
|
|
691
|
+
return { call: "", success: false };
|
|
692
|
+
if (i6 < 0 || i6 >= A4.length)
|
|
693
|
+
return { call: "", success: false };
|
|
694
|
+
const call = (A1[i1] + A2[i2] + A3[i3] + A4[i4] + A4[i5] + A4[i6]).trim();
|
|
695
|
+
return { call, success: call.length > 0 };
|
|
696
|
+
}
|
|
697
|
+
function toGrid4(igrid4) {
|
|
698
|
+
if (igrid4 < 0 || igrid4 > MAXGRID4)
|
|
699
|
+
return { grid: "", success: false };
|
|
700
|
+
let n = igrid4;
|
|
701
|
+
const j4 = n % 10;
|
|
702
|
+
n = Math.floor(n / 10);
|
|
703
|
+
const j3 = n % 10;
|
|
704
|
+
n = Math.floor(n / 10);
|
|
705
|
+
const j2 = n % 18;
|
|
706
|
+
n = Math.floor(n / 18);
|
|
707
|
+
const j1 = n;
|
|
708
|
+
if (j1 < 0 || j1 > 17 || j2 < 0 || j2 > 17)
|
|
709
|
+
return { grid: "", success: false };
|
|
710
|
+
const grid = String.fromCharCode(65 + j1) + String.fromCharCode(65 + j2) + j3.toString() + j4.toString();
|
|
711
|
+
return { grid, success: true };
|
|
712
|
+
}
|
|
713
|
+
function unpackText77(bits71) {
|
|
714
|
+
// Reconstruct 9 bytes from 71 bits (7 + 8*8)
|
|
715
|
+
const qa = new Uint8Array(9);
|
|
716
|
+
let val = 0;
|
|
717
|
+
for (let b = 6; b >= 0; b--) {
|
|
718
|
+
val = (val << 1) | (bits71[6 - b] ?? 0);
|
|
719
|
+
}
|
|
720
|
+
qa[0] = val;
|
|
721
|
+
for (let li = 1; li <= 8; li++) {
|
|
722
|
+
val = 0;
|
|
723
|
+
for (let b = 7; b >= 0; b--) {
|
|
724
|
+
val = (val << 1) | (bits71[7 + (li - 1) * 8 + (7 - b)] ?? 0);
|
|
725
|
+
}
|
|
726
|
+
qa[li] = val;
|
|
727
|
+
}
|
|
728
|
+
// Decode from base-42 big-endian
|
|
729
|
+
// Convert qa (9 bytes) to a bigint, then repeatedly divide by 42
|
|
730
|
+
let n = 0n;
|
|
731
|
+
for (let i = 0; i < 9; i++) {
|
|
732
|
+
n = (n << 8n) | BigInt(qa[i]);
|
|
733
|
+
}
|
|
734
|
+
const chars = [];
|
|
735
|
+
for (let i = 0; i < 13; i++) {
|
|
736
|
+
const j = Number(n % 42n);
|
|
737
|
+
n = n / 42n;
|
|
738
|
+
chars.unshift(FTALPH[j] ?? " ");
|
|
739
|
+
}
|
|
740
|
+
return chars.join("").trimStart();
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Unpack a 77-bit FT8 message into a human-readable string.
|
|
744
|
+
*/
|
|
745
|
+
function unpack77(bits77) {
|
|
746
|
+
const n3 = bitsToUint(bits77, 71, 3);
|
|
747
|
+
const i3 = bitsToUint(bits77, 74, 3);
|
|
748
|
+
if (i3 === 0 && n3 === 0) {
|
|
749
|
+
// Type 0.0: Free text
|
|
750
|
+
const msg = unpackText77(bits77.slice(0, 71));
|
|
751
|
+
if (msg.trim().length === 0)
|
|
752
|
+
return { msg: "", success: false };
|
|
753
|
+
return { msg: msg.trim(), success: true };
|
|
754
|
+
}
|
|
755
|
+
if (i3 === 1 || i3 === 2) {
|
|
756
|
+
// Type 1/2: Standard message
|
|
757
|
+
const n28a = bitsToUint(bits77, 0, 28);
|
|
758
|
+
const ipa = bits77[28];
|
|
759
|
+
const n28b = bitsToUint(bits77, 29, 28);
|
|
760
|
+
const ipb = bits77[57];
|
|
761
|
+
const ir = bits77[58];
|
|
762
|
+
const igrid4 = bitsToUint(bits77, 59, 15);
|
|
763
|
+
const { call: call1, success: ok1 } = unpack28(n28a);
|
|
764
|
+
const { call: call2Raw, success: ok2 } = unpack28(n28b);
|
|
765
|
+
if (!ok1 || !ok2)
|
|
766
|
+
return { msg: "", success: false };
|
|
767
|
+
let c1 = call1;
|
|
768
|
+
let c2 = call2Raw;
|
|
769
|
+
if (c1.startsWith("CQ_"))
|
|
770
|
+
c1 = c1.replace("_", " ");
|
|
771
|
+
if (c1.indexOf("<") < 0) {
|
|
772
|
+
if (ipa === 1 && i3 === 1 && c1.length >= 3)
|
|
773
|
+
c1 += "/R";
|
|
774
|
+
if (ipa === 1 && i3 === 2 && c1.length >= 3)
|
|
775
|
+
c1 += "/P";
|
|
776
|
+
}
|
|
777
|
+
if (c2.indexOf("<") < 0) {
|
|
778
|
+
if (ipb === 1 && i3 === 1 && c2.length >= 3)
|
|
779
|
+
c2 += "/R";
|
|
780
|
+
if (ipb === 1 && i3 === 2 && c2.length >= 3)
|
|
781
|
+
c2 += "/P";
|
|
782
|
+
}
|
|
783
|
+
if (igrid4 <= MAXGRID4) {
|
|
784
|
+
const { grid, success: gridOk } = toGrid4(igrid4);
|
|
785
|
+
if (!gridOk)
|
|
786
|
+
return { msg: "", success: false };
|
|
787
|
+
const msg = ir === 0 ? `${c1} ${c2} ${grid}` : `${c1} ${c2} R ${grid}`;
|
|
788
|
+
return { msg, success: true };
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
const irpt = igrid4 - MAXGRID4;
|
|
792
|
+
if (irpt === 1)
|
|
793
|
+
return { msg: `${c1} ${c2}`, success: true };
|
|
794
|
+
if (irpt === 2)
|
|
795
|
+
return { msg: `${c1} ${c2} RRR`, success: true };
|
|
796
|
+
if (irpt === 3)
|
|
797
|
+
return { msg: `${c1} ${c2} RR73`, success: true };
|
|
798
|
+
if (irpt === 4)
|
|
799
|
+
return { msg: `${c1} ${c2} 73`, success: true };
|
|
800
|
+
if (irpt >= 5) {
|
|
801
|
+
let isnr = irpt - 35;
|
|
802
|
+
if (isnr > 50)
|
|
803
|
+
isnr -= 101;
|
|
804
|
+
const absStr = Math.abs(isnr).toString().padStart(2, "0");
|
|
805
|
+
const crpt = (isnr >= 0 ? "+" : "-") + absStr;
|
|
806
|
+
const msg = ir === 0 ? `${c1} ${c2} ${crpt}` : `${c1} ${c2} R${crpt}`;
|
|
807
|
+
return { msg, success: true };
|
|
808
|
+
}
|
|
809
|
+
return { msg: "", success: false };
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (i3 === 4) {
|
|
813
|
+
// Type 4: One nonstandard call
|
|
814
|
+
let n58 = 0n;
|
|
815
|
+
for (let i = 0; i < 58; i++) {
|
|
816
|
+
n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
|
|
817
|
+
}
|
|
818
|
+
const iflip = bits77[70];
|
|
819
|
+
const nrpt = bitsToUint(bits77, 71, 2);
|
|
820
|
+
const icq = bits77[73];
|
|
821
|
+
// Decode n58 to 11-char string using C38 alphabet
|
|
822
|
+
const c11chars = [];
|
|
823
|
+
let remain = n58;
|
|
824
|
+
for (let i = 10; i >= 0; i--) {
|
|
825
|
+
const j = Number(remain % 38n);
|
|
826
|
+
remain = remain / 38n;
|
|
827
|
+
c11chars.unshift(C38[j] ?? " ");
|
|
828
|
+
}
|
|
829
|
+
const c11 = c11chars.join("").trim();
|
|
830
|
+
const call3 = "<...>"; // We don't have a hash table for n12
|
|
831
|
+
let call1;
|
|
832
|
+
let call2;
|
|
833
|
+
if (iflip === 0) {
|
|
834
|
+
call1 = call3;
|
|
835
|
+
call2 = c11;
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
call1 = c11;
|
|
839
|
+
call2 = call3;
|
|
840
|
+
}
|
|
841
|
+
let msg;
|
|
842
|
+
if (icq === 1) {
|
|
843
|
+
msg = `CQ ${call2}`;
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
if (nrpt === 0)
|
|
847
|
+
msg = `${call1} ${call2}`;
|
|
848
|
+
else if (nrpt === 1)
|
|
849
|
+
msg = `${call1} ${call2} RRR`;
|
|
850
|
+
else if (nrpt === 2)
|
|
851
|
+
msg = `${call1} ${call2} RR73`;
|
|
852
|
+
else
|
|
853
|
+
msg = `${call1} ${call2} 73`;
|
|
854
|
+
}
|
|
855
|
+
return { msg, success: true };
|
|
856
|
+
}
|
|
857
|
+
return { msg: "", success: false };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Decode all FT8 signals in an audio buffer.
|
|
862
|
+
* Input: mono audio samples at `sampleRate` Hz, duration ~15s.
|
|
863
|
+
*/
|
|
864
|
+
function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
|
|
865
|
+
const nfa = options.freqLow ?? 200;
|
|
866
|
+
const nfb = options.freqHigh ?? 3000;
|
|
867
|
+
const syncmin = options.syncMin ?? 1.2;
|
|
868
|
+
const depth = options.depth ?? 2;
|
|
869
|
+
const maxCandidates = options.maxCandidates ?? 300;
|
|
870
|
+
// Resample to 12000 Hz if needed
|
|
871
|
+
let dd;
|
|
872
|
+
if (sampleRate === SAMPLE_RATE) {
|
|
873
|
+
dd = new Float64Array(NMAX);
|
|
874
|
+
const len = Math.min(samples.length, NMAX);
|
|
875
|
+
for (let i = 0; i < len; i++)
|
|
876
|
+
dd[i] = samples[i];
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
|
|
880
|
+
}
|
|
881
|
+
// Compute spectrogram and find sync candidates
|
|
882
|
+
const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
|
|
883
|
+
const decoded = [];
|
|
884
|
+
const seenMessages = new Set();
|
|
885
|
+
for (const cand of candidates) {
|
|
886
|
+
const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
|
|
887
|
+
if (!result)
|
|
888
|
+
continue;
|
|
889
|
+
if (seenMessages.has(result.msg))
|
|
890
|
+
continue;
|
|
891
|
+
seenMessages.add(result.msg);
|
|
892
|
+
decoded.push({
|
|
893
|
+
freq: result.freq,
|
|
894
|
+
dt: result.dt - 0.5,
|
|
895
|
+
snr: result.snr,
|
|
896
|
+
msg: result.msg,
|
|
897
|
+
sync: cand.sync,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return decoded;
|
|
901
|
+
}
|
|
902
|
+
function sync8(dd, nfa, nfb, syncmin, maxcand) {
|
|
903
|
+
const JZ = 62;
|
|
904
|
+
// Fortran uses NFFT1=3840 for the spectrogram FFT; we need a power of 2
|
|
905
|
+
const fftSize = nextPow2(NFFT1); // 4096
|
|
906
|
+
const halfSize = fftSize / 2; // 2048
|
|
907
|
+
const tstep = NSTEP / SAMPLE_RATE;
|
|
908
|
+
const df = SAMPLE_RATE / fftSize;
|
|
909
|
+
const fac = 1.0 / 300.0;
|
|
910
|
+
// Compute symbol spectra, stepping by NSTEP
|
|
911
|
+
const s = new Float64Array(halfSize * NHSYM);
|
|
912
|
+
const savg = new Float64Array(halfSize);
|
|
913
|
+
const xRe = new Float64Array(fftSize);
|
|
914
|
+
const xIm = new Float64Array(fftSize);
|
|
915
|
+
for (let j = 0; j < NHSYM; j++) {
|
|
916
|
+
const ia = j * NSTEP;
|
|
917
|
+
xRe.fill(0);
|
|
918
|
+
xIm.fill(0);
|
|
919
|
+
for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
|
|
920
|
+
xRe[i] = fac * dd[ia + i];
|
|
921
|
+
}
|
|
922
|
+
fftComplex(xRe, xIm);
|
|
923
|
+
for (let i = 0; i < halfSize; i++) {
|
|
924
|
+
const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
|
|
925
|
+
s[i * NHSYM + j] = power;
|
|
926
|
+
savg[i] = (savg[i] ?? 0) + power;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// Compute baseline
|
|
930
|
+
const sbase = computeBaseline(savg, nfa, nfb, df, halfSize);
|
|
931
|
+
const ia = Math.max(1, Math.round(nfa / df));
|
|
932
|
+
const ib = Math.min(halfSize - 14, Math.round(nfb / df));
|
|
933
|
+
const nssy = Math.floor(NSPS / NSTEP);
|
|
934
|
+
const nfos = Math.round(SAMPLE_RATE / NSPS / df); // ~2 bins per tone spacing
|
|
935
|
+
const jstrt = Math.round(0.5 / tstep);
|
|
936
|
+
// 2D sync correlation
|
|
937
|
+
const sync2d = new Float64Array((ib - ia + 1) * (2 * JZ + 1));
|
|
938
|
+
const width = 2 * JZ + 1;
|
|
939
|
+
for (let i = ia; i <= ib; i++) {
|
|
940
|
+
for (let jj = -JZ; jj <= JZ; jj++) {
|
|
941
|
+
let ta = 0, tb = 0, tc = 0;
|
|
942
|
+
let t0a = 0, t0b = 0, t0c = 0;
|
|
943
|
+
for (let n = 0; n < 7; n++) {
|
|
944
|
+
const m = jj + jstrt + nssy * n;
|
|
945
|
+
const iCostas = i + nfos * icos7[n];
|
|
946
|
+
if (m >= 0 && m < NHSYM && iCostas < halfSize) {
|
|
947
|
+
ta += s[iCostas * NHSYM + m];
|
|
948
|
+
for (let tone = 0; tone <= 6; tone++) {
|
|
949
|
+
const idx = i + nfos * tone;
|
|
950
|
+
if (idx < halfSize)
|
|
951
|
+
t0a += s[idx * NHSYM + m];
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const m36 = m + nssy * 36;
|
|
955
|
+
if (m36 >= 0 && m36 < NHSYM && iCostas < halfSize) {
|
|
956
|
+
tb += s[iCostas * NHSYM + m36];
|
|
957
|
+
for (let tone = 0; tone <= 6; tone++) {
|
|
958
|
+
const idx = i + nfos * tone;
|
|
959
|
+
if (idx < halfSize)
|
|
960
|
+
t0b += s[idx * NHSYM + m36];
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const m72 = m + nssy * 72;
|
|
964
|
+
if (m72 >= 0 && m72 < NHSYM && iCostas < halfSize) {
|
|
965
|
+
tc += s[iCostas * NHSYM + m72];
|
|
966
|
+
for (let tone = 0; tone <= 6; tone++) {
|
|
967
|
+
const idx = i + nfos * tone;
|
|
968
|
+
if (idx < halfSize)
|
|
969
|
+
t0c += s[idx * NHSYM + m72];
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const t = ta + tb + tc;
|
|
974
|
+
const t0total = t0a + t0b + t0c;
|
|
975
|
+
const t0 = (t0total - t) / 6.0;
|
|
976
|
+
const syncVal = t0 > 0 ? t / t0 : 0;
|
|
977
|
+
const tbc = tb + tc;
|
|
978
|
+
const t0bc = t0b + t0c;
|
|
979
|
+
const t0bc2 = (t0bc - tbc) / 6.0;
|
|
980
|
+
const syncBc = t0bc2 > 0 ? tbc / t0bc2 : 0;
|
|
981
|
+
sync2d[(i - ia) * width + (jj + JZ)] = Math.max(syncVal, syncBc);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// Find peaks
|
|
985
|
+
const candidates0 = [];
|
|
986
|
+
const mlag = 10;
|
|
987
|
+
for (let i = ia; i <= ib; i++) {
|
|
988
|
+
let bestSync = -1;
|
|
989
|
+
let bestJ = 0;
|
|
990
|
+
for (let j = -mlag; j <= mlag; j++) {
|
|
991
|
+
const v = sync2d[(i - ia) * width + (j + JZ)];
|
|
992
|
+
if (v > bestSync) {
|
|
993
|
+
bestSync = v;
|
|
994
|
+
bestJ = j;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// Also check wider range
|
|
998
|
+
let bestSync2 = -1;
|
|
999
|
+
let bestJ2 = 0;
|
|
1000
|
+
for (let j = -JZ; j <= JZ; j++) {
|
|
1001
|
+
const v = sync2d[(i - ia) * width + (j + JZ)];
|
|
1002
|
+
if (v > bestSync2) {
|
|
1003
|
+
bestSync2 = v;
|
|
1004
|
+
bestJ2 = j;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (bestSync >= syncmin) {
|
|
1008
|
+
candidates0.push({
|
|
1009
|
+
freq: i * df,
|
|
1010
|
+
dt: (bestJ - 0.5) * tstep,
|
|
1011
|
+
sync: bestSync,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
if (Math.abs(bestJ2 - bestJ) > 0 && bestSync2 >= syncmin) {
|
|
1015
|
+
candidates0.push({
|
|
1016
|
+
freq: i * df,
|
|
1017
|
+
dt: (bestJ2 - 0.5) * tstep,
|
|
1018
|
+
sync: bestSync2,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Compute baseline normalization for sync values
|
|
1023
|
+
const syncValues = candidates0.map((c) => c.sync);
|
|
1024
|
+
syncValues.sort((a, b) => a - b);
|
|
1025
|
+
const pctileIdx = Math.max(0, Math.round(0.4 * syncValues.length) - 1);
|
|
1026
|
+
const base = syncValues[pctileIdx] ?? 1;
|
|
1027
|
+
if (base > 0) {
|
|
1028
|
+
for (const c of candidates0)
|
|
1029
|
+
c.sync /= base;
|
|
1030
|
+
}
|
|
1031
|
+
// Remove near-duplicate candidates
|
|
1032
|
+
for (let i = 0; i < candidates0.length; i++) {
|
|
1033
|
+
for (let j = 0; j < i; j++) {
|
|
1034
|
+
const fdiff = Math.abs(candidates0[i].freq - candidates0[j].freq);
|
|
1035
|
+
const tdiff = Math.abs(candidates0[i].dt - candidates0[j].dt);
|
|
1036
|
+
if (fdiff < 4.0 && tdiff < 0.04) {
|
|
1037
|
+
if (candidates0[i].sync >= candidates0[j].sync) {
|
|
1038
|
+
candidates0[j].sync = 0;
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
candidates0[i].sync = 0;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
// Sort by sync descending, take top maxcand
|
|
1047
|
+
const filtered = candidates0.filter((c) => c.sync >= syncmin);
|
|
1048
|
+
filtered.sort((a, b) => b.sync - a.sync);
|
|
1049
|
+
return { candidates: filtered.slice(0, maxcand), sbase };
|
|
1050
|
+
}
|
|
1051
|
+
function computeBaseline(savg, nfa, nfb, df, nh1) {
|
|
1052
|
+
const sbase = new Float64Array(nh1);
|
|
1053
|
+
const ia = Math.max(1, Math.round(nfa / df));
|
|
1054
|
+
const ib = Math.min(nh1 - 1, Math.round(nfb / df));
|
|
1055
|
+
// Smooth the spectrum to get baseline
|
|
1056
|
+
const window = 50; // bins
|
|
1057
|
+
for (let i = 0; i < nh1; i++) {
|
|
1058
|
+
let sum = 0;
|
|
1059
|
+
let count = 0;
|
|
1060
|
+
const lo = Math.max(ia, i - window);
|
|
1061
|
+
const hi = Math.min(ib, i + window);
|
|
1062
|
+
for (let j = lo; j <= hi; j++) {
|
|
1063
|
+
sum += savg[j];
|
|
1064
|
+
count++;
|
|
1065
|
+
}
|
|
1066
|
+
sbase[i] = count > 0 ? 10 * Math.log10(Math.max(1e-30, sum / count)) : 0;
|
|
1067
|
+
}
|
|
1068
|
+
return sbase;
|
|
1069
|
+
}
|
|
1070
|
+
function ft8b(dd0, f1, xdt, _sbase, depth) {
|
|
1071
|
+
const NFFT2 = 3200;
|
|
1072
|
+
const NP2 = 2812;
|
|
1073
|
+
const NFFT1_LONG = 192000;
|
|
1074
|
+
const fs2 = SAMPLE_RATE / NDOWN;
|
|
1075
|
+
const dt2 = 1.0 / fs2;
|
|
1076
|
+
const twopi = 2 * Math.PI;
|
|
1077
|
+
// Downsample: mix to baseband and filter
|
|
1078
|
+
const cd0Re = new Float64Array(NFFT2);
|
|
1079
|
+
const cd0Im = new Float64Array(NFFT2);
|
|
1080
|
+
ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
|
|
1081
|
+
// Find best time offset
|
|
1082
|
+
const i0 = Math.round((xdt + 0.5) * fs2);
|
|
1083
|
+
let smax = 0;
|
|
1084
|
+
let ibest = i0;
|
|
1085
|
+
for (let idt = i0 - 10; idt <= i0 + 10; idt++) {
|
|
1086
|
+
const sync = sync8d(cd0Re, cd0Im, idt, null, null, false);
|
|
1087
|
+
if (sync > smax) {
|
|
1088
|
+
smax = sync;
|
|
1089
|
+
ibest = idt;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
// Fine frequency search
|
|
1093
|
+
smax = 0;
|
|
1094
|
+
let delfbest = 0;
|
|
1095
|
+
for (let ifr = -5; ifr <= 5; ifr++) {
|
|
1096
|
+
const delf = ifr * 0.5;
|
|
1097
|
+
const dphi = twopi * delf * dt2;
|
|
1098
|
+
const twkRe = new Float64Array(32);
|
|
1099
|
+
const twkIm = new Float64Array(32);
|
|
1100
|
+
let phi = 0;
|
|
1101
|
+
for (let i = 0; i < 32; i++) {
|
|
1102
|
+
twkRe[i] = Math.cos(phi);
|
|
1103
|
+
twkIm[i] = Math.sin(phi);
|
|
1104
|
+
phi = (phi + dphi) % twopi;
|
|
1105
|
+
}
|
|
1106
|
+
const sync = sync8d(cd0Re, cd0Im, ibest, twkRe, twkIm, true);
|
|
1107
|
+
if (sync > smax) {
|
|
1108
|
+
smax = sync;
|
|
1109
|
+
delfbest = delf;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
// Apply frequency correction and re-downsample
|
|
1113
|
+
f1 += delfbest;
|
|
1114
|
+
ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
|
|
1115
|
+
// Refine time offset
|
|
1116
|
+
const ss = new Float64Array(9);
|
|
1117
|
+
for (let idt = -4; idt <= 4; idt++) {
|
|
1118
|
+
ss[idt + 4] = sync8d(cd0Re, cd0Im, ibest + idt, null, null, false);
|
|
1119
|
+
}
|
|
1120
|
+
let maxss = -1;
|
|
1121
|
+
let maxIdx = 4;
|
|
1122
|
+
for (let i = 0; i < 9; i++) {
|
|
1123
|
+
if (ss[i] > maxss) {
|
|
1124
|
+
maxss = ss[i];
|
|
1125
|
+
maxIdx = i;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
ibest = ibest + maxIdx - 4;
|
|
1129
|
+
xdt = (ibest - 1) * dt2;
|
|
1130
|
+
// Extract 8-tone soft symbols for each of NN=79 symbols
|
|
1131
|
+
const s8 = new Float64Array(8 * NN);
|
|
1132
|
+
const csRe = new Float64Array(8 * NN);
|
|
1133
|
+
const csIm = new Float64Array(8 * NN);
|
|
1134
|
+
const symbRe = new Float64Array(32);
|
|
1135
|
+
const symbIm = new Float64Array(32);
|
|
1136
|
+
for (let k = 0; k < NN; k++) {
|
|
1137
|
+
const i1 = ibest + k * 32;
|
|
1138
|
+
symbRe.fill(0);
|
|
1139
|
+
symbIm.fill(0);
|
|
1140
|
+
if (i1 >= 0 && i1 + 31 < NP2) {
|
|
1141
|
+
for (let j = 0; j < 32; j++) {
|
|
1142
|
+
symbRe[j] = cd0Re[i1 + j];
|
|
1143
|
+
symbIm[j] = cd0Im[i1 + j];
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
fftComplex(symbRe, symbIm);
|
|
1147
|
+
for (let tone = 0; tone < 8; tone++) {
|
|
1148
|
+
const re = symbRe[tone] / 1000;
|
|
1149
|
+
const im = symbIm[tone] / 1000;
|
|
1150
|
+
csRe[tone * NN + k] = re;
|
|
1151
|
+
csIm[tone * NN + k] = im;
|
|
1152
|
+
s8[tone * NN + k] = Math.sqrt(re * re + im * im);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
// Sync quality check
|
|
1156
|
+
let nsync = 0;
|
|
1157
|
+
for (let k = 0; k < 7; k++) {
|
|
1158
|
+
for (const offset of [0, 36, 72]) {
|
|
1159
|
+
let maxTone = 0;
|
|
1160
|
+
let maxVal = -1;
|
|
1161
|
+
for (let t = 0; t < 8; t++) {
|
|
1162
|
+
const v = s8[t * NN + k + offset];
|
|
1163
|
+
if (v > maxVal) {
|
|
1164
|
+
maxVal = v;
|
|
1165
|
+
maxTone = t;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (maxTone === icos7[k])
|
|
1169
|
+
nsync++;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (nsync <= 6)
|
|
1173
|
+
return null;
|
|
1174
|
+
// Compute soft bit metrics for multiple nsym values (1, 2, 3)
|
|
1175
|
+
// and a normalized version, matching the Fortran ft8b passes 1-4
|
|
1176
|
+
const bmeta = new Float64Array(N_LDPC); // nsym=1
|
|
1177
|
+
const bmetb = new Float64Array(N_LDPC); // nsym=2
|
|
1178
|
+
const bmetc = new Float64Array(N_LDPC); // nsym=3
|
|
1179
|
+
const bmetd = new Float64Array(N_LDPC); // nsym=1 normalized
|
|
1180
|
+
for (let nsym = 1; nsym <= 3; nsym++) {
|
|
1181
|
+
const nt = 1 << (3 * nsym); // 8, 64, 512
|
|
1182
|
+
const ibmax = nsym === 1 ? 2 : nsym === 2 ? 5 : 8;
|
|
1183
|
+
for (let ihalf = 1; ihalf <= 2; ihalf++) {
|
|
1184
|
+
for (let k = 1; k <= 29; k += nsym) {
|
|
1185
|
+
const ks = ihalf === 1 ? k + 7 : k + 43;
|
|
1186
|
+
const s2 = new Float64Array(nt);
|
|
1187
|
+
for (let i = 0; i < nt; i++) {
|
|
1188
|
+
const i1 = Math.floor(i / 64);
|
|
1189
|
+
const i2 = Math.floor((i & 63) / 8);
|
|
1190
|
+
const i3 = i & 7;
|
|
1191
|
+
if (nsym === 1) {
|
|
1192
|
+
const re = csRe[graymap[i3] * NN + ks - 1];
|
|
1193
|
+
const im = csIm[graymap[i3] * NN + ks - 1];
|
|
1194
|
+
s2[i] = Math.sqrt(re * re + im * im);
|
|
1195
|
+
}
|
|
1196
|
+
else if (nsym === 2) {
|
|
1197
|
+
const sRe = csRe[graymap[i2] * NN + ks - 1] + csRe[graymap[i3] * NN + ks];
|
|
1198
|
+
const sIm = csIm[graymap[i2] * NN + ks - 1] + csIm[graymap[i3] * NN + ks];
|
|
1199
|
+
s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
const sRe = csRe[graymap[i1] * NN + ks - 1] +
|
|
1203
|
+
csRe[graymap[i2] * NN + ks] +
|
|
1204
|
+
csRe[graymap[i3] * NN + ks + 1];
|
|
1205
|
+
const sIm = csIm[graymap[i1] * NN + ks - 1] +
|
|
1206
|
+
csIm[graymap[i2] * NN + ks] +
|
|
1207
|
+
csIm[graymap[i3] * NN + ks + 1];
|
|
1208
|
+
s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
// Fortran: i32 = 1 + (k-1)*3 + (ihalf-1)*87 (1-based)
|
|
1212
|
+
const i32 = 1 + (k - 1) * 3 + (ihalf - 1) * 87;
|
|
1213
|
+
for (let ib = 0; ib <= ibmax; ib++) {
|
|
1214
|
+
// max of s2 where bit (ibmax-ib) of index is 1
|
|
1215
|
+
let max1 = -1e30, max0 = -1e30;
|
|
1216
|
+
for (let i = 0; i < nt; i++) {
|
|
1217
|
+
const bitSet = (i & (1 << (ibmax - ib))) !== 0;
|
|
1218
|
+
if (bitSet) {
|
|
1219
|
+
if (s2[i] > max1)
|
|
1220
|
+
max1 = s2[i];
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
if (s2[i] > max0)
|
|
1224
|
+
max0 = s2[i];
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const idx = i32 + ib - 1; // Convert to 0-based
|
|
1228
|
+
if (idx >= 0 && idx < N_LDPC) {
|
|
1229
|
+
const bm = max1 - max0;
|
|
1230
|
+
if (nsym === 1) {
|
|
1231
|
+
bmeta[idx] = bm;
|
|
1232
|
+
const den = Math.max(max1, max0);
|
|
1233
|
+
bmetd[idx] = den > 0 ? bm / den : 0;
|
|
1234
|
+
}
|
|
1235
|
+
else if (nsym === 2) {
|
|
1236
|
+
bmetb[idx] = bm;
|
|
1237
|
+
}
|
|
1238
|
+
else {
|
|
1239
|
+
bmetc[idx] = bm;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
normalizeBmet(bmeta);
|
|
1247
|
+
normalizeBmet(bmetb);
|
|
1248
|
+
normalizeBmet(bmetc);
|
|
1249
|
+
normalizeBmet(bmetd);
|
|
1250
|
+
const bmetrics = [bmeta, bmetb, bmetc, bmetd];
|
|
1251
|
+
const scalefac = 2.83;
|
|
1252
|
+
const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
|
|
1253
|
+
const apmask = new Int8Array(N_LDPC);
|
|
1254
|
+
// Try 4 passes with different soft-symbol metrics (matching Fortran)
|
|
1255
|
+
let result = null;
|
|
1256
|
+
for (let ipass = 0; ipass < 4; ipass++) {
|
|
1257
|
+
const llr = new Float64Array(N_LDPC);
|
|
1258
|
+
for (let i = 0; i < N_LDPC; i++)
|
|
1259
|
+
llr[i] = scalefac * bmetrics[ipass][i];
|
|
1260
|
+
result = decode174_91(llr, apmask, maxosd);
|
|
1261
|
+
if (result && result.nharderrors >= 0 && result.nharderrors <= 36)
|
|
1262
|
+
break;
|
|
1263
|
+
result = null;
|
|
1264
|
+
}
|
|
1265
|
+
if (!result || result.nharderrors < 0 || result.nharderrors > 36)
|
|
1266
|
+
return null;
|
|
1267
|
+
// Check for all-zero codeword
|
|
1268
|
+
if (result.cw.every((b) => b === 0))
|
|
1269
|
+
return null;
|
|
1270
|
+
const message77 = result.message91.slice(0, 77);
|
|
1271
|
+
// Validate message type
|
|
1272
|
+
const n3v = (message77[71] << 2) | (message77[72] << 1) | message77[73];
|
|
1273
|
+
const i3v = (message77[74] << 2) | (message77[75] << 1) | message77[76];
|
|
1274
|
+
if (i3v > 5 || (i3v === 0 && n3v > 6))
|
|
1275
|
+
return null;
|
|
1276
|
+
if (i3v === 0 && n3v === 2)
|
|
1277
|
+
return null;
|
|
1278
|
+
// Unpack
|
|
1279
|
+
const { msg, success } = unpack77(message77);
|
|
1280
|
+
if (!success || msg.trim().length === 0)
|
|
1281
|
+
return null;
|
|
1282
|
+
// Estimate SNR
|
|
1283
|
+
let xsig = 0;
|
|
1284
|
+
let xnoi = 0;
|
|
1285
|
+
const itone = getTones$1(result.cw);
|
|
1286
|
+
for (let i = 0; i < 79; i++) {
|
|
1287
|
+
xsig += s8[itone[i] * NN + i] ** 2;
|
|
1288
|
+
const ios = (itone[i] + 4) % 7;
|
|
1289
|
+
xnoi += s8[ios * NN + i] ** 2;
|
|
1290
|
+
}
|
|
1291
|
+
let snr = 0.001;
|
|
1292
|
+
const arg = xsig / Math.max(xnoi, 1e-30) - 1.0;
|
|
1293
|
+
if (arg > 0.1)
|
|
1294
|
+
snr = arg;
|
|
1295
|
+
snr = 10 * Math.log10(snr) - 27.0;
|
|
1296
|
+
if (snr < -24)
|
|
1297
|
+
snr = -24;
|
|
1298
|
+
return { msg, freq: f1, dt: xdt, snr };
|
|
1299
|
+
}
|
|
1300
|
+
function getTones$1(cw) {
|
|
1301
|
+
const tones = new Array(79).fill(0);
|
|
1302
|
+
for (let i = 0; i < 7; i++)
|
|
1303
|
+
tones[i] = icos7[i];
|
|
1304
|
+
for (let i = 0; i < 7; i++)
|
|
1305
|
+
tones[36 + i] = icos7[i];
|
|
1306
|
+
for (let i = 0; i < 7; i++)
|
|
1307
|
+
tones[72 + i] = icos7[i];
|
|
1308
|
+
let k = 7;
|
|
1309
|
+
for (let j = 1; j <= 58; j++) {
|
|
1310
|
+
const i = (j - 1) * 3;
|
|
1311
|
+
if (j === 30)
|
|
1312
|
+
k += 7;
|
|
1313
|
+
const indx = cw[i] * 4 + cw[i + 1] * 2 + cw[i + 2];
|
|
1314
|
+
tones[k] = graymap[indx];
|
|
1315
|
+
k++;
|
|
1316
|
+
}
|
|
1317
|
+
return tones;
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
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).
|
|
1323
|
+
*/
|
|
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;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
|
|
1361
|
+
const NP2 = 2812;
|
|
1362
|
+
const twopi = 2 * Math.PI;
|
|
1363
|
+
// Precompute Costas sync waveforms
|
|
1364
|
+
const csyncRe = new Float64Array(7 * 32);
|
|
1365
|
+
const csyncIm = new Float64Array(7 * 32);
|
|
1366
|
+
for (let i = 0; i < 7; i++) {
|
|
1367
|
+
let phi = 0;
|
|
1368
|
+
const dphi = (twopi * icos7[i]) / 32;
|
|
1369
|
+
for (let j = 0; j < 32; j++) {
|
|
1370
|
+
csyncRe[i * 32 + j] = Math.cos(phi);
|
|
1371
|
+
csyncIm[i * 32 + j] = Math.sin(phi);
|
|
1372
|
+
phi = (phi + dphi) % twopi;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
let sync = 0;
|
|
1376
|
+
for (let i = 0; i < 7; i++) {
|
|
1377
|
+
const i1 = i0 + i * 32;
|
|
1378
|
+
const i2 = i1 + 36 * 32;
|
|
1379
|
+
const i3 = i1 + 72 * 32;
|
|
1380
|
+
for (const iStart of [i1, i2, i3]) {
|
|
1381
|
+
let zRe = 0, zIm = 0;
|
|
1382
|
+
if (iStart >= 0 && iStart + 31 < NP2) {
|
|
1383
|
+
for (let j = 0; j < 32; j++) {
|
|
1384
|
+
let sRe = csyncRe[i * 32 + j];
|
|
1385
|
+
let sIm = csyncIm[i * 32 + j];
|
|
1386
|
+
if (useTwk && twkRe && twkIm) {
|
|
1387
|
+
const tRe = twkRe[j] * sRe - twkIm[j] * sIm;
|
|
1388
|
+
const tIm = twkRe[j] * sIm + twkIm[j] * sRe;
|
|
1389
|
+
sRe = tRe;
|
|
1390
|
+
sIm = tIm;
|
|
1391
|
+
}
|
|
1392
|
+
// Conjugate multiply: cd0 * conj(csync)
|
|
1393
|
+
const dRe = cd0Re[iStart + j];
|
|
1394
|
+
const dIm = cd0Im[iStart + j];
|
|
1395
|
+
zRe += dRe * sRe + dIm * sIm;
|
|
1396
|
+
zIm += dIm * sRe - dRe * sIm;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
sync += zRe * zRe + zIm * zIm;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return sync;
|
|
1403
|
+
}
|
|
1404
|
+
function normalizeBmet(bmet) {
|
|
1405
|
+
const n = bmet.length;
|
|
1406
|
+
let sum = 0, sum2 = 0;
|
|
1407
|
+
for (let i = 0; i < n; i++) {
|
|
1408
|
+
sum += bmet[i];
|
|
1409
|
+
sum2 += bmet[i] * bmet[i];
|
|
1410
|
+
}
|
|
1411
|
+
const avg = sum / n;
|
|
1412
|
+
const avg2 = sum2 / n;
|
|
1413
|
+
const variance = avg2 - avg * avg;
|
|
1414
|
+
const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
|
|
1415
|
+
if (sigma > 0) {
|
|
1416
|
+
for (let i = 0; i < n; i++)
|
|
1417
|
+
bmet[i] = bmet[i] / sigma;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
function resample(input, fromRate, toRate, outLen) {
|
|
1421
|
+
const out = new Float64Array(outLen);
|
|
1422
|
+
const ratio = fromRate / toRate;
|
|
1423
|
+
for (let i = 0; i < outLen; i++) {
|
|
1424
|
+
const srcIdx = i * ratio;
|
|
1425
|
+
const lo = Math.floor(srcIdx);
|
|
1426
|
+
const frac = srcIdx - lo;
|
|
1427
|
+
const v0 = lo < input.length ? (input[lo] ?? 0) : 0;
|
|
1428
|
+
const v1 = lo + 1 < input.length ? (input[lo + 1] ?? 0) : 0;
|
|
1429
|
+
out[i] = v0 * (1 - frac) + v1 * frac;
|
|
1430
|
+
}
|
|
1431
|
+
return out;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* FT8 message packing – TypeScript port of packjt77.f90
|
|
1436
|
+
*
|
|
1437
|
+
* Implemented message types
|
|
1438
|
+
* ─────────────────────────
|
|
1439
|
+
* 0.0 Free text (≤13 chars from the 42-char FT8 alphabet)
|
|
1440
|
+
* 1 Standard (two callsigns + grid/report/RR73/73)
|
|
1441
|
+
* /R and /P suffixes on either callsign → ipa/ipb = 1 (triggers i3=2 for /P)
|
|
1442
|
+
* 4 One nonstandard (<hash>) call + one standard call
|
|
1443
|
+
* e.g. <YW18FIFA> KA1ABC 73
|
|
1444
|
+
* KA1ABC <YW18FIFA> -11
|
|
1445
|
+
* CQ YW18FIFA
|
|
1446
|
+
*
|
|
1447
|
+
* Reference: lib/77bit/packjt77.f90 (subroutines pack77, pack28, pack77_1,
|
|
1448
|
+
* pack77_4, packtext77, ihashcall)
|
|
1449
|
+
*/
|
|
1450
|
+
function mpZero() {
|
|
1451
|
+
return new Uint8Array(9);
|
|
1452
|
+
}
|
|
1453
|
+
/** qa = 42 * qb + carry from high limbs, working with 9 limbs (indices 0..8) */
|
|
1454
|
+
function mpMult42(a) {
|
|
1455
|
+
const b = mpZero();
|
|
1456
|
+
let carry = 0;
|
|
1457
|
+
for (let i = 8; i >= 0; i--) {
|
|
1458
|
+
const v = 42 * (a[i] ?? 0) + carry;
|
|
1459
|
+
b[i] = v & 0xff;
|
|
1460
|
+
carry = v >>> 8;
|
|
1461
|
+
}
|
|
1462
|
+
return b;
|
|
1463
|
+
}
|
|
1464
|
+
/** qa = qb + j */
|
|
1465
|
+
function mpAdd(a, j) {
|
|
1466
|
+
const b = new Uint8Array(a);
|
|
1467
|
+
let carry = j;
|
|
1468
|
+
for (let i = 8; i >= 0 && carry > 0; i--) {
|
|
1469
|
+
const v = (b[i] ?? 0) + carry;
|
|
1470
|
+
b[i] = v & 0xff;
|
|
1471
|
+
carry = v >>> 8;
|
|
1472
|
+
}
|
|
1473
|
+
return b;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Pack a 13-char free-text string (42-char alphabet) into 71 bits.
|
|
1477
|
+
* Mirrors Fortran packtext77 / mp_short_* logic.
|
|
1478
|
+
* Alphabet: ' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?' (42 chars)
|
|
1479
|
+
*/
|
|
1480
|
+
function packtext77(c13) {
|
|
1481
|
+
// Right-justify in 13 chars
|
|
1482
|
+
const w = c13.padStart(13, " ");
|
|
1483
|
+
let qa = mpZero();
|
|
1484
|
+
for (let i = 0; i < 13; i++) {
|
|
1485
|
+
let j = FTALPH.indexOf(w[i] ?? " ");
|
|
1486
|
+
if (j < 0)
|
|
1487
|
+
j = 0;
|
|
1488
|
+
qa = mpMult42(qa);
|
|
1489
|
+
qa = mpAdd(qa, j);
|
|
1490
|
+
}
|
|
1491
|
+
// Extract 71 bits: first 7 then 8*8
|
|
1492
|
+
const bits = [];
|
|
1493
|
+
// limb 0 gives 7 bits (high), limbs 1..8 give 8 bits each → 7 + 64 = 71
|
|
1494
|
+
// But we need exactly 71 bits. The Fortran writes b7.7 then 8*b8.8 for 71 total.
|
|
1495
|
+
// That equals: 7 + 8*8 = 71 bits from the 9 bytes (72 bits), skipping the top bit of byte 0.
|
|
1496
|
+
const byte0 = qa[0] ?? 0;
|
|
1497
|
+
for (let b = 6; b >= 0; b--)
|
|
1498
|
+
bits.push((byte0 >> b) & 1);
|
|
1499
|
+
for (let li = 1; li <= 8; li++) {
|
|
1500
|
+
const byte = qa[li] ?? 0;
|
|
1501
|
+
for (let b = 7; b >= 0; b--)
|
|
1502
|
+
bits.push((byte >> b) & 1);
|
|
1503
|
+
}
|
|
1504
|
+
return bits; // 71 bits
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* ihashcall(c0, m): compute a hash of c0 and return bits [m-1 .. 63-m] of
|
|
1508
|
+
* (47055833459n * n8) shifted right by (64 - m).
|
|
1509
|
+
*
|
|
1510
|
+
* Fortran: ishft(47055833459_8 * n8, m - 64)
|
|
1511
|
+
* → arithmetic right-shift of 64-bit product by (64 - m), keeping low m bits.
|
|
1512
|
+
*
|
|
1513
|
+
* Here we only ever call with m=22 (per pack28 for <...> callsigns).
|
|
1514
|
+
*/
|
|
1515
|
+
function ihashcall22(c0) {
|
|
1516
|
+
const C = C38;
|
|
1517
|
+
let n8 = 0n;
|
|
1518
|
+
const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
|
|
1519
|
+
for (let i = 0; i < 11; i++) {
|
|
1520
|
+
const j = C.indexOf(s[i] ?? " ");
|
|
1521
|
+
n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
|
|
1522
|
+
}
|
|
1523
|
+
const MAGIC = 47055833459n;
|
|
1524
|
+
const prod = BigInt.asUintN(64, MAGIC * n8);
|
|
1525
|
+
// arithmetic right-shift by (64 - 22) = 42 bits → take top 22 bits
|
|
1526
|
+
const result = Number(prod >> 42n) & 0x3fffff; // 22 bits
|
|
1527
|
+
return result;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Checks whether c0 is a valid standard callsign (may also have /R or /P suffix).
|
|
1531
|
+
* Returns { basecall, isStandard, hasSuffix: '/R'|'/P'|null }
|
|
1532
|
+
*/
|
|
1533
|
+
function parseCallsign(raw) {
|
|
1534
|
+
let call = raw.trim().toUpperCase();
|
|
1535
|
+
let suffix = null;
|
|
1536
|
+
if (call.endsWith("/R")) {
|
|
1537
|
+
suffix = "/R";
|
|
1538
|
+
call = call.slice(0, -2);
|
|
1539
|
+
}
|
|
1540
|
+
if (call.endsWith("/P")) {
|
|
1541
|
+
suffix = "/P";
|
|
1542
|
+
call = call.slice(0, -2);
|
|
1543
|
+
}
|
|
1544
|
+
const isLetter = (c) => c >= "A" && c <= "Z";
|
|
1545
|
+
const isDigit = (c) => c >= "0" && c <= "9";
|
|
1546
|
+
// Find the call-area digit (last digit in the call)
|
|
1547
|
+
let iarea = -1;
|
|
1548
|
+
for (let i = call.length - 1; i >= 1; i--) {
|
|
1549
|
+
if (isDigit(call[i] ?? "")) {
|
|
1550
|
+
iarea = i;
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (iarea < 1)
|
|
1555
|
+
return { basecall: call, isStandard: false, suffix };
|
|
1556
|
+
// Count letters/digits before the call-area digit
|
|
1557
|
+
let npdig = 0, nplet = 0;
|
|
1558
|
+
for (let i = 0; i < iarea; i++) {
|
|
1559
|
+
if (isDigit(call[i] ?? ""))
|
|
1560
|
+
npdig++;
|
|
1561
|
+
if (isLetter(call[i] ?? ""))
|
|
1562
|
+
nplet++;
|
|
1563
|
+
}
|
|
1564
|
+
// Count suffix letters after call-area digit
|
|
1565
|
+
let nslet = 0;
|
|
1566
|
+
for (let i = iarea + 1; i < call.length; i++) {
|
|
1567
|
+
if (isLetter(call[i] ?? ""))
|
|
1568
|
+
nslet++;
|
|
1569
|
+
}
|
|
1570
|
+
const standard = iarea >= 1 &&
|
|
1571
|
+
iarea <= 2 && // Fortran: iarea (1-indexed) must be 2 or 3 → 0-indexed: 1 or 2
|
|
1572
|
+
nplet >= 1 && // at least one letter before area digit
|
|
1573
|
+
npdig < iarea && // not all digits before area
|
|
1574
|
+
nslet >= 1 && // must have at least one letter after area digit
|
|
1575
|
+
nslet <= 3; // at most 3 suffix letters
|
|
1576
|
+
return { basecall: call, isStandard: standard, suffix };
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* pack28: pack a single callsign/token to a 28-bit integer.
|
|
1580
|
+
* Mirrors Fortran pack28 subroutine.
|
|
1581
|
+
*/
|
|
1582
|
+
function pack28(token) {
|
|
1583
|
+
const t = token.trim().toUpperCase();
|
|
1584
|
+
// Special tokens
|
|
1585
|
+
if (t === "DE")
|
|
1586
|
+
return 0;
|
|
1587
|
+
if (t === "QRZ")
|
|
1588
|
+
return 1;
|
|
1589
|
+
if (t === "CQ")
|
|
1590
|
+
return 2;
|
|
1591
|
+
// CQ_nnn (CQ with frequency offset in kHz)
|
|
1592
|
+
if (t.startsWith("CQ_")) {
|
|
1593
|
+
const rest = t.slice(3);
|
|
1594
|
+
const nqsy = parseInt(rest, 10);
|
|
1595
|
+
if (!Number.isNaN(nqsy) && /^\d{3}$/.test(rest))
|
|
1596
|
+
return 3 + nqsy;
|
|
1597
|
+
// CQ_aaaa (up to 4 letters)
|
|
1598
|
+
if (/^[A-Z]{1,4}$/.test(rest)) {
|
|
1599
|
+
const padded = rest.padStart(4, " ");
|
|
1600
|
+
let m = 0;
|
|
1601
|
+
for (let i = 0; i < 4; i++) {
|
|
1602
|
+
const c = padded[i] ?? " ";
|
|
1603
|
+
const j = c >= "A" && c <= "Z" ? c.charCodeAt(0) - 64 : 0;
|
|
1604
|
+
m = 27 * m + j;
|
|
1605
|
+
}
|
|
1606
|
+
return 3 + 1000 + m;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
// <...> hash calls
|
|
1610
|
+
if (t.startsWith("<") && t.endsWith(">")) {
|
|
1611
|
+
const inner = t.slice(1, -1);
|
|
1612
|
+
const n22 = ihashcall22(inner);
|
|
1613
|
+
return (NTOKENS + n22) & (MAX28 - 1);
|
|
1614
|
+
}
|
|
1615
|
+
// Standard callsign
|
|
1616
|
+
const { basecall, isStandard } = parseCallsign(t);
|
|
1617
|
+
if (isStandard) {
|
|
1618
|
+
const cs = basecall.length === 5 ? ` ${basecall}` : basecall;
|
|
1619
|
+
const i1 = A1.indexOf(cs[0] ?? " ");
|
|
1620
|
+
const i2 = A2.indexOf(cs[1] ?? "0");
|
|
1621
|
+
const i3 = A3.indexOf(cs[2] ?? "0");
|
|
1622
|
+
const i4 = A4.indexOf(cs[3] ?? " ");
|
|
1623
|
+
const i5 = A4.indexOf(cs[4] ?? " ");
|
|
1624
|
+
const i6 = A4.indexOf(cs[5] ?? " ");
|
|
1625
|
+
const n28 = 36 * 10 * 27 * 27 * 27 * i1 +
|
|
1626
|
+
10 * 27 * 27 * 27 * i2 +
|
|
1627
|
+
27 * 27 * 27 * i3 +
|
|
1628
|
+
27 * 27 * i4 +
|
|
1629
|
+
27 * i5 +
|
|
1630
|
+
i6;
|
|
1631
|
+
return (n28 + NTOKENS + MAX22) & (MAX28 - 1);
|
|
1632
|
+
}
|
|
1633
|
+
// Non-standard → 22-bit hash
|
|
1634
|
+
const n22 = ihashcall22(basecall);
|
|
1635
|
+
return (NTOKENS + n22) & (MAX28 - 1);
|
|
1636
|
+
}
|
|
1637
|
+
function packgrid4(s) {
|
|
1638
|
+
if (s === "RRR")
|
|
1639
|
+
return MAXGRID4 + 2;
|
|
1640
|
+
if (s === "73")
|
|
1641
|
+
return MAXGRID4 + 4;
|
|
1642
|
+
// Numeric report (+NN / -NN)
|
|
1643
|
+
const r = /^(R?)([+-]\d+)$/.exec(s);
|
|
1644
|
+
if (r) {
|
|
1645
|
+
let irpt = parseInt(r[2], 10);
|
|
1646
|
+
if (irpt >= -50 && irpt <= -31)
|
|
1647
|
+
irpt += 101;
|
|
1648
|
+
irpt += 35; // encode in range 5..85
|
|
1649
|
+
return MAXGRID4 + irpt;
|
|
1650
|
+
}
|
|
1651
|
+
// 4-char grid locator
|
|
1652
|
+
const j1 = (s.charCodeAt(0) - 65) * 18 * 10 * 10;
|
|
1653
|
+
const j2 = (s.charCodeAt(1) - 65) * 10 * 10;
|
|
1654
|
+
const j3 = (s.charCodeAt(2) - 48) * 10;
|
|
1655
|
+
const j4 = s.charCodeAt(3) - 48;
|
|
1656
|
+
return j1 + j2 + j3 + j4;
|
|
1657
|
+
}
|
|
1658
|
+
function appendBits(bits, val, width) {
|
|
1659
|
+
for (let i = width - 1; i >= 0; i--) {
|
|
1660
|
+
bits.push(Math.floor(val / 2 ** i) % 2);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Pack an FT8 message into 77 bits.
|
|
1665
|
+
* Returns an array of 0/1 values, length 77.
|
|
1666
|
+
*
|
|
1667
|
+
* Supported message types:
|
|
1668
|
+
* Type 1/2 Standard two-callsign messages including /R and /P suffixes
|
|
1669
|
+
* Type 4 One nonstandard (<hash>) call + one standard or nonstandard call
|
|
1670
|
+
* Type 0.0 Free text (≤13 chars from FTALPH)
|
|
1671
|
+
*/
|
|
1672
|
+
/**
|
|
1673
|
+
* Preprocess a message in the same way as Fortran split77:
|
|
1674
|
+
* - Collapse multiple spaces, force uppercase
|
|
1675
|
+
* - If the first word is "CQ" and there are ≥3 words and the 3rd word is a
|
|
1676
|
+
* valid base callsign, merge words 1+2 into "CQ_<word2>" and shift the rest.
|
|
1677
|
+
*/
|
|
1678
|
+
function split77(msg) {
|
|
1679
|
+
const parts = msg.trim().toUpperCase().replace(/\s+/g, " ").split(" ").filter(Boolean);
|
|
1680
|
+
if (parts.length >= 3 && parts[0] === "CQ") {
|
|
1681
|
+
// Check if word 3 (index 2) is a valid base callsign
|
|
1682
|
+
const w3 = parts[2].replace(/\/[RP]$/, ""); // strip /R or /P for check
|
|
1683
|
+
const { isStandard } = parseCallsign(w3);
|
|
1684
|
+
if (isStandard) {
|
|
1685
|
+
// merge CQ + word2 → CQ_word2
|
|
1686
|
+
const merged = [`CQ_${parts[1]}`, ...parts.slice(2)];
|
|
1687
|
+
return merged;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return parts;
|
|
1691
|
+
}
|
|
1692
|
+
function pack77(msg) {
|
|
1693
|
+
const parts = split77(msg);
|
|
1694
|
+
if (parts.length < 1)
|
|
1695
|
+
throw new Error("Empty message");
|
|
1696
|
+
// ── Try Type 1/2: standard message ────────────────────────────────────────
|
|
1697
|
+
const t1 = tryPackType1(parts);
|
|
1698
|
+
if (t1)
|
|
1699
|
+
return t1;
|
|
1700
|
+
// ── Try Type 4: one hash call ──────────────────────────────────────────────
|
|
1701
|
+
const t4 = tryPackType4(parts);
|
|
1702
|
+
if (t4)
|
|
1703
|
+
return t4;
|
|
1704
|
+
// ── Default: Type 0.0 free text ───────────────────────────────────────────
|
|
1705
|
+
return packFreeText(msg);
|
|
1706
|
+
}
|
|
1707
|
+
function tryPackType1(parts) {
|
|
1708
|
+
// Minimum 2 words, maximum 4
|
|
1709
|
+
if (parts.length < 2 || parts.length > 4)
|
|
1710
|
+
return null;
|
|
1711
|
+
const w1 = parts[0];
|
|
1712
|
+
const w2 = parts[1];
|
|
1713
|
+
const wLast = parts[parts.length - 1];
|
|
1714
|
+
// Neither word may be a hash call if the other has a slash
|
|
1715
|
+
if (w1.startsWith("<") && w2.includes("/"))
|
|
1716
|
+
return null;
|
|
1717
|
+
if (w2.startsWith("<") && w1.includes("/"))
|
|
1718
|
+
return null;
|
|
1719
|
+
// Parse callsign 1
|
|
1720
|
+
let call1;
|
|
1721
|
+
let ipa = 0;
|
|
1722
|
+
let ok1;
|
|
1723
|
+
if (w1 === "CQ" || w1 === "DE" || w1 === "QRZ" || w1.startsWith("CQ_")) {
|
|
1724
|
+
call1 = w1;
|
|
1725
|
+
ok1 = true;
|
|
1726
|
+
ipa = 0;
|
|
1727
|
+
}
|
|
1728
|
+
else if (w1.startsWith("<") && w1.endsWith(">")) {
|
|
1729
|
+
call1 = w1;
|
|
1730
|
+
ok1 = true;
|
|
1731
|
+
ipa = 0;
|
|
1732
|
+
}
|
|
1733
|
+
else {
|
|
1734
|
+
const p1 = parseCallsign(w1);
|
|
1735
|
+
call1 = p1.basecall;
|
|
1736
|
+
ok1 = p1.isStandard;
|
|
1737
|
+
if (p1.suffix === "/R" || p1.suffix === "/P")
|
|
1738
|
+
ipa = 1;
|
|
1739
|
+
}
|
|
1740
|
+
// Parse callsign 2
|
|
1741
|
+
let call2;
|
|
1742
|
+
let ipb = 0;
|
|
1743
|
+
let ok2;
|
|
1744
|
+
if (w2.startsWith("<") && w2.endsWith(">")) {
|
|
1745
|
+
call2 = w2;
|
|
1746
|
+
ok2 = true;
|
|
1747
|
+
ipb = 0;
|
|
1748
|
+
}
|
|
1749
|
+
else {
|
|
1750
|
+
const p2 = parseCallsign(w2);
|
|
1751
|
+
call2 = p2.basecall;
|
|
1752
|
+
ok2 = p2.isStandard;
|
|
1753
|
+
if (p2.suffix === "/R" || p2.suffix === "/P")
|
|
1754
|
+
ipb = 1;
|
|
1755
|
+
}
|
|
1756
|
+
if (!ok1 || !ok2)
|
|
1757
|
+
return null;
|
|
1758
|
+
// Determine message type (1 or 2)
|
|
1759
|
+
const i1psfx = ipa === 1 && (w1.endsWith("/P") || w1.includes("/P "));
|
|
1760
|
+
const i2psfx = ipb === 1 && (w2.endsWith("/P") || w2.includes("/P "));
|
|
1761
|
+
const i3 = i1psfx || i2psfx ? 2 : 1;
|
|
1762
|
+
// Decode the grid/report/special from the last word
|
|
1763
|
+
let igrid4;
|
|
1764
|
+
let ir = 0;
|
|
1765
|
+
if (parts.length === 2) {
|
|
1766
|
+
// Two-word message: <call1> <call2> → special irpt=1
|
|
1767
|
+
igrid4 = MAXGRID4 + 1;
|
|
1768
|
+
ir = 0;
|
|
1769
|
+
}
|
|
1770
|
+
else {
|
|
1771
|
+
// Check whether wLast is a grid, report, or special
|
|
1772
|
+
const lastUpper = wLast.toUpperCase();
|
|
1773
|
+
if (isGrid4(lastUpper)) {
|
|
1774
|
+
igrid4 = packgrid4(lastUpper);
|
|
1775
|
+
ir = parts.length === 4 && parts[2] === "R" ? 1 : 0;
|
|
1776
|
+
}
|
|
1777
|
+
else if (lastUpper === "RRR") {
|
|
1778
|
+
igrid4 = MAXGRID4 + 2;
|
|
1779
|
+
ir = 0;
|
|
1780
|
+
}
|
|
1781
|
+
else if (lastUpper === "RR73") {
|
|
1782
|
+
igrid4 = MAXGRID4 + 3;
|
|
1783
|
+
ir = 0;
|
|
1784
|
+
}
|
|
1785
|
+
else if (lastUpper === "73") {
|
|
1786
|
+
igrid4 = MAXGRID4 + 4;
|
|
1787
|
+
ir = 0;
|
|
1788
|
+
}
|
|
1789
|
+
else if (/^R[+-]\d+$/.test(lastUpper)) {
|
|
1790
|
+
ir = 1;
|
|
1791
|
+
const reportStr = lastUpper.slice(1); // strip leading R
|
|
1792
|
+
let irpt = parseInt(reportStr, 10);
|
|
1793
|
+
if (irpt >= -50 && irpt <= -31)
|
|
1794
|
+
irpt += 101;
|
|
1795
|
+
irpt += 35;
|
|
1796
|
+
igrid4 = MAXGRID4 + irpt;
|
|
1797
|
+
}
|
|
1798
|
+
else if (/^[+-]\d+$/.test(lastUpper)) {
|
|
1799
|
+
ir = 0;
|
|
1800
|
+
let irpt = parseInt(lastUpper, 10);
|
|
1801
|
+
if (irpt >= -50 && irpt <= -31)
|
|
1802
|
+
irpt += 101;
|
|
1803
|
+
irpt += 35;
|
|
1804
|
+
igrid4 = MAXGRID4 + irpt;
|
|
1805
|
+
}
|
|
1806
|
+
else {
|
|
1807
|
+
return null; // Not a valid Type 1 last word
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
const n28a = pack28(call1);
|
|
1811
|
+
const n28b = pack28(call2);
|
|
1812
|
+
const bits = [];
|
|
1813
|
+
appendBits(bits, n28a, 28);
|
|
1814
|
+
appendBits(bits, ipa, 1);
|
|
1815
|
+
appendBits(bits, n28b, 28);
|
|
1816
|
+
appendBits(bits, ipb, 1);
|
|
1817
|
+
appendBits(bits, ir, 1);
|
|
1818
|
+
appendBits(bits, igrid4, 15);
|
|
1819
|
+
appendBits(bits, i3, 3);
|
|
1820
|
+
return bits;
|
|
1821
|
+
}
|
|
1822
|
+
function isGrid4(s) {
|
|
1823
|
+
return (s.length === 4 &&
|
|
1824
|
+
s[0] >= "A" &&
|
|
1825
|
+
s[0] <= "R" &&
|
|
1826
|
+
s[1] >= "A" &&
|
|
1827
|
+
s[1] <= "R" &&
|
|
1828
|
+
s[2] >= "0" &&
|
|
1829
|
+
s[2] <= "9" &&
|
|
1830
|
+
s[3] >= "0" &&
|
|
1831
|
+
s[3] <= "9");
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Type 4: one nonstandard (or hashed <...>) call + one standard call.
|
|
1835
|
+
* Format: <HASH> CALL [RRR|RR73|73]
|
|
1836
|
+
* CALL <HASH> [RRR|RR73|73]
|
|
1837
|
+
* CQ NONSTDCALL
|
|
1838
|
+
*
|
|
1839
|
+
* Bit layout: n12(12) n58(58) iflip(1) nrpt(2) icq(1) i3=4(3) → 77 bits
|
|
1840
|
+
*/
|
|
1841
|
+
function tryPackType4(parts) {
|
|
1842
|
+
if (parts.length < 2 || parts.length > 3)
|
|
1843
|
+
return null;
|
|
1844
|
+
const w1 = parts[0];
|
|
1845
|
+
const w2 = parts[1];
|
|
1846
|
+
const w3 = parts[2]; // optional
|
|
1847
|
+
let icq = 0;
|
|
1848
|
+
let iflip = 0;
|
|
1849
|
+
let n12 = 0;
|
|
1850
|
+
let n58 = 0n;
|
|
1851
|
+
let nrpt = 0;
|
|
1852
|
+
const parsedW1 = parseCallsign(w1);
|
|
1853
|
+
const parsedW2 = parseCallsign(w2);
|
|
1854
|
+
// If both are standard callsigns (no hash), type 4 doesn't apply
|
|
1855
|
+
if (parsedW1.isStandard && parsedW2.isStandard && !w1.startsWith("<") && !w2.startsWith("<"))
|
|
1856
|
+
return null;
|
|
1857
|
+
if (w1 === "CQ") {
|
|
1858
|
+
// CQ <nonstdcall>
|
|
1859
|
+
if (w2.length <= 4)
|
|
1860
|
+
return null; // too short for type 4
|
|
1861
|
+
icq = 1;
|
|
1862
|
+
iflip = 0;
|
|
1863
|
+
// save_hash_call updates n12 with ihashcall12 of the callsign
|
|
1864
|
+
n12 = ihashcall12(w2);
|
|
1865
|
+
const c11 = w2.padStart(11, " ");
|
|
1866
|
+
n58 = encodeC11(c11);
|
|
1867
|
+
nrpt = 0;
|
|
1868
|
+
}
|
|
1869
|
+
else if (w1.startsWith("<") && w1.endsWith(">")) {
|
|
1870
|
+
// <HASH> CALL [rpt]
|
|
1871
|
+
iflip = 0;
|
|
1872
|
+
const inner = w1.slice(1, -1);
|
|
1873
|
+
n12 = ihashcall12(inner);
|
|
1874
|
+
const c11 = w2.padStart(11, " ");
|
|
1875
|
+
n58 = encodeC11(c11);
|
|
1876
|
+
nrpt = decodeRpt(w3);
|
|
1877
|
+
}
|
|
1878
|
+
else if (w2.startsWith("<") && w2.endsWith(">")) {
|
|
1879
|
+
// CALL <HASH> [rpt]
|
|
1880
|
+
iflip = 1;
|
|
1881
|
+
const inner = w2.slice(1, -1);
|
|
1882
|
+
n12 = ihashcall12(inner);
|
|
1883
|
+
const c11 = w1.padStart(11, " ");
|
|
1884
|
+
n58 = encodeC11(c11);
|
|
1885
|
+
nrpt = decodeRpt(w3);
|
|
1886
|
+
}
|
|
1887
|
+
else {
|
|
1888
|
+
return null;
|
|
1889
|
+
}
|
|
1890
|
+
const i3 = 4;
|
|
1891
|
+
const bits = [];
|
|
1892
|
+
appendBits(bits, n12, 12);
|
|
1893
|
+
// n58 is a BigInt, need 58 bits
|
|
1894
|
+
for (let b = 57; b >= 0; b--) {
|
|
1895
|
+
bits.push(Number((n58 >> BigInt(b)) & 1n));
|
|
1896
|
+
}
|
|
1897
|
+
appendBits(bits, iflip, 1);
|
|
1898
|
+
appendBits(bits, nrpt, 2);
|
|
1899
|
+
appendBits(bits, icq, 1);
|
|
1900
|
+
appendBits(bits, i3, 3);
|
|
1901
|
+
return bits;
|
|
1902
|
+
}
|
|
1903
|
+
function ihashcall12(c0) {
|
|
1904
|
+
let n8 = 0n;
|
|
1905
|
+
const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
|
|
1906
|
+
for (let i = 0; i < 11; i++) {
|
|
1907
|
+
const j = C38.indexOf(s[i] ?? " ");
|
|
1908
|
+
n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
|
|
1909
|
+
}
|
|
1910
|
+
const MAGIC = 47055833459n;
|
|
1911
|
+
const prod = BigInt.asUintN(64, MAGIC * n8);
|
|
1912
|
+
return Number(prod >> 52n) & 0xfff; // 12 bits
|
|
1913
|
+
}
|
|
1914
|
+
function encodeC11(c11) {
|
|
1915
|
+
const padded = c11.padStart(11, " ");
|
|
1916
|
+
let n = 0n;
|
|
1917
|
+
for (let i = 0; i < 11; i++) {
|
|
1918
|
+
const j = C38.indexOf(padded[i].toUpperCase());
|
|
1919
|
+
n = n * 38n + BigInt(j < 0 ? 0 : j);
|
|
1920
|
+
}
|
|
1921
|
+
return n;
|
|
1922
|
+
}
|
|
1923
|
+
function decodeRpt(w) {
|
|
1924
|
+
if (!w)
|
|
1925
|
+
return 0;
|
|
1926
|
+
if (w === "RRR")
|
|
1927
|
+
return 1;
|
|
1928
|
+
if (w === "RR73")
|
|
1929
|
+
return 2;
|
|
1930
|
+
if (w === "73")
|
|
1931
|
+
return 3;
|
|
1932
|
+
return 0;
|
|
1933
|
+
}
|
|
1934
|
+
function packFreeText(msg) {
|
|
1935
|
+
// Truncate to 13 chars, only characters from FTALPH
|
|
1936
|
+
const raw = msg.slice(0, 13).toUpperCase();
|
|
1937
|
+
const bits71 = packtext77(raw);
|
|
1938
|
+
// Type 0.0: n3=0, i3=0 → last 6 bits are 000 000
|
|
1939
|
+
const bits = [...bits71, 0, 0, 0, 0, 0, 0];
|
|
1940
|
+
return bits; // 77 bits
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
const TWO_PI = 2 * Math.PI;
|
|
1944
|
+
const DEFAULT_SAMPLE_RATE = 12_000;
|
|
1945
|
+
const DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
|
|
1946
|
+
const DEFAULT_BT = 2.0;
|
|
1947
|
+
const MODULATION_INDEX = 1.0;
|
|
1948
|
+
function assertPositiveFinite(value, name) {
|
|
1949
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1950
|
+
throw new Error(`${name} must be a positive finite number`);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
// Abramowitz and Stegun 7.1.26 approximation.
|
|
1954
|
+
function erfApprox(x) {
|
|
1955
|
+
const sign = x < 0 ? -1 : 1;
|
|
1956
|
+
const ax = Math.abs(x);
|
|
1957
|
+
const t = 1 / (1 + 0.3275911 * ax);
|
|
1958
|
+
const y = 1 -
|
|
1959
|
+
((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
|
|
1960
|
+
t *
|
|
1961
|
+
Math.exp(-ax * ax);
|
|
1962
|
+
return sign * y;
|
|
1963
|
+
}
|
|
1964
|
+
function gfskPulse(bt, tt) {
|
|
1965
|
+
// Same expression used by lib/ft2/gfsk_pulse.f90.
|
|
1966
|
+
const scale = Math.PI * Math.sqrt(2 / Math.log(2)) * bt;
|
|
1967
|
+
return 0.5 * (erfApprox(scale * (tt + 0.5)) - erfApprox(scale * (tt - 0.5)));
|
|
1968
|
+
}
|
|
1969
|
+
function generateFT8Waveform(tones, options = {}) {
|
|
1970
|
+
// Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
|
|
1971
|
+
const nsym = tones.length;
|
|
1972
|
+
if (nsym === 0) {
|
|
1973
|
+
return new Float32Array(0);
|
|
1974
|
+
}
|
|
1975
|
+
const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
1976
|
+
const nsps = options.samplesPerSymbol ?? DEFAULT_SAMPLES_PER_SYMBOL;
|
|
1977
|
+
const bt = options.bt ?? DEFAULT_BT;
|
|
1978
|
+
const f0 = options.baseFrequency ?? 0;
|
|
1979
|
+
assertPositiveFinite(sampleRate, "sampleRate");
|
|
1980
|
+
assertPositiveFinite(nsps, "samplesPerSymbol");
|
|
1981
|
+
assertPositiveFinite(bt, "bt");
|
|
1982
|
+
if (!Number.isFinite(f0)) {
|
|
1983
|
+
throw new Error("baseFrequency must be finite");
|
|
1984
|
+
}
|
|
1985
|
+
if (!Number.isInteger(nsps)) {
|
|
1986
|
+
throw new Error("samplesPerSymbol must be an integer");
|
|
1987
|
+
}
|
|
1988
|
+
const nwave = nsym * nsps;
|
|
1989
|
+
const pulse = new Float64Array(3 * nsps);
|
|
1990
|
+
for (let i = 0; i < pulse.length; i++) {
|
|
1991
|
+
const tt = (i + 1 - 1.5 * nsps) / nsps;
|
|
1992
|
+
pulse[i] = gfskPulse(bt, tt);
|
|
1993
|
+
}
|
|
1994
|
+
const dphi = new Float64Array((nsym + 2) * nsps);
|
|
1995
|
+
const dphiPeak = (TWO_PI * MODULATION_INDEX) / nsps;
|
|
1996
|
+
for (let j = 0; j < nsym; j++) {
|
|
1997
|
+
const tone = tones[j];
|
|
1998
|
+
const ib = j * nsps;
|
|
1999
|
+
for (let i = 0; i < pulse.length; i++) {
|
|
2000
|
+
dphi[ib + i] += dphiPeak * pulse[i] * tone;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const firstTone = tones[0];
|
|
2004
|
+
const lastTone = tones[nsym - 1];
|
|
2005
|
+
const tailBase = nsym * nsps;
|
|
2006
|
+
for (let i = 0; i < 2 * nsps; i++) {
|
|
2007
|
+
dphi[i] += dphiPeak * firstTone * pulse[nsps + i];
|
|
2008
|
+
dphi[tailBase + i] += dphiPeak * lastTone * pulse[i];
|
|
2009
|
+
}
|
|
2010
|
+
const carrierDphi = (TWO_PI * f0) / sampleRate;
|
|
2011
|
+
for (let i = 0; i < dphi.length; i++) {
|
|
2012
|
+
dphi[i] += carrierDphi;
|
|
2013
|
+
}
|
|
2014
|
+
const wave = new Float32Array(nwave);
|
|
2015
|
+
let phi = 0;
|
|
2016
|
+
for (let k = 0; k < nwave; k++) {
|
|
2017
|
+
const j = nsps + k; // skip the leading dummy symbol
|
|
2018
|
+
wave[k] = Math.sin(phi);
|
|
2019
|
+
phi += dphi[j];
|
|
2020
|
+
phi %= TWO_PI;
|
|
2021
|
+
if (phi < 0) {
|
|
2022
|
+
phi += TWO_PI;
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
const nramp = Math.round(nsps / 8);
|
|
2026
|
+
for (let i = 0; i < nramp; i++) {
|
|
2027
|
+
const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
|
|
2028
|
+
wave[i] *= up;
|
|
2029
|
+
}
|
|
2030
|
+
const tailStart = nwave - nramp;
|
|
2031
|
+
for (let i = 0; i < nramp; i++) {
|
|
2032
|
+
const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
|
|
2033
|
+
wave[tailStart + i] *= down;
|
|
2034
|
+
}
|
|
2035
|
+
return wave;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
function generateLdpcGMatrix() {
|
|
2039
|
+
const K = 91;
|
|
2040
|
+
const M = 83; // 174 - 91
|
|
2041
|
+
const gen = Array.from({ length: M }, () => new Array(K).fill(0));
|
|
2042
|
+
for (let i = 0; i < M; i++) {
|
|
2043
|
+
const hexStr = gHex[i];
|
|
2044
|
+
for (let j = 0; j < 23; j++) {
|
|
2045
|
+
const val = parseInt(hexStr[j], 16);
|
|
2046
|
+
const limit = j === 22 ? 3 : 4;
|
|
2047
|
+
for (let jj = 1; jj <= limit; jj++) {
|
|
2048
|
+
const col = j * 4 + jj - 1; // 0-indexed
|
|
2049
|
+
if ((val & (1 << (4 - jj))) !== 0) {
|
|
2050
|
+
gen[i][col] = 1;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return gen;
|
|
2056
|
+
}
|
|
2057
|
+
const G = generateLdpcGMatrix();
|
|
2058
|
+
function encode174_91(msg77) {
|
|
2059
|
+
const poly = 0x2757;
|
|
2060
|
+
let crc = 0;
|
|
2061
|
+
// padded with 19 zeros (3 zeros + 16 zero-bits for flush)
|
|
2062
|
+
const bitArray = [...msg77, 0, 0, 0, ...new Array(16).fill(0)];
|
|
2063
|
+
for (let bit = 0; bit < 96; bit++) {
|
|
2064
|
+
const nextBit = bitArray[bit];
|
|
2065
|
+
if ((crc & 0x2000) !== 0) {
|
|
2066
|
+
crc = ((crc << 1) | nextBit) ^ poly;
|
|
2067
|
+
}
|
|
2068
|
+
else {
|
|
2069
|
+
crc = (crc << 1) | nextBit;
|
|
2070
|
+
}
|
|
2071
|
+
crc &= 0x3fff;
|
|
2072
|
+
}
|
|
2073
|
+
const msg91 = [...msg77];
|
|
2074
|
+
for (let i = 0; i < 14; i++) {
|
|
2075
|
+
msg91.push((crc >> (13 - i)) & 1);
|
|
2076
|
+
}
|
|
2077
|
+
const codeword = [...msg91];
|
|
2078
|
+
for (let i = 0; i < 83; i++) {
|
|
2079
|
+
let sum = 0;
|
|
2080
|
+
for (let j = 0; j < 91; j++) {
|
|
2081
|
+
sum += msg91[j] * G[i][j];
|
|
2082
|
+
}
|
|
2083
|
+
codeword.push(sum % 2);
|
|
2084
|
+
}
|
|
2085
|
+
return codeword;
|
|
2086
|
+
}
|
|
2087
|
+
function getTones(codeword) {
|
|
2088
|
+
const tones = new Array(79).fill(0);
|
|
2089
|
+
for (let i = 0; i < 7; i++)
|
|
2090
|
+
tones[i] = icos7[i];
|
|
2091
|
+
for (let i = 0; i < 7; i++)
|
|
2092
|
+
tones[36 + i] = icos7[i];
|
|
2093
|
+
for (let i = 0; i < 7; i++)
|
|
2094
|
+
tones[72 + i] = icos7[i];
|
|
2095
|
+
let k = 7;
|
|
2096
|
+
for (let j = 1; j <= 58; j++) {
|
|
2097
|
+
const i = j * 3 - 3; // codeword is 0-indexed in JS, but the loop was j=1 to 58
|
|
2098
|
+
if (j === 30)
|
|
2099
|
+
k += 7;
|
|
2100
|
+
const indx = codeword[i] * 4 + codeword[i + 1] * 2 + codeword[i + 2];
|
|
2101
|
+
tones[k] = graymap[indx];
|
|
2102
|
+
k++;
|
|
2103
|
+
}
|
|
2104
|
+
return tones;
|
|
2105
|
+
}
|
|
2106
|
+
function encodeMessage(msg) {
|
|
2107
|
+
const bits77 = pack77(msg);
|
|
2108
|
+
const codeword = encode174_91(bits77);
|
|
2109
|
+
return getTones(codeword);
|
|
2110
|
+
}
|
|
2111
|
+
function encode(msg, options = {}) {
|
|
2112
|
+
return generateFT8Waveform(encodeMessage(msg), options);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
export { decode as decodeFT8, encode as encodeFT8 };
|
|
2116
|
+
//# sourceMappingURL=ft8js.mjs.map
|