@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.
@@ -0,0 +1,40 @@
1
+ import { encode174_91 } from "../ft8/encode.js";
2
+ import { pack77 } from "../util/pack_jt77.js";
3
+ import { generateFT4Waveform, type WaveformOptions } from "../util/waveform.js";
4
+ import { COSTAS_A, COSTAS_B, COSTAS_C, COSTAS_D, GRAYMAP } from "./constants.js";
5
+ import { xorWithScrambler } from "./scramble.js";
6
+
7
+ /**
8
+ * Convert FT4 LDPC codeword bits into 103 channel tones.
9
+ * Port of lib/ft4/genft4.f90.
10
+ */
11
+ export function getTones(codeword: readonly number[]): number[] {
12
+ const dataTones = new Array<number>(87);
13
+ for (let i = 0; i < 87; i++) {
14
+ const b0 = codeword[2 * i] ?? 0;
15
+ const b1 = codeword[2 * i + 1] ?? 0;
16
+ const symbol = b1 + 2 * b0;
17
+ dataTones[i] = GRAYMAP[symbol]!;
18
+ }
19
+
20
+ const tones = new Array<number>(103);
21
+ tones.splice(0, 4, ...COSTAS_A);
22
+ tones.splice(4, 29, ...dataTones.slice(0, 29));
23
+ tones.splice(33, 4, ...COSTAS_B);
24
+ tones.splice(37, 29, ...dataTones.slice(29, 58));
25
+ tones.splice(66, 4, ...COSTAS_C);
26
+ tones.splice(70, 29, ...dataTones.slice(58, 87));
27
+ tones.splice(99, 4, ...COSTAS_D);
28
+ return tones;
29
+ }
30
+
31
+ export function encodeMessage(msg: string): number[] {
32
+ const bits77 = pack77(msg);
33
+ const scrambled = xorWithScrambler(bits77);
34
+ const codeword = encode174_91(scrambled);
35
+ return getTones(codeword);
36
+ }
37
+
38
+ export function encode(msg: string, options: WaveformOptions = {}): Float32Array {
39
+ return generateFT4Waveform(encodeMessage(msg), options);
40
+ }
@@ -0,0 +1,9 @@
1
+ import { RVEC } from "./constants.js";
2
+
3
+ export function xorWithScrambler(bits77: readonly number[]): number[] {
4
+ const out = new Array<number>(77);
5
+ for (let i = 0; i < 77; i++) {
6
+ out[i] = ((bits77[i] ?? 0) + RVEC[i]!) & 1;
7
+ }
8
+ return out;
9
+ }
@@ -0,0 +1,18 @@
1
+ /** FT8-specific constants (lib/ft8/ft8_params.f90). */
2
+
3
+ export const NSPS = 1920;
4
+ export const NFFT1 = 2 * NSPS; // 3840
5
+ export const NH1 = NFFT1 / 2; // 1920
6
+ export const NSTEP = NSPS / 4; // 480
7
+ export const NMAX = 15 * 12_000; // 180000
8
+ export const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
9
+ export const NDOWN = 60;
10
+ export const NN = 79;
11
+ export const NS = 21;
12
+ export const ND = 58;
13
+
14
+ /** 7-symbol Costas array for sync. */
15
+ export const COSTAS = [3, 1, 4, 0, 6, 5, 2] as const;
16
+
17
+ /** 8-tone Gray mapping. */
18
+ export const GRAY_MAP = [0, 1, 3, 2, 5, 6, 4, 7] as const;
package/src/ft8/decode.ts CHANGED
@@ -1,20 +1,9 @@
1
- import {
2
- graymap,
3
- icos7,
4
- N_LDPC,
5
- NDOWN,
6
- NFFT1,
7
- NHSYM,
8
- NMAX,
9
- NN,
10
- NSPS,
11
- NSTEP,
12
- SAMPLE_RATE,
13
- } from "../util/constants.js";
1
+ import { N_LDPC, SAMPLE_RATE } from "../util/constants.js";
14
2
  import { decode174_91 } from "../util/decode174_91.js";
