@e04/ft8ts 0.0.3 → 0.0.8

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.
@@ -1,10 +1,12 @@
1
- 'use strict';
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
2
4
 
3
- const SAMPLE_RATE = 12_000;
5
+ const SAMPLE_RATE$1 = 12_000;
4
6
  const NSPS = 1920;
5
7
  const NFFT1 = 2 * NSPS; // 3840
6
8
  const NSTEP = NSPS / 4; // 480
7
- const NMAX = 15 * SAMPLE_RATE; // 180000
9
+ const NMAX = 15 * SAMPLE_RATE$1; // 180000
8
10
  const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
9
11
  const NDOWN = 60;
10
12
  const NN = 79;
@@ -568,6 +570,10 @@ function fftComplex(re, im, inverse) {
568
570
  const n = re.length;
569
571
  if (n <= 1)
570
572
  return;
573
+ if ((n & (n - 1)) !== 0) {
574
+ bluestein(re, im, inverse);
575
+ return;
576
+ }
571
577
  // Bit-reversal permutation
572
578
  let j = 0;
573
579
  for (let i = 0; i < n; i++) {
@@ -586,7 +592,7 @@ function fftComplex(re, im, inverse) {
586
592
  }
587
593
  j += m;
588
594
  }
589
- const sign = -1;
595
+ const sign = inverse ? 1 : -1;
590
596
  for (let size = 2; size <= n; size <<= 1) {
591
597
  const halfsize = size >> 1;
592
598
  const step = (sign * Math.PI) / halfsize;
@@ -610,6 +616,53 @@ function fftComplex(re, im, inverse) {
610
616
  }
611
617
  }
612
618
  }
619
+ if (inverse) {
620
+ for (let i = 0; i < n; i++) {
621
+ re[i] /= n;
622
+ im[i] /= n;
623
+ }
624
+ }
625
+ }
626
+ function bluestein(re, im, inverse) {
627
+ const n = re.length;
628
+ const m = nextPow2(n * 2 - 1);
629
+ const s = inverse ? 1 : -1;
630
+ const aRe = new Float64Array(m);
631
+ const aIm = new Float64Array(m);
632
+ const bRe = new Float64Array(m);
633
+ const bIm = new Float64Array(m);
634
+ for (let i = 0; i < n; i++) {
635
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
636
+ const cosA = Math.cos(angle);
637
+ const sinA = Math.sin(angle);
638
+ aRe[i] = re[i] * cosA - im[i] * sinA;
639
+ aIm[i] = re[i] * sinA + im[i] * cosA;
640
+ bRe[i] = cosA;
641
+ bIm[i] = -sinA;
642
+ }
643
+ for (let i = 1; i < n; i++) {
644
+ bRe[m - i] = bRe[i];
645
+ bIm[m - i] = bIm[i];
646
+ }
647
+ fftComplex(aRe, aIm, false);
648
+ fftComplex(bRe, bIm, false);
649
+ for (let i = 0; i < m; i++) {
650
+ const r = aRe[i] * bRe[i] - aIm[i] * bIm[i];
651
+ const iIm = aRe[i] * bIm[i] + aIm[i] * bRe[i];
652
+ aRe[i] = r;
653
+ aIm[i] = iIm;
654
+ }
655
+ fftComplex(aRe, aIm, true);
656
+ const scale = inverse ? 1 / n : 1;
657
+ for (let i = 0; i < n; i++) {
658
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
659
+ const cosA = Math.cos(angle);
660
+ const sinA = Math.sin(angle);
661
+ const r = aRe[i] * cosA - aIm[i] * sinA;
662
+ const iIm = aRe[i] * sinA + aIm[i] * cosA;
663
+ re[i] = r * scale;
664
+ im[i] = iIm * scale;
665
+ }
613
666
  }
614
667
  /** Next power of 2 >= n */
615
668
  function nextPow2(n) {
@@ -635,7 +688,7 @@ function bitsToUint(bits, start, len) {
635
688
  }
636
689
  return val;
637
690
  }
