@e04/ft8ts 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ft8ts.mjs CHANGED
@@ -566,6 +566,10 @@ function fftComplex(re, im, inverse) {
566
566
  const n = re.length;
567
567
  if (n <= 1)
568
568
  return;
569
+ if ((n & (n - 1)) !== 0) {
570
+ bluestein(re, im, inverse);
571
+ return;
572
+ }
569
573
  // Bit-reversal permutation
570
574
  let j = 0;
571
575
  for (let i = 0; i < n; i++) {
@@ -584,7 +588,7 @@ function fftComplex(re, im, inverse) {
584
588
  }
585
589
  j += m;
586
590
  }
587
- const sign = -1;
591
+ const sign = inverse ? 1 : -1;
588
592
  for (let size = 2; size <= n; size <<= 1) {
589
593
  const halfsize = size >> 1;
590
594
  const step = (sign * Math.PI) / halfsize;
@@ -608,6 +612,53 @@ function fftComplex(re, im, inverse) {
608
612
  }
609
613
  }
610
614
  }
615
+ if (inverse) {
616
+ for (let i = 0; i < n; i++) {
617
+ re[i] /= n;
618
+ im[i] /= n;
619
+ }
620
+ }
621
+ }
622
+ function bluestein(re, im, inverse) {
623
+ const n = re.length;
624
+ const m = nextPow2(n * 2 - 1);
625
+ const s = inverse ? 1 : -1;
626
+ const aRe = new Float64Array(m);
627
+ const aIm = new Float64Array(m);
628
+ const bRe = new Float64Array(m);
629
+ const bIm = new Float64Array(m);
630
+ for (let i = 0; i < n; i++) {
631
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
632
+ const cosA = Math.cos(angle);
633
+ const sinA = Math.sin(angle);
634
+ aRe[i] = re[i] * cosA - im[i] * sinA;
635
+ aIm[i] = re[i] * sinA + im[i] * cosA;
636
+ bRe[i] = cosA;
637
+ bIm[i] = -sinA;
638
+ }
639
+ for (let i = 1; i < n; i++) {
640
+ bRe[m - i] = bRe[i];
641
+ bIm[m - i] = bIm[i];
642
+ }
643
+ fftComplex(aRe, aIm, false);
644
+ fftComplex(bRe, bIm, false);
645
+ for (let i = 0; i < m; i++) {
646
+ const r = aRe[i] * bRe[i] - aIm[i] * bIm[i];
647
+ const iIm = aRe[i] * bIm[i] + aIm[i] * bRe[i];
648
+ aRe[i] = r;
649
+ aIm[i] = iIm;
650
+ }
651
+ fftComplex(aRe, aIm, true);
652
+ const scale = inverse ? 1 / n : 1;
653
+ for (let i = 0; i < n; i++) {
654
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
655
+ const cosA = Math.cos(angle);
656
+ const sinA = Math.sin(angle);
657
+ const r = aRe[i] * cosA - aIm[i] * sinA;
658
+ const iIm = aRe[i] * sinA + aIm[i] * cosA;
659
+ re[i] = r * scale;
660
+ im[i] = iIm * scale;
661
+ }
611
662
  }
612
663
  /** Next power of 2 >= n */
613
664
  function nextPow2(n) {
@@ -878,12 +929,20 @@ function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
878
929
  else {
879
930
  dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
880
931
  }
932
+ // Compute huge FFT for downsampling caching
933
+ const NFFT1_LONG = 192000;
934
+ const cxRe = new Float64Array(NFFT1_LONG);
935
+ const cxIm = new Float64Array(NFFT1_LONG);
936
+ for (let i = 0; i < NMAX; i++) {
937
+ cxRe[i] = dd[i] ?? 0;
938
+ }
939
+ fftComplex(cxRe, cxIm, false);
881
940
  // Compute spectrogram and find sync candidates
882
941
  const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
883
942
  const decoded = [];
884
943
  const seenMessages = new Set();
885
944
  for (const cand of candidates) {
886
- const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
945
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth);
887
946
  if (!result)
888
947
  continue;
889
948
  if (seenMessages.has(result.msg))
@@ -919,7 +978,7 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
919
978
  for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
920
979
  xRe[i] = fac * dd[ia + i];
921
980
  }
922
- fftComplex(xRe, xIm);
981
+ fftComplex(xRe, xIm, false);
923
982
  for (let i = 0; i < halfSize; i++) {
924
983
  const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
925
984
  s[i * NHSYM + j] = power;
@@ -1067,17 +1126,16 @@ function computeBaseline(savg, nfa, nfb, df, nh1) {
1067
1126
  }
1068
1127
  return sbase;
1069
1128
  }