15
3
  import { fftComplex, nextPow2 } from "../util/fft.js";
16
4
  import type { HashCallBook } from "../util/hashcall.js";
17
5
  import { unpack77 } from "../util/unpack_jt77.js";
6
+ import { COSTAS, GRAY_MAP, NDOWN, NFFT1, NHSYM, NMAX, NN, NSPS, NSTEP } from "./constants.js";
18
7
 
19
8
  export interface DecodedMessage {
20
9
  freq: number;
@@ -174,7 +163,7 @@ function sync8(
174
163
 
175
164
  for (let n = 0; n < 7; n++) {
176
165
  const m = jj + jstrt + nssy * n;
177
- const iCostas = i + nfos * icos7[n]!;
166
+ const iCostas = i + nfos * COSTAS[n]!;
178
167
 
179
168
  if (m >= 0 && m < NHSYM && iCostas < halfSize) {
180
169
  ta += s[iCostas * NHSYM + m]!;
@@ -440,7 +429,7 @@ function ft8b(
440
429
  maxTone = t;
441
430
  }
442
431
  }
443
- if (maxTone === icos7[k]) nsync++;
432
+ if (maxTone === COSTAS[k]) nsync++;
444
433
  }
445
434
  }
446
435
  if (nsync <= 6) return null;
@@ -466,22 +455,22 @@ function ft8b(
466
455
  const i2 = Math.floor((i & 63) / 8);
467
456
  const i3 = i & 7;
468
457
  if (nsym === 1) {
469
- const re = csRe[graymap[i3]! * NN + ks - 1]!;
470
- const im = csIm[graymap[i3]! * NN + ks - 1]!;
458
+ const re = csRe[GRAY_MAP[i3]! * NN + ks - 1]!;
459
+ const im = csIm[GRAY_MAP[i3]! * NN + ks - 1]!;
471
460
  s2[i] = Math.sqrt(re * re + im * im);
472
461
  } else if (nsym === 2) {
473
- const sRe = csRe[graymap[i2]! * NN + ks - 1]! + csRe[graymap[i3]! * NN + ks]!;
474
- const sIm = csIm[graymap[i2]! * NN + ks - 1]! + csIm[graymap[i3]! * NN + ks]!;
462
+ const sRe = csRe[GRAY_MAP[i2]! * NN + ks - 1]! + csRe[GRAY_MAP[i3]! * NN + ks]!;
463
+ const sIm = csIm[GRAY_MAP[i2]! * NN + ks - 1]! + csIm[GRAY_MAP[i3]! * NN + ks]!;
475
464
  s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
476
465
  } else {
477
466
  const sRe =
478
- csRe[graymap[i1]! * NN + ks - 1]! +
479
- csRe[graymap[i2]! * NN + ks]! +
480
- csRe[graymap[i3]! * NN + ks + 1]!;
467
+ csRe[GRAY_MAP[i1]! * NN + ks - 1]! +
468
+ csRe[GRAY_MAP[i2]! * NN + ks]! +
469
+ csRe[GRAY_MAP[i3]! * NN + ks + 1]!;
481
470
  const sIm =
482
- csIm[graymap[i1]! * NN + ks - 1]! +
483
- csIm[graymap[i2]! * NN + ks]! +
484
- csIm[graymap[i3]! * NN + ks + 1]!;
471
+ csIm[GRAY_MAP[i1]! * NN + ks - 1]! +
472
+ csIm[GRAY_MAP[i2]! * NN + ks]! +
473
+ csIm[GRAY_MAP[i3]! * NN + ks + 1]!;
485
474
  s2[i] = Math.sqrt(sRe * sRe + sIm * sIm);
486
475
  }
487
476
  }
@@ -577,15 +566,15 @@ function ft8b(
577
566
 
578
567
  function getTones(cw: number[]): number[] {
579
568
  const tones = new Array(79).fill(0) as number[];
580
- for (let i = 0; i < 7; i++) tones[i] = icos7[i]!;
581
- for (let i = 0; i < 7; i++) tones[36 + i] = icos7[i]!;
582
- for (let i = 0; i < 7; i++) tones[72 + i] = icos7[i]!;
569
+ for (let i = 0; i < 7; i++) tones[i] = COSTAS[i]!;
570
+ for (let i = 0; i < 7; i++) tones[36 + i] = COSTAS[i]!;
571
+ for (let i = 0; i < 7; i++) tones[72 + i] = COSTAS[i]!;
583
572
  let k = 7;
584
573
  for (let j = 1; j <= 58; j++) {
585
574
  const i = (j - 1) * 3;
586
575
  if (j === 30) k += 7;
587
576
  const indx = cw[i]! * 4 + cw[i + 1]! * 2 + cw[i + 2]!;
588
- tones[k] = graymap[indx]!;
577
+ tones[k] = GRAY_MAP[indx]!;
589
578
  k++;
590
579
  }
591
580
  return tones;
@@ -691,7 +680,7 @@ function sync8d(
691
680
  const csyncIm = new Float64Array(7 * 32);
692
681
  for (let i = 0; i < 7; i++) {
693
682
  let phi = 0;
694
- const dphi = (twopi * icos7[i]!) / 32;
683
+ const dphi = (twopi * COSTAS[i]!) / 32;
695
684
  for (let j = 0; j < 32; j++) {
696
685
  csyncRe[i * 32 + j] = Math.cos(phi);
697
686
  csyncIm[i * 32 + j] = Math.sin(phi);
package/src/ft8/encode.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { gHex, graymap, icos7 } from "../util/constants.js";
1
+ import { gHex } from "../util/constants.js";
2
2
  import { pack77 } from "../util/pack_jt77.js";
3
3
  import { generateFT8Waveform, type WaveformOptions } from "../util/waveform.js";
4
+ import { COSTAS, GRAY_MAP } from "./constants.js";
4
5
 
5
6
  function generateLdpcGMatrix(): number[][] {
6
7
  const K = 91;
@@ -59,16 +60,16 @@ export function encode174_91(msg77: number[]): number[] {
59
60
  export function getTones(codeword: number[]): number[] {
60
61
  const tones = new Array(79).fill(0);
61
62
 
62
- for (let i = 0; i < 7; i++) tones[i] = icos7[i]!;
63
- for (let i = 0; i < 7; i++) tones[36 + i] = icos7[i]!;
64
- for (let i = 0; i < 7; i++) tones[72 + i] = icos7[i]!;
63
+ for (let i = 0; i < 7; i++) tones[i] = COSTAS[i]!;
64
+ for (let i = 0; i < 7; i++) tones[36 + i] = COSTAS[i]!;
65
+ for (let i = 0; i < 7; i++) tones[72 + i] = COSTAS[i]!;
65
66
 
66
67
  let k = 7;
67
68
  for (let j = 1; j <= 58; j++) {
68
69
  const i = j * 3 - 3; // codeword is 0-indexed in JS, but the loop was j=1 to 58
69
70
  if (j === 30) k += 7;
70
71
  const indx = codeword[i]! * 4 + codeword[i + 1]! * 2 + codeword[i + 2]!;
71
- tones[k] = graymap[indx]!;
72
+ tones[k] = GRAY_MAP[indx]!;
72
73
  k++;
73
74
  }
74
75
  return tones;
package/src/index.ts CHANGED
@@ -1,3 +1,9 @@
1
+ export {
2
+ type DecodedMessage as DecodedFT4Message,
3
+ type DecodeOptions as DecodeFT4Options,
4
+ decode as decodeFT4,
5
+ } from "./ft4/decode.js";
6
+ export { encode as encodeFT4 } from "./ft4/encode.js";
1
7
  export { type DecodedMessage, type DecodeOptions, decode as decodeFT8 } from "./ft8/decode.js";
2
8
  export { encode as encodeFT8 } from "./ft8/encode.js";
3
9
  export { HashCallBook } from "./util/hashcall.js";
@@ -1,21 +1,12 @@
1
+ /** Shared constants used by FT8, FT4, pack77, etc. */
2
+
1
3
  export const SAMPLE_RATE = 12_000;
2
- export const NSPS = 1920;
3
- export const NFFT1 = 2 * NSPS; // 3840
4
- export const NH1 = NFFT1 / 2; // 1920
5
- export const NSTEP = NSPS / 4; // 480
6
- export const NMAX = 15 * SAMPLE_RATE; // 180000
7
- export const NHSYM = Math.floor(NMAX / NSTEP) - 3; // 372
8
- export const NDOWN = 60;
9
- export const NN = 79;
10
- export const NS = 21;
11
- export const ND = 58;
4
+
5
+ /** LDPC(174,91) code (shared by FT8 and FT4). */
12
6
  export const KK = 91;
13
7
  export const N_LDPC = 174;
14
8
  export const M_LDPC = N_LDPC - KK; // 83
15
9
 
16
- export const icos7 = [3, 1, 4, 0, 6, 5, 2] as const;
17
- export const graymap = [0, 1, 3, 2, 5, 6, 4, 7] as const;
18
-
19
10
  export const gHex = [
20
11
  "8329ce11bf31eaf509f27fc",
21
12
  "761c264e25c259335493132",
@@ -1,7 +1,13 @@
1
1
  const TWO_PI = 2 * Math.PI;
2
- const DEFAULT_SAMPLE_RATE = 12_000;
3
- const DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
4
- const DEFAULT_BT = 2.0;
2
+ const FT8_DEFAULT_SAMPLE_RATE = 12_000;
3
+ const FT8_DEFAULT_SAMPLES_PER_SYMBOL = 1_920;
4
+ const FT8_DEFAULT_BT = 2.0;
5
+
6
+ import { NSPS as FT4_NSPS } from "../ft4/constants.js";
7
+
8
+ const FT4_DEFAULT_SAMPLE_RATE = 12_000;
9
+ const FT4_DEFAULT_SAMPLES_PER_SYMBOL = FT4_NSPS;
10
+ const FT4_DEFAULT_BT = 1.0;
5
11
  const MODULATION_INDEX = 1.0;
6
12
 
7
13
  export interface WaveformOptions {
@@ -11,6 +17,17 @@ export interface WaveformOptions {
11
17
  baseFrequency?: number;
12
18
  }
13
19
 
20
+ interface WaveformDefaults {
21
+ sampleRate: number;
22
+ samplesPerSymbol: number;
23
+ bt: number;
24
+ }
25
+
26
+ interface WaveformShape {
27
+ includeRampSymbols: boolean;
28
+ fullSymbolRamp: boolean;
29
+ }
30
+
14
31
  function assertPositiveFinite(value: number, name: string): void {
15
32
  if (!Number.isFinite(value) || value <= 0) {
16
33
  throw new Error(`${name} must be a positive finite number`);
@@ -36,19 +53,20 @@ function gfskPulse(bt: number, tt: number): number {
36
53
  return 0.5 * (erfApprox(scale * (tt + 0.5)) - erfApprox(scale * (tt - 0.5)));
37
54
  }
38
55
 
39
- export function generateFT8Waveform(
56
+ function generateGfskWaveform(
40
57
  tones: readonly number[],
41
- options: WaveformOptions = {},
58
+ options: WaveformOptions,
59
+ defaults: WaveformDefaults,
60
+ shape: WaveformShape,
42
61
  ): Float32Array {
43
- // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
44
62
  const nsym = tones.length;
45
63
  if (nsym === 0) {
46
64
  return new Float32Array(0);
47
65
  }
48
66
 
49
- const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
50
- const nsps = options.samplesPerSymbol ?? DEFAULT_SAMPLES_PER_SYMBOL;
51
- const bt = options.bt ?? DEFAULT_BT;
67
+ const sampleRate = options.sampleRate ?? defaults.sampleRate;
68
+ const nsps = options.samplesPerSymbol ?? defaults.samplesPerSymbol;
69
+ const bt = options.bt ?? defaults.bt;
52
70
  const f0 = options.baseFrequency ?? 0;
53
71
 
54
72
  assertPositiveFinite(sampleRate, "sampleRate");
@@ -61,7 +79,7 @@ export function generateFT8Waveform(
61
79
  throw new Error("samplesPerSymbol must be an integer");
62
80
  }
63
81
 
64
- const nwave = nsym * nsps;
82
+ const nwave = (shape.includeRampSymbols ? nsym + 2 : nsym) * nsps;
65
83
  const pulse = new Float64Array(3 * nsps);
66
84
  for (let i = 0; i < pulse.length; i++) {
67
85
  const tt = (i + 1 - 1.5 * nsps) / nsps;
@@ -94,8 +112,9 @@ export function generateFT8Waveform(
94
112
 
95
113
  const wave = new Float32Array(nwave);
96
114
  let phi = 0;
115
+ const phaseStart = shape.includeRampSymbols ? 0 : nsps;
97
116
  for (let k = 0; k < nwave; k++) {
98
- const j = nsps + k; // skip the leading dummy symbol
117
+ const j = phaseStart + k;
99
118
  wave[k] = Math.sin(phi);
100
119
  phi += dphi[j]!;
101
120
  phi %= TWO_PI;
@@ -104,17 +123,70 @@ export function generateFT8Waveform(
104
123
  }
105
124
  }
106
125
 
107
- const nramp = Math.round(nsps / 8);
108
- for (let i = 0; i < nramp; i++) {
109
- const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
110
- wave[i]! *= up;
111
- }
126
+ if (shape.fullSymbolRamp) {
127
+ for (let i = 0; i < nsps; i++) {
128
+ const up = (1 - Math.cos((TWO_PI * i) / (2 * nsps))) / 2;
129
+ wave[i]! *= up;
130
+ }
131
+
132
+ const tailStart = (nsym + 1) * nsps;
133
+ for (let i = 0; i < nsps; i++) {
134
+ const down = (1 + Math.cos((TWO_PI * i) / (2 * nsps))) / 2;
135
+ wave[tailStart + i]! *= down;
136
+ }
137
+ } else {
138
+ const nramp = Math.round(nsps / 8);
139
+ for (let i = 0; i < nramp; i++) {
140
+ const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
141
+ wave[i]! *= up;
142
+ }
112
143
 
113
- const tailStart = nwave - nramp;
114
- for (let i = 0; i < nramp; i++) {
115
- const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
116
- wave[tailStart + i]! *= down;
144
+ const tailStart = nwave - nramp;
145
+ for (let i = 0; i < nramp; i++) {
146
+ const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
147
+ wave[tailStart + i]! *= down;
148
+ }
117
149
  }
118
150
 
119
151
  return wave;
120
152
  }
153
+
154
+ export function generateFT8Waveform(
155
+ tones: readonly number[],
156
+ options: WaveformOptions = {},
157
+ ): Float32Array {
158
+ // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
159
+ return generateGfskWaveform(
160
+ tones,
161
+ options,
162
+ {
163
+ sampleRate: FT8_DEFAULT_SAMPLE_RATE,
164
+ samplesPerSymbol: FT8_DEFAULT_SAMPLES_PER_SYMBOL,
165
+ bt: FT8_DEFAULT_BT,
166
+ },
167
+ {
168
+ includeRampSymbols: false,
169
+ fullSymbolRamp: false,
170
+ },
171
+ );
172
+ }
173
+
174
+ export function generateFT4Waveform(
175
+ tones: readonly number[],
176
+ options: WaveformOptions = {},
177
+ ): Float32Array {
178
+ // Mirrors lib/ft4/gen_ft4wave.f90.
179
+ return generateGfskWaveform(
180
+ tones,
181
+ options,
182
+ {
183
+ sampleRate: FT4_DEFAULT_SAMPLE_RATE,
184
+ samplesPerSymbol: FT4_DEFAULT_SAMPLES_PER_SYMBOL,
185
+ bt: FT4_DEFAULT_BT,
186
+ },
187
+ {
188
+ includeRampSymbols: true,
189
+ fullSymbolRamp: true,
190
+ },
191
+ );
192
+ }