@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.
@@ -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
+ }