@e04/ft8ts 0.0.8 → 0.0.9

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/cli.js CHANGED
@@ -2,19 +2,12 @@
2
2
  import { writeFileSync, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
 
5
+ /** Shared constants used by FT8, FT4, pack77, etc. */
5
6
  const SAMPLE_RATE$1 = 12_000;
6
- const NSPS = 1920;
7
- const NFFT1 = 2 * NSPS; // 3840
8
- const NSTEP = NSPS / 4; // 480
9
- const NMAX = 15 * SAMPLE_RATE$1; // 180000
10
- const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
11
- const NDOWN = 60;
12
- const NN = 79;
7
+ /** LDPC(174,91) code (shared by FT8 and FT4). */
13
8
  const KK = 91;
14
9
  const N_LDPC = 174;
15
10
  const M_LDPC = N_LDPC - KK; // 83
16
- const icos7 = [3, 1, 4, 0, 6, 5, 2];
17
- const graymap = [0, 1, 3, 2, 5, 6, 4, 7];
18
11
  const gHex = [
19
12
  "8329ce11bf31eaf509f27fc",
20
13
  "761c264e25c259335493132",
@@ -923,607 +916,812 @@ function unpack77(bits77, book) {
923
916
  return { msg: "", success: false };
924
917
  }
925
918
 
919
+ /** FT4-specific constants (lib/ft4/ft4_params.f90). */
920
+ const COSTAS_A = [0, 1, 3, 2];
921
+ const COSTAS_B = [1, 0, 2, 3];
922
+ const COSTAS_C = [2, 3, 1, 0];
923
+ const COSTAS_D = [3, 2, 0, 1];
924
+ const GRAYMAP = [0, 1, 3, 2];
925
+ // Message scrambling vector (rvec) from WSJT-X.
926
+ const RVEC = [
927
+ 0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1,
928
+ 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0,
929
+ 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1,
930
+ ];
931
+ const NSPS$1 = 576;
932
+ const NFFT1$1 = 4 * NSPS$1; // 2304
933
+ const NH1 = NFFT1$1 / 2; // 1152
934
+ const NSTEP$1 = NSPS$1;
935
+ const NMAX$1 = 21 * 3456; // 72576
936
+ const NHSYM$1 = Math.floor((NMAX$1 - NFFT1$1) / NSTEP$1); // 122
937
+ const NDOWN$1 = 18;
938
+ const ND = 87;
939
+ const NS = 16;
940
+ const NN$1 = NS + ND; // 103
941
+ const NFFT2 = NMAX$1 / NDOWN$1; // 4032
942
+ const NSS = NSPS$1 / NDOWN$1; // 32
943
+ const FS2 = SAMPLE_RATE$1 / NDOWN$1; // 666.67 Hz
944
+ const MAX_FREQ = 4910;
945
+ const SYNC_PASS_MIN = 1.2;
946
+ const TWO_PI$1 = 2 * Math.PI;
947
+ const HARD_SYNC_PATTERNS = [
948
+ { offset: 0, bits: [0, 0, 0, 1, 1, 0, 1, 1] },
949
+ { offset: 66, bits: [0, 1, 0, 0, 1, 1, 1, 0] },
950
+ { offset: 132, bits: [1, 1, 1, 0, 0, 1, 0, 0] },
951
+ { offset: 198, bits: [1, 0, 1, 1, 0, 0, 0, 1] },
952
+ ];
953
+
954
+ function xorWithScrambler(bits77) {
955
+ const out = new Array(77);
956
+ for (let i = 0; i < 77; i++) {
957
+ out[i] = ((bits77[i] ?? 0) + RVEC[i]) & 1;
958
+ }
959
+ return out;
960
+ }
961
+
926
962
  /**
927
- * Decode all FT8 signals in an audio buffer.
928
- * Input: mono audio samples at `sampleRate` Hz, duration ~15s.
963
+ * Decode all FT4 signals in a buffer.
964
+ * Input: mono audio samples at `sampleRate` Hz, duration ~6s.
929
965
  */
930
- function decode(samples, options = {}) {
966
+ function decode$1(samples, options = {}) {
931
967
  const sampleRate = options.sampleRate ?? SAMPLE_RATE$1;
932
- const nfa = options.freqLow ?? 200;
933
- const nfb = options.freqHigh ?? 3000;
934
- const syncmin = options.syncMin ?? 1.2;
968
+ const freqLow = options.freqLow ?? 200;
969
+ const freqHigh = options.freqHigh ?? 3000;
970
+ const syncMin = options.syncMin ?? 1.2;
935
971
  const depth = options.depth ?? 2;
936
- const maxCandidates = options.maxCandidates ?? 300;
972
+ const maxCandidates = options.maxCandidates ?? 100;
937
973
  const book = options.hashCallBook;
938
- // Resample to 12000 Hz if needed
939
- let dd;
940
- if (sampleRate === SAMPLE_RATE$1) {
941
- dd = new Float64Array(NMAX);
942
- const len = Math.min(samples.length, NMAX);
943
- for (let i = 0; i < len; i++)
944
- dd[i] = samples[i];
945
- }
946
- else {
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++) {
974
+ const dd = sampleRate === SAMPLE_RATE$1
975
+ ? copyIntoFt4Buffer(samples)
976
+ : resample$1(samples, sampleRate, SAMPLE_RATE$1, NMAX$1);
977
+ const cxRe = new Float64Array(NMAX$1);
978
+ const cxIm = new Float64Array(NMAX$1);
979
+ for (let i = 0; i < NMAX$1; i++) {
954
980
  cxRe[i] = dd[i] ?? 0;
955
981
  }
956
982
  fftComplex(cxRe, cxIm, false);
957
- // Compute spectrogram and find sync candidates
958
- const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
983
+ const candidates = getCandidates4(dd, freqLow, freqHigh, syncMin, maxCandidates);
984
+ if (candidates.length === 0) {
985
+ return [];
986
+ }
987
+ const downsampleCtx = createDownsampleContext();
988
+ const tweakedSyncTemplates = createTweakedSyncTemplates();
959
989
  const decoded = [];
960
990
  const seenMessages = new Set();
961
- for (const cand of candidates) {
962
- const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
963
- if (!result)
991
+ const apmask = new Int8Array(174);
992
+ for (const candidate of candidates) {
993
+ const one = decodeCandidate(candidate, cxRe, cxIm, downsampleCtx, tweakedSyncTemplates, depth, book, apmask);
994
+ if (!one) {
964
995
  continue;
965
- if (seenMessages.has(result.msg))
996
+ }
997
+ if (seenMessages.has(one.msg)) {
966
998
  continue;
967
- seenMessages.add(result.msg);
968
- decoded.push({
969
- freq: result.freq,
970
- dt: result.dt - 0.5,
971
- snr: result.snr,
972
- msg: result.msg,
973
- sync: cand.sync,
974
- });
999
+ }
1000
+ seenMessages.add(one.msg);
1001
+ decoded.push(one);
975
1002
  }
976
1003
  return decoded;
977
1004
  }
978
- function sync8(dd, nfa, nfb, syncmin, maxcand) {
979
- const JZ = 62;
980
- // Fortran uses NFFT1=3840 for the spectrogram FFT; we need a power of 2
981
- const fftSize = nextPow2(NFFT1); // 4096
982
- const halfSize = fftSize / 2; // 2048
983
- const tstep = NSTEP / SAMPLE_RATE$1;
984
- const df = SAMPLE_RATE$1 / fftSize;
985
- const fac = 1.0 / 300.0;
986
- // Compute symbol spectra, stepping by NSTEP
987
- const s = new Float64Array(halfSize * NHSYM);
988
- const savg = new Float64Array(halfSize);
989
- const xRe = new Float64Array(fftSize);
990
- const xIm = new Float64Array(fftSize);
991
- for (let j = 0; j < NHSYM; j++) {
992
- const ia = j * NSTEP;
993
- xRe.fill(0);
994
- xIm.fill(0);
995
- for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
996
- xRe[i] = fac * dd[ia + i];
997
- }
998
- fftComplex(xRe, xIm, false);
999
- for (let i = 0; i < halfSize; i++) {
1000
- const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
1001
- s[i * NHSYM + j] = power;
1002
- savg[i] = (savg[i] ?? 0) + power;
1003
- }
1004
- }
1005
- // Compute baseline
1006
- const sbase = computeBaseline(savg, nfa, nfb, df, halfSize);
1007
- const ia = Math.max(1, Math.round(nfa / df));
1008
- const ib = Math.min(halfSize - 14, Math.round(nfb / df));
1009
- const nssy = Math.floor(NSPS / NSTEP);
1010
- const nfos = Math.round(SAMPLE_RATE$1 / NSPS / df); // ~2 bins per tone spacing
1011
- const jstrt = Math.round(0.5 / tstep);
1012
- // 2D sync correlation
1013
- const sync2d = new Float64Array((ib - ia + 1) * (2 * JZ + 1));
1014
- const width = 2 * JZ + 1;
1015
- for (let i = ia; i <= ib; i++) {
1016
- for (let jj = -JZ; jj <= JZ; jj++) {
1017
- let ta = 0, tb = 0, tc = 0;
1018
- let t0a = 0, t0b = 0, t0c = 0;
1019
- for (let n = 0; n < 7; n++) {
1020
- const m = jj + jstrt + nssy * n;
1021
- const iCostas = i + nfos * icos7[n];
1022
- if (m >= 0 && m < NHSYM && iCostas < halfSize) {
1023
- ta += s[iCostas * NHSYM + m];
1024
- for (let tone = 0; tone <= 6; tone++) {
1025
- const idx = i + nfos * tone;
1026
- if (idx < halfSize)
1027
- t0a += s[idx * NHSYM + m];
1028
- }
1005
+ function decodeCandidate(candidate, cxRe, cxIm, downsampleCtx, tweakedSyncTemplates, depth, book, apmask) {
1006
+ const cd2 = ft4Downsample(cxRe, cxIm, candidate.freq, downsampleCtx);
1007
+ normalizeComplexPower(cd2.re, cd2.im, NMAX$1 / NDOWN$1);
1008
+ for (let segment = 1; segment <= 3; segment++) {
1009
+ let ibest = -1;
1010
+ let idfbest = 0;
1011
+ let smax = -99;
1012
+ for (let isync = 1; isync <= 2; isync++) {
1013
+ let idfmin;
1014
+ let idfmax;
1015
+ let idfstp;
1016
+ let ibmin;
1017
+ let ibmax;
1018
+ let ibstp;
1019
+ if (isync === 1) {
1020
+ idfmin = -12;
1021
+ idfmax = 12;
1022
+ idfstp = 3;
1023
+ ibmin = -344;
1024
+ ibmax = 1012;
1025
+ if (segment === 1) {
1026
+ ibmin = 108;
1027
+ ibmax = 560;
1029
1028
  }
1030
- const m36 = m + nssy * 36;
1031
- if (m36 >= 0 && m36 < NHSYM && iCostas < halfSize) {
1032
- tb += s[iCostas * NHSYM + m36];
1033
- for (let tone = 0; tone <= 6; tone++) {
1034
- const idx = i + nfos * tone;
1035
- if (idx < halfSize)
1036
- t0b += s[idx * NHSYM + m36];
1037
- }
1029
+ else if (segment === 2) {
1030
+ ibmin = 560;
1031
+ ibmax = 1012;
1038
1032
  }
1039
- const m72 = m + nssy * 72;
1040
- if (m72 >= 0 && m72 < NHSYM && iCostas < halfSize) {
1041
- tc += s[iCostas * NHSYM + m72];
1042
- for (let tone = 0; tone <= 6; tone++) {
1043
- const idx = i + nfos * tone;
1044
- if (idx < halfSize)
1045
- t0c += s[idx * NHSYM + m72];
1033
+ else {
1034
+ ibmin = -344;
1035
+ ibmax = 108;
1036
+ }
1037
+ ibstp = 4;
1038
+ }
1039
+ else {
1040
+ idfmin = idfbest - 4;
1041
+ idfmax = idfbest + 4;
1042
+ idfstp = 1;
1043
+ ibmin = ibest - 5;
1044
+ ibmax = ibest + 5;
1045
+ ibstp = 1;
1046
+ }
1047
+ for (let idf = idfmin; idf <= idfmax; idf += idfstp) {
1048
+ const templates = tweakedSyncTemplates.get(idf);
1049
+ if (!templates) {
1050
+ continue;
1051
+ }
1052
+ for (let istart = ibmin; istart <= ibmax; istart += ibstp) {
1053
+ const sync = sync4d(cd2.re, cd2.im, istart, templates);
1054
+ if (sync > smax) {
1055
+ smax = sync;
1056
+ ibest = istart;
1057
+ idfbest = idf;
1046
1058
  }
1047
1059
  }
1048
1060
  }
1049
- const t = ta + tb + tc;
1050
- const t0total = t0a + t0b + t0c;
1051
- const t0 = (t0total - t) / 6.0;
1052
- const syncVal = t0 > 0 ? t / t0 : 0;
1053
- const tbc = tb + tc;
1054
- const t0bc = t0b + t0c;
1055
- const t0bc2 = (t0bc - tbc) / 6.0;
1056
- const syncBc = t0bc2 > 0 ? tbc / t0bc2 : 0;
1057
- sync2d[(i - ia) * width + (jj + JZ)] = Math.max(syncVal, syncBc);
1058
1061
  }
1059
- }
1060
- // Find peaks
1061
- const candidates0 = [];
1062
- const mlag = 10;
1063
- for (let i = ia; i <= ib; i++) {
1064
- let bestSync = -1;
1065
- let bestJ = 0;
1066
- for (let j = -mlag; j <= mlag; j++) {
1067
- const v = sync2d[(i - ia) * width + (j + JZ)];
1068
- if (v > bestSync) {
1069
- bestSync = v;
1070
- bestJ = j;
1071
- }
1062
+ if (smax < SYNC_PASS_MIN) {
1063
+ continue;
1072
1064
  }
1073
- // Also check wider range
1074
- let bestSync2 = -1;
1075
- let bestJ2 = 0;
1076
- for (let j = -JZ; j <= JZ; j++) {
1077
- const v = sync2d[(i - ia) * width + (j + JZ)];
1078
- if (v > bestSync2) {
1079
- bestSync2 = v;
1080
- bestJ2 = j;
1065
+ const f1 = candidate.freq + idfbest;
1066
+ if (f1 <= 10 || f1 >= 4990) {
1067
+ continue;
1068
+ }
1069
+ const cb = ft4Downsample(cxRe, cxIm, f1, downsampleCtx);
1070
+ normalizeComplexPower(cb.re, cb.im, NSS * NN$1);
1071
+ const frame = extractFrame(cb.re, cb.im, ibest);
1072
+ const metrics = getFt4Bitmetrics(frame.re, frame.im);
1073
+ if (metrics.badsync) {
1074
+ continue;
1075
+ }
1076
+ if (!passesHardSyncQuality(metrics.bitmetrics1)) {
1077
+ continue;
1078
+ }
1079
+ const [llra, llrb, llrc] = buildLlrs(metrics.bitmetrics1, metrics.bitmetrics2, metrics.bitmetrics3);
1080
+ const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
1081
+ const scalefac = 2.83;
1082
+ for (const src of [llra, llrb, llrc]) {
1083
+ const llr = new Float64Array(174);
1084
+ for (let i = 0; i < 174; i++) {
1085
+ llr[i] = scalefac * src[i];
1086
+ }
1087
+ const result = decode174_91(llr, apmask, maxosd);
1088
+ if (!result) {
1089
+ continue;
1090
+ }
1091
+ const message77Scrambled = result.message91.slice(0, 77);
1092
+ if (!hasNonZeroBit(message77Scrambled)) {
1093
+ continue;
1094
+ }
1095
+ const message77 = xorWithScrambler(message77Scrambled);
1096
+ const { msg, success } = unpack77(message77, book);
1097
+ if (!success || msg.trim().length === 0) {
1098
+ continue;
1081
1099
  }
1100
+ return {
1101
+ freq: f1,
1102
+ dt: ibest / FS2 - 0.5,
1103
+ snr: toFt4Snr(candidate.sync - 1.0),
1104
+ msg,
1105
+ sync: smax,
1106
+ };
1082
1107
  }
1083
- if (bestSync >= syncmin) {
1084
- candidates0.push({
1085
- freq: i * df,
1086
- dt: (bestJ - 0.5) * tstep,
1087
- sync: bestSync,
1088
- });
1108
+ }
1109
+ return null;
1110
+ }
1111
+ function copyIntoFt4Buffer(samples) {
1112
+ const out = new Float64Array(NMAX$1);
1113
+ const len = Math.min(samples.length, NMAX$1);
1114
+ for (let i = 0; i < len; i++) {
1115
+ out[i] = samples[i];
1116
+ }
1117
+ return out;
1118
+ }
1119
+ function getCandidates4(dd, freqLow, freqHigh, syncMin, maxCandidates) {
1120
+ const df = SAMPLE_RATE$1 / NFFT1$1;
1121
+ const fac = 1 / 300;
1122
+ const window = makeNuttallWindow(NFFT1$1);
1123
+ const savg = new Float64Array(NH1);
1124
+ const s = new Float64Array(NH1 * NHSYM$1);
1125
+ const savsm = new Float64Array(NH1);
1126
+ const xRe = new Float64Array(NFFT1$1);
1127
+ const xIm = new Float64Array(NFFT1$1);
1128
+ for (let j = 0; j < NHSYM$1; j++) {
1129
+ const ia = j * NSPS$1;
1130
+ const ib = ia + NFFT1$1;
1131
+ if (ib > NMAX$1) {
1132
+ break;
1089
1133
  }
1090
- if (Math.abs(bestJ2 - bestJ) > 0 && bestSync2 >= syncmin) {
1091
- candidates0.push({
1092
- freq: i * df,
1093
- dt: (bestJ2 - 0.5) * tstep,
1094
- sync: bestSync2,
1095
- });
1134
+ xIm.fill(0);
1135
+ for (let i = 0; i < NFFT1$1; i++) {
1136
+ xRe[i] = fac * dd[ia + i] * window[i];
1137
+ }
1138
+ fftComplex(xRe, xIm, false);
1139
+ for (let bin = 1; bin <= NH1; bin++) {
1140
+ const idx = bin - 1;
1141
+ const re = xRe[bin] ?? 0;
1142
+ const im = xIm[bin] ?? 0;
1143
+ const power = re * re + im * im;
1144
+ s[idx * NHSYM$1 + j] = power;
1145
+ savg[idx] = (savg[idx] ?? 0) + power;
1096
1146
  }
1097
1147
  }
1098
- // Compute baseline normalization for sync values
1099
- const syncValues = candidates0.map((c) => c.sync);
1100
- syncValues.sort((a, b) => a - b);
1101
- const pctileIdx = Math.max(0, Math.round(0.4 * syncValues.length) - 1);
1102
- const base = syncValues[pctileIdx] ?? 1;
1103
- if (base > 0) {
1104
- for (const c of candidates0)
1105
- c.sync /= base;
1148
+ for (let i = 0; i < NH1; i++) {
1149
+ savg[i] = (savg[i] ?? 0) / NHSYM$1;
1106
1150
  }
1107
- // Remove near-duplicate candidates
1108
- for (let i = 0; i < candidates0.length; i++) {
1109
- for (let j = 0; j < i; j++) {
1110
- const fdiff = Math.abs(candidates0[i].freq - candidates0[j].freq);
1111
- const tdiff = Math.abs(candidates0[i].dt - candidates0[j].dt);
1112
- if (fdiff < 4.0 && tdiff < 0.04) {
1113
- if (candidates0[i].sync >= candidates0[j].sync) {
1114
- candidates0[j].sync = 0;
1115
- }
1116
- else {
1117
- candidates0[i].sync = 0;
1118
- }
1151
+ for (let i = 7; i < NH1 - 7; i++) {
1152
+ let sum = 0;
1153
+ for (let j = i - 7; j <= i + 7; j++) {
1154
+ sum += savg[j];
1155
+ }
1156
+ savsm[i] = sum / 15;
1157
+ }
1158
+ let nfa = Math.round(freqLow / df);
1159
+ if (nfa < Math.round(200 / df)) {
1160
+ nfa = Math.round(200 / df);
1161
+ }
1162
+ let nfb = Math.round(freqHigh / df);
1163
+ if (nfb > Math.round(MAX_FREQ / df)) {
1164
+ nfb = Math.round(MAX_FREQ / df);
1165
+ }
1166
+ const sbase = ft4Baseline(savg, nfa, nfb, df);
1167
+ for (let bin = nfa; bin <= nfb; bin++) {
1168
+ if ((sbase[bin - 1] ?? 0) <= 0) {
1169
+ return [];
1170
+ }
1171
+ }
1172
+ for (let bin = nfa; bin <= nfb; bin++) {
1173
+ const idx = bin - 1;
1174
+ savsm[idx] = (savsm[idx] ?? 0) / sbase[idx];
1175
+ }
1176
+ const fOffset = (-1.5 * SAMPLE_RATE$1) / NSPS$1;
1177
+ const candidates = [];
1178
+ for (let i = nfa + 1; i <= nfb - 1; i++) {
1179
+ const left = savsm[i - 2] ?? 0;
1180
+ const center = savsm[i - 1] ?? 0;
1181
+ const right = savsm[i] ?? 0;
1182
+ if (center >= left && center >= right && center >= syncMin) {
1183
+ const den = left - 2 * center + right;
1184
+ const del = den !== 0 ? (0.5 * (left - right)) / den : 0;
1185
+ const fpeak = (i + del) * df + fOffset;
1186
+ if (fpeak < 200 || fpeak > MAX_FREQ) {
1187
+ continue;
1119
1188
  }
1189
+ const speak = center - 0.25 * (left - right) * del;
1190
+ candidates.push({ freq: fpeak, sync: speak });
1120
1191
  }
1121
1192
  }
1122
- // Sort by sync descending, take top maxcand
1123
- const filtered = candidates0.filter((c) => c.sync >= syncmin);
1124
- filtered.sort((a, b) => b.sync - a.sync);
1125
- return { candidates: filtered.slice(0, maxcand), sbase };
1193
+ candidates.sort((a, b) => b.sync - a.sync);
1194
+ return candidates.slice(0, maxCandidates);
1126
1195
  }
1127
- function computeBaseline(savg, nfa, nfb, df, nh1) {
1128
- const sbase = new Float64Array(nh1);
1129
- const ia = Math.max(1, Math.round(nfa / df));
1130
- const ib = Math.min(nh1 - 1, Math.round(nfb / df));
1131
- // Smooth the spectrum to get baseline
1132
- const window = 50; // bins
1133
- for (let i = 0; i < nh1; i++) {
1134
- let sum = 0;
1135
- let count = 0;
1136
- const lo = Math.max(ia, i - window);
1137
- const hi = Math.min(ib, i + window);
1138
- for (let j = lo; j <= hi; j++) {
1139
- sum += savg[j];
1140
- count++;
1196
+ function makeNuttallWindow(n) {
1197
+ const out = new Float64Array(n);
1198
+ const a0 = 0.3635819;
1199
+ const a1 = -0.4891775;
1200
+ const a2 = 0.1365995;
1201
+ const a3 = -0.0106411;
1202
+ for (let i = 0; i < n; i++) {
1203
+ out[i] =
1204
+ a0 +
1205
+ a1 * Math.cos((2 * Math.PI * i) / n) +
1206
+ a2 * Math.cos((4 * Math.PI * i) / n) +
1207
+ a3 * Math.cos((6 * Math.PI * i) / n);
1208
+ }
1209
+ return out;
1210
+ }
1211
+ function ft4Baseline(savg, nfa, nfb, df) {
1212
+ const sbase = new Float64Array(NH1);
1213
+ sbase.fill(1);
1214
+ const ia = Math.max(Math.round(200 / df), nfa);
1215
+ const ib = Math.min(NH1, nfb);
1216
+ if (ib <= ia) {
1217
+ return sbase;
1218
+ }
1219
+ const sDb = new Float64Array(NH1);
1220
+ for (let i = ia; i <= ib; i++) {
1221
+ sDb[i - 1] = 10 * Math.log10(Math.max(1e-30, savg[i - 1]));
1222
+ }
1223
+ const nseg = 10;
1224
+ const npct = 10;
1225
+ const nlen = Math.max(1, Math.trunc((ib - ia + 1) / nseg));
1226
+ const i0 = Math.trunc((ib - ia + 1) / 2);
1227
+ const x = [];
1228
+ const y = [];
1229
+ for (let seg = 0; seg < nseg; seg++) {
1230
+ const ja = ia + seg * nlen;
1231
+ if (ja > ib) {
1232
+ break;
1233
+ }
1234
+ const jb = Math.min(ib, ja + nlen - 1);
1235
+ const vals = [];
1236
+ for (let i = ja; i <= jb; i++) {
1237
+ vals.push(sDb[i - 1]);
1238
+ }
1239
+ const base = percentile(vals, npct);
1240
+ for (let i = ja; i <= jb; i++) {
1241
+ const v = sDb[i - 1];
1242
+ if (v <= base) {
1243
+ x.push(i - i0);
1244
+ y.push(v);
1245
+ }
1246
+ }
1247
+ }
1248
+ const coeff = x.length >= 5 ? polyfitLeastSquares(x, y, 4) : null;
1249
+ if (coeff) {
1250
+ for (let i = ia; i <= ib; i++) {
1251
+ const t = i - i0;
1252
+ const db = coeff[0] + t * (coeff[1] + t * (coeff[2] + t * (coeff[3] + t * coeff[4]))) + 0.65;
1253
+ sbase[i - 1] = 10 ** (db / 10);
1254
+ }
1255
+ }
1256
+ else {
1257
+ const halfWindow = 25;
1258
+ for (let i = ia; i <= ib; i++) {
1259
+ const lo = Math.max(ia, i - halfWindow);
1260
+ const hi = Math.min(ib, i + halfWindow);
1261
+ let sum = 0;
1262
+ let count = 0;
1263
+ for (let j = lo; j <= hi; j++) {
1264
+ sum += savg[j - 1];
1265
+ count++;
1266
+ }
1267
+ sbase[i - 1] = count > 0 ? sum / count : 1;
1141
1268
  }
1142
- sbase[i] = count > 0 ? 10 * Math.log10(Math.max(1e-30, sum / count)) : 0;
1143
1269
  }
1144
1270
  return sbase;
1145
1271
  }
1146
- function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
1147
- const NFFT2 = 3200;
1148
- const NP2 = 2812;
1149
- const fs2 = SAMPLE_RATE$1 / NDOWN;
1150
- const dt2 = 1.0 / fs2;
1151
- const twopi = 2 * Math.PI;
1152
- // Downsample: mix to baseband and filter
1153
- const cd0Re = new Float64Array(NFFT2);
1154
- const cd0Im = new Float64Array(NFFT2);
1155
- ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1156
- // Find best time offset
1157
- const i0 = Math.round((xdt + 0.5) * fs2);
1158
- let smax = 0;
1159
- let ibest = i0;
1160
- for (let idt = i0 - 10; idt <= i0 + 10; idt++) {
1161
- const sync = sync8d(cd0Re, cd0Im, idt, null, null, false);
1162
- if (sync > smax) {
1163
- smax = sync;
1164
- ibest = idt;
1272
+ function percentile(values, pct) {
1273
+ if (values.length === 0) {
1274
+ return 0;
1275
+ }
1276
+ const sorted = [...values].sort((a, b) => a - b);
1277
+ const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((pct / 100) * (sorted.length - 1))));
1278
+ return sorted[idx];
1279
+ }
1280
+ function polyfitLeastSquares(x, y, degree) {
1281
+ const n = degree + 1;
1282
+ const mat = Array.from({ length: n }, () => new Float64Array(n + 1));
1283
+ const xPows = new Float64Array(2 * degree + 1);
1284
+ for (let p = 0; p <= 2 * degree; p++) {
1285
+ let sum = 0;
1286
+ for (let i = 0; i < x.length; i++) {
1287
+ sum += x[i] ** p;
1165
1288
  }
1289
+ xPows[p] = sum;
1166
1290
  }
1167
- // Fine frequency search
1168
- smax = 0;
1169
- let delfbest = 0;
1170
- for (let ifr = -5; ifr <= 5; ifr++) {
1171
- const delf = ifr * 0.5;
1172
- const dphi = twopi * delf * dt2;
1173
- const twkRe = new Float64Array(32);
1174
- const twkIm = new Float64Array(32);
1175
- let phi = 0;
1176
- for (let i = 0; i < 32; i++) {
1177
- twkRe[i] = Math.cos(phi);
1178
- twkIm[i] = Math.sin(phi);
1179
- phi = (phi + dphi) % twopi;
1291
+ for (let row = 0; row < n; row++) {
1292
+ for (let col = 0; col < n; col++) {
1293
+ mat[row][col] = xPows[row + col];
1180
1294
  }
1181
- const sync = sync8d(cd0Re, cd0Im, ibest, twkRe, twkIm, true);
1182
- if (sync > smax) {
1183
- smax = sync;
1184
- delfbest = delf;
1295
+ let rhs = 0;
1296
+ for (let i = 0; i < x.length; i++) {
1297
+ rhs += y[i] * x[i] ** row;
1185
1298
  }
1299
+ mat[row][n] = rhs;
1186
1300
  }
1187
- // Apply frequency correction and re-downsample
1188
- f1 += delfbest;
1189
- ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
1190
- // Refine time offset
1191
- const ss = new Float64Array(9);
1192
- for (let idt = -4; idt <= 4; idt++) {
1193
- ss[idt + 4] = sync8d(cd0Re, cd0Im, ibest + idt, null, null, false);
1301
+ for (let col = 0; col < n; col++) {
1302
+ let pivot = col;
1303
+ let maxAbs = Math.abs(mat[col][col]);
1304
+ for (let row = col + 1; row < n; row++) {
1305
+ const a = Math.abs(mat[row][col]);
1306
+ if (a > maxAbs) {
1307
+ maxAbs = a;
1308
+ pivot = row;
1309
+ }
1310
+ }
1311
+ if (maxAbs < 1e-12) {
1312
+ return null;
1313
+ }
1314
+ if (pivot !== col) {
1315
+ const tmp = mat[col];
1316
+ mat[col] = mat[pivot];
1317
+ mat[pivot] = tmp;
1318
+ }
1319
+ const pivotVal = mat[col][col];
1320
+ for (let c = col; c <= n; c++) {
1321
+ mat[col][c] = mat[col][c] / pivotVal;
1322
+ }
1323
+ for (let row = 0; row < n; row++) {
1324
+ if (row === col) {
1325
+ continue;
1326
+ }
1327
+ const factor = mat[row][col];
1328
+ if (factor === 0) {
1329
+ continue;
1330
+ }
1331
+ for (let c = col; c <= n; c++) {
1332
+ mat[row][c] = mat[row][c] - factor * mat[col][c];
1333
+ }
1334
+ }
1194
1335
  }
1195
- let maxss = -1;
1196
- let maxIdx = 4;
1197
- for (let i = 0; i < 9; i++) {
1198
- if (ss[i] > maxss) {
1199
- maxss = ss[i];
1200
- maxIdx = i;
1336
+ const coeff = new Array(n);
1337
+ for (let i = 0; i < n; i++) {
1338
+ coeff[i] = mat[i][n];
1339
+ }
1340
+ return coeff;
1341
+ }
1342
+ function createDownsampleContext() {
1343
+ const df = SAMPLE_RATE$1 / NMAX$1;
1344
+ const baud = SAMPLE_RATE$1 / NSPS$1;
1345
+ const bwTransition = 0.5 * baud;
1346
+ const bwFlat = 4 * baud;
1347
+ const iwt = Math.max(1, Math.trunc(bwTransition / df));
1348
+ const iwf = Math.max(1, Math.trunc(bwFlat / df));
1349
+ const iws = Math.trunc(baud / df);
1350
+ const raw = new Float64Array(NFFT2);
1351
+ for (let i = 0; i < iwt && i < raw.length; i++) {
1352
+ raw[i] = 0.5 * (1 + Math.cos((Math.PI * (iwt - 1 - i)) / iwt));
1353
+ }
1354
+ for (let i = iwt; i < iwt + iwf && i < raw.length; i++) {
1355
+ raw[i] = 1;
1356
+ }
1357
+ for (let i = iwt + iwf; i < 2 * iwt + iwf && i < raw.length; i++) {
1358
+ raw[i] = 0.5 * (1 + Math.cos((Math.PI * (i - (iwt + iwf))) / iwt));
1359
+ }
1360
+ const window = new Float64Array(NFFT2);
1361
+ for (let i = 0; i < NFFT2; i++) {
1362
+ const src = (i + iws) % NFFT2;
1363
+ window[i] = raw[src];
1364
+ }
1365
+ return { df, window };
1366
+ }
1367
+ function ft4Downsample(cxRe, cxIm, f0, ctx) {
1368
+ const c1Re = new Float64Array(NFFT2);
1369
+ const c1Im = new Float64Array(NFFT2);
1370
+ const i0 = Math.round(f0 / ctx.df);
1371
+ if (i0 >= 0 && i0 <= NMAX$1 / 2) {
1372
+ c1Re[0] = cxRe[i0] ?? 0;
1373
+ c1Im[0] = cxIm[i0] ?? 0;
1374
+ }
1375
+ for (let i = 1; i <= NFFT2 / 2; i++) {
1376
+ const hi = i0 + i;
1377
+ if (hi >= 0 && hi <= NMAX$1 / 2) {
1378
+ c1Re[i] = cxRe[hi] ?? 0;
1379
+ c1Im[i] = cxIm[hi] ?? 0;
1380
+ }
1381
+ const lo = i0 - i;
1382
+ if (lo >= 0 && lo <= NMAX$1 / 2) {
1383
+ const idx = NFFT2 - i;
1384
+ c1Re[idx] = cxRe[lo] ?? 0;
1385
+ c1Im[idx] = cxIm[lo] ?? 0;
1386
+ }
1387
+ }
1388
+ const scale = 1 / NFFT2;
1389
+ for (let i = 0; i < NFFT2; i++) {
1390
+ const w = (ctx.window[i] ?? 0) * scale;
1391
+ c1Re[i] = c1Re[i] * w;
1392
+ c1Im[i] = c1Im[i] * w;
1393
+ }
1394
+ fftComplex(c1Re, c1Im, true);
1395
+ return { re: c1Re, im: c1Im };
1396
+ }
1397
+ function normalizeComplexPower(re, im, denom) {
1398
+ let sum = 0;
1399
+ for (let i = 0; i < re.length; i++) {
1400
+ sum += re[i] * re[i] + im[i] * im[i];
1401
+ }
1402
+ if (sum <= 0) {
1403
+ return;
1404
+ }
1405
+ const scale = 1 / Math.sqrt(sum / denom);
1406
+ for (let i = 0; i < re.length; i++) {
1407
+ re[i] = re[i] * scale;
1408
+ im[i] = im[i] * scale;
1409
+ }
1410
+ }
1411
+ function extractFrame(cbRe, cbIm, ibest) {
1412
+ const outRe = new Float64Array(NN$1 * NSS);
1413
+ const outIm = new Float64Array(NN$1 * NSS);
1414
+ for (let i = 0; i < outRe.length; i++) {
1415
+ const src = ibest + i;
1416
+ if (src >= 0 && src < cbRe.length) {
1417
+ outRe[i] = cbRe[src];
1418
+ outIm[i] = cbIm[src];
1201
1419
  }
1202
1420
  }
1203
- ibest = ibest + maxIdx - 4;
1204
- xdt = (ibest - 1) * dt2;
1205
- // Extract 8-tone soft symbols for each of NN=79 symbols
1206
- const s8 = new Float64Array(8 * NN);
1207
- const csRe = new Float64Array(8 * NN);
1208
- const csIm = new Float64Array(8 * NN);
1209
- const symbRe = new Float64Array(32);
1210
- const symbIm = new Float64Array(32);
1211
- for (let k = 0; k < NN; k++) {
1212
- const i1 = ibest + k * 32;
1213
- symbRe.fill(0);
1214
- symbIm.fill(0);
1215
- if (i1 >= 0 && i1 + 31 < NP2) {
1216
- for (let j = 0; j < 32; j++) {
1217
- symbRe[j] = cd0Re[i1 + j];
1218
- symbIm[j] = cd0Im[i1 + j];
1219
- }
1421
+ return { re: outRe, im: outIm };
1422
+ }
1423
+ function createTweakedSyncTemplates() {
1424
+ const base = createBaseSyncTemplates();
1425
+ const fsample = FS2 / 2;
1426
+ const out = new Map();
1427
+ for (let idf = -16; idf <= 16; idf++) {
1428
+ const tweak = createFrequencyTweak(idf, 2 * NSS, fsample);
1429
+ out.set(idf, [
1430
+ applyTweak(base[0], tweak),
1431
+ applyTweak(base[1], tweak),
1432
+ applyTweak(base[2], tweak),
1433
+ applyTweak(base[3], tweak),
1434
+ ]);
1435
+ }
1436
+ return out;
1437
+ }
1438
+ function createBaseSyncTemplates() {
1439
+ return [
1440
+ buildSyncTemplate(COSTAS_A),
1441
+ buildSyncTemplate(COSTAS_B),
1442
+ buildSyncTemplate(COSTAS_C),
1443
+ buildSyncTemplate(COSTAS_D),
1444
+ ];
1445
+ }
1446
+ function buildSyncTemplate(tones) {
1447
+ const re = new Float64Array(2 * NSS);
1448
+ const im = new Float64Array(2 * NSS);
1449
+ let k = 0;
1450
+ let phi = 0;
1451
+ for (const tone of tones) {
1452
+ const dphi = (TWO_PI$1 * tone * 2) / NSS;
1453
+ for (let j = 0; j < NSS / 2; j++) {
1454
+ re[k] = Math.cos(phi);
1455
+ im[k] = Math.sin(phi);
1456
+ phi = (phi + dphi) % TWO_PI$1;
1457
+ k++;
1458
+ }
1459
+ }
1460
+ return { re, im };
1461
+ }
1462
+ function createFrequencyTweak(idf, npts, fsample) {
1463
+ const re = new Float64Array(npts);
1464
+ const im = new Float64Array(npts);
1465
+ const dphi = (TWO_PI$1 * idf) / fsample;
1466
+ const stepRe = Math.cos(dphi);
1467
+ const stepIm = Math.sin(dphi);
1468
+ let wRe = 1;
1469
+ let wIm = 0;
1470
+ for (let i = 0; i < npts; i++) {
1471
+ const newRe = wRe * stepRe - wIm * stepIm;
1472
+ const newIm = wRe * stepIm + wIm * stepRe;
1473
+ wRe = newRe;
1474
+ wIm = newIm;
1475
+ re[i] = wRe;
1476
+ im[i] = wIm;
1477
+ }
1478
+ return { re, im };
1479
+ }
1480
+ function applyTweak(template, tweak) {
1481
+ const re = new Float64Array(template.re.length);
1482
+ const im = new Float64Array(template.im.length);
1483
+ for (let i = 0; i < template.re.length; i++) {
1484
+ const sr = template.re[i];
1485
+ const si = template.im[i];
1486
+ const tr = tweak.re[i];
1487
+ const ti = tweak.im[i];
1488
+ re[i] = tr * sr - ti * si;
1489
+ im[i] = tr * si + ti * sr;
1490
+ }
1491
+ return { re, im };
1492
+ }
1493
+ function sync4d(cdRe, cdIm, i0, templates) {
1494
+ const starts = [i0, i0 + 33 * NSS, i0 + 66 * NSS, i0 + 99 * NSS];
1495
+ let sync = 0;
1496
+ for (let i = 0; i < 4; i++) {
1497
+ const z = correlateStride2(cdRe, cdIm, starts[i], templates[i].re, templates[i].im);
1498
+ if (z.count <= 16) {
1499
+ continue;
1500
+ }
1501
+ sync += Math.hypot(z.re, z.im) / (2 * NSS);
1502
+ }
1503
+ return sync;
1504
+ }
1505
+ function correlateStride2(cdRe, cdIm, start, templateRe, templateIm) {
1506
+ let zRe = 0;
1507
+ let zIm = 0;
1508
+ let count = 0;
1509
+ for (let i = 0; i < templateRe.length; i++) {
1510
+ const idx = start + 2 * i;
1511
+ if (idx < 0 || idx >= cdRe.length) {
1512
+ continue;
1513
+ }
1514
+ const sRe = templateRe[i];
1515
+ const sIm = templateIm[i];
1516
+ const dRe = cdRe[idx];
1517
+ const dIm = cdIm[idx];
1518
+ zRe += dRe * sRe + dIm * sIm;
1519
+ zIm += dIm * sRe - dRe * sIm;
1520
+ count++;
1521
+ }
1522
+ return { re: zRe, im: zIm, count };
1523
+ }
1524
+ function getFt4Bitmetrics(cdRe, cdIm) {
1525
+ const csRe = new Float64Array(4 * NN$1);
1526
+ const csIm = new Float64Array(4 * NN$1);
1527
+ const s4 = new Float64Array(4 * NN$1);
1528
+ const symbRe = new Float64Array(NSS);
1529
+ const symbIm = new Float64Array(NSS);
1530
+ for (let k = 0; k < NN$1; k++) {
1531
+ const i1 = k * NSS;
1532
+ for (let i = 0; i < NSS; i++) {
1533
+ symbRe[i] = cdRe[i1 + i];
1534
+ symbIm[i] = cdIm[i1 + i];
1220
1535
  }
1221
1536
  fftComplex(symbRe, symbIm, false);
1222
- for (let tone = 0; tone < 8; tone++) {
1223
- const re = symbRe[tone] / 1000;
1224
- const im = symbIm[tone] / 1000;
1225
- csRe[tone * NN + k] = re;
1226
- csIm[tone * NN + k] = im;
1227
- s8[tone * NN + k] = Math.sqrt(re * re + im * im);
1537
+ for (let tone = 0; tone < 4; tone++) {
1538
+ const idx = tone * NN$1 + k;
1539
+ const re = symbRe[tone];
1540
+ const im = symbIm[tone];
1541
+ csRe[idx] = re;
1542
+ csIm[idx] = im;
1543
+ s4[idx] = Math.hypot(re, im);
1228
1544
  }
1229
1545
  }
1230
- // Sync quality check
1231
1546
  let nsync = 0;
1232
- for (let k = 0; k < 7; k++) {
1233
- for (const offset of [0, 36, 72]) {
1234
- let maxTone = 0;
1235
- let maxVal = -1;
1236
- for (let t = 0; t < 8; t++) {
1237
- const v = s8[t * NN + k + offset];
1238
- if (v > maxVal) {
1239
- maxVal = v;
1240
- maxTone = t;
1547
+ for (let k = 0; k < 4; k++) {
1548
+ if (maxTone(s4, k) === COSTAS_A[k]) {
1549
+ nsync++;
1550
+ }
1551
+ if (maxTone(s4, 33 + k) === COSTAS_B[k]) {
1552
+ nsync++;
1553
+ }
1554
+ if (maxTone(s4, 66 + k) === COSTAS_C[k]) {
1555
+ nsync++;
1556
+ }
1557
+ if (maxTone(s4, 99 + k) === COSTAS_D[k]) {
1558
+ nsync++;
1559
+ }
1560
+ }
1561
+ const bitmetrics1 = new Float64Array(2 * NN$1);
1562
+ const bitmetrics2 = new Float64Array(2 * NN$1);
1563
+ const bitmetrics3 = new Float64Array(2 * NN$1);
1564
+ if (nsync < 6) {
1565
+ return { bitmetrics1, bitmetrics2, bitmetrics3, badsync: true };
1566
+ }
1567
+ for (let nseq = 1; nseq <= 3; nseq++) {
1568
+ const nsym = nseq === 1 ? 1 : nseq === 2 ? 2 : 4;
1569
+ const nt = 1 << (2 * nsym); // 4, 16, 256
1570
+ const ibmax = nseq === 1 ? 1 : nseq === 2 ? 3 : 7;
1571
+ const s2 = new Float64Array(nt);
1572
+ for (let ks = 1; ks <= NN$1 - nsym + 1; ks += nsym) {
1573
+ for (let i = 0; i < nt; i++) {
1574
+ const i1 = Math.floor(i / 64);
1575
+ const i2 = Math.floor((i & 63) / 16);
1576
+ const i3 = Math.floor((i & 15) / 4);
1577
+ const i4 = i & 3;
1578
+ if (nsym === 1) {
1579
+ const t = GRAYMAP[i4];
1580
+ const idx = t * NN$1 + (ks - 1);
1581
+ s2[i] = Math.hypot(csRe[idx], csIm[idx]);
1582
+ }
1583
+ else if (nsym === 2) {
1584
+ const t3 = GRAYMAP[i3];
1585
+ const t4 = GRAYMAP[i4];
1586
+ const iA = t3 * NN$1 + (ks - 1);
1587
+ const iB = t4 * NN$1 + ks;
1588
+ const re = csRe[iA] + csRe[iB];
1589
+ const im = csIm[iA] + csIm[iB];
1590
+ s2[i] = Math.hypot(re, im);
1591
+ }
1592
+ else {
1593
+ const t1 = GRAYMAP[i1];
1594
+ const t2 = GRAYMAP[i2];
1595
+ const t3 = GRAYMAP[i3];
1596
+ const t4 = GRAYMAP[i4];
1597
+ const iA = t1 * NN$1 + (ks - 1);
1598
+ const iB = t2 * NN$1 + ks;
1599
+ const iC = t3 * NN$1 + (ks + 1);
1600
+ const iD = t4 * NN$1 + (ks + 2);
1601
+ const re = csRe[iA] + csRe[iB] + csRe[iC] + csRe[iD];
1602
+ const im = csIm[iA] + csIm[iB] + csIm[iC] + csIm[iD];
1603
+ s2[i] = Math.hypot(re, im);
1241
1604
  }
1242
1605
  }
1243
- if (maxTone === icos7[k])
1244
- nsync++;
1245
- }
1246
- }
1247
- if (nsync <= 6)
1248
- return null;
1249
- // Compute soft bit metrics for multiple nsym values (1, 2, 3)
1250
- // and a normalized version, matching the Fortran ft8b passes 1-4
1251
- const bmeta = new Float64Array(N_LDPC); // nsym=1
1252
- const bmetb = new Float64Array(N_LDPC); // nsym=2
1253
- const bmetc = new Float64Array(N_LDPC); // nsym=3
1254
- const bmetd = new Float64Array(N_LDPC); // nsym=1 normalized
1255
- for (let nsym = 1; nsym <= 3; nsym++) {
1256
- const nt = 1 << (3 * nsym); // 8, 64, 512
1257
- const ibmax = nsym === 1 ? 2 : nsym === 2 ? 5 : 8;
1258
- for (let ihalf = 1; ihalf <= 2; ihalf++) {
1259
- for (let k = 1; k <= 29; k += nsym) {
1260
- const ks = ihalf === 1 ? k + 7 : k + 43;
1261
- const s2 = new Float64Array(nt);
1606
+ const ipt = 1 + (ks - 1) * 2;
1607
+ for (let ib = 0; ib <= ibmax; ib++) {
1608
+ const mask = 1 << (ibmax - ib);
1609
+ let max1 = -1e30;
1610
+ let max0 = -1e30;
1262
1611
  for (let i = 0; i < nt; i++) {
1263
- const i1 = Math.floor(i / 64);
1264
- const i2 = Math.floor((i & 63) / 8);
1265
- const i3 = i & 7;
1266
- if (nsym === 1) {
1267
- const re = csRe[graymap[i3] * NN + ks - 1];
1268
- const im = csIm[graymap[i3] * NN + ks - 1];
1269
- s2[i] = Math.sqrt(re * re + im * im);
1270
- }
1271
- else if (nsym === 2) {
1272
- const sRe = csRe[graymap[i2] * NN + ks - 1] + csRe[graymap[i3] * NN + ks];
1273
- const sIm = csIm[graymap[i2] * NN + ks - 1] + csIm[graymap[i3] * NN + ks];
1274
- s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
1275
- }
1276
- else {
1277
- const sRe = csRe[graymap[i1] * NN + ks - 1] +
1278
- csRe[graymap[i2] * NN + ks] +
1279
- csRe[graymap[i3] * NN + ks + 1];
1280
- const sIm = csIm[graymap[i1] * NN + ks - 1] +
1281
- csIm[graymap[i2] * NN + ks] +
1282
- csIm[graymap[i3] * NN + ks + 1];
1283
- s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
1284
- }
1285
- }
1286
- // Fortran: i32 = 1 + (k-1)*3 + (ihalf-1)*87 (1-based)
1287
- const i32 = 1 + (k - 1) * 3 + (ihalf - 1) * 87;
1288
- for (let ib = 0; ib <= ibmax; ib++) {
1289
- // max of s2 where bit (ibmax-ib) of index is 1
1290
- let max1 = -1e30, max0 = -1e30;
1291
- for (let i = 0; i < nt; i++) {
1292
- const bitSet = (i & (1 << (ibmax - ib))) !== 0;
1293
- if (bitSet) {
1294
- if (s2[i] > max1)
1295
- max1 = s2[i];
1296
- }
1297
- else {
1298
- if (s2[i] > max0)
1299
- max0 = s2[i];
1612
+ const v = s2[i];
1613
+ if ((i & mask) !== 0) {
1614
+ if (v > max1) {
1615
+ max1 = v;
1300
1616
  }
1301
1617
  }
1302
- const idx = i32 + ib - 1; // Convert to 0-based
1303
- if (idx >= 0 && idx < N_LDPC) {
1304
- const bm = max1 - max0;
1305
- if (nsym === 1) {
1306
- bmeta[idx] = bm;
1307
- const den = Math.max(max1, max0);
1308
- bmetd[idx] = den > 0 ? bm / den : 0;
1309
- }
1310
- else if (nsym === 2) {
1311
- bmetb[idx] = bm;
1312
- }
1313
- else {
1314
- bmetc[idx] = bm;
1315
- }
1618
+ else if (v > max0) {
1619
+ max0 = v;
1316
1620
  }
1317
1621
  }
1622
+ const idx = ipt + ib;
1623
+ if (idx > 2 * NN$1) {
1624
+ continue;
1625
+ }
1626
+ const bm = max1 - max0;
1627
+ if (nseq === 1) {
1628
+ bitmetrics1[idx - 1] = bm;
1629
+ }
1630
+ else if (nseq === 2) {
1631
+ bitmetrics2[idx - 1] = bm;
1632
+ }
1633
+ else {
1634
+ bitmetrics3[idx - 1] = bm;
1635
+ }
1318
1636
  }
1319
1637
  }
1320
1638
  }
1321
- normalizeBmet(bmeta);
1322
- normalizeBmet(bmetb);
1323
- normalizeBmet(bmetc);
1324
- normalizeBmet(bmetd);
1325
- const bmetrics = [bmeta, bmetb, bmetc, bmetd];
1326
- const scalefac = 2.83;
1327
- const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
1328
- const apmask = new Int8Array(N_LDPC);
1329
- // Try 4 passes with different soft-symbol metrics (matching Fortran)
1330
- let result = null;
1331
- for (let ipass = 0; ipass < 4; ipass++) {
1332
- const llr = new Float64Array(N_LDPC);
1333
- for (let i = 0; i < N_LDPC; i++)
1334
- llr[i] = scalefac * bmetrics[ipass][i];
1335
- result = decode174_91(llr, apmask, maxosd);
1336
- if (result && result.nharderrors >= 0 && result.nharderrors <= 36)
1337
- break;
1338
- result = null;
1639
+ bitmetrics2[208] = bitmetrics1[208];
1640
+ bitmetrics2[209] = bitmetrics1[209];
1641
+ bitmetrics3[208] = bitmetrics1[208];
1642
+ bitmetrics3[209] = bitmetrics1[209];
1643
+ normalizeBitMetrics(bitmetrics1);
1644
+ normalizeBitMetrics(bitmetrics2);
1645
+ normalizeBitMetrics(bitmetrics3);
1646
+ return { bitmetrics1, bitmetrics2, bitmetrics3, badsync: false };
1647
+ }
1648
+ function maxTone(s4, symbolIndex) {
1649
+ let bestTone = 0;
1650
+ let bestValue = -1;
1651
+ for (let tone = 0; tone < 4; tone++) {
1652
+ const v = s4[tone * NN$1 + symbolIndex];
1653
+ if (v > bestValue) {
1654
+ bestValue = v;
1655
+ bestTone = tone;
1656
+ }
1339
1657
  }
1340
- if (!result || result.nharderrors < 0 || result.nharderrors > 36)
1341
- return null;
1342
- // Check for all-zero codeword
1343
- if (result.cw.every((b) => b === 0))
1344
- return null;
1345
- const message77 = result.message91.slice(0, 77);
1346
- // Validate message type
1347
- const n3v = (message77[71] << 2) | (message77[72] << 1) | message77[73];
1348
- const i3v = (message77[74] << 2) | (message77[75] << 1) | message77[76];
1349
- if (i3v > 5 || (i3v === 0 && n3v > 6))
1350
- return null;
1351
- if (i3v === 0 && n3v === 2)
1352
- return null;
1353
- // Unpack
1354
- const { msg, success } = unpack77(message77, book);
1355
- if (!success || msg.trim().length === 0)
1356
- return null;
1357
- // Estimate SNR
1358
- let xsig = 0;
1359
- let xnoi = 0;
1360
- const itone = getTones$1(result.cw);
1361
- for (let i = 0; i < 79; i++) {
1362
- xsig += s8[itone[i] * NN + i] ** 2;
1363
- const ios = (itone[i] + 4) % 7;
1364
- xnoi += s8[ios * NN + i] ** 2;
1658
+ return bestTone;
1659
+ }
1660
+ function normalizeBitMetrics(bmet) {
1661
+ let sum = 0;
1662
+ let sum2 = 0;
1663
+ for (let i = 0; i < bmet.length; i++) {
1664
+ sum += bmet[i];
1665
+ sum2 += bmet[i] * bmet[i];
1666
+ }
1667
+ const avg = sum / bmet.length;
1668
+ const avg2 = sum2 / bmet.length;
1669
+ const variance = avg2 - avg * avg;
1670
+ const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
1671
+ if (sigma <= 0) {
1672
+ return;
1673
+ }
1674
+ for (let i = 0; i < bmet.length; i++) {
1675
+ bmet[i] = bmet[i] / sigma;
1365
1676
  }
1366
- let snr = 0.001;
1367
- const arg = xsig / Math.max(xnoi, 1e-30) - 1.0;
1368
- if (arg > 0.1)
1369
- snr = arg;
1370
- snr = 10 * Math.log10(snr) - 27.0;
1371
- if (snr < -24)
1372
- snr = -24;
1373
- return { msg, freq: f1, dt: xdt, snr };
1374
1677
  }
1375
- function getTones$1(cw) {
1376
- const tones = new Array(79).fill(0);
1377
- for (let i = 0; i < 7; i++)
1378
- tones[i] = icos7[i];
1379
- for (let i = 0; i < 7; i++)
1380
- tones[36 + i] = icos7[i];
1381
- for (let i = 0; i < 7; i++)
1382
- tones[72 + i] = icos7[i];
1383
- let k = 7;
1384
- for (let j = 1; j <= 58; j++) {
1385
- const i = (j - 1) * 3;
1386
- if (j === 30)
1387
- k += 7;
1388
- const indx = cw[i] * 4 + cw[i + 1] * 2 + cw[i + 2];
1389
- tones[k] = graymap[indx];
1390
- k++;
1391
- }
1392
- return tones;
1393
- }
1394
- /**
1395
- * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
1396
- * Identical to Fortran ft8_downsample.
1397
- */
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;
1678
+ function passesHardSyncQuality(bitmetrics1) {
1679
+ const hard = new Uint8Array(bitmetrics1.length);
1680
+ for (let i = 0; i < bitmetrics1.length; i++) {
1681
+ hard[i] = bitmetrics1[i] >= 0 ? 1 : 0;
1682
+ }
1683
+ let score = 0;
1684
+ for (const pattern of HARD_SYNC_PATTERNS) {
1685
+ for (let i = 0; i < pattern.bits.length; i++) {
1686
+ if (hard[pattern.offset + i] === pattern.bits[i]) {
1687
+ score++;
1688
+ }
1439
1689
  }
1440
1690
  }
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;
1464
- }
1691
+ return score >= 10;
1465
1692
  }
1466
- function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
1467
- const NP2 = 2812;
1468
- const twopi = 2 * Math.PI;
1469
- // Precompute Costas sync waveforms
1470
- const csyncRe = new Float64Array(7 * 32);
1471
- const csyncIm = new Float64Array(7 * 32);
1472
- for (let i = 0; i < 7; i++) {
1473
- let phi = 0;
1474
- const dphi = (twopi * icos7[i]) / 32;
1475
- for (let j = 0; j < 32; j++) {
1476
- csyncRe[i * 32 + j] = Math.cos(phi);
1477
- csyncIm[i * 32 + j] = Math.sin(phi);
1478
- phi = (phi + dphi) % twopi;
1479
- }
1480
- }
1481
- let sync = 0;
1482
- for (let i = 0; i < 7; i++) {
1483
- const i1 = i0 + i * 32;
1484
- const i2 = i1 + 36 * 32;
1485
- const i3 = i1 + 72 * 32;
1486
- for (const iStart of [i1, i2, i3]) {
1487
- let zRe = 0, zIm = 0;
1488
- if (iStart >= 0 && iStart + 31 < NP2) {
1489
- for (let j = 0; j < 32; j++) {
1490
- let sRe = csyncRe[i * 32 + j];
1491
- let sIm = csyncIm[i * 32 + j];
1492
- if (useTwk && twkRe && twkIm) {
1493
- const tRe = twkRe[j] * sRe - twkIm[j] * sIm;
1494
- const tIm = twkRe[j] * sIm + twkIm[j] * sRe;
1495
- sRe = tRe;
1496
- sIm = tIm;
1497
- }
1498
- // Conjugate multiply: cd0 * conj(csync)
1499
- const dRe = cd0Re[iStart + j];
1500
- const dIm = cd0Im[iStart + j];
1501
- zRe += dRe * sRe + dIm * sIm;
1502
- zIm += dIm * sRe - dRe * sIm;
1503
- }
1504
- }
1505
- sync += zRe * zRe + zIm * zIm;
1693
+ function buildLlrs(bitmetrics1, bitmetrics2, bitmetrics3) {
1694
+ const llra = new Float64Array(174);
1695
+ const llrb = new Float64Array(174);
1696
+ const llrc = new Float64Array(174);
1697
+ for (let i = 0; i < 58; i++) {
1698
+ llra[i] = bitmetrics1[8 + i];
1699
+ llra[58 + i] = bitmetrics1[74 + i];
1700
+ llra[116 + i] = bitmetrics1[140 + i];
1701
+ llrb[i] = bitmetrics2[8 + i];
1702
+ llrb[58 + i] = bitmetrics2[74 + i];
1703
+ llrb[116 + i] = bitmetrics2[140 + i];
1704
+ llrc[i] = bitmetrics3[8 + i];
1705
+ llrc[58 + i] = bitmetrics3[74 + i];
1706
+ llrc[116 + i] = bitmetrics3[140 + i];
1707
+ }
1708
+ return [llra, llrb, llrc];
1709
+ }
1710
+ function hasNonZeroBit(bits) {
1711
+ for (const bit of bits) {
1712
+ if (bit !== 0) {
1713
+ return true;
1506
1714
  }
1507
1715
  }
1508
- return sync;
1716
+ return false;
1509
1717
  }
1510
- function normalizeBmet(bmet) {
1511
- const n = bmet.length;
1512
- let sum = 0, sum2 = 0;
1513
- for (let i = 0; i < n; i++) {
1514
- sum += bmet[i];
1515
- sum2 += bmet[i] * bmet[i];
1516
- }
1517
- const avg = sum / n;
1518
- const avg2 = sum2 / n;
1519
- const variance = avg2 - avg * avg;
1520
- const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
1521
- if (sigma > 0) {
1522
- for (let i = 0; i < n; i++)
1523
- bmet[i] = bmet[i] / sigma;
1718
+ function toFt4Snr(syncMinusOne) {
1719
+ if (syncMinusOne > 0) {
1720
+ return Math.round(Math.max(-21, 10 * Math.log10(syncMinusOne) - 14.8));
1524
1721
  }
1722
+ return -21;
1525
1723
  }
1526
- function resample(input, fromRate, toRate, outLen) {
1724
+ function resample$1(input, fromRate, toRate, outLen) {
1527
1725
  const out = new Float64Array(outLen);
1528
1726
  const ratio = fromRate / toRate;
1529
1727
  for (let i = 0; i < outLen; i++) {
@@ -2047,9 +2245,9 @@ function packFreeText(msg) {
2047
2245
  }
2048
2246
 
2049
2247
  const TWO_PI = 2 * Math.PI;
2050
- const DEFAULT_SAMPLE_RATE = 12_000;
2051
- const DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
2052
- const DEFAULT_BT = 2.0;
2248
+ const FT8_DEFAULT_SAMPLE_RATE = 12_000;
2249
+ const FT8_DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
2250
+ const FT8_DEFAULT_BT = 2.0;
2053
2251
  const MODULATION_INDEX = 1.0;
2054
2252
  function assertPositiveFinite(value, name) {
2055
2253
  if (!Number.isFinite(value) || value <= 0) {
@@ -2072,15 +2270,14 @@ function gfskPulse(bt, tt) {
2072
2270
  const scale = Math.PI * Math.sqrt(2 / Math.log(2)) * bt;
2073
2271
  return 0.5 * (erfApprox(scale * (tt + 0.5)) - erfApprox(scale * (tt - 0.5)));
2074
2272
  }
2075
- function generateFT8Waveform(tones, options = {}) {
2076
- // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
2273
+ function generateGfskWaveform(tones, options, defaults, shape) {
2077
2274
  const nsym = tones.length;
2078
2275
  if (nsym === 0) {
2079
2276
  return new Float32Array(0);
2080
2277
  }
2081
- const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
2082
- const nsps = options.samplesPerSymbol ?? DEFAULT_SAMPLES_PER_SYMBOL;
2083
- const bt = options.bt ?? DEFAULT_BT;
2278
+ const sampleRate = options.sampleRate ?? defaults.sampleRate;
2279
+ const nsps = options.samplesPerSymbol ?? defaults.samplesPerSymbol;
2280
+ const bt = options.bt ?? defaults.bt;
2084
2281
  const f0 = options.baseFrequency ?? 0;
2085
2282
  assertPositiveFinite(sampleRate, "sampleRate");
2086
2283
  assertPositiveFinite(nsps, "samplesPerSymbol");
@@ -2091,7 +2288,7 @@ function generateFT8Waveform(tones, options = {}) {
2091
2288
  if (!Number.isInteger(nsps)) {
2092
2289
  throw new Error("samplesPerSymbol must be an integer");
2093
2290
  }
2094
- const nwave = nsym * nsps;
2291
+ const nwave = (nsym) * nsps;
2095
2292
  const pulse = new Float64Array(3 * nsps);
2096
2293
  for (let i = 0; i < pulse.length; i++) {
2097
2294
  const tt = (i + 1 - 1.5 * nsps) / nsps;
@@ -2119,8 +2316,9 @@ function generateFT8Waveform(tones, options = {}) {
2119
2316
  }
2120
2317
  const wave = new Float32Array(nwave);
2121
2318
  let phi = 0;
2319
+ const phaseStart = nsps;
2122
2320
  for (let k = 0; k < nwave; k++) {
2123
- const j = nsps + k; // skip the leading dummy symbol
2321
+ const j = phaseStart + k;
2124
2322
  wave[k] = Math.sin(phi);
2125
2323
  phi += dphi[j];
2126
2324
  phi %= TWO_PI;
@@ -2128,18 +2326,41 @@ function generateFT8Waveform(tones, options = {}) {
2128
2326
  phi += TWO_PI;
2129
2327
  }
2130
2328
  }
2131
- const nramp = Math.round(nsps / 8);
2132
- for (let i = 0; i < nramp; i++) {
2133
- const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
2134
- wave[i] *= up;
2135
- }
2136
- const tailStart = nwave - nramp;
2137
- for (let i = 0; i < nramp; i++) {
2138
- const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
2139
- wave[tailStart + i] *= down;
2329
+ {
2330
+ const nramp = Math.round(nsps / 8);
2331
+ for (let i = 0; i < nramp; i++) {
2332
+ const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
2333
+ wave[i] *= up;
2334
+ }
2335
+ const tailStart = nwave - nramp;
2336
+ for (let i = 0; i < nramp; i++) {
2337
+ const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
2338
+ wave[tailStart + i] *= down;
2339
+ }
2140
2340
  }
2141
2341
  return wave;
2142
2342
  }
2343
+ function generateFT8Waveform(tones, options = {}) {
2344
+ // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
2345
+ return generateGfskWaveform(tones, options, {
2346
+ sampleRate: FT8_DEFAULT_SAMPLE_RATE,
2347
+ samplesPerSymbol: FT8_DEFAULT_SAMPLES_PER_SYMBOL,
2348
+ bt: FT8_DEFAULT_BT,
2349
+ });
2350
+ }
2351
+
2352
+ /** FT8-specific constants (lib/ft8/ft8_params.f90). */
2353
+ const NSPS = 1920;
2354
+ const NFFT1 = 2 * NSPS; // 3840
2355
+ const NSTEP = NSPS / 4; // 480
2356
+ const NMAX = 15 * 12_000; // 180000
2357
+ const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
2358
+ const NDOWN = 60;
2359
+ const NN = 79;
2360
+ /** 7-symbol Costas array for sync. */
2361
+ const COSTAS = [3, 1, 4, 0, 6, 5, 2];
2362
+ /** 8-tone Gray mapping. */
2363
+ const GRAY_MAP = [0, 1, 3, 2, 5, 6, 4, 7];
2143
2364
 
2144
2365
  function generateLdpcGMatrix() {
2145
2366
  const K = 91;
@@ -2158,64 +2379,678 @@ function generateLdpcGMatrix() {
2158
2379
  }
2159
2380
  }
2160
2381
  }
2161
- return gen;
2162
- }
2163
- const G = generateLdpcGMatrix();
2164
- function encode174_91(msg77) {
2165
- const poly = 0x2757;
2166
- let crc = 0;
2167
- // padded with 19 zeros (3 zeros + 16 zero-bits for flush)
2168
- const bitArray = [...msg77, 0, 0, 0, ...new Array(16).fill(0)];
2169
- for (let bit = 0; bit < 96; bit++) {
2170
- const nextBit = bitArray[bit];
2171
- if ((crc & 0x2000) !== 0) {
2172
- crc = ((crc << 1) | nextBit) ^ poly;
2173
- }
2174
- else {
2175
- crc = (crc << 1) | nextBit;
2176
- }
2177
- crc &= 0x3fff;
2178
- }
2179
- const msg91 = [...msg77];
2180
- for (let i = 0; i < 14; i++) {
2181
- msg91.push((crc >> (13 - i)) & 1);
2382
+ return gen;
2383
+ }
2384
+ const G = generateLdpcGMatrix();
2385
+ function encode174_91(msg77) {
2386
+ const poly = 0x2757;
2387
+ let crc = 0;
2388
+ // padded with 19 zeros (3 zeros + 16 zero-bits for flush)
2389
+ const bitArray = [...msg77, 0, 0, 0, ...new Array(16).fill(0)];
2390
+ for (let bit = 0; bit < 96; bit++) {
2391
+ const nextBit = bitArray[bit];
2392
+ if ((crc & 0x2000) !== 0) {
2393
+ crc = ((crc << 1) | nextBit) ^ poly;
2394
+ }
2395
+ else {
2396
+ crc = (crc << 1) | nextBit;
2397
+ }
2398
+ crc &= 0x3fff;
2399
+ }
2400
+ const msg91 = [...msg77];
2401
+ for (let i = 0; i < 14; i++) {
2402
+ msg91.push((crc >> (13 - i)) & 1);
2403
+ }
2404
+ const codeword = [...msg91];
2405
+ for (let i = 0; i < 83; i++) {
2406
+ let sum = 0;
2407
+ for (let j = 0; j < 91; j++) {
2408
+ sum += msg91[j] * G[i][j];
2409
+ }
2410
+ codeword.push(sum % 2);
2411
+ }
2412
+ return codeword;
2413
+ }
2414
+ function getTones$1(codeword) {
2415
+ const tones = new Array(79).fill(0);
2416
+ for (let i = 0; i < 7; i++)
2417
+ tones[i] = COSTAS[i];
2418
+ for (let i = 0; i < 7; i++)
2419
+ tones[36 + i] = COSTAS[i];
2420
+ for (let i = 0; i < 7; i++)
2421
+ tones[72 + i] = COSTAS[i];
2422
+ let k = 7;
2423
+ for (let j = 1; j <= 58; j++) {
2424
+ const i = j * 3 - 3; // codeword is 0-indexed in JS, but the loop was j=1 to 58
2425
+ if (j === 30)
2426
+ k += 7;
2427
+ const indx = codeword[i] * 4 + codeword[i + 1] * 2 + codeword[i + 2];
2428
+ tones[k] = GRAY_MAP[indx];
2429
+ k++;
2430
+ }
2431
+ return tones;
2432
+ }
2433
+ function encodeMessage(msg) {
2434
+ const bits77 = pack77(msg);
2435
+ const codeword = encode174_91(bits77);
2436
+ return getTones$1(codeword);
2437
+ }
2438
+ function encode(msg, options = {}) {
2439
+ return generateFT8Waveform(encodeMessage(msg), options);
2440
+ }
2441
+
2442
+ /**
2443
+ * Decode all FT8 signals in an audio buffer.
2444
+ * Input: mono audio samples at `sampleRate` Hz, duration ~15s.
2445
+ */
2446
+ function decode(samples, options = {}) {
2447
+ const sampleRate = options.sampleRate ?? SAMPLE_RATE$1;
2448
+ const nfa = options.freqLow ?? 200;
2449
+ const nfb = options.freqHigh ?? 3000;
2450
+ const syncmin = options.syncMin ?? 1.2;
2451
+ const depth = options.depth ?? 2;
2452
+ const maxCandidates = options.maxCandidates ?? 300;
2453
+ const book = options.hashCallBook;
2454
+ // Resample to 12000 Hz if needed
2455
+ let dd;
2456
+ if (sampleRate === SAMPLE_RATE$1) {
2457
+ dd = new Float64Array(NMAX);
2458
+ const len = Math.min(samples.length, NMAX);
2459
+ for (let i = 0; i < len; i++)
2460
+ dd[i] = samples[i];
2461
+ }
2462
+ else {
2463
+ dd = resample(samples, sampleRate, SAMPLE_RATE$1, NMAX);
2464
+ }
2465
+ // Compute huge FFT for downsampling caching
2466
+ const NFFT1_LONG = 192000;
2467
+ const cxRe = new Float64Array(NFFT1_LONG);
2468
+ const cxIm = new Float64Array(NFFT1_LONG);
2469
+ for (let i = 0; i < NMAX; i++) {
2470
+ cxRe[i] = dd[i] ?? 0;
2471
+ }
2472
+ fftComplex(cxRe, cxIm, false);
2473
+ // Compute spectrogram and find sync candidates
2474
+ const { candidates, sbase } = sync8(dd, nfa, nfb, syncmin, maxCandidates);
2475
+ const decoded = [];
2476
+ const seenMessages = new Set();
2477
+ for (const cand of candidates) {
2478
+ const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
2479
+ if (!result)
2480
+ continue;
2481
+ if (seenMessages.has(result.msg))
2482
+ continue;
2483
+ seenMessages.add(result.msg);
2484
+ decoded.push({
2485
+ freq: result.freq,
2486
+ dt: result.dt - 0.5,
2487
+ snr: result.snr,
2488
+ msg: result.msg,
2489
+ sync: cand.sync,
2490
+ });
2491
+ }
2492
+ return decoded;
2493
+ }
2494
+ function sync8(dd, nfa, nfb, syncmin, maxcand) {
2495
+ const JZ = 62;
2496
+ // Fortran uses NFFT1=3840 for the spectrogram FFT; we need a power of 2
2497
+ const fftSize = nextPow2(NFFT1); // 4096
2498
+ const halfSize = fftSize / 2; // 2048
2499
+ const tstep = NSTEP / SAMPLE_RATE$1;
2500
+ const df = SAMPLE_RATE$1 / fftSize;
2501
+ const fac = 1.0 / 300.0;
2502
+ // Compute symbol spectra, stepping by NSTEP
2503
+ const s = new Float64Array(halfSize * NHSYM);
2504
+ const savg = new Float64Array(halfSize);
2505
+ const xRe = new Float64Array(fftSize);
2506
+ const xIm = new Float64Array(fftSize);
2507
+ for (let j = 0; j < NHSYM; j++) {
2508
+ const ia = j * NSTEP;
2509
+ xRe.fill(0);
2510
+ xIm.fill(0);
2511
+ for (let i = 0; i < NSPS && ia + i < dd.length; i++) {
2512
+ xRe[i] = fac * dd[ia + i];
2513
+ }
2514
+ fftComplex(xRe, xIm, false);
2515
+ for (let i = 0; i < halfSize; i++) {
2516
+ const power = xRe[i] * xRe[i] + xIm[i] * xIm[i];
2517
+ s[i * NHSYM + j] = power;
2518
+ savg[i] = (savg[i] ?? 0) + power;
2519
+ }
2520
+ }
2521
+ // Compute baseline
2522
+ const sbase = computeBaseline(savg, nfa, nfb, df, halfSize);
2523
+ const ia = Math.max(1, Math.round(nfa / df));
2524
+ const ib = Math.min(halfSize - 14, Math.round(nfb / df));
2525
+ const nssy = Math.floor(NSPS / NSTEP);
2526
+ const nfos = Math.round(SAMPLE_RATE$1 / NSPS / df); // ~2 bins per tone spacing
2527
+ const jstrt = Math.round(0.5 / tstep);
2528
+ // 2D sync correlation
2529
+ const sync2d = new Float64Array((ib - ia + 1) * (2 * JZ + 1));
2530
+ const width = 2 * JZ + 1;
2531
+ for (let i = ia; i <= ib; i++) {
2532
+ for (let jj = -JZ; jj <= JZ; jj++) {
2533
+ let ta = 0, tb = 0, tc = 0;
2534
+ let t0a = 0, t0b = 0, t0c = 0;
2535
+ for (let n = 0; n < 7; n++) {
2536
+ const m = jj + jstrt + nssy * n;
2537
+ const iCostas = i + nfos * COSTAS[n];
2538
+ if (m >= 0 && m < NHSYM && iCostas < halfSize) {
2539
+ ta += s[iCostas * NHSYM + m];
2540
+ for (let tone = 0; tone <= 6; tone++) {
2541
+ const idx = i + nfos * tone;
2542
+ if (idx < halfSize)
2543
+ t0a += s[idx * NHSYM + m];
2544
+ }
2545
+ }
2546
+ const m36 = m + nssy * 36;
2547
+ if (m36 >= 0 && m36 < NHSYM && iCostas < halfSize) {
2548
+ tb += s[iCostas * NHSYM + m36];
2549
+ for (let tone = 0; tone <= 6; tone++) {
2550
+ const idx = i + nfos * tone;
2551
+ if (idx < halfSize)
2552
+ t0b += s[idx * NHSYM + m36];
2553
+ }
2554
+ }
2555
+ const m72 = m + nssy * 72;
2556
+ if (m72 >= 0 && m72 < NHSYM && iCostas < halfSize) {
2557
+ tc += s[iCostas * NHSYM + m72];
2558
+ for (let tone = 0; tone <= 6; tone++) {
2559
+ const idx = i + nfos * tone;
2560
+ if (idx < halfSize)
2561
+ t0c += s[idx * NHSYM + m72];
2562
+ }
2563
+ }
2564
+ }
2565
+ const t = ta + tb + tc;
2566
+ const t0total = t0a + t0b + t0c;
2567
+ const t0 = (t0total - t) / 6.0;
2568
+ const syncVal = t0 > 0 ? t / t0 : 0;
2569
+ const tbc = tb + tc;
2570
+ const t0bc = t0b + t0c;
2571
+ const t0bc2 = (t0bc - tbc) / 6.0;
2572
+ const syncBc = t0bc2 > 0 ? tbc / t0bc2 : 0;
2573
+ sync2d[(i - ia) * width + (jj + JZ)] = Math.max(syncVal, syncBc);
2574
+ }
2575
+ }
2576
+ // Find peaks
2577
+ const candidates0 = [];
2578
+ const mlag = 10;
2579
+ for (let i = ia; i <= ib; i++) {
2580
+ let bestSync = -1;
2581
+ let bestJ = 0;
2582
+ for (let j = -mlag; j <= mlag; j++) {
2583
+ const v = sync2d[(i - ia) * width + (j + JZ)];
2584
+ if (v > bestSync) {
2585
+ bestSync = v;
2586
+ bestJ = j;
2587
+ }
2588
+ }
2589
+ // Also check wider range
2590
+ let bestSync2 = -1;
2591
+ let bestJ2 = 0;
2592
+ for (let j = -JZ; j <= JZ; j++) {
2593
+ const v = sync2d[(i - ia) * width + (j + JZ)];
2594
+ if (v > bestSync2) {
2595
+ bestSync2 = v;
2596
+ bestJ2 = j;
2597
+ }
2598
+ }
2599
+ if (bestSync >= syncmin) {
2600
+ candidates0.push({
2601
+ freq: i * df,
2602
+ dt: (bestJ - 0.5) * tstep,
2603
+ sync: bestSync,
2604
+ });
2605
+ }
2606
+ if (Math.abs(bestJ2 - bestJ) > 0 && bestSync2 >= syncmin) {
2607
+ candidates0.push({
2608
+ freq: i * df,
2609
+ dt: (bestJ2 - 0.5) * tstep,
2610
+ sync: bestSync2,
2611
+ });
2612
+ }
2613
+ }
2614
+ // Compute baseline normalization for sync values
2615
+ const syncValues = candidates0.map((c) => c.sync);
2616
+ syncValues.sort((a, b) => a - b);
2617
+ const pctileIdx = Math.max(0, Math.round(0.4 * syncValues.length) - 1);
2618
+ const base = syncValues[pctileIdx] ?? 1;
2619
+ if (base > 0) {
2620
+ for (const c of candidates0)
2621
+ c.sync /= base;
2622
+ }
2623
+ // Remove near-duplicate candidates
2624
+ for (let i = 0; i < candidates0.length; i++) {
2625
+ for (let j = 0; j < i; j++) {
2626
+ const fdiff = Math.abs(candidates0[i].freq - candidates0[j].freq);
2627
+ const tdiff = Math.abs(candidates0[i].dt - candidates0[j].dt);
2628
+ if (fdiff < 4.0 && tdiff < 0.04) {
2629
+ if (candidates0[i].sync >= candidates0[j].sync) {
2630
+ candidates0[j].sync = 0;
2631
+ }
2632
+ else {
2633
+ candidates0[i].sync = 0;
2634
+ }
2635
+ }
2636
+ }
2637
+ }
2638
+ // Sort by sync descending, take top maxcand
2639
+ const filtered = candidates0.filter((c) => c.sync >= syncmin);
2640
+ filtered.sort((a, b) => b.sync - a.sync);
2641
+ return { candidates: filtered.slice(0, maxcand), sbase };
2642
+ }
2643
+ function computeBaseline(savg, nfa, nfb, df, nh1) {
2644
+ const sbase = new Float64Array(nh1);
2645
+ const ia = Math.max(1, Math.round(nfa / df));
2646
+ const ib = Math.min(nh1 - 1, Math.round(nfb / df));
2647
+ // Smooth the spectrum to get baseline
2648
+ const window = 50; // bins
2649
+ for (let i = 0; i < nh1; i++) {
2650
+ let sum = 0;
2651
+ let count = 0;
2652
+ const lo = Math.max(ia, i - window);
2653
+ const hi = Math.min(ib, i + window);
2654
+ for (let j = lo; j <= hi; j++) {
2655
+ sum += savg[j];
2656
+ count++;
2657
+ }
2658
+ sbase[i] = count > 0 ? 10 * Math.log10(Math.max(1e-30, sum / count)) : 0;
2659
+ }
2660
+ return sbase;
2661
+ }
2662
+ function ft8b(_dd0, cxRe, cxIm, f1, xdt, _sbase, depth, book) {
2663
+ const NFFT2 = 3200;
2664
+ const NP2 = 2812;
2665
+ const fs2 = SAMPLE_RATE$1 / NDOWN;
2666
+ const dt2 = 1.0 / fs2;
2667
+ const twopi = 2 * Math.PI;
2668
+ // Downsample: mix to baseband and filter
2669
+ const cd0Re = new Float64Array(NFFT2);
2670
+ const cd0Im = new Float64Array(NFFT2);
2671
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
2672
+ // Find best time offset
2673
+ const i0 = Math.round((xdt + 0.5) * fs2);
2674
+ let smax = 0;
2675
+ let ibest = i0;
2676
+ for (let idt = i0 - 10; idt <= i0 + 10; idt++) {
2677
+ const sync = sync8d(cd0Re, cd0Im, idt, null, null, false);
2678
+ if (sync > smax) {
2679
+ smax = sync;
2680
+ ibest = idt;
2681
+ }
2682
+ }
2683
+ // Fine frequency search
2684
+ smax = 0;
2685
+ let delfbest = 0;
2686
+ for (let ifr = -5; ifr <= 5; ifr++) {
2687
+ const delf = ifr * 0.5;
2688
+ const dphi = twopi * delf * dt2;
2689
+ const twkRe = new Float64Array(32);
2690
+ const twkIm = new Float64Array(32);
2691
+ let phi = 0;
2692
+ for (let i = 0; i < 32; i++) {
2693
+ twkRe[i] = Math.cos(phi);
2694
+ twkIm[i] = Math.sin(phi);
2695
+ phi = (phi + dphi) % twopi;
2696
+ }
2697
+ const sync = sync8d(cd0Re, cd0Im, ibest, twkRe, twkIm, true);
2698
+ if (sync > smax) {
2699
+ smax = sync;
2700
+ delfbest = delf;
2701
+ }
2702
+ }
2703
+ // Apply frequency correction and re-downsample
2704
+ f1 += delfbest;
2705
+ ft8Downsample(cxRe, cxIm, f1, cd0Re, cd0Im);
2706
+ // Refine time offset
2707
+ const ss = new Float64Array(9);
2708
+ for (let idt = -4; idt <= 4; idt++) {
2709
+ ss[idt + 4] = sync8d(cd0Re, cd0Im, ibest + idt, null, null, false);
2710
+ }
2711
+ let maxss = -1;
2712
+ let maxIdx = 4;
2713
+ for (let i = 0; i < 9; i++) {
2714
+ if (ss[i] > maxss) {
2715
+ maxss = ss[i];
2716
+ maxIdx = i;
2717
+ }
2718
+ }
2719
+ ibest = ibest + maxIdx - 4;
2720
+ xdt = (ibest - 1) * dt2;
2721
+ // Extract 8-tone soft symbols for each of NN=79 symbols
2722
+ const s8 = new Float64Array(8 * NN);
2723
+ const csRe = new Float64Array(8 * NN);
2724
+ const csIm = new Float64Array(8 * NN);
2725
+ const symbRe = new Float64Array(32);
2726
+ const symbIm = new Float64Array(32);
2727
+ for (let k = 0; k < NN; k++) {
2728
+ const i1 = ibest + k * 32;
2729
+ symbRe.fill(0);
2730
+ symbIm.fill(0);
2731
+ if (i1 >= 0 && i1 + 31 < NP2) {
2732
+ for (let j = 0; j < 32; j++) {
2733
+ symbRe[j] = cd0Re[i1 + j];
2734
+ symbIm[j] = cd0Im[i1 + j];
2735
+ }
2736
+ }
2737
+ fftComplex(symbRe, symbIm, false);
2738
+ for (let tone = 0; tone < 8; tone++) {
2739
+ const re = symbRe[tone] / 1000;
2740
+ const im = symbIm[tone] / 1000;
2741
+ csRe[tone * NN + k] = re;
2742
+ csIm[tone * NN + k] = im;
2743
+ s8[tone * NN + k] = Math.sqrt(re * re + im * im);
2744
+ }
2745
+ }
2746
+ // Sync quality check
2747
+ let nsync = 0;
2748
+ for (let k = 0; k < 7; k++) {
2749
+ for (const offset of [0, 36, 72]) {
2750
+ let maxTone = 0;
2751
+ let maxVal = -1;
2752
+ for (let t = 0; t < 8; t++) {
2753
+ const v = s8[t * NN + k + offset];
2754
+ if (v > maxVal) {
2755
+ maxVal = v;
2756
+ maxTone = t;
2757
+ }
2758
+ }
2759
+ if (maxTone === COSTAS[k])
2760
+ nsync++;
2761
+ }
2762
+ }
2763
+ if (nsync <= 6)
2764
+ return null;
2765
+ // Compute soft bit metrics for multiple nsym values (1, 2, 3)
2766
+ // and a normalized version, matching the Fortran ft8b passes 1-4
2767
+ const bmeta = new Float64Array(N_LDPC); // nsym=1
2768
+ const bmetb = new Float64Array(N_LDPC); // nsym=2
2769
+ const bmetc = new Float64Array(N_LDPC); // nsym=3
2770
+ const bmetd = new Float64Array(N_LDPC); // nsym=1 normalized
2771
+ for (let nsym = 1; nsym <= 3; nsym++) {
2772
+ const nt = 1 << (3 * nsym); // 8, 64, 512
2773
+ const ibmax = nsym === 1 ? 2 : nsym === 2 ? 5 : 8;
2774
+ for (let ihalf = 1; ihalf <= 2; ihalf++) {
2775
+ for (let k = 1; k <= 29; k += nsym) {
2776
+ const ks = ihalf === 1 ? k + 7 : k + 43;
2777
+ const s2 = new Float64Array(nt);
2778
+ for (let i = 0; i < nt; i++) {
2779
+ const i1 = Math.floor(i / 64);
2780
+ const i2 = Math.floor((i & 63) / 8);
2781
+ const i3 = i & 7;
2782
+ if (nsym === 1) {
2783
+ const re = csRe[GRAY_MAP[i3] * NN + ks - 1];
2784
+ const im = csIm[GRAY_MAP[i3] * NN + ks - 1];
2785
+ s2[i] = Math.sqrt(re * re + im * im);
2786
+ }
2787
+ else if (nsym === 2) {
2788
+ const sRe = csRe[GRAY_MAP[i2] * NN + ks - 1] + csRe[GRAY_MAP[i3] * NN + ks];
2789
+ const sIm = csIm[GRAY_MAP[i2] * NN + ks - 1] + csIm[GRAY_MAP[i3] * NN + ks];
2790
+ s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
2791
+ }
2792
+ else {
2793
+ const sRe = csRe[GRAY_MAP[i1] * NN + ks - 1] +
2794
+ csRe[GRAY_MAP[i2] * NN + ks] +
2795
+ csRe[GRAY_MAP[i3] * NN + ks + 1];
2796
+ const sIm = csIm[GRAY_MAP[i1] * NN + ks - 1] +
2797
+ csIm[GRAY_MAP[i2] * NN + ks] +
2798
+ csIm[GRAY_MAP[i3] * NN + ks + 1];
2799
+ s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
2800
+ }
2801
+ }
2802
+ // Fortran: i32 = 1 + (k-1)*3 + (ihalf-1)*87 (1-based)
2803
+ const i32 = 1 + (k - 1) * 3 + (ihalf - 1) * 87;
2804
+ for (let ib = 0; ib <= ibmax; ib++) {
2805
+ // max of s2 where bit (ibmax-ib) of index is 1
2806
+ let max1 = -1e30, max0 = -1e30;
2807
+ for (let i = 0; i < nt; i++) {
2808
+ const bitSet = (i & (1 << (ibmax - ib))) !== 0;
2809
+ if (bitSet) {
2810
+ if (s2[i] > max1)
2811
+ max1 = s2[i];
2812
+ }
2813
+ else {
2814
+ if (s2[i] > max0)
2815
+ max0 = s2[i];
2816
+ }
2817
+ }
2818
+ const idx = i32 + ib - 1; // Convert to 0-based
2819
+ if (idx >= 0 && idx < N_LDPC) {
2820
+ const bm = max1 - max0;
2821
+ if (nsym === 1) {
2822
+ bmeta[idx] = bm;
2823
+ const den = Math.max(max1, max0);
2824
+ bmetd[idx] = den > 0 ? bm / den : 0;
2825
+ }
2826
+ else if (nsym === 2) {
2827
+ bmetb[idx] = bm;
2828
+ }
2829
+ else {
2830
+ bmetc[idx] = bm;
2831
+ }
2832
+ }
2833
+ }
2834
+ }
2835
+ }
2836
+ }
2837
+ normalizeBmet(bmeta);
2838
+ normalizeBmet(bmetb);
2839
+ normalizeBmet(bmetc);
2840
+ normalizeBmet(bmetd);
2841
+ const bmetrics = [bmeta, bmetb, bmetc, bmetd];
2842
+ const scalefac = 2.83;
2843
+ const maxosd = depth >= 3 ? 2 : depth >= 2 ? 0 : -1;
2844
+ const apmask = new Int8Array(N_LDPC);
2845
+ // Try 4 passes with different soft-symbol metrics (matching Fortran)
2846
+ let result = null;
2847
+ for (let ipass = 0; ipass < 4; ipass++) {
2848
+ const llr = new Float64Array(N_LDPC);
2849
+ for (let i = 0; i < N_LDPC; i++)
2850
+ llr[i] = scalefac * bmetrics[ipass][i];
2851
+ result = decode174_91(llr, apmask, maxosd);
2852
+ if (result && result.nharderrors >= 0 && result.nharderrors <= 36)
2853
+ break;
2854
+ result = null;
2182
2855
  }
2183
- const codeword = [...msg91];
2184
- for (let i = 0; i < 83; i++) {
2185
- let sum = 0;
2186
- for (let j = 0; j < 91; j++) {
2187
- sum += msg91[j] * G[i][j];
2188
- }
2189
- codeword.push(sum % 2);
2856
+ if (!result || result.nharderrors < 0 || result.nharderrors > 36)
2857
+ return null;
2858
+ // Check for all-zero codeword
2859
+ if (result.cw.every((b) => b === 0))
2860
+ return null;
2861
+ const message77 = result.message91.slice(0, 77);
2862
+ // Validate message type
2863
+ const n3v = (message77[71] << 2) | (message77[72] << 1) | message77[73];
2864
+ const i3v = (message77[74] << 2) | (message77[75] << 1) | message77[76];
2865
+ if (i3v > 5 || (i3v === 0 && n3v > 6))
2866
+ return null;
2867
+ if (i3v === 0 && n3v === 2)
2868
+ return null;
2869
+ // Unpack
2870
+ const { msg, success } = unpack77(message77, book);
2871
+ if (!success || msg.trim().length === 0)
2872
+ return null;
2873
+ // Estimate SNR
2874
+ let xsig = 0;
2875
+ let xnoi = 0;
2876
+ const itone = getTones(result.cw);
2877
+ for (let i = 0; i < 79; i++) {
2878
+ xsig += s8[itone[i] * NN + i] ** 2;
2879
+ const ios = (itone[i] + 4) % 7;
2880
+ xnoi += s8[ios * NN + i] ** 2;
2190
2881
  }
2191
- return codeword;
2882
+ let snr = 0.001;
2883
+ const arg = xsig / Math.max(xnoi, 1e-30) - 1.0;
2884
+ if (arg > 0.1)
2885
+ snr = arg;
2886
+ snr = 10 * Math.log10(snr) - 27.0;
2887
+ if (snr < -24)
2888
+ snr = -24;
2889
+ return { msg, freq: f1, dt: xdt, snr };
2192
2890
  }
2193
- function getTones(codeword) {
2891
+ function getTones(cw) {
2194
2892
  const tones = new Array(79).fill(0);
2195
2893
  for (let i = 0; i < 7; i++)
2196
- tones[i] = icos7[i];
2894
+ tones[i] = COSTAS[i];
2197
2895
  for (let i = 0; i < 7; i++)
2198
- tones[36 + i] = icos7[i];
2896
+ tones[36 + i] = COSTAS[i];
2199
2897
  for (let i = 0; i < 7; i++)
2200
- tones[72 + i] = icos7[i];
2898
+ tones[72 + i] = COSTAS[i];
2201
2899
  let k = 7;
2202
2900
  for (let j = 1; j <= 58; j++) {
2203
- const i = j * 3 - 3; // codeword is 0-indexed in JS, but the loop was j=1 to 58
2901
+ const i = (j - 1) * 3;
2204
2902
  if (j === 30)
2205
2903
  k += 7;
2206
- const indx = codeword[i] * 4 + codeword[i + 1] * 2 + codeword[i + 2];
2207
- tones[k] = graymap[indx];
2904
+ const indx = cw[i] * 4 + cw[i + 1] * 2 + cw[i + 2];
2905
+ tones[k] = GRAY_MAP[indx];
2208
2906
  k++;
2209
2907
  }
2210
2908
  return tones;
2211
2909
  }
2212
- function encodeMessage(msg) {
2213
- const bits77 = pack77(msg);
2214
- const codeword = encode174_91(bits77);
2215
- return getTones(codeword);
2910
+ /**
2911
+ * Mix f0 to baseband and decimate by NDOWN (60x) by extracting frequency bins.
2912
+ * Identical to Fortran ft8_downsample.
2913
+ */
2914
+ function ft8Downsample(cxRe, cxIm, f0, c1Re, c1Im) {
2915
+ const NFFT1 = 192000;
2916
+ const NFFT2 = 3200;
2917
+ const df = 12000.0 / NFFT1;
2918
+ // NSPS is imported, should be 1920
2919
+ const baud = 12000.0 / NSPS; // 6.25
2920
+ const i0 = Math.round(f0 / df);
2921
+ const ft = f0 + 8.5 * baud;
2922
+ const it = Math.min(Math.round(ft / df), NFFT1 / 2);
2923
+ const fb = f0 - 1.5 * baud;
2924
+ const ib = Math.max(1, Math.round(fb / df));
2925
+ c1Re.fill(0);
2926
+ c1Im.fill(0);
2927
+ let k = 0;
2928
+ for (let i = ib; i <= it; i++) {
2929
+ if (k >= NFFT2)
2930
+ break;
2931
+ c1Re[k] = cxRe[i] ?? 0;
2932
+ c1Im[k] = cxIm[i] ?? 0;
2933
+ k++;
2934
+ }
2935
+ // Taper
2936
+ const pi = Math.PI;
2937
+ const taper = new Float64Array(101);
2938
+ for (let i = 0; i <= 100; i++) {
2939
+ taper[i] = 0.5 * (1.0 + Math.cos((i * pi) / 100));
2940
+ }
2941
+ for (let i = 0; i <= 100; i++) {
2942
+ if (i >= NFFT2)
2943
+ break;
2944
+ const tap = taper[100 - i];
2945
+ c1Re[i] = c1Re[i] * tap;
2946
+ c1Im[i] = c1Im[i] * tap;
2947
+ }
2948
+ const endTap = k - 1;
2949
+ for (let i = 0; i <= 100; i++) {
2950
+ const idx = endTap - 100 + i;
2951
+ if (idx >= 0 && idx < NFFT2) {
2952
+ const tap = taper[i];
2953
+ c1Re[idx] = c1Re[idx] * tap;
2954
+ c1Im[idx] = c1Im[idx] * tap;
2955
+ }
2956
+ }
2957
+ // CSHIFT
2958
+ const shift = i0 - ib;
2959
+ const tempRe = new Float64Array(NFFT2);
2960
+ const tempIm = new Float64Array(NFFT2);
2961
+ for (let i = 0; i < NFFT2; i++) {
2962
+ let srcIdx = (i + shift) % NFFT2;
2963
+ if (srcIdx < 0)
2964
+ srcIdx += NFFT2;
2965
+ tempRe[i] = c1Re[srcIdx];
2966
+ tempIm[i] = c1Im[srcIdx];
2967
+ }
2968
+ for (let i = 0; i < NFFT2; i++) {
2969
+ c1Re[i] = tempRe[i];
2970
+ c1Im[i] = tempIm[i];
2971
+ }
2972
+ // iFFT
2973
+ fftComplex(c1Re, c1Im, true);
2974
+ // Scale
2975
+ // Fortran uses 1.0/sqrt(NFFT1 * NFFT2), but our fftComplex(true) scales by 1/NFFT2
2976
+ const scale = Math.sqrt(NFFT2 / NFFT1);
2977
+ for (let i = 0; i < NFFT2; i++) {
2978
+ c1Re[i] = c1Re[i] * scale;
2979
+ c1Im[i] = c1Im[i] * scale;
2980
+ }
2216
2981
  }
2217
- function encode(msg, options = {}) {
2218
- return generateFT8Waveform(encodeMessage(msg), options);
2982
+ function sync8d(cd0Re, cd0Im, i0, twkRe, twkIm, useTwk) {
2983
+ const NP2 = 2812;
2984
+ const twopi = 2 * Math.PI;
2985
+ // Precompute Costas sync waveforms
2986
+ const csyncRe = new Float64Array(7 * 32);
2987
+ const csyncIm = new Float64Array(7 * 32);
2988
+ for (let i = 0; i < 7; i++) {
2989
+ let phi = 0;
2990
+ const dphi = (twopi * COSTAS[i]) / 32;
2991
+ for (let j = 0; j < 32; j++) {
2992
+ csyncRe[i * 32 + j] = Math.cos(phi);
2993
+ csyncIm[i * 32 + j] = Math.sin(phi);
2994
+ phi = (phi + dphi) % twopi;
2995
+ }
2996
+ }
2997
+ let sync = 0;
2998
+ for (let i = 0; i < 7; i++) {
2999
+ const i1 = i0 + i * 32;
3000
+ const i2 = i1 + 36 * 32;
3001
+ const i3 = i1 + 72 * 32;
3002
+ for (const iStart of [i1, i2, i3]) {
3003
+ let zRe = 0, zIm = 0;
3004
+ if (iStart >= 0 && iStart + 31 < NP2) {
3005
+ for (let j = 0; j < 32; j++) {
3006
+ let sRe = csyncRe[i * 32 + j];
3007
+ let sIm = csyncIm[i * 32 + j];
3008
+ if (useTwk && twkRe && twkIm) {
3009
+ const tRe = twkRe[j] * sRe - twkIm[j] * sIm;
3010
+ const tIm = twkRe[j] * sIm + twkIm[j] * sRe;
3011
+ sRe = tRe;
3012
+ sIm = tIm;
3013
+ }
3014
+ // Conjugate multiply: cd0 * conj(csync)
3015
+ const dRe = cd0Re[iStart + j];
3016
+ const dIm = cd0Im[iStart + j];
3017
+ zRe += dRe * sRe + dIm * sIm;
3018
+ zIm += dIm * sRe - dRe * sIm;
3019
+ }
3020
+ }
3021
+ sync += zRe * zRe + zIm * zIm;
3022
+ }
3023
+ }
3024
+ return sync;
3025
+ }
3026
+ function normalizeBmet(bmet) {
3027
+ const n = bmet.length;
3028
+ let sum = 0, sum2 = 0;
3029
+ for (let i = 0; i < n; i++) {
3030
+ sum += bmet[i];
3031
+ sum2 += bmet[i] * bmet[i];
3032
+ }
3033
+ const avg = sum / n;
3034
+ const avg2 = sum2 / n;
3035
+ const variance = avg2 - avg * avg;
3036
+ const sigma = variance > 0 ? Math.sqrt(variance) : Math.sqrt(avg2);
3037
+ if (sigma > 0) {
3038
+ for (let i = 0; i < n; i++)
3039
+ bmet[i] = bmet[i] / sigma;
3040
+ }
3041
+ }
3042
+ function resample(input, fromRate, toRate, outLen) {
3043
+ const out = new Float64Array(outLen);
3044
+ const ratio = fromRate / toRate;
3045
+ for (let i = 0; i < outLen; i++) {
3046
+ const srcIdx = i * ratio;
3047
+ const lo = Math.floor(srcIdx);
3048
+ const frac = srcIdx - lo;
3049
+ const v0 = lo < input.length ? (input[lo] ?? 0) : 0;
3050
+ const v1 = lo + 1 < input.length ? (input[lo + 1] ?? 0) : 0;
3051
+ out[i] = v0 * (1 - frac) + v1 * frac;
3052
+ }
3053
+ return out;
2219
3054
  }
2220
3055
 
2221
3056
  /// <reference types="node" />
@@ -2341,6 +3176,7 @@ Usage:
2341
3176
  ft8ts encode "<message>" [options]
2342
3177
 
2343
3178
  Decode options:
3179
+ --mode <ft8|ft4> Mode: ft8 (default) or ft4
2344
3180
  --low <hz> Lower frequency bound (default: 200)
2345
3181
  --high <hz> Upper frequency bound (default: 3000)
2346
3182
  --depth <1|2|3> Decoding depth (default: 2)
@@ -2364,9 +3200,19 @@ function runDecode(argv) {
2364
3200
  }
2365
3201
  const wavFile = argv[0];
2366
3202
  const options = {};
3203
+ let mode = "ft8";
2367
3204
  for (let i = 1; i < argv.length; i++) {
2368
3205
  const arg = argv[i];
2369
- if (arg === "--low") {
3206
+ if (arg === "--mode") {
3207
+ const value = argv[++i];
3208
+ if (value === "ft8" || value === "ft4") {
3209
+ mode = value;
3210
+ }
3211
+ else {
3212
+ throw new Error(`Invalid --mode: ${value ?? "(missing)"}. Use ft8 or ft4`);
3213
+ }
3214
+ }
3215
+ else if (arg === "--low") {
2370
3216
  options.freqLow = Number(argv[++i]);
2371
3217
  }
2372
3218
  else if (arg === "--high") {
@@ -2384,7 +3230,9 @@ function runDecode(argv) {
2384
3230
  const { sampleRate, samples } = parseWavBuffer(readFileSync(filePath));
2385
3231
  console.log(`WAV: ${sampleRate} Hz, ${samples.length} samples, ${(samples.length / sampleRate).toFixed(1)}s`);
2386
3232
  const startTime = performance.now();
2387
- const decoded = decode(samples, { ...options, sampleRate });
3233
+ const decoded = mode === "ft4"
3234
+ ? decode$1(samples, { ...options, sampleRate })
3235
+ : decode(samples, { ...options, sampleRate });
2388
3236
  const elapsed = performance.now() - startTime;
2389
3237
  console.log(`\nDecoded ${decoded.length} messages in ${(elapsed / 1000).toFixed(2)}s:\n`);
2390
3238
  console.log(" dt snr freq message");