@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.
- package/README.md +7 -9
- package/dist/{ft8js.cjs → cli.js} +415 -67
- package/dist/cli.js.map +1 -0
- package/dist/ft8ts.cjs +4 -3
- package/dist/ft8ts.cjs.map +1 -1
- package/dist/ft8ts.d.ts +5 -3
- package/dist/ft8ts.mjs +4 -3
- package/dist/ft8ts.mjs.map +1 -1
- package/example/browser/index.html +3 -2
- package/package.json +4 -1
- package/src/cli.ts +158 -0
- package/src/ft8/decode.ts +3 -1
- package/src/util/hashcall.ts +2 -2
- package/dist/ft8js.cjs.map +0 -1
- package/dist/ft8js.mjs +0 -2116
- package/dist/ft8js.mjs.map +0 -1
- package/example/decode-ft8-wav.ts +0 -78
- package/example/generate-ft8-wav.ts +0 -82
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
1146
|
+
function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
|
|
1073
1147
|
const NFFT2 = 3200;
|
|
1074
1148
|
const NP2 = 2812;
|
|
1075
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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(
|
|
1327
|
-
const
|
|
1328
|
-
const
|
|
1329
|
-
const
|
|
1330
|
-
//
|
|
1331
|
-
const
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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
|