@e04/ft8ts 0.0.8 → 0.0.10

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/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,20 +1,9 @@
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;
12
- export const KK = 91;
13
- export const N_LDPC = 174;
14
- export const M_LDPC = N_LDPC - KK; // 83
15
4
 
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;
5
+ /** LDPC(174,91) code (shared by FT8 and FT4). */
6
+ export const N_LDPC = 174;
18
7
 
19
8
  export const gHex = [
20
9
  "8329ce11bf31eaf509f27fc",
@@ -102,8 +91,6 @@ export const gHex = [
102
91
  "608cc857594bfbb55d69600",
103
92
  ];
104
93
 
105
- export const CRC_POLY = 0x2757;
106
-
107
94
  export const FTALPH = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?";
108
95
 
109
96
  export const A1 = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -3,10 +3,13 @@
3
3
  * Port of bpdecode174_91.f90 and decode174_91.f90.
4
4
  */
5
5
 
6
- import { gHex, KK, M_LDPC, N_LDPC } from "./constants.js";
6
+ import { gHex, N_LDPC } from "./constants.js";
7
7
  import { checkCRC14 } from "./crc.js";
8
8
  import { Mn, Nm, ncw, nrw } from "./ldpc_tables.js";
9
9
 
10
+ const KK = 91;
11
+ const M_LDPC = N_LDPC - KK; // 83
12
+
10
13
  export interface DecodeResult {
11
14
  message91: number[];
12
15
  cw: number[];
@@ -183,39 +186,48 @@ function osdDecode174_91(
183
186
  const K = KK;
184
187
 
185
188
  const gen = getGenerator();
189
+ const absllr = new Float64Array(N);
190
+ for (let i = 0; i < N; i++) absllr[i] = Math.abs(llr[i]!);
186
191
 
187
192
  // Sort by reliability (descending)
188
- const indices = Array.from({ length: N }, (_, i) => i);
189
- indices.sort((a, b) => Math.abs(llr[b]!) - Math.abs(llr[a]!));
193
+ const indices = new Array<number>(N);
194
+ for (let i = 0; i < N; i++) indices[i] = i;
195
+ indices.sort((a, b) => absllr[b]! - absllr[a]!);
190
196
 
191
197
  // Reorder generator matrix columns
192
198
  const genmrb = new Uint8Array(K * N);
193
- for (let i = 0; i < N; i++) {
194
- for (let k = 0; k < K; k++) {
195
- genmrb[k * N + i] = gen[k * N + indices[i]!]!;
199
+ for (let k = 0; k < K; k++) {
200
+ const row = k * N;
201
+ for (let i = 0; i < N; i++) {
202
+ genmrb[row + i] = gen[row + indices[i]!]!;
196
203
  }
197
204
  }
198
205
 
199
206
  // Gaussian elimination to get systematic form on the K most-reliable bits
207
+ const maxPivotCol = Math.min(K + 20, N);
200
208
  for (let id = 0; id < K; id++) {
201
209
  let found = false;
202
- for (let icol = id; icol < Math.min(K + 20, N); icol++) {
203
- if (genmrb[id * N + icol] === 1) {
210
+ const idRow = id * N;
211
+ for (let icol = id; icol < maxPivotCol; icol++) {
212
+ if (genmrb[idRow + icol] === 1) {
204
213
  if (icol !== id) {
205
214
  // Swap columns
206
215
  for (let k = 0; k < K; k++) {
207
- const tmp = genmrb[k * N + id]!;
208
- genmrb[k * N + id] = genmrb[k * N + icol]!;
209
- genmrb[k * N + icol] = tmp;
216
+ const row = k * N;
217
+ const tmp = genmrb[row + id]!;
218
+ genmrb[row + id] = genmrb[row + icol]!;
219
+ genmrb[row + icol] = tmp;
210
220
  }
211
221
  const tmp = indices[id]!;
212
222
  indices[id] = indices[icol]!;
213
223
  indices[icol] = tmp;
214
224
  }
215
225
  for (let ii = 0; ii < K; ii++) {
216
- if (ii !== id && genmrb[ii * N + id] === 1) {
226
+ if (ii === id) continue;
227
+ const iiRow = ii * N;
228
+ if (genmrb[iiRow + id] === 1) {
217
229
  for (let c = 0; c < N; c++) {
218
- genmrb[ii * N + c]! ^= genmrb[id * N + c]!;
230
+ genmrb[iiRow + c]! ^= genmrb[idRow + c]!;
219
231
  }
220
232
  }
221
233
  }
@@ -229,87 +241,82 @@ function osdDecode174_91(
229
241
  // Hard decisions on reordered received word
230
242
  const hdec = new Int8Array(N);
231
243
  for (let i = 0; i < N; i++) {
232
- hdec[i] = llr[indices[i]!]! >= 0 ? 1 : 0;
244
+ const idx = indices[i]!;
245
+ hdec[i] = llr[idx]! >= 0 ? 1 : 0;
233
246
  }
234
247
  const absrx = new Float64Array(N);
235
248
  for (let i = 0; i < N; i++) {
236
- absrx[i] = Math.abs(llr[indices[i]!]!);
249
+ absrx[i] = absllr[indices[i]!]!;
237
250
  }
238
251
 
239
- // Transpose of reordered gen matrix
240
- const g2 = new Uint8Array(N * K);
252
+ // Encode hard decision on MRB (c0): xor selected rows of genmrb.
253
+ const c0 = new Int8Array(N);
241
254
  for (let i = 0; i < K; i++) {
255
+ if (hdec[i] !== 1) continue;
256
+ const row = i * N;
242
257
  for (let j = 0; j < N; j++) {
243
- g2[j * K + i] = genmrb[i * N + j]!;
244
- }
245
- }
246
-
247
- function mrbencode(me: Int8Array): Int8Array {
248
- const codeword = new Int8Array(N);
249
- for (let i = 0; i < K; i++) {
250
- if (me[i] === 1) {
251
- for (let j = 0; j < N; j++) {
252
- codeword[j]! ^= g2[j * K + i]!;
253
- }
254
- }
258
+ c0[j]! ^= genmrb[row + j]!;
255
259
  }
256
- return codeword;
257
260
  }
258
261
 
259
- const m0 = hdec.slice(0, K);
260
- const c0 = mrbencode(m0);
261
- const bestCw = new Int8Array(c0);
262
262
  let dmin = 0;
263
263
  for (let i = 0; i < N; i++) {
264
264
  const x = c0[i]! ^ hdec[i]!;
265
265
  dmin += x * absrx[i]!;
266
266
  }
267
+ let bestFlip1 = -1;
268
+ let bestFlip2 = -1;
267
269
 
268
270
  // Order-1: flip single bits in the info portion
269
271
  for (let i1 = K - 1; i1 >= 0; i1--) {
270
272
  if (apmask[indices[i1]!] === 1) continue;
271
- const me = new Int8Array(m0);
272
- me[i1]! ^= 1;
273
- const ce = mrbencode(me);
274
- let _nh = 0;
273
+ const row1 = i1 * N;
275
274
  let dd = 0;
276
275
  for (let j = 0; j < N; j++) {
277
- const x = ce[j]! ^ hdec[j]!;
278
- _nh += x;
276
+ const x = c0[j]! ^ genmrb[row1 + j]! ^ hdec[j]!;
279
277
  dd += x * absrx[j]!;
280
278
  }
281
279
  if (dd < dmin) {
282
280
  dmin = dd;
283
- bestCw.set(ce);
281
+ bestFlip1 = i1;
282
+ bestFlip2 = -1;
284
283
  }
285
284
  }
286
285
 
287
286
  // Order-2: flip pairs of least-reliable info bits (limited search)
288
287
  if (norder >= 2) {
289
- const ntry = Math.min(40, K);
290
- for (let i1 = K - 1; i1 >= K - ntry; i1--) {
288
+ const ntry = Math.min(64, K);
289
+ const iMin = Math.max(0, K - ntry);
290
+ for (let i1 = K - 1; i1 >= iMin; i1--) {
291
291
  if (apmask[indices[i1]!] === 1) continue;
292
- for (let i2 = i1 - 1; i2 >= K - ntry; i2--) {
292
+ const row1 = i1 * N;
293
+ for (let i2 = i1 - 1; i2 >= iMin; i2--) {
293
294
  if (apmask[indices[i2]!] === 1) continue;
294
- const me = new Int8Array(m0);
295
- me[i1]! ^= 1;
296
- me[i2]! ^= 1;
297
- const ce = mrbencode(me);
298
- let _nh = 0;
295
+ const row2 = i2 * N;
299
296
  let dd = 0;
300
297
  for (let j = 0; j < N; j++) {
301
- const x = ce[j]! ^ hdec[j]!;
302
- _nh += x;
298
+ const x = c0[j]! ^ genmrb[row1 + j]! ^ genmrb[row2 + j]! ^ hdec[j]!;
303
299
  dd += x * absrx[j]!;
304
300
  }
305
301
  if (dd < dmin) {
306
302
  dmin = dd;
307
- bestCw.set(ce);
303
+ bestFlip1 = i1;
304
+ bestFlip2 = i2;
308
305
  }
309
306
  }
310
307
  }
311
308
  }
312
309
 
310
+ const bestCw = new Int8Array(c0);
311
+ if (bestFlip1 >= 0) {
312
+ const row1 = bestFlip1 * N;
313
+ for (let j = 0; j < N; j++) bestCw[j]! ^= genmrb[row1 + j]!;
314
+ if (bestFlip2 >= 0) {
315
+ const row2 = bestFlip2 * N;
316
+ for (let j = 0; j < N; j++) bestCw[j]! ^= genmrb[row2 + j]!;
317
+ }
318
+ }
319
+
313
320
  // Reorder codeword back to original order
314
321
  const finalCw = new Int8Array(N);
315
322
  for (let i = 0; i < N; i++) {
@@ -321,13 +328,12 @@ function osdDecode174_91(
321
328
 
322
329
  // Compute dmin in original order
323
330
  let dminOrig = 0;
324
- const hdecOrig = new Int8Array(N);
325
- for (let i = 0; i < N; i++) hdecOrig[i] = llr[i]! >= 0 ? 1 : 0;
326
331
  let nhe = 0;
327
332
  for (let i = 0; i < N; i++) {
328
- const x = finalCw[i]! ^ hdecOrig[i]!;
333
+ const hard = llr[i]! >= 0 ? 1 : 0;
334
+ const x = finalCw[i]! ^ hard;
329
335
  nhe += x;
330
- dminOrig += x * Math.abs(llr[i]!);
336
+ dminOrig += x * absllr[i]!;
331
337
  }
332
338
 
333
339
  return {
package/src/util/fft.ts CHANGED
@@ -3,6 +3,23 @@
3
3
  * Supports real-to-complex, complex-to-complex, and inverse transforms.
4
4
  */
5
5
 
6
+ interface Radix2Plan {
7
+ bitReversed: Uint32Array;
8
+ }
9
+
10
+ interface BluesteinPlan {
11
+ m: number;
12
+ chirpRe: Float64Array;
13
+ chirpIm: Float64Array;
14
+ bFftRe: Float64Array;
15
+ bFftIm: Float64Array;
16
+ aRe: Float64Array;
17
+ aIm: Float64Array;
18
+ }
19
+
20
+ const RADIX2_PLAN_CACHE = new Map<number, Radix2Plan>();
21
+ const BLUESTEIN_PLAN_CACHE = new Map<string, BluesteinPlan>();
22
+
6
23
  export function fftComplex(re: Float64Array, im: Float64Array, inverse: boolean): void {
7
24
  const n = re.length;
8
25
  if (n <= 1) return;
@@ -12,9 +29,11 @@ export function fftComplex(re: Float64Array, im: Float64Array, inverse: boolean)
12
29
  return;
13
30
  }
14
31
 
32
+ const { bitReversed } = getRadix2Plan(n);
33
+
15
34
  // Bit-reversal permutation
16
- let j = 0;
17
35
  for (let i = 0; i < n; i++) {
36
+ const j = bitReversed[i]!;
18
37
  if (j > i) {
19
38
  let tmp = re[i]!;
20
39
  re[i] = re[j]!;
@@ -23,12 +42,6 @@ export function fftComplex(re: Float64Array, im: Float64Array, inverse: boolean)
23
42
  im[i] = im[j]!;
24
43
  im[j] = tmp;
25
44
  }
26
- let m = n >> 1;
27
- while (m >= 1 && j >= m) {
28
- j -= m;
29
- m >>= 1;
30
- }
31
- j += m;
32
45
  }
33
46
 
34
47
  const sign = inverse ? 1 : -1;
@@ -59,56 +72,46 @@ export function fftComplex(re: Float64Array, im: Float64Array, inverse: boolean)
59
72
  }
60
73
 
61
74
  if (inverse) {
75
+ const scale = 1 / n;
62
76
  for (let i = 0; i < n; i++) {
63
- re[i]! /= n;
64
- im[i]! /= n;
77
+ re[i] = re[i]! * scale;
78
+ im[i] = im[i]! * scale;
65
79
  }
66
80
  }
67
81
  }
68
82
 
69
83
  function bluestein(re: Float64Array, im: Float64Array, inverse: boolean): void {
70
84
  const n = re.length;
71
- const m = nextPow2(n * 2 - 1);
72
- const s = inverse ? 1 : -1;
73
-
74
- const aRe = new Float64Array(m);
75
- const aIm = new Float64Array(m);
76
- const bRe = new Float64Array(m);
77
- const bIm = new Float64Array(m);
85
+ const { m, chirpRe, chirpIm, bFftRe, bFftIm, aRe, aIm } = getBluesteinPlan(n, inverse);
78
86
 
87
+ aRe.fill(0);
88
+ aIm.fill(0);
79
89
  for (let i = 0; i < n; i++) {
80
- const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
81
- const cosA = Math.cos(angle);
82
- const sinA = Math.sin(angle);
83
-
84
- aRe[i] = re[i]! * cosA - im[i]! * sinA;
85
- aIm[i] = re[i]! * sinA + im[i]! * cosA;
86
-
87
- bRe[i] = cosA;
88
- bIm[i] = -sinA;
89
- }
90
- for (let i = 1; i < n; i++) {
91
- bRe[m - i] = bRe[i]!;
92
- bIm[m - i] = bIm[i]!;
90
+ const cosA = chirpRe[i]!;
91
+ const sinA = chirpIm[i]!;
92
+ const inRe = re[i]!;
93
+ const inIm = im[i]!;
94
+ aRe[i] = inRe * cosA - inIm * sinA;
95
+ aIm[i] = inRe * sinA + inIm * cosA;
93
96
  }
94
97
 
95
98
  fftComplex(aRe, aIm, false);
96
- fftComplex(bRe, bIm, false);
97
99
 
98
100
  for (let i = 0; i < m; i++) {
99
- const r = aRe[i]! * bRe[i]! - aIm[i]! * bIm[i]!;
100
- const iIm = aRe[i]! * bIm[i]! + aIm[i]! * bRe[i]!;
101
- aRe[i] = r;
102
- aIm[i] = iIm;
101
+ const ar = aRe[i]!;
102
+ const ai = aIm[i]!;
103
+ const br = bFftRe[i]!;
104
+ const bi = bFftIm[i]!;
105
+ aRe[i] = ar * br - ai * bi;
106
+ aIm[i] = ar * bi + ai * br;
103
107
  }
104
108
 
105
109
  fftComplex(aRe, aIm, true);
106
110
 
107
111
  const scale = inverse ? 1 / n : 1;
108
112
  for (let i = 0; i < n; i++) {
109
- const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
110
- const cosA = Math.cos(angle);
111
- const sinA = Math.sin(angle);
113
+ const cosA = chirpRe[i]!;
114
+ const sinA = chirpIm[i]!;
112
115
 
113
116
  const r = aRe[i]! * cosA - aIm[i]! * sinA;
114
117
  const iIm = aRe[i]! * sinA + aIm[i]! * cosA;
@@ -117,6 +120,63 @@ function bluestein(re: Float64Array, im: Float64Array, inverse: boolean): void {
117
120
  }
118
121
  }
119
122
 
123
+ function getRadix2Plan(n: number): Radix2Plan {
124
+ let plan = RADIX2_PLAN_CACHE.get(n);
125
+ if (plan) return plan;
126
+
127
+ const bits = 31 - Math.clz32(n);
128
+ const bitReversed = new Uint32Array(n);
129
+ for (let i = 1; i < n; i++) {
130
+ bitReversed[i] = (bitReversed[i >> 1]! >> 1) | ((i & 1) << (bits - 1));
131
+ }
132
+
133
+ plan = { bitReversed };
134
+ RADIX2_PLAN_CACHE.set(n, plan);
135
+ return plan;
136
+ }
137
+
138
+ function getBluesteinPlan(n: number, inverse: boolean): BluesteinPlan {
139
+ const key = `${n}:${inverse ? 1 : 0}`;
140
+ const cached = BLUESTEIN_PLAN_CACHE.get(key);
141
+ if (cached) return cached;
142
+
143
+ const m = nextPow2(n * 2 - 1);
144
+ const s = inverse ? 1 : -1;
145
+ const chirpRe = new Float64Array(n);
146
+ const chirpIm = new Float64Array(n);
147
+ for (let i = 0; i < n; i++) {
148
+ const angle = (s * Math.PI * ((i * i) % (2 * n))) / n;
149
+ chirpRe[i] = Math.cos(angle);
150
+ chirpIm[i] = Math.sin(angle);
151
+ }
152
+
153
+ const bFftRe = new Float64Array(m);
154
+ const bFftIm = new Float64Array(m);
155
+ for (let i = 0; i < n; i++) {
156
+ const cosA = chirpRe[i]!;
157
+ const sinA = chirpIm[i]!;
158
+ bFftRe[i] = cosA;
159
+ bFftIm[i] = -sinA;
160
+ }
161
+ for (let i = 1; i < n; i++) {
162
+ bFftRe[m - i] = bFftRe[i]!;
163
+ bFftIm[m - i] = bFftIm[i]!;
164
+ }
165
+ fftComplex(bFftRe, bFftIm, false);
166
+
167
+ const plan: BluesteinPlan = {
168
+ m,
169
+ chirpRe,
170
+ chirpIm,
171
+ bFftRe,
172
+ bFftIm,
173
+ aRe: new Float64Array(m),
174
+ aIm: new Float64Array(m),
175
+ };
176
+ BLUESTEIN_PLAN_CACHE.set(key, plan);
177
+ return plan;
178
+ }
179
+
120
180
  /**
121
181
  * Real-to-complex FFT. Input: n real values. Output: n/2+1 complex values
122
182
  * stored in re[0..n/2] and im[0..n/2].
@@ -1,7 +1,11 @@
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
+ const FT4_DEFAULT_SAMPLE_RATE = 12_000;
7
+ const FT4_DEFAULT_SAMPLES_PER_SYMBOL = 576;
8
+ const FT4_DEFAULT_BT = 1.0;
5
9
  const MODULATION_INDEX = 1.0;
6
10
 
7
11
  export interface WaveformOptions {
@@ -9,6 +13,18 @@ export interface WaveformOptions {
9
13
  samplesPerSymbol?: number;
10
14
  bt?: number;
11
15
  baseFrequency?: number;
16
+ initialPhase?: number;
17
+ }
18
+
19
+ interface WaveformDefaults {
20
+ sampleRate: number;
21
+ samplesPerSymbol: number;
22
+ bt: number;
23
+ }
24
+
25
+ interface WaveformShape {
26
+ includeRampSymbols: boolean;
27
+ fullSymbolRamp: boolean;
12
28
  }
13
29
 
14
30
  function assertPositiveFinite(value: number, name: string): void {
@@ -36,20 +52,22 @@ function gfskPulse(bt: number, tt: number): number {
36
52
  return 0.5 * (erfApprox(scale * (tt + 0.5)) - erfApprox(scale * (tt - 0.5)));
37
53
  }
38
54
 
39
- export function generateFT8Waveform(
55
+ function generateGfskWaveform(
40
56
  tones: readonly number[],
41
- options: WaveformOptions = {},
57
+ options: WaveformOptions,
58
+ defaults: WaveformDefaults,
59
+ shape: WaveformShape,
42
60
  ): Float32Array {
43
- // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
44
61
  const nsym = tones.length;
45
62
  if (nsym === 0) {
46
63
  return new Float32Array(0);
47
64
  }
48
65
 
49
- const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
50
- const nsps = options.samplesPerSymbol ?? DEFAULT_SAMPLES_PER_SYMBOL;
51
- const bt = options.bt ?? DEFAULT_BT;
66
+ const sampleRate = options.sampleRate ?? defaults.sampleRate;
67
+ const nsps = options.samplesPerSymbol ?? defaults.samplesPerSymbol;
68
+ const bt = options.bt ?? defaults.bt;
52
69
  const f0 = options.baseFrequency ?? 0;
70
+ const initialPhase = options.initialPhase ?? 0;
53
71
 
54
72
  assertPositiveFinite(sampleRate, "sampleRate");
55
73
  assertPositiveFinite(nsps, "samplesPerSymbol");
@@ -57,11 +75,14 @@ export function generateFT8Waveform(
57
75
  if (!Number.isFinite(f0)) {
58
76
  throw new Error("baseFrequency must be finite");
59
77
  }
78
+ if (!Number.isFinite(initialPhase)) {
79
+ throw new Error("initialPhase must be finite");
80
+ }
60
81
  if (!Number.isInteger(nsps)) {
61
82
  throw new Error("samplesPerSymbol must be an integer");
62
83
  }
63
84
 
64
- const nwave = nsym * nsps;
85
+ const nwave = (shape.includeRampSymbols ? nsym + 2 : nsym) * nsps;
65
86
  const pulse = new Float64Array(3 * nsps);
66
87
  for (let i = 0; i < pulse.length; i++) {
67
88
  const tt = (i + 1 - 1.5 * nsps) / nsps;
@@ -93,9 +114,11 @@ export function generateFT8Waveform(
93
114
  }
94
115
 
95
116
  const wave = new Float32Array(nwave);
96
- let phi = 0;
117
+ let phi = initialPhase % TWO_PI;
118
+ if (phi < 0) phi += TWO_PI;
119
+ const phaseStart = shape.includeRampSymbols ? 0 : nsps;
97
120
  for (let k = 0; k < nwave; k++) {
98
- const j = nsps + k; // skip the leading dummy symbol
121
+ const j = phaseStart + k;
99
122
  wave[k] = Math.sin(phi);
100
123
  phi += dphi[j]!;
101
124
  phi %= TWO_PI;
@@ -104,17 +127,70 @@ export function generateFT8Waveform(
104
127
  }
105
128
  }
106
129
 
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
- }
130
+ if (shape.fullSymbolRamp) {
131
+ for (let i = 0; i < nsps; i++) {
132
+ const up = (1 - Math.cos((TWO_PI * i) / (2 * nsps))) / 2;
133
+ wave[i]! *= up;
134
+ }
135
+
136
+ const tailStart = (nsym + 1) * nsps;
137
+ for (let i = 0; i < nsps; i++) {
138
+ const down = (1 + Math.cos((TWO_PI * i) / (2 * nsps))) / 2;
139
+ wave[tailStart + i]! *= down;
140
+ }
141
+ } else {
142
+ const nramp = Math.round(nsps / 8);
143
+ for (let i = 0; i < nramp; i++) {
144
+ const up = (1 - Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
145
+ wave[i]! *= up;
146
+ }
112
147
 
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;
148
+ const tailStart = nwave - nramp;
149
+ for (let i = 0; i < nramp; i++) {
150
+ const down = (1 + Math.cos((TWO_PI * i) / (2 * nramp))) / 2;
151
+ wave[tailStart + i]! *= down;
152
+ }
117
153
  }
118
154
 
119
155
  return wave;
120
156
  }
157
+
158
+ export function generateFT8Waveform(
159
+ tones: readonly number[],
160
+ options: WaveformOptions = {},
161
+ ): Float32Array {
162
+ // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
163
+ return generateGfskWaveform(
164
+ tones,
165
+ options,
166
+ {
167
+ sampleRate: FT8_DEFAULT_SAMPLE_RATE,
168
+ samplesPerSymbol: FT8_DEFAULT_SAMPLES_PER_SYMBOL,
169
+ bt: FT8_DEFAULT_BT,
170
+ },
171
+ {
172
+ includeRampSymbols: false,
173
+ fullSymbolRamp: false,
174
+ },
175
+ );
176
+ }
177
+
178
+ export function generateFT4Waveform(
179
+ tones: readonly number[],
180
+ options: WaveformOptions = {},
181
+ ): Float32Array {
182
+ // Mirrors lib/ft4/gen_ft4wave.f90.
183
+ return generateGfskWaveform(
184
+ tones,
185
+ options,
186
+ {
187
+ sampleRate: FT4_DEFAULT_SAMPLE_RATE,
188
+ samplesPerSymbol: FT4_DEFAULT_SAMPLES_PER_SYMBOL,
189
+ bt: FT4_DEFAULT_BT,
190
+ },
191
+ {
192
+ includeRampSymbols: true,
193
+ fullSymbolRamp: true,
194
+ },
195
+ );
196
+ }