@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/README.md +53 -34
- package/dist/cli.js +1747 -703
- package/dist/cli.js.map +1 -1
- package/dist/ft8ts.cjs +1756 -663
- package/dist/ft8ts.cjs.map +1 -1
- package/dist/ft8ts.d.ts +44 -9
- package/dist/ft8ts.mjs +1754 -663
- package/dist/ft8ts.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +18 -2
- package/src/ft4/constants.ts +3 -0
- package/src/ft4/decode.ts +977 -0
- package/src/ft4/encode.ts +45 -0
- package/src/ft4/scramble.ts +14 -0
- package/src/ft8/constants.ts +7 -0
- package/src/ft8/decode.ts +547 -298
- package/src/ft8/encode.ts +6 -5
- package/src/index.ts +6 -0
- package/src/util/constants.ts +4 -17
- package/src/util/decode174_91.ts +61 -55
- package/src/util/fft.ts +97 -37
- package/src/util/waveform.ts +97 -21
package/src/ft8/encode.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { gHex
|
|
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] =
|
|
63
|
-
for (let i = 0; i < 7; i++) tones[36 + i] =
|
|
64
|
-
for (let i = 0; i < 7; i++) tones[72 + 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] =
|
|
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";
|
package/src/util/constants.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
export 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";
|
package/src/util/decode174_91.ts
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* Port of bpdecode174_91.f90 and decode174_91.f90.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { gHex,
|
|
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
|
|
189
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
genmrb[
|
|
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
|
|
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[
|
|
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
|
-
|
|
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] =
|
|
249
|
+
absrx[i] = absllr[indices[i]!]!;
|
|
237
250
|
}
|
|
238
251
|
|
|
239
|
-
//
|
|
240
|
-
const
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
290
|
-
|
|
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
|
-
|
|
292
|
+
const row1 = i1 * N;
|
|
293
|
+
for (let i2 = i1 - 1; i2 >= iMin; i2--) {
|
|
293
294
|
if (apmask[indices[i2]!] === 1) continue;
|
|
294
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
|
333
|
+
const hard = llr[i]! >= 0 ? 1 : 0;
|
|
334
|
+
const x = finalCw[i]! ^ hard;
|
|
329
335
|
nhe += x;
|
|
330
|
-
dminOrig += x *
|
|
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]!
|
|
64
|
-
im[i]!
|
|
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 =
|
|
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
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
aRe[i] =
|
|
85
|
-
aIm[i] =
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
110
|
-
const
|
|
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].
|
package/src/util/waveform.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
const TWO_PI = 2 * Math.PI;
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const
|
|
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
|
-
|
|
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 ??
|
|
50
|
-
const nsps = options.samplesPerSymbol ??
|
|
51
|
-
const bt = options.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 =
|
|
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 =
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|