638
- function unpack28(n28) {
691
+ function unpack28(n28, book) {
639
692
  if (n28 < 0 || n28 >= 268435456)
640
693
  return { call: "", success: false };
641
694
  if (n28 === 0)
@@ -649,7 +702,6 @@ function unpack28(n28) {
649
702
  return { call: `CQ ${nqsy.toString().padStart(3, "0")}`, success: true };
650
703
  }
651
704
  if (n28 >= 1003 && n28 < NTOKENS) {
652
- // CQ with 4-letter directed call
653
705
  let m = n28 - 1003;
654
706
  let chars = "";
655
707
  for (let i = 3; i >= 0; i--) {
@@ -663,7 +715,10 @@ function unpack28(n28) {
663
715
  return { call: "CQ", success: true };
664
716
  }
665
717
  if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
666
- // Hashed call we don't have a hash table, so show <...>
718
+ const n22 = n28 - NTOKENS;
719
+ const resolved = book?.lookup22(n22);
720
+ if (resolved)
721
+ return { call: `<${resolved}>`, success: true };
667
722
  return { call: "<...>", success: true };
668
723
  }
669
724
  // Standard callsign
@@ -743,8 +798,11 @@ function unpackText77(bits71) {
743
798
  }
744
799
  /**
745
800
  * Unpack a 77-bit FT8 message into a human-readable string.
801
+ *
802
+ * When a {@link HashCallBook} is provided, hashed callsigns are resolved from
803
+ * the book, and newly decoded standard callsigns are saved into it.
746
804
  */
747
- function unpack77(bits77) {
805
+ function unpack77(bits77, book) {
748
806
  const n3 = bitsToUint(bits77, 71, 3);
749
807
  const i3 = bitsToUint(bits77, 74, 3);
750
808
  if (i3 === 0 && n3 === 0) {
@@ -762,8 +820,8 @@ function unpack77(bits77) {
762
820
  const ipb = bits77[57];
763
821
  const ir = bits77[58];
764
822
  const igrid4 = bitsToUint(bits77, 59, 15);
765
- const { call: call1, success: ok1 } = unpack28(n28a);
766
- const { call: call2Raw, success: ok2 } = unpack28(n28b);
823
+ const { call: call1, success: ok1 } = unpack28(n28a, book);
824
+ const { call: call2Raw, success: ok2 } = unpack28(n28b, book);
767
825
  if (!ok1 || !ok2)
768
826
  return { msg: "", success: false };
769
827
  let c1 = call1;
@@ -781,6 +839,9 @@ function unpack77(bits77) {
781
839
  c2 += "/R";
782
840
  if (ipb === 1 && i3 === 2 && c2.length >= 3)
783
841
  c2 += "/P";
842
+ // Save the "from" call (call_2) into the hash book
843
+ if (book && c2.length >= 3)
844
+ book.save(c2);
784
845
  }
785
846
  if (igrid4 <= MAXGRID4) {
786
847
  const { grid, success: gridOk } = toGrid4(igrid4);
@@ -813,6 +874,7 @@ function unpack77(bits77) {
813
874
  }
814
875
  if (i3 === 4) {
815
876
  // Type 4: One nonstandard call
877
+ const n12 = bitsToUint(bits77, 0, 12);
816
878
  let n58 = 0n;
817
879
  for (let i = 0; i < 58; i++) {
818
880
  n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
@@ -820,7 +882,6 @@ function unpack77(bits77) {
820
882
  const iflip = bits77[70];
821
883
  const nrpt = bitsToUint(bits77, 71, 2);
822
884
  const icq = bits77[73];
823
- // Decode n58 to 11-char string using C38 alphabet
824
885
  const c11chars = [];
825
886
  let remain = n58;
826
887
  for (let i = 10; i >= 0; i--) {
@@ -829,12 +890,15 @@ function unpack77(bits77) {
829
890
  c11chars.unshift(C38[j] ?? " ");
830
891
  }
831
892
  const c11 = c11chars.join("").trim();
832
- const call3 = "<...>"; // We don't have a hash table for n12
893
+ const resolved = book?.lookup12(n12);
894
+ const call3 = resolved ? `<${resolved}>` : "<...>";
833
895
  let call1;
834
896
  let call2;
835
897
  if (iflip === 0) {
836
898
  call1 = call3;
837
899
  call2 = c11;
900
+ if (book)
901
+ book.save(c11);
838
902
  }
839
903
  else {
840
904
  call1 = c11;
@@ -863,29 +927,39 @@ function unpack77(bits77) {
863
927
  * Decode all FT8 signals in an audio buffer.
864
928
  * Input: mono audio samples at `sampleRate` Hz, duration ~15s.
865
929
  */
866
- function decode(samples, sampleRate = SAMPLE_RATE, options = {}) {
930
+ function decode(samples, options = {}) {
931
+ const sampleRate = options.sampleRate ?? SAMPLE_RATE$1;
867
932
  const nfa = options.freqLow ?? 200;
868
933
  const nfb = options.freqHigh ?? 3000;
869
934
  const syncmin = options.syncMin ?? 1.2;
870
935
  const depth = options.depth ?? 2;
871
936
  const maxCandidates = options.maxCandidates ?? 300;
937
+ const book = options.hashCallBook;
872
938
  // Resample to 12000 Hz if needed
873
939
  let dd;
874
- if (sampleRate === SAMPLE_RATE) {
940
+ if (sampleRate === SAMPLE_RATE$1) {
875
941
  dd = new Float64Array(NMAX);
876
942
  const len = Math.min(samples.length, NMAX);
877
943
  for (let i = 0; i < len; i++)
878
944
  dd[i] = samples[i];
879
945
  }
880
946
  else {
881
- dd = resample(samples, sampleRate, SAMPLE_RATE, NMAX);
947
+ dd = resample(samples, sampleRate, SAMPLE_RATE$1, NMAX);
948
+ }
949
+ // Compute huge FFT for downsampling caching
950
+ const NFFT1_LONG = 192000;
951
+ const cxRe = new Float64Array(NFFT1_LONG);
952
+ const cxIm = new Float64Array(NFFT1_LONG);
953
+ for (let i = 0; i < NMAX; i++) {
954
+ cxRe[i] = dd[i] ?? 0;
882
955
  }
956
+ fftComplex(cxRe, cxIm, false);
883
957
  // Compute spectrogram and find sync candidates
884
958
  const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
885
959
  const decoded = [];
886
960
  const seenMessages = new Set();
887
961
  for (const cand of candidates) {
888
- const result = ft8b(dd, cand.freq, cand.dt, sbase, depth);
962
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
889
963
  if (!result)
890
964
  continue;
891
965
  if (seenMessages.has(result.msg))
@@ -906,8 +980,8 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
906
980
  // Fortran uses NFFT1=3840 for the spectrogram FFT; we need a power of 2
907
981
  const fftSize = nextPow2(NFFT1); // 4096
908
982
  const halfSize = fftSize / 2; // 2048
909
- const tstep = NSTEP / SAMPLE_RATE;
910
- const df = SAMPLE_RATE / fftSize;
983
+ const tstep = NSTEP / SAMPLE_RATE$1;
984
+ const df = SAMPLE_RATE$1 / fftSize;
911
985
  const fac = 1.0 / 300.0;
912
986
  // Compute symbol spectra, stepping by NSTEP
913
987
  const s = new Float64Array(halfSize * NHSYM);
@@ -921,7 +995,7 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
921
995
  for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
922
996
  xRe[i] = fac * dd[ia + i];
923
997
  }
924
- fftComplex(xRe, xIm);
998
+ fftComplex(xRe, xIm, false);
925
999
  for (let i = 0; i < halfSize; i++) {
926
1000
  const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
927
1001
  s[i * NHSYM + j] = power;
@@ -933,7 +1007,7 @@ function sync8(dd, nfa, nfb, syncmin, maxcand) {
933
1007
  const ia = Math.max(1, Math.round(nfa / df));
934
1008
  const ib = Math.min(halfSize - 14, Math.round(nfb / df));
935
1009
  const nssy = Math.floor(NSPS / NSTEP);
936
- const nfos = Math.round(SAMPLE_RATE / NSPS / df); // ~2 bins per tone spacing
1010
+ const nfos = Math.round(SAMPLE_RATE$1 / NSPS / df); // ~2 bins per tone spacing
937
1011
  const jstrt = Math.round(0.5 / tstep);
938
1012
  // 2D sync correlation
939
1013
  const sync2d = new Float64Array((ib - ia + 1) * (2 * JZ + 1));
@@ -1069,17 +1143,16 @@ function computeBaseline(savg, nfa, nfb, df, nh1) {
1069
1143
  }
1070
1144
  return sbase;
1071
1145
  }
1072
- function ft8b(dd0, f1, xdt, _sbase, depth) {
1146
+ function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
1073
1147
  const NFFT2 = 3200;
1074
1148
  const NP2 = 2812;
1075
- const NFFT1_LONG = 192000;
1076
- const fs2 = SAMPLE_RATE / NDOWN;
1149
+ const fs2 = SAMPLE_RATE$1 / NDOWN;
1077
1150
  const dt2 = 1.0 / fs2;
1078
1151
  const twopi = 2 * Math.PI;
1079
1152
  // Downsample: mix to baseband and filter
1080
1153
  const cd0Re = new Float64Array(NFFT2);
1081
1154
  const cd0Im = new Float64Array(NFFT2);
1082
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1155
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1083
1156
  // Find best time offset
1084
1157
  const i0 = Math.round((xdt + 0.5) * fs2);
1085
1158
  let smax = 0;
@@ -1113,7 +1186,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1113
1186
  }
1114
1187
  // Apply frequency correction and re-downsample
1115
1188
  f1 += delfbest;
1116
- ft8Downsample(dd0, f1, cd0Re, cd0Im, NFFT1_LONG, NFFT2);
1189
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1117
1190
  // Refine time offset
1118
1191
  const ss = new Float64Array(9);
1119
1192
  for (let idt = -4; idt <= 4; idt++) {
@@ -1145,7 +1218,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1145
1218
  symbIm[j] = cd0Im[i1 + j];
1146
1219
  }
1147
1220
  }
1148
- fftComplex(symbRe, symbIm);
1221
+ fftComplex(symbRe, symbIm, false);
1149
1222
  for (let tone = 0; tone < 8; tone++) {
1150
1223
  const re = symbRe[tone] / 1000;
1151
1224
  const im = symbIm[tone] / 1000;
@@ -1278,7 +1351,7 @@ function ft8b(dd0, f1, xdt, _sbase, depth) {
1278
1351
  if (i3v === 0 && n3v === 2)
1279
1352
  return null;
1280
1353
  // Unpack
1281
- const { msg, success } = unpack77(message77);
1354
+ const { msg, success } = unpack77(message77, book);
1282
1355
  if (!success || msg.trim().length === 0)
1283
1356
  return null;
1284
1357
  // Estimate SNR
@@ -1319,44 +1392,75 @@ function getTones$1(cw) {
1319
1392
  return tones;
1320
1393
  }
1321
1394
  /**
1322
- * Mix f0 to baseband and decimate by NDOWN (60x).
1323
- * Time-domain approach: mix down, low-pass filter via moving average, decimate.
1324
- * Output: complex baseband signal at 200 Hz sample rate (32 samples/symbol).
1395
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1396
+ * Identical to Fortran ft8_downsample.
1325
1397
  */
1326
- function ft8Downsample(dd, f0, outRe, outIm, _nfft1Long, nfft2) {
1327
- const twopi = 2 * Math.PI;
1328
- const len = Math.min(dd.length, NMAX);
1329
- const dphi = (twopi * f0) / SAMPLE_RATE;
1330
- // Mix to baseband
1331
- const mixRe = new Float64Array(len);
1332
- const mixIm = new Float64Array(len);
1333
- let phi = 0;
1334
- for (let i = 0; i < len; i++) {
1335
- mixRe[i] = dd[i] * Math.cos(phi);
1336
- mixIm[i] = -dd[i] * Math.sin(phi);
1337
- phi += dphi;
1338
- if (phi > twopi)
1339
- phi -= twopi;
1340
- }
1341
- // Low-pass filter: simple moving-average with window = NDOWN
1342
- // then decimate by NDOWN to get 200 Hz sample rate
1343
- const outLen = Math.min(nfft2, Math.floor(len / NDOWN));
1344
- outRe.fill(0);
1345
- outIm.fill(0);
1346
- // Running sum filter
1347
- const halfWin = NDOWN >> 1;
1348
- for (let k = 0; k < outLen; k++) {
1349
- const center = k * NDOWN + halfWin;
1350
- let sumRe = 0, sumIm = 0;
1351
- const start = Math.max(0, center - halfWin);
1352
- const end = Math.min(len, center + halfWin);
1353
- for (let j = start; j < end; j++) {
1354
- sumRe += mixRe[j];
1355
- sumIm += mixIm[j];
1356
- }
1357
- const n = end - start;
1358
- outRe[k] = sumRe / n;
1359
- outIm[k] = sumIm / n;
1398
+ function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
1399
+ const NFFT1 = 192000;
1400
+ const NFFT2 = 3200;
1401
+ const df = 12000.0 / NFFT1;
1402
+ // NSPS is imported, should be 1920
1403
+ const baud = 12000.0 / NSPS; // 6.25
1404
+ const i0 = Math.round(f0 / df);
1405
+ const ft = f0 + 8.5 * baud;
1406
+ const it = Math.min(Math.round(ft / df), NFFT1 / 2);
1407
+ const fb = f0 - 1.5 * baud;
1408
+ const ib = Math.max(1, Math.round(fb / df));
1409
+ c1Re.fill(0);
1410
+ c1Im.fill(0);
1411
+ let k = 0;
1412
+ for (let i = ib; i <= it; i++) {
1413
+ if (k >= NFFT2)
1414
+ break;
1415
+ c1Re[k] = cxRe[i] ?? 0;
1416
+ c1Im[k] = cxIm[i] ?? 0;
1417
+ k++;
1418
+ }
1419
+ // Taper
1420
+ const pi = Math.PI;
1421
+ const taper = new Float64Array(101);
1422
+ for (let i = 0; i <= 100; i++) {
1423
+ taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
1424
+ }
1425
+ for (let i = 0; i <= 100; i++) {
1426
+ if (i >= NFFT2)
1427
+ break;
1428
+ const tap = taper[100 - i];
1429
+ c1Re[i] = c1Re[i] * tap;
1430
+ c1Im[i] = c1Im[i] * tap;
1431
+ }
1432
+ const endTap = k - 1;
1433
+ for (let i = 0; i <= 100; i++) {
1434
+ const idx = endTap - 100 + i;
1435
+ if (idx >= 0 && idx < NFFT2) {
1436
+ const tap = taper[i];
1437
+ c1Re[idx] = c1Re[idx] * tap;
1438
+ c1Im[idx] = c1Im[idx] * tap;
1439
+ }
1440
+ }
1441
+ // CSHIFT
1442
+ const shift = i0 - ib;
1443
+ const tempRe = new Float64Array(NFFT2);
1444
+ const tempIm = new Float64Array(NFFT2);
1445
+ for (let i = 0; i < NFFT2; i++) {
1446
+ let srcIdx = (i + shift) % NFFT2;
1447
+ if (srcIdx < 0)
1448
+ srcIdx += NFFT2;
1449
+ tempRe[i] = c1Re[srcIdx];
1450
+ tempIm[i] = c1Im[srcIdx];
1451
+ }
1452
+ for (let i = 0; i < NFFT2; i++) {
1453
+ c1Re[i] = tempRe[i];
1454
+ c1Im[i] = tempIm[i];
1455
+ }
1456
+ // iFFT
1457
+ fftComplex(c1Re, c1Im, true);
1458
+ // Scale
1459
+ // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
1460
+ const scale = Math.sqrt(NFFT2 / NFFT1);
1461
+ for (let i = 0; i < NFFT2; i++) {
1462
+ c1Re[i] = c1Re[i] * scale;
1463
+ c1Im[i] = c1Im[i] * scale;
1360
1464
  }
1361
1465
  }
1362
1466
  function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
@@ -2114,6 +2218,250 @@ function encode(msg, options = {}) {
2114
2218
  return generateFT8Waveform(encodeMessage(msg), options);
2115
2219
  }
2116
2220
 
2117
- exports.decodeFT8 = decode;
2118
- exports.encodeFT8 = encode;
2119
- //# sourceMappingURL=ft8js.cjs.map
2221
+ /// <reference types="node" />
2222
+ /**
2223
+ * Parse a WAV file buffer into sample rate and normalized float samples.
2224
+ * Supports PCM format 1, 8/16/32-bit samples.
2225
+ */
2226
+ function parseWavBuffer(buf) {
2227
+ if (buf.length < 44)
2228
+ throw new Error("File too small for WAV");
2229
+ const riff = buf.toString("ascii", 0, 4);
2230
+ const wave = buf.toString("ascii", 8, 12);
2231
+ if (riff !== "RIFF" || wave !== "WAVE")
2232
+ throw new Error("Not a WAV file");
2233
+ let offset = 12;
2234
+ let fmtFound = false;
2235
+ let sampleRate = 0;
2236
+ let bitsPerSample = 0;
2237
+ let numChannels = 1;
2238
+ let audioFormat = 0;
2239
+ let dataOffset = 0;
2240
+ let dataSize = 0;
2241
+ while (offset < buf.length - 8) {
2242
+ const chunkId = buf.toString("ascii", offset, offset + 4);
2243
+ const chunkSize = buf.readUInt32LE(offset + 4);
2244
+ offset += 8;
2245
+ if (chunkId === "fmt ") {
2246
+ audioFormat = buf.readUInt16LE(offset);
2247
+ numChannels = buf.readUInt16LE(offset + 2);
2248
+ sampleRate = buf.readUInt32LE(offset + 4);
2249
+ bitsPerSample = buf.readUInt16LE(offset + 14);
2250
+ fmtFound = true;
2251
+ }
2252
+ else if (chunkId === "data") {
2253
+ dataOffset = offset;
2254
+ dataSize = chunkSize;
2255
+ break;
2256
+ }
2257
+ offset += chunkSize;
2258
+ }
2259
+ if (!fmtFound)
2260
+ throw new Error("No fmt chunk found");
2261
+ if (audioFormat !== 1)
2262
+ throw new Error(`Unsupported audio format: ${audioFormat} (only PCM=1)`);
2263
+ if (dataOffset === 0)
2264
+ throw new Error("No data chunk found");
2265
+ const bytesPerSample = bitsPerSample / 8;
2266
+ const totalSamples = Math.floor(dataSize / (bytesPerSample * numChannels));
2267
+ const samples = new Float32Array(totalSamples);
2268
+ for (let i = 0; i < totalSamples; i++) {
2269
+ const pos = dataOffset + i * numChannels * bytesPerSample;
2270
+ let val;
2271
+ if (bitsPerSample === 16) {
2272
+ val = buf.readInt16LE(pos) / 32768;
2273
+ }
2274
+ else if (bitsPerSample === 32) {
2275
+ val = buf.readInt32LE(pos) / 2147483648;
2276
+ }
2277
+ else if (bitsPerSample === 8) {
2278
+ val = (buf.readUInt8(pos) - 128) / 128;
2279
+ }
2280
+ else {
2281
+ throw new Error(`Unsupported bits per sample: ${bitsPerSample}`);
2282
+ }
2283
+ samples[i] = val;
2284
+ }
2285
+ return { sampleRate, samples };
2286
+ }
2287
+ function floatToInt16(sample) {
2288
+ const clamped = Math.max(-1, Math.min(1, sample));
2289
+ return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
2290
+ }
2291
+ /**
2292
+ * Write mono 16-bit PCM WAV file from normalized float samples (-1..1).
2293
+ */
2294
+ function writeMono16WavFile(filePath, samples, sampleRate) {
2295
+ const numChannels = 1;
2296
+ const bitsPerSample = 16;
2297
+ const blockAlign = numChannels * (bitsPerSample / 8);
2298
+ const dataSize = samples.length * blockAlign;
2299
+ const wav = Buffer.alloc(44 + dataSize);
2300
+ let offset = 0;
2301
+ wav.write("RIFF", offset);
2302
+ offset += 4;
2303
+ wav.writeUInt32LE(36 + dataSize, offset);
2304
+ offset += 4;
2305
+ wav.write("WAVE", offset);
2306
+ offset += 4;
2307
+ wav.write("fmt ", offset);
2308
+ offset += 4;
2309
+ wav.writeUInt32LE(16, offset);
2310
+ offset += 4; // PCM chunk size
2311
+ wav.writeUInt16LE(1, offset);
2312
+ offset += 2; // PCM format
2313
+ wav.writeUInt16LE(numChannels, offset);
2314
+ offset += 2;
2315
+ wav.writeUInt32LE(sampleRate, offset);
2316
+ offset += 4;
2317
+ wav.writeUInt32LE(sampleRate * blockAlign, offset);
2318
+ offset += 4;
2319
+ wav.writeUInt16LE(blockAlign, offset);
2320
+ offset += 2;
2321
+ wav.writeUInt16LE(bitsPerSample, offset);
2322
+ offset += 2;
2323
+ wav.write("data", offset);
2324
+ offset += 4;
2325
+ wav.writeUInt32LE(dataSize, offset);
2326
+ offset += 4;
2327
+ for (let i = 0; i < samples.length; i++) {
2328
+ wav.writeInt16LE(floatToInt16(samples[i]), offset + i * 2);
2329
+ }
2330
+ writeFileSync(filePath, wav);
2331
+ }
2332
+
2333
+ const SAMPLE_RATE = 12_000;
2334
+ const DEFAULT_OUTPUT = "output.wav";
2335
+ const DEFAULT_DF_HZ = 1_000;
2336
+ function printUsage() {
2337
+ console.error(`ft8ts - FT8 encoder/decoder
2338
+
2339
+ Usage:
2340
+ ft8ts decode <file.wav> [options]
2341
+ ft8ts encode "<message>" [options]
2342
+
2343
+ Decode options:
2344
+ --low <hz> Lower frequency bound (default: 200)
2345
+ --high <hz> Upper frequency bound (default: 3000)
2346
+ --depth <1|2|3> Decoding depth (default: 2)
2347
+
2348
+ Encode options:
2349
+ --out <file> Output WAV file (default: output.wav)
2350
+ --df <hz> Base frequency in Hz (default: 1000)
2351
+ `);
2352
+ }
2353
+ function formatMessage(d) {
2354
+ const freq = d.freq.toFixed(0).padStart(5);
2355
+ const dt = (d.dt >= 0 ? "+" : "") + d.dt.toFixed(1);
2356
+ const snr = (d.snr >= 0 ? "+" : "") + Math.round(d.snr).toString().padStart(3);
2357
+ return `${dt.padStart(5)} ${snr} ${freq} ${d.msg}`;
2358
+ }
2359
+ function runDecode(argv) {
2360
+ if (argv.length === 0) {
2361
+ console.error("Error: missing input file");
2362
+ printUsage();
2363
+ process.exit(1);
2364
+ }
2365
+ const wavFile = argv[0];
2366
+ const options = {};
2367
+ for (let i = 1; i < argv.length; i++) {
2368
+ const arg = argv[i];
2369
+ if (arg === "--low") {
2370
+ options.freqLow = Number(argv[++i]);
2371
+ }
2372
+ else if (arg === "--high") {
2373
+ options.freqHigh = Number(argv[++i]);
2374
+ }
2375
+ else if (arg === "--depth") {
2376
+ options.depth = Number(argv[++i]);
2377
+ }
2378
+ else {
2379
+ throw new Error(`Unknown argument: ${arg}`);
2380
+ }
2381
+ }
2382
+ const filePath = resolve(process.cwd(), wavFile);
2383
+ console.log(`Reading ${filePath}...`);
2384
+ const { sampleRate, samples } = parseWavBuffer(readFileSync(filePath));
2385
+ console.log(`WAV: ${sampleRate} Hz, ${samples.length} samples, ${(samples.length / sampleRate).toFixed(1)}s`);
2386
+ const startTime = performance.now();
2387
+ const decoded = decode(samples, { ...options, sampleRate });
2388
+ const elapsed = performance.now() - startTime;
2389
+ console.log(`\nDecoded ${decoded.length} messages in ${(elapsed / 1000).toFixed(2)}s:\n`);
2390
+ console.log(" dt snr freq message");
2391
+ console.log(" --- --- ----- -------");
2392
+ for (const d of decoded) {
2393
+ console.log(formatMessage(d));
2394
+ }
2395
+ }
2396
+ function runEncode(argv) {
2397
+ if (argv.length === 0) {
2398
+ console.error("Error: missing message");
2399
+ printUsage();
2400
+ process.exit(1);
2401
+ }
2402
+ const message = argv[0];
2403
+ let outputFile = DEFAULT_OUTPUT;
2404
+ let dfHz = DEFAULT_DF_HZ;
2405
+ for (let i = 1; i < argv.length; i++) {
2406
+ const arg = argv[i];
2407
+ if (arg === "--out") {
2408
+ i++;
2409
+ const value = argv[i];
2410
+ if (!value)
2411
+ throw new Error("Missing value for --out");
2412
+ outputFile = value;
2413
+ }
2414
+ else if (arg === "--df") {
2415
+ i++;
2416
+ const value = argv[i];
2417
+ if (!value)
2418
+ throw new Error("Missing value for --df");
2419
+ const parsed = Number(value);
2420
+ if (!Number.isFinite(parsed) || parsed < 0) {
2421
+ throw new Error(`Invalid --df value: ${value}`);
2422
+ }
2423
+ dfHz = parsed;
2424
+ }
2425
+ else {
2426
+ throw new Error(`Unknown argument: ${arg}`);
2427
+ }
2428
+ }
2429
+ const waveform = encode(message, {
2430
+ sampleRate: SAMPLE_RATE,
2431
+ samplesPerSymbol: 1_920,
2432
+ baseFrequency: dfHz,
2433
+ });
2434
+ const outPath = resolve(process.cwd(), outputFile);
2435
+ writeMono16WavFile(outPath, waveform, SAMPLE_RATE);
2436
+ console.log(`Wrote ${outPath} (${waveform.length} samples, ${(waveform.length / SAMPLE_RATE).toFixed(3)} s)`);
2437
+ }
2438
+ function main() {
2439
+ const args = process.argv.slice(2);
2440
+ const subcommand = args[0];
2441
+ const subArgs = args.slice(1);
2442
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
2443
+ printUsage();
2444
+ process.exit(0);
2445
+ }
2446
+ try {
2447
+ if (subcommand === "decode") {
2448
+ runDecode(subArgs);
2449
+ }
2450
+ else if (subcommand === "encode") {
2451
+ runEncode(subArgs);
2452
+ }
2453
+ else {
2454
+ console.error(`Error: unknown subcommand '${subcommand}'`);
2455
+ printUsage();
2456
+ process.exit(1);
2457
+ }
2458
+ }
2459
+ catch (error) {
2460
+ const msg = error instanceof Error ? error.message : String(error);
2461
+ console.error(`Error: ${msg}`);
2462
+ printUsage();
2463
+ process.exit(1);
2464
+ }
2465
+ }
2466
+ main();
2467
+ //# sourceMappingURL=cli.js.map