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