@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
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import {
|
|
2
|
+
graymap,
|
|
3
|
+
icos7,
|
|
4
|
+
N_LDPC,
|
|
5
|
+
NDOWN,
|
|
6
|
+
NFFT1,
|
|
7
|
+
NHSYM,
|
|
8
|
+
NMAX,
|
|
9
|
+
NN,
|
|
10
|
+
NSPS,
|
|
11
|
+
NSTEP,
|
|
12
|
+
SAMPLE_RATE,
|
|
13
|
+
} from "../util/constants.js";
|
|
14
|
+
import { decode174_91 } from "../util/decode174_91.js";
|
|
15
|
+
import { fftComplex, nextPow2 } from "../util/fft.js";
|
|
16
|
+
import { unpack77 } from "../util/unpack_jt77.js";
|
|
17
|
+
|
|
18
|
+
export interface DecodedMessage {
|
|
19
|
+
freq: number;
|
|
20
|
+
dt: number;
|
|
21
|
+
snr: number;
|
|
22
|
+
msg: string;
|
|
23
|
+
sync: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DecodeOptions {
|
|
27
|
+
/** Lower frequency bound (Hz), default 200 */
|
|
28
|
+
freqLow?: number;
|
|
29
|
+
/** Upper frequency bound (Hz), default 3000 */
|
|
30
|
+
freqHigh?: number;
|
|
31
|
+
/** Minimum sync threshold, default 1.3 */
|
|
32
|
+
syncMin?: number;
|
|
33
|
+
/** Decoding depth: 1=fast BP only, 2=BP+OSD, 3=deep */
|
|
34
|
+
depth?: number;
|
|
35
|
+
/** Maximum candidates to process */
|
|
36
|
+
maxCandidates?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Decode all FT8 signals in an audio buffer.
|
|
41
|
+
* Input: mono audio samples at `sampleRate` Hz, duration ~15s.
|
|
42
|
+
*/
|
|
43
|
+
export function decode(
|
|
44
|
+
samples: Float32Array | Float64Array,
|
|
45
|
+
sampleRate: number = SAMPLE_RATE,
|
|
46
|
+
options: DecodeOptions = {},
|
|
47
|
+
): DecodedMessage[] {
|
|
48
|
+
const nfa = options.freqLow ?? 200;
|
|
49
|
+
const nfb = options.freqHigh ?? 3000;
|
|
50
|
+
const syncmin = options.syncMin ?? 1.2;
|
|
51
|
+
const depth = options.depth ?? 2;
|
|
52
|
+
const maxCandidates = options.maxCandidates ?? 300;
|
|
53
|
+
|
|
54
|
+
// Resample to 12000 Hz if needed
|
|
55
|
+
let dd: Float64Array;
|
|
56
|
+
if (sampleRate === SAMPLE_RATE) {
|
|
57
|
+
dd = new Float64Array(NMAX);
|
|
58
|
+
const len = Math.min(samples.length, NMAX);
|
|
59
|
+
for (let i = 0; i < len; i++) dd[i] = samples[i]!;
|
|
60
|
+
} else {
|
|
61
|
+
dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Compute spectrogram and find sync candidates
|
|
65
|
+
const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
|
|
66
|
+
|
|
67
|
+
const decoded: DecodedMessage[] = [];
|
|
68
|
+
const seenMessages = new Set<string>();
|
|
69
|
+
|
|
70
|
+
for (const cand of candidates) {
|
|
71
|
+
const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
|
|
72
|
+
if (!result) continue;
|
|
73
|
+
|
|
74
|
+
if (seenMessages.has(result.msg)) continue;
|
|
75
|
+
seenMessages.add(result.msg);
|
|
76
|
+
|
|
77
|
+
decoded.push({
|
|
78
|
+
freq: result.freq,
|
|
79
|
+
dt: result.dt - 0.5,
|
|
80
|
+
snr: result.snr,
|
|
81
|
+
msg: result.msg,
|
|
82
|
+
sync: cand.sync,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return decoded;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface Candidate {
|
|
90
|
+
freq: number;
|
|
91
|
+
dt: number;
|
|
92
|
+
sync: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sync8(
|
|
96
|
+
dd: Float64Array,
|
|
97
|
+
nfa: number,
|
|
98
|
+
nfb: number,
|
|
99
|
+
syncmin: number,
|
|
100
|
+
maxcand: number,
|
|
101
|
+
): { candidates: Candidate[]; sbase: Float64Array } {
|
|
102
|
+
const JZ = 62;
|
|
103
|
+
// Fortran uses NFFT1=3840 for the spectrogram FFT; we need a power of 2
|
|
104
|
+
const fftSize = nextPow2(NFFT1); // 4096
|
|
105
|
+
const halfSize = fftSize / 2; // 2048
|
|
106
|
+
const tstep = NSTEP / SAMPLE_RATE;
|
|
107
|
+
const df = SAMPLE_RATE / fftSize;
|
|
108
|
+
const fac = 1.0 / 300.0;
|
|
109
|
+
|
|
110
|
+
// Compute symbol spectra, stepping by NSTEP
|
|
111
|
+
const s = new Float64Array(halfSize * NHSYM);
|
|
112
|
+
const savg = new Float64Array(halfSize);
|
|
113
|
+
|
|
114
|
+
const xRe = new Float64Array(fftSize);
|
|
115
|
+
const xIm = new Float64Array(fftSize);
|
|
116
|
+
|
|
117
|
+
for (let j = 0; j < NHSYM; j++) {
|
|
118
|
+
const ia = j * NSTEP;
|
|
119
|
+
xRe.fill(0);
|
|
120
|
+
xIm.fill(0);
|
|
121
|
+
for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
|
|
122
|
+
xRe[i] = fac * dd[ia + i]!;
|
|
123
|
+
}
|
|
124
|
+
fftComplex(xRe, xIm, false);
|
|
125
|
+
for (let i = 0; i < halfSize; i++) {
|
|
126
|
+
const power = xRe[i]! * xRe[i]! + xIm[i]! * xIm[i]!;
|
|
127
|
+
s[i * NHSYM + j] = power;
|
|
128
|
+
savg[i] = (savg[i] ?? 0) + power;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Compute baseline
|
|
133
|
+
const sbase = computeBaseline(savg, nfa, nfb, df, halfSize);
|
|
134
|
+
|
|
135
|
+
const ia = Math.max(1, Math.round(nfa / df));
|
|
136
|
+
const ib = Math.min(halfSize - 14, Math.round(nfb / df));
|
|
137
|
+
const nssy = Math.floor(NSPS / NSTEP);
|
|
138
|
+
const nfos = Math.round(SAMPLE_RATE / NSPS / df); // ~2 bins per tone spacing
|
|
139
|
+
const jstrt = Math.round(0.5 / tstep);
|
|
140
|
+
|
|
141
|
+
// 2D sync correlation
|
|
142
|
+
const sync2d = new Float64Array((ib - ia + 1) * (2 * JZ + 1));
|
|
143
|
+
const width = 2 * JZ + 1;
|
|
144
|
+
|
|
145
|
+
for (let i = ia; i <= ib; i++) {
|
|
146
|
+
for (let jj = -JZ; jj <= JZ; jj++) {
|
|
147
|
+
let ta = 0,
|
|
148
|
+
tb = 0,
|
|
149
|
+
tc = 0;
|
|
150
|
+
let t0a = 0,
|
|
151
|
+
t0b = 0,
|
|
152
|
+
t0c = 0;
|
|
153
|
+
|
|
154
|
+
for (let n = 0; n < 7; n++) {
|
|
155
|
+
const m = jj + jstrt + nssy * n;
|
|
156
|
+
const iCostas = i + nfos * icos7[n]!;
|
|
157
|
+
|
|
158
|
+
if (m >= 0 && m < NHSYM && iCostas < halfSize) {
|
|
159
|
+
ta += s[iCostas * NHSYM + m]!;
|
|
160
|
+
for (let tone = 0; tone <= 6; tone++) {
|
|
161
|
+
const idx = i + nfos * tone;
|
|
162
|
+
if (idx < halfSize) t0a += s[idx * NHSYM + m]!;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const m36 = m + nssy * 36;
|
|
167
|
+
if (m36 >= 0 && m36 < NHSYM && iCostas < halfSize) {
|
|
168
|
+
tb += s[iCostas * NHSYM + m36]!;
|
|
169
|
+
for (let tone = 0; tone <= 6; tone++) {
|
|
170
|
+
const idx = i + nfos * tone;
|
|
171
|
+
if (idx < halfSize) t0b += s[idx * NHSYM + m36]!;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const m72 = m + nssy * 72;
|
|
176
|
+
if (m72 >= 0 && m72 < NHSYM && iCostas < halfSize) {
|
|
177
|
+
tc += s[iCostas * NHSYM + m72]!;
|
|
178
|
+
for (let tone = 0; tone <= 6; tone++) {
|
|
179
|
+
const idx = i + nfos * tone;
|
|
180
|
+
if (idx < halfSize) t0c += s[idx * NHSYM + m72]!;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const t = ta + tb + tc;
|
|
186
|
+
const t0total = t0a + t0b + t0c;
|
|
187
|
+
const t0 = (t0total - t) / 6.0;
|
|
188
|
+
const syncVal = t0 > 0 ? t / t0 : 0;
|
|
189
|
+
|
|
190
|
+
const tbc = tb + tc;
|
|
191
|
+
const t0bc = t0b + t0c;
|
|
192
|
+
const t0bc2 = (t0bc - tbc) / 6.0;
|
|
193
|
+
const syncBc = t0bc2 > 0 ? tbc / t0bc2 : 0;
|
|
194
|
+
|
|
195
|
+
sync2d[(i - ia) * width + (jj + JZ)] = Math.max(syncVal, syncBc);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Find peaks
|
|
200
|
+
const candidates0: Candidate[] = [];
|
|
201
|
+
const mlag = 10;
|
|
202
|
+
|
|
203
|
+
for (let i = ia; i <= ib; i++) {
|
|
204
|
+
let bestSync = -1;
|
|
205
|
+
let bestJ = 0;
|
|
206
|
+
for (let j = -mlag; j <= mlag; j++) {
|
|
207
|
+
const v = sync2d[(i - ia) * width + (j + JZ)]!;
|
|
208
|
+
if (v > bestSync) {
|
|
209
|
+
bestSync = v;
|
|
210
|
+
bestJ = j;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Also check wider range
|
|
214
|
+
let bestSync2 = -1;
|
|
215
|
+
let bestJ2 = 0;
|
|
216
|
+
for (let j = -JZ; j <= JZ; j++) {
|
|
217
|
+
const v = sync2d[(i - ia) * width + (j + JZ)]!;
|
|
218
|
+
if (v > bestSync2) {
|
|
219
|
+
bestSync2 = v;
|
|
220
|
+
bestJ2 = j;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (bestSync >= syncmin) {
|
|
225
|
+
candidates0.push({
|
|
226
|
+
freq: i * df,
|
|
227
|
+
dt: (bestJ - 0.5) * tstep,
|
|
228
|
+
sync: bestSync,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (Math.abs(bestJ2 - bestJ) > 0 && bestSync2 >= syncmin) {
|
|
232
|
+
candidates0.push({
|
|
233
|
+
freq: i * df,
|
|
234
|
+
dt: (bestJ2 - 0.5) * tstep,
|
|
235
|
+
sync: bestSync2,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Compute baseline normalization for sync values
|
|
241
|
+
const syncValues = candidates0.map((c) => c.sync);
|
|
242
|
+
syncValues.sort((a, b) => a - b);
|
|
243
|
+
const pctileIdx = Math.max(0, Math.round(0.4 * syncValues.length) - 1);
|
|
244
|
+
const base = syncValues[pctileIdx] ?? 1;
|
|
245
|
+
if (base > 0) {
|
|
246
|
+
for (const c of candidates0) c.sync /= base;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Remove near-duplicate candidates
|
|
250
|
+
for (let i = 0; i < candidates0.length; i++) {
|
|
251
|
+
for (let j = 0; j < i; j++) {
|
|
252
|
+
const fdiff = Math.abs(candidates0[i]!.freq - candidates0[j]!.freq);
|
|
253
|
+
const tdiff = Math.abs(candidates0[i]!.dt - candidates0[j]!.dt);
|
|
254
|
+
if (fdiff < 4.0 && tdiff < 0.04) {
|
|
255
|
+
if (candidates0[i]!.sync >= candidates0[j]!.sync) {
|
|
256
|
+
candidates0[j]!.sync = 0;
|
|
257
|
+
} else {
|
|
258
|
+
candidates0[i]!.sync = 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Sort by sync descending, take top maxcand
|
|
265
|
+
const filtered = candidates0.filter((c) => c.sync >= syncmin);
|
|
266
|
+
filtered.sort((a, b) => b.sync - a.sync);
|
|
267
|
+
|
|
268
|
+
return { candidates: filtered.slice(0, maxcand), sbase };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function computeBaseline(
|
|
272
|
+
savg: Float64Array,
|
|
273
|
+
nfa: number,
|
|
274
|
+
nfb: number,
|
|
275
|
+
df: number,
|
|
276
|
+
nh1: number,
|
|
277
|
+
): Float64Array {
|
|
278
|
+
const sbase = new Float64Array(nh1);
|
|
279
|
+
const ia = Math.max(1, Math.round(nfa / df));
|
|
280
|
+
const ib = Math.min(nh1 - 1, Math.round(nfb / df));
|
|
281
|
+
|
|
282
|
+
// Smooth the spectrum to get baseline
|
|
283
|
+
const window = 50; // bins
|
|
284
|
+
for (let i = 0; i < nh1; i++) {
|
|
285
|
+
let sum = 0;
|
|
286
|
+
let count = 0;
|
|
287
|
+
const lo = Math.max(ia, i - window);
|
|
288
|
+
const hi = Math.min(ib, i + window);
|
|
289
|
+
for (let j = lo; j <= hi; j++) {
|
|
290
|
+
sum += savg[j]!;
|
|
291
|
+
count++;
|
|
292
|
+
}
|
|
293
|
+
sbase[i] = count > 0 ? 10 * Math.log10(Math.max(1e-30, sum / count)) : 0;
|
|
294
|
+
}
|
|
295
|
+
return sbase;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface Ft8bResult {
|
|
299
|
+
msg: string;
|
|
300
|
+
freq: number;
|
|
301
|
+
dt: number;
|
|
302
|
+
snr: number;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function ft8b(
|
|
306
|
+
dd0: Float64Array,
|
|
307
|
+
f1: number,
|
|
308
|
+
xdt: number,
|
|
309
|
+
_sbase: Float64Array,
|
|
310
|
+
depth: number,
|
|
311
|
+
): Ft8bResult | null {
|
|
312
|
+
const NFFT2 = 3200;
|
|
313
|
+
const NP2 = 2812;
|
|
314
|
+
const NFFT1_LONG = 192000;
|
|
315
|
+
const fs2 = SAMPLE_RATE / NDOWN;
|
|
316
|
+
const dt2 = 1.0 / fs2;
|
|
317
|
+
const twopi = 2 * Math.PI;
|
|
318
|
+
|
|
319
|
+
// Downsample: mix to baseband and filter
|
|
320
|
+
const cd0Re = new Float64Array(NFFT2);
|
|
321
|
+
const cd0Im = new Float64Array(NFFT2);
|
|
322
|
+
ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
|
|
323
|
+
|
|
324
|
+
// Find best time offset
|
|
325
|
+
const i0 = Math.round((xdt + 0.5) * fs2);
|
|
326
|
+
let smax = 0;
|
|
327
|
+
let ibest = i0;
|
|
328
|
+
|
|
329
|
+
for (let idt = i0 - 10; idt <= i0 + 10; idt++) {
|
|
330
|
+
const sync = sync8d(cd0Re, cd0Im, idt, null, null, false);
|
|
331
|
+
if (sync > smax) {
|
|
332
|
+
smax = sync;
|
|
333
|
+
ibest = idt;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Fine frequency search
|
|
338
|
+
smax = 0;
|
|
339
|
+
let delfbest = 0;
|
|
340
|
+
for (let ifr = -5; ifr <= 5; ifr++) {
|
|
341
|
+
const delf = ifr * 0.5;
|
|
342
|
+
const dphi = twopi * delf * dt2;
|
|
343
|
+
const twkRe = new Float64Array(32);
|
|
344
|
+
const twkIm = new Float64Array(32);
|
|
345
|
+
let phi = 0;
|
|
346
|
+
for (let i = 0; i < 32; i++) {
|
|
347
|
+
twkRe[i] = Math.cos(phi);
|
|
348
|
+
twkIm[i] = Math.sin(phi);
|
|
349
|
+
phi = (phi + dphi) % twopi;
|
|
350
|
+
}
|
|
351
|
+
const sync = sync8d(cd0Re, cd0Im, ibest, twkRe, twkIm, true);
|
|
352
|
+
if (sync > smax) {
|
|
353
|
+
smax = sync;
|
|
354
|
+
delfbest = delf;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Apply frequency correction and re-downsample
|
|
359
|
+
f1 += delfbest;
|
|
360
|
+
ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
|
|
361
|
+
|
|
362
|
+
// Refine time offset
|
|
363
|
+
const ss = new Float64Array(9);
|
|
364
|
+
for (let idt = -4; idt <= 4; idt++) {
|
|
365
|
+
ss[idt + 4] = sync8d(cd0Re, cd0Im, ibest + idt, null, null, false);
|
|
366
|
+
}
|
|
367
|
+
let maxss = -1;
|
|
368
|
+
let maxIdx = 4;
|
|
369
|
+
for (let i = 0; i < 9; i++) {
|
|
370
|
+
if (ss[i]! > maxss) {
|
|
371
|
+
maxss = ss[i]!;
|
|
372
|
+
maxIdx = i;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
ibest = ibest + maxIdx - 4;
|
|
376
|
+
xdt = (ibest - 1) * dt2;
|
|
377
|
+
|
|
378
|
+
// Extract 8-tone soft symbols for each of NN=79 symbols
|
|
379
|
+
const s8 = new Float64Array(8 * NN);
|
|
380
|
+
const csRe = new Float64Array(8 * NN);
|
|
381
|
+
const csIm = new Float64Array(8 * NN);
|
|
382
|
+
|
|
383
|
+
const symbRe = new Float64Array(32);
|
|
384
|
+
const symbIm = new Float64Array(32);
|
|
385
|
+
|
|
386
|
+
for (let k = 0; k < NN; k++) {
|
|
387
|
+
const i1 = ibest + k * 32;
|
|
388
|
+
symbRe.fill(0);
|
|
389
|
+
symbIm.fill(0);
|
|
390
|
+
if (i1 >= 0 && i1 + 31 < NP2) {
|
|
391
|
+
for (let j = 0; j < 32; j++) {
|
|
392
|
+
symbRe[j] = cd0Re[i1 + j]!;
|
|
393
|
+
symbIm[j] = cd0Im[i1 + j]!;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
fftComplex(symbRe, symbIm, false);
|
|
397
|
+
for (let tone = 0; tone < 8; tone++) {
|
|
398
|
+
const re = symbRe[tone]! / 1000;
|
|
399
|
+
const im = symbIm[tone]! / 1000;
|
|
400
|
+
csRe[tone * NN + k] = re;
|
|
401
|
+
csIm[tone * NN + k] = im;
|
|
402
|
+
s8[tone * NN + k] = Math.sqrt(re * re + im * im);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Sync quality check
|
|
407
|
+
let nsync = 0;
|
|
408
|
+
for (let k = 0; k < 7; k++) {
|
|
409
|
+
for (const offset of [0, 36, 72]) {
|
|
410
|
+
let maxTone = 0;
|
|
411
|
+
let maxVal = -1;
|
|
412
|
+
for (let t = 0; t < 8; t++) {
|
|
413
|
+
const v = s8[t * NN + k + offset]!;
|
|
414
|
+
if (v > maxVal) {
|
|
415
|
+
maxVal = v;
|
|
416
|
+
maxTone = t;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (maxTone === icos7[k]) nsync++;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (nsync <= 6) return null;
|
|
423
|
+
|
|
424
|
+
// Compute soft bit metrics for multiple nsym values (1, 2, 3)
|
|
425
|
+
// and a normalized version, matching the Fortran ft8b passes 1-4
|
|
426
|
+
const bmeta = new Float64Array(N_LDPC); // nsym=1
|
|
427
|
+
const bmetb = new Float64Array(N_LDPC); // nsym=2
|
|
428
|
+
const bmetc = new Float64Array(N_LDPC); // nsym=3
|
|
429
|
+
const bmetd = new Float64Array(N_LDPC); // nsym=1 normalized
|
|
430
|
+
|
|
431
|
+
for (let nsym = 1; nsym <= 3; nsym++) {
|
|
432
|
+
const nt = 1 << (3 * nsym); // 8, 64, 512
|
|
433
|
+
const ibmax = nsym === 1 ? 2 : nsym === 2 ? 5 : 8;
|
|
434
|
+
|
|
435
|
+
for (let ihalf = 1; ihalf <= 2; ihalf++) {
|
|
436
|
+
for (let k = 1; k <= 29; k += nsym) {
|
|
437
|
+
const ks = ihalf === 1 ? k + 7 : k + 43;
|
|
438
|
+
const s2 = new Float64Array(nt);
|
|
439
|
+
|
|
440
|
+
for (let i = 0; i < nt; i++) {
|
|
441
|
+
const i1 = Math.floor(i / 64);
|
|
442
|
+
const i2 = Math.floor((i & 63) / 8);
|
|
443
|
+
const i3 = i & 7;
|
|
444
|
+
if (nsym === 1) {
|
|
445
|
+
const re = csRe[graymap[i3]! * NN + ks - 1]!;
|
|
446
|
+
const im = csIm[graymap[i3]! * NN + ks - 1]!;
|
|
447
|
+
s2[i] = Math.sqrt(re * re + im * im);
|
|
448
|
+
} else if (nsym === 2) {
|
|
449
|
+
const sRe = csRe[graymap[i2]! * NN + ks - 1]! + csRe[graymap[i3]! * NN + ks]!;
|
|
450
|
+
const sIm = csIm[graymap[i2]! * NN + ks - 1]! + csIm[graymap[i3]! * NN + ks]!;
|
|
451
|
+
s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
|
|
452
|
+
} else {
|
|
453
|
+
const sRe =
|
|
454
|
+
csRe[graymap[i1]! * NN + ks - 1]! +
|
|
455
|
+
csRe[graymap[i2]! * NN + ks]! +
|
|
456
|
+
csRe[graymap[i3]! * NN + ks + 1]!;
|
|
457
|
+
const sIm =
|
|
458
|
+
csIm[graymap[i1]! * NN + ks - 1]! +
|
|
459
|
+
csIm[graymap[i2]! * NN + ks]! +
|
|
460
|
+
csIm[graymap[i3]! * NN + ks + 1]!;
|
|
461
|
+
s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Fortran: i32 = 1 + (k-1)*3 + (ihalf-1)*87 (1-based)
|
|
466
|
+
const i32 = 1 + (k - 1) * 3 + (ihalf - 1) * 87;
|
|
467
|
+
|
|
468
|
+
for (let ib = 0; ib <= ibmax; ib++) {
|
|
469
|
+
// max of s2 where bit (ibmax-ib) of index is 1
|
|
470
|
+
let max1 = -1e30,
|
|
471
|
+
max0 = -1e30;
|
|
472
|
+
for (let i = 0; i < nt; i++) {
|
|
473
|
+
const bitSet = (i & (1 << (ibmax - ib))) !== 0;
|
|
474
|
+
if (bitSet) {
|
|
475
|
+
if (s2[i]! > max1) max1 = s2[i]!;
|
|
476
|
+
} else {
|
|
477
|
+
if (s2[i]! > max0) max0 = s2[i]!;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const idx = i32 + ib - 1; // Convert to 0-based
|
|
481
|
+
if (idx >= 0 && idx < N_LDPC) {
|
|
482
|
+
const bm = max1 - max0;
|
|
483
|
+
if (nsym === 1) {
|
|
484
|
+
bmeta[idx] = bm;
|
|
485
|
+
const den = Math.max(max1, max0);
|
|
486
|
+
bmetd[idx] = den > 0 ? bm / den : 0;
|
|
487
|
+
} else if (nsym === 2) {
|
|
488
|
+
bmetb[idx] = bm;
|
|
489
|
+
} else {
|
|
490
|
+
bmetc[idx] = bm;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
normalizeBmet(bmeta);
|
|
499
|
+
normalizeBmet(bmetb);
|
|
500
|
+
normalizeBmet(bmetc);
|
|
501
|
+
normalizeBmet(bmetd);
|
|
502
|
+
|
|
503
|
+
const bmetrics = [bmeta, bmetb, bmetc, bmetd];
|
|
504
|
+
|
|
505
|
+
const scalefac = 2.83;
|
|
506
|
+
const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
|
|
507
|
+
const apmask = new Int8Array(N_LDPC);
|
|
508
|
+
|
|
509
|
+
// Try 4 passes with different soft-symbol metrics (matching Fortran)
|
|
510
|
+
let result: import("../util/decode174_91.js").DecodeResult | null = null;
|
|
511
|
+
for (let ipass = 0; ipass < 4; ipass++) {
|
|
512
|
+
const llr = new Float64Array(N_LDPC);
|
|
513
|
+
for (let i = 0; i < N_LDPC; i++) llr[i] = scalefac * bmetrics[ipass]![i]!;
|
|
514
|
+
result = decode174_91(llr, apmask, maxosd);
|
|
515
|
+
if (result && result.nharderrors >= 0 && result.nharderrors <= 36) break;
|
|
516
|
+
result = null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (!result || result.nharderrors < 0 || result.nharderrors > 36) return null;
|
|
520
|
+
|
|
521
|
+
// Check for all-zero codeword
|
|
522
|
+
if (result.cw.every((b) => b === 0)) return null;
|
|
523
|
+
|
|
524
|
+
const message77 = result.message91.slice(0, 77);
|
|
525
|
+
|
|
526
|
+
// Validate message type
|
|
527
|
+
const n3v = (message77[71]! << 2) | (message77[72]! << 1) | message77[73]!;
|
|
528
|
+
const i3v = (message77[74]! << 2) | (message77[75]! << 1) | message77[76]!;
|
|
529
|
+
if (i3v > 5 || (i3v === 0 && n3v > 6)) return null;
|
|
530
|
+
if (i3v === 0 && n3v === 2) return null;
|
|
531
|
+
|
|
532
|
+
// Unpack
|
|
533
|
+
const { msg, success } = unpack77(message77);
|
|
534
|
+
if (!success || msg.trim().length === 0) return null;
|
|
535
|
+
|
|
536
|
+
// Estimate SNR
|
|
537
|
+
let xsig = 0;
|
|
538
|
+
let xnoi = 0;
|
|
539
|
+
const itone = getTones(result.cw);
|
|
540
|
+
for (let i = 0; i < 79; i++) {
|
|
541
|
+
xsig += s8[itone[i]! * NN + i]! ** 2;
|
|
542
|
+
const ios = (itone[i]! + 4) % 7;
|
|
543
|
+
xnoi += s8[ios * NN + i]! ** 2;
|
|
544
|
+
}
|
|
545
|
+
let snr = 0.001;
|
|
546
|
+
const arg = xsig / Math.max(xnoi, 1e-30) - 1.0;
|
|
547
|
+
if (arg > 0.1) snr = arg;
|
|
548
|
+
snr = 10 * Math.log10(snr) - 27.0;
|
|
549
|
+
if (snr < -24) snr = -24;
|
|
550
|
+
|
|
551
|
+
return { msg, freq: f1, dt: xdt, snr };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function getTones(cw: number[]): number[] {
|
|
555
|
+
const tones = new Array(79).fill(0) as number[];
|
|
556
|
+
for (let i = 0; i < 7; i++) tones[i] = icos7[i]!;
|
|
557
|
+
for (let i = 0; i < 7; i++) tones[36 + i] = icos7[i]!;
|
|
558
|
+
for (let i = 0; i < 7; i++) tones[72 + i] = icos7[i]!;
|
|
559
|
+
let k = 7;
|
|
560
|
+
for (let j = 1; j <= 58; j++) {
|
|
561
|
+
const i = (j - 1) * 3;
|
|
562
|
+
if (j === 30) k += 7;
|
|
563
|
+
const indx = cw[i]! * 4 + cw[i + 1]! * 2 + cw[i + 2]!;
|
|
564
|
+
tones[k] = graymap[indx]!;
|
|
565
|
+
k++;
|
|
566
|
+
}
|
|
567
|
+
return tones;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Mix f0 to baseband and decimate by NDOWN (60x).
|
|
572
|
+
* Time-domain approach: mix down, low-pass filter via moving average, decimate.
|
|
573
|
+
* Output: complex baseband signal at 200 Hz sample rate (32 samples/symbol).
|
|
574
|
+
*/
|
|
575
|
+
function ft8Downsample(
|
|
576
|
+
dd: Float64Array,
|
|
577
|
+
f0: number,
|
|
578
|
+
outRe: Float64Array,
|
|
579
|
+
outIm: Float64Array,
|
|
580
|
+
_nfft1Long: number,
|
|
581
|
+
nfft2: number,
|
|
582
|
+
): void {
|
|
583
|
+
const twopi = 2 * Math.PI;
|
|
584
|
+
const len = Math.min(dd.length, NMAX);
|
|
585
|
+
const dphi = (twopi * f0) / SAMPLE_RATE;
|
|
586
|
+
|
|
587
|
+
// Mix to baseband
|
|
588
|
+
const mixRe = new Float64Array(len);
|
|
589
|
+
const mixIm = new Float64Array(len);
|
|
590
|
+
let phi = 0;
|
|
591
|
+
for (let i = 0; i < len; i++) {
|
|
592
|
+
mixRe[i] = dd[i]! * Math.cos(phi);
|
|
593
|
+
mixIm[i] = -dd[i]! * Math.sin(phi);
|
|
594
|
+
phi += dphi;
|
|
595
|
+
if (phi > twopi) phi -= twopi;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Low-pass filter: simple moving-average with window = NDOWN
|
|
599
|
+
// then decimate by NDOWN to get 200 Hz sample rate
|
|
600
|
+
const outLen = Math.min(nfft2, Math.floor(len / NDOWN));
|
|
601
|
+
outRe.fill(0);
|
|
602
|
+
outIm.fill(0);
|
|
603
|
+
|
|
604
|
+
// Running sum filter
|
|
605
|
+
const halfWin = NDOWN >> 1;
|
|
606
|
+
for (let k = 0; k < outLen; k++) {
|
|
607
|
+
const center = k * NDOWN + halfWin;
|
|
608
|
+
let sumRe = 0,
|
|
609
|
+
sumIm = 0;
|
|
610
|
+
const start = Math.max(0, center - halfWin);
|
|
611
|
+
const end = Math.min(len, center + halfWin);
|
|
612
|
+
for (let j = start; j < end; j++) {
|
|
613
|
+
sumRe += mixRe[j]!;
|
|
614
|
+
sumIm += mixIm[j]!;
|
|
615
|
+
}
|
|
616
|
+
const n = end - start;
|
|
617
|
+
outRe[k] = sumRe / n;
|
|
618
|
+
outIm[k] = sumIm / n;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function sync8d(
|
|
623
|
+
cd0Re: Float64Array,
|
|
624
|
+
cd0Im: Float64Array,
|
|
625
|
+
i0: number,
|
|
626
|
+
twkRe: Float64Array | null,
|
|
627
|
+
twkIm: Float64Array | null,
|
|
628
|
+
useTwk: boolean,
|
|
629
|
+
): number {
|
|
630
|
+
const NP2 = 2812;
|
|
631
|
+
const twopi = 2 * Math.PI;
|
|
632
|
+
|
|
633
|
+
// Precompute Costas sync waveforms
|
|
634
|
+
const csyncRe = new Float64Array(7 * 32);
|
|
635
|
+
const csyncIm = new Float64Array(7 * 32);
|
|
636
|
+
for (let i = 0; i < 7; i++) {
|
|
637
|
+
let phi = 0;
|
|
638
|
+
const dphi = (twopi * icos7[i]!) / 32;
|
|
639
|
+
for (let j = 0; j < 32; j++) {
|
|
640
|
+
csyncRe[i * 32 + j] = Math.cos(phi);
|
|
641
|
+
csyncIm[i * 32 + j] = Math.sin(phi);
|
|
642
|
+
phi = (phi + dphi) % twopi;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let sync = 0;
|
|
647
|
+
for (let i = 0; i < 7; i++) {
|
|
648
|
+
const i1 = i0 + i * 32;
|
|
649
|
+
const i2 = i1 + 36 * 32;
|
|
650
|
+
const i3 = i1 + 72 * 32;
|
|
651
|
+
|
|
652
|
+
for (const iStart of [i1, i2, i3]) {
|
|
653
|
+
let zRe = 0,
|
|
654
|
+
zIm = 0;
|
|
655
|
+
if (iStart >= 0 && iStart + 31 < NP2) {
|
|
656
|
+
for (let j = 0; j < 32; j++) {
|
|
657
|
+
let sRe = csyncRe[i * 32 + j]!;
|
|
658
|
+
let sIm = csyncIm[i * 32 + j]!;
|
|
659
|
+
if (useTwk && twkRe && twkIm) {
|
|
660
|
+
const tRe = twkRe[j]! * sRe - twkIm[j]! * sIm;
|
|
661
|
+
const tIm = twkRe[j]! * sIm + twkIm[j]! * sRe;
|
|
662
|
+
sRe = tRe;
|
|
663
|
+
sIm = tIm;
|
|
664
|
+
}
|
|
665
|
+
// Conjugate multiply: cd0 * conj(csync)
|
|
666
|
+
const dRe = cd0Re[iStart + j]!;
|
|
667
|
+
const dIm = cd0Im[iStart + j]!;
|
|
668
|
+
zRe += dRe * sRe + dIm * sIm;
|
|
669
|
+
zIm += dIm * sRe - dRe * sIm;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
sync += zRe * zRe + zIm * zIm;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return sync;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function normalizeBmet(bmet: Float64Array): void {
|
|
680
|
+
const n = bmet.length;
|
|
681
|
+
let sum = 0,
|
|
682
|
+
sum2 = 0;
|
|
683
|
+
for (let i = 0; i < n; i++) {
|
|
684
|
+
sum += bmet[i]!;
|
|
685
|
+
sum2 += bmet[i]! * bmet[i]!;
|
|
686
|
+
}
|
|
687
|
+
const avg = sum / n;
|
|
688
|
+
const avg2 = sum2 / n;
|
|
689
|
+
const variance = avg2 - avg * avg;
|
|
690
|
+
const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
|
|
691
|
+
if (sigma > 0) {
|
|
692
|
+
for (let i = 0; i < n; i++) bmet[i] = bmet[i]! / sigma;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function resample(
|
|
697
|
+
input: Float32Array | Float64Array,
|
|
698
|
+
fromRate: number,
|
|
699
|
+
toRate: number,
|
|
700
|
+
outLen: number,
|
|
701
|
+
): Float64Array {
|
|
702
|
+
const out = new Float64Array(outLen);
|
|
703
|
+
const ratio = fromRate / toRate;
|
|
704
|
+
for (let i = 0; i < outLen; i++) {
|
|
705
|
+
const srcIdx = i * ratio;
|
|
706
|
+
const lo = Math.floor(srcIdx);
|
|
707
|
+
const frac = srcIdx - lo;
|
|
708
|
+
const v0 = lo < input.length ? (input[lo] ?? 0) : 0;
|
|
709
|
+
const v1 = lo + 1 < input.length ? (input[lo + 1] ?? 0) : 0;
|
|
710
|
+
out[i] = v0 * (1 - frac) + v1 * frac;
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
}
|