1070
- function ft8b(dd0, f1, xdt, _sbase, depth) {
1129
+ function ft8b(dd0, cxRe, cxIm, f1, xdt, _sbase, depth) {
1071
1130
  const NFFT2 = 3200;
1072
1131
  const NP2 = 2812;
1073
- const NFFT1_LONG = 192000;
1074
1132
  const fs2 = SAMPLE_RATE / NDOWN;
1075
1133
  const dt2 = 1.0 / fs2;
1076
1134
  const twopi = 2 * Math.PI;
1077
1135
  // Downsample: mix to baseband and filter
1078
1136
  const cd0Re = new Float64Array(NFFT2);
1079
1137
  const cd0Im = new Float64Array(NFFT2);
1080
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1138
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1081
1139
  // Find best time offset
1082
1140
  const i0 = Math.round((xdt + 0.5) * fs2);
1083
1141
  let smax = 0;
@@ -1111,7 +1169,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1111
1169
  }
1112
1170
  // Apply frequency correction and re-downsample
1113
1171
  f1 += delfbest;
1114
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1172
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1115
1173
  // Refine time offset
1116
1174
  const ss = new Float64Array(9);
1117
1175
  for (let idt = -4; idt <= 4; idt++) {
@@ -1143,7 +1201,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1143
1201
  symbIm[j] = cd0Im[i1 + j];
1144
1202
  }
1145
1203
  }
1146
- fftComplex(symbRe, symbIm);
1204
+ fftComplex(symbRe, symbIm, false);
1147
1205
  for (let tone = 0; tone < 8; tone++) {
1148
1206
  const re = symbRe[tone] / 1000;
1149
1207
  const im = symbIm[tone] / 1000;
@@ -1317,44 +1375,75 @@ function getTones$1(cw) {
1317
1375
  return tones;
1318
1376
  }
1319
1377
  /**
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).
1378
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1379
+ * Identical to Fortran ft8_downsample.
1323
1380
  */
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;
1381
+ function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
1382
+ const NFFT1 = 192000;
1383
+ const NFFT2 = 3200;
1384
+ const df = 12000.0 / NFFT1;
1385
+ // NSPS is imported, should be 1920
1386
+ const baud = 12000.0 / NSPS; // 6.25
1387
+ const i0 = Math.round(f0 / df);
1388
+ const ft = f0 + 8.5 * baud;
1389
+ const it = Math.min(Math.round(ft / df), NFFT1 / 2);
1390
+ const fb = f0 - 1.5 * baud;
1391
+ const ib = Math.max(1, Math.round(fb / df));
1392
+ c1Re.fill(0);
1393
+ c1Im.fill(0);
1394
+ let k = 0;
1395
+ for (let i = ib; i <= it; i++) {
1396
+ if (k >= NFFT2)
1397
+ break;
1398
+ c1Re[k] = cxRe[i] ?? 0;
1399
+ c1Im[k] = cxIm[i] ?? 0;
1400
+ k++;
1401
+ }
1402
+ // Taper
1403
+ const pi = Math.PI;
1404
+ const taper = new Float64Array(101);
1405
+ for (let i = 0; i <= 100; i++) {
1406
+ taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
1407
+ }
1408
+ for (let i = 0; i <= 100; i++) {
1409
+ if (i >= NFFT2)
1410
+ break;
1411
+ const tap = taper[100 - i];
1412
+ c1Re[i] = c1Re[i] * tap;
1413
+ c1Im[i] = c1Im[i] * tap;
1414
+ }
1415
+ const endTap = k - 1;
1416
+ for (let i = 0; i <= 100; i++) {
1417
+ const idx = endTap - 100 + i;
1418
+ if (idx >= 0 && idx < NFFT2) {
1419
+ const tap = taper[i];
1420
+ c1Re[idx] = c1Re[idx] * tap;
1421
+ c1Im[idx] = c1Im[idx] * tap;
1422
+ }
1423
+ }
1424
+ // CSHIFT
1425
+ const shift = i0 - ib;
1426
+ const tempRe = new Float64Array(NFFT2);
1427
+ const tempIm = new Float64Array(NFFT2);
1428
+ for (let i = 0; i < NFFT2; i++) {
1429
+ let srcIdx = (i + shift) % NFFT2;
1430
+ if (srcIdx < 0)
1431
+ srcIdx += NFFT2;
1432
+ tempRe[i] = c1Re[srcIdx];
1433
+ tempIm[i] = c1Im[srcIdx];
1434
+ }
1435
+ for (let i = 0; i < NFFT2; i++) {
1436
+ c1Re[i] = tempRe[i];
1437
+ c1Im[i] = tempIm[i];
1438
+ }
1439
+ // iFFT
1440
+ fftComplex(c1Re, c1Im, true);
1441
+ // Scale
1442
+ // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
1443
+ const scale = Math.sqrt(NFFT2 / NFFT1);
1444
+ for (let i = 0; i < NFFT2; i++) {
1445
+ c1Re[i] = c1Re[i] * scale;
1446
+ c1Im[i] = c1Im[i] * scale;
1358
1447
  }
1359
1448
  }
1360
1449
  function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {