@e04/ft8ts 0.0.1

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,237 @@
1
+ /**
2
+ * FT8 message unpacking – TypeScript port of unpack77 from packjt77.f90
3
+ *
4
+ * Supported message types:
5
+ * Type 0.0 Free text
6
+ * Type 1 Standard (two callsigns + grid/report/RR73/73)
7
+ * Type 2 /P form for EU VHF contest
8
+ * Type 4 One nonstandard call and one hashed call
9
+ */
10
+
11
+ import { A1, A2, A3, A4, C38, FTALPH, MAX22, MAXGRID4, NTOKENS } from "./constants.js";
12
+
13
+ function bitsToUint(bits: number[], start: number, len: number): number {
14
+ let val = 0;
15
+ for (let i = 0; i < len; i++) {
16
+ val = val * 2 + (bits[start + i] ?? 0);
17
+ }
18
+ return val;
19
+ }
20
+
21
+ function unpack28(n28: number): { call: string; success: boolean } {
22
+ if (n28 < 0 || n28 >= 268435456) return { call: "", success: false };
23
+
24
+ if (n28 === 0) return { call: "DE", success: true };
25
+ if (n28 === 1) return { call: "QRZ", success: true };
26
+ if (n28 === 2) return { call: "CQ", success: true };
27
+
28
+ if (n28 >= 3 && n28 < 3 + 1000) {
29
+ const nqsy = n28 - 3;
30
+ return { call: `CQ ${nqsy.toString().padStart(3, "0")}`, success: true };
31
+ }
32
+
33
+ if (n28 >= 1003 && n28 < NTOKENS) {
34
+ // CQ with 4-letter directed call
35
+ let m = n28 - 1003;
36
+ let chars = "";
37
+ for (let i = 3; i >= 0; i--) {
38
+ const j = m % 27;
39
+ m = Math.floor(m / 27);
40
+ chars = (j === 0 ? " " : String.fromCharCode(64 + j)) + chars;
41
+ }
42
+ const directed = chars.trim();
43
+ if (directed.length > 0) return { call: `CQ ${directed}`, success: true };
44
+ return { call: "CQ", success: true };
45
+ }
46
+
47
+ if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
48
+ // Hashed call – we don't have a hash table, so show <...>
49
+ return { call: "<...>", success: true };
50
+ }
51
+
52
+ // Standard callsign
53
+ let n = n28 - NTOKENS - MAX22;
54
+ if (n < 0) return { call: "", success: false };
55
+
56
+ const i6 = n % 27;
57
+ n = Math.floor(n / 27);
58
+ const i5 = n % 27;
59
+ n = Math.floor(n / 27);
60
+ const i4 = n % 27;
61
+ n = Math.floor(n / 27);
62
+ const i3 = n % 10;
63
+ n = Math.floor(n / 10);
64
+ const i2 = n % 36;
65
+ n = Math.floor(n / 36);
66
+ const i1 = n;
67
+
68
+ if (i1 < 0 || i1 >= A1.length) return { call: "", success: false };
69
+ if (i2 < 0 || i2 >= A2.length) return { call: "", success: false };
70
+ if (i3 < 0 || i3 >= A3.length) return { call: "", success: false };
71
+ if (i4 < 0 || i4 >= A4.length) return { call: "", success: false };
72
+ if (i5 < 0 || i5 >= A4.length) return { call: "", success: false };
73
+ if (i6 < 0 || i6 >= A4.length) return { call: "", success: false };
74
+
75
+ const call = (A1[i1]! + A2[i2]! + A3[i3]! + A4[i4]! + A4[i5]! + A4[i6]!).trim();
76
+
77
+ return { call, success: call.length > 0 };
78
+ }
79
+
80
+ function toGrid4(igrid4: number): { grid: string; success: boolean } {
81
+ if (igrid4 < 0 || igrid4 > MAXGRID4) return { grid: "", success: false };
82
+ let n = igrid4;
83
+ const j4 = n % 10;
84
+ n = Math.floor(n / 10);
85
+ const j3 = n % 10;
86
+ n = Math.floor(n / 10);
87
+ const j2 = n % 18;
88
+ n = Math.floor(n / 18);
89
+ const j1 = n;
90
+ if (j1 < 0 || j1 > 17 || j2 < 0 || j2 > 17) return { grid: "", success: false };
91
+ const grid =
92
+ String.fromCharCode(65 + j1) + String.fromCharCode(65 + j2) + j3.toString() + j4.toString();
93
+ return { grid, success: true };
94
+ }
95
+
96
+ function unpackText77(bits71: number[]): string {
97
+ // Reconstruct 9 bytes from 71 bits (7 + 8*8)
98
+ const qa = new Uint8Array(9);
99
+ let val = 0;
100
+ for (let b = 6; b >= 0; b--) {
101
+ val = (val << 1) | (bits71[6 - b] ?? 0);
102
+ }
103
+ qa[0] = val;
104
+ for (let li = 1; li <= 8; li++) {
105
+ val = 0;
106
+ for (let b = 7; b >= 0; b--) {
107
+ val = (val << 1) | (bits71[7 + (li - 1) * 8 + (7 - b)] ?? 0);
108
+ }
109
+ qa[li] = val;
110
+ }
111
+
112
+ // Decode from base-42 big-endian
113
+ // Convert qa (9 bytes) to a bigint, then repeatedly divide by 42
114
+ let n = 0n;
115
+ for (let i = 0; i < 9; i++) {
116
+ n = (n << 8n) | BigInt(qa[i]!);
117
+ }
118
+
119
+ const chars: string[] = [];
120
+ for (let i = 0; i < 13; i++) {
121
+ const j = Number(n % 42n);
122
+ n = n / 42n;
123
+ chars.unshift(FTALPH[j] ?? " ");
124
+ }
125
+ return chars.join("").trimStart();
126
+ }
127
+
128
+ /**
129
+ * Unpack a 77-bit FT8 message into a human-readable string.
130
+ */
131
+ export function unpack77(bits77: number[]): { msg: string; success: boolean } {
132
+ const n3 = bitsToUint(bits77, 71, 3);
133
+ const i3 = bitsToUint(bits77, 74, 3);
134
+
135
+ if (i3 === 0 && n3 === 0) {
136
+ // Type 0.0: Free text
137
+ const msg = unpackText77(bits77.slice(0, 71));
138
+ if (msg.trim().length === 0) return { msg: "", success: false };
139
+ return { msg: msg.trim(), success: true };
140
+ }
141
+
142
+ if (i3 === 1 || i3 === 2) {
143
+ // Type 1/2: Standard message
144
+ const n28a = bitsToUint(bits77, 0, 28);
145
+ const ipa = bits77[28]!;
146
+ const n28b = bitsToUint(bits77, 29, 28);
147
+ const ipb = bits77[57]!;
148
+ const ir = bits77[58]!;
149
+ const igrid4 = bitsToUint(bits77, 59, 15);
150
+
151
+ const { call: call1, success: ok1 } = unpack28(n28a);
152
+ const { call: call2Raw, success: ok2 } = unpack28(n28b);
153
+ if (!ok1 || !ok2) return { msg: "", success: false };
154
+
155
+ let c1 = call1;
156
+ let c2 = call2Raw;
157
+
158
+ if (c1.startsWith("CQ_")) c1 = c1.replace("_", " ");
159
+
160
+ if (c1.indexOf("<") < 0) {
161
+ if (ipa === 1 && i3 === 1 && c1.length >= 3) c1 += "/R";
162
+ if (ipa === 1 && i3 === 2 && c1.length >= 3) c1 += "/P";
163
+ }
164
+ if (c2.indexOf("<") < 0) {
165
+ if (ipb === 1 && i3 === 1 && c2.length >= 3) c2 += "/R";
166
+ if (ipb === 1 && i3 === 2 && c2.length >= 3) c2 += "/P";
167
+ }
168
+
169
+ if (igrid4 <= MAXGRID4) {
170
+ const { grid, success: gridOk } = toGrid4(igrid4);
171
+ if (!gridOk) return { msg: "", success: false };
172
+ const msg = ir === 0 ? `${c1} ${c2} ${grid}` : `${c1} ${c2} R ${grid}`;
173
+ return { msg, success: true };
174
+ } else {
175
+ const irpt = igrid4 - MAXGRID4;
176
+ if (irpt === 1) return { msg: `${c1} ${c2}`, success: true };
177
+ if (irpt === 2) return { msg: `${c1} ${c2} RRR`, success: true };
178
+ if (irpt === 3) return { msg: `${c1} ${c2} RR73`, success: true };
179
+ if (irpt === 4) return { msg: `${c1} ${c2} 73`, success: true };
180
+ if (irpt >= 5) {
181
+ let isnr = irpt - 35;
182
+ if (isnr > 50) isnr -= 101;
183
+ const absStr = Math.abs(isnr).toString().padStart(2, "0");
184
+ const crpt = (isnr >= 0 ? "+" : "-") + absStr;
185
+ const msg = ir === 0 ? `${c1} ${c2} ${crpt}` : `${c1} ${c2} R${crpt}`;
186
+ return { msg, success: true };
187
+ }
188
+ return { msg: "", success: false };
189
+ }
190
+ }
191
+
192
+ if (i3 === 4) {
193
+ // Type 4: One nonstandard call
194
+ let n58 = 0n;
195
+ for (let i = 0; i < 58; i++) {
196
+ n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
197
+ }
198
+ const iflip = bits77[70]!;
199
+ const nrpt = bitsToUint(bits77, 71, 2);
200
+ const icq = bits77[73]!;
201
+
202
+ // Decode n58 to 11-char string using C38 alphabet
203
+ const c11chars: string[] = [];
204
+ let remain = n58;
205
+ for (let i = 10; i >= 0; i--) {
206
+ const j = Number(remain % 38n);
207
+ remain = remain / 38n;
208
+ c11chars.unshift(C38[j] ?? " ");
209
+ }
210
+ const c11 = c11chars.join("").trim();
211
+
212
+ const call3 = "<...>"; // We don't have a hash table for n12
213
+
214
+ let call1: string;
215
+ let call2: string;
216
+ if (iflip === 0) {
217
+ call1 = call3;
218
+ call2 = c11;
219
+ } else {
220
+ call1 = c11;
221
+ call2 = call3;
222
+ }
223
+
224
+ let msg: string;
225
+ if (icq === 1) {
226
+ msg = `CQ ${call2}`;
227
+ } else {
228
+ if (nrpt === 0) msg = `${call1} ${call2}`;
229
+ else if (nrpt === 1) msg = `${call1} ${call2} RRR`;
230
+ else if (nrpt === 2) msg = `${call1} ${call2} RR73`;
231
+ else msg = `${call1} ${call2} 73`;
232
+ }
233
+ return { msg, success: true };
234
+ }
235
+
236
+ return { msg: "", success: false };
237
+ }
@@ -0,0 +1,129 @@
1
+ /// <reference types="node" />
2
+
3
+ import { writeFileSync } from "node:fs";
4
+
5
+ export interface WavData {
6
+ sampleRate: number;
7
+ samples: Float32Array;
8
+ }
9
+
10
+ /**
11
+ * Parse a WAV file buffer into sample rate and normalized float samples.
12
+ * Supports PCM format 1, 8/16/32-bit samples.
13
+ */
14
+ export function parseWavBuffer(buf: Buffer): WavData {
15
+ if (buf.length < 44) throw new Error("File too small for WAV");
16
+
17
+ const riff = buf.toString("ascii", 0, 4);
18
+ const wave = buf.toString("ascii", 8, 12);
19
+ if (riff !== "RIFF" || wave !== "WAVE") throw new Error("Not a WAV file");
20
+
21
+ let offset = 12;
22
+ let fmtFound = false;
23
+ let sampleRate = 0;
24
+ let bitsPerSample = 0;
25
+ let numChannels = 1;
26
+ let audioFormat = 0;
27
+ let dataOffset = 0;
28
+ let dataSize = 0;
29
+
30
+ while (offset < buf.length - 8) {
31
+ const chunkId = buf.toString("ascii", offset, offset + 4);
32
+ const chunkSize = buf.readUInt32LE(offset + 4);
33
+ offset += 8;
34
+
35
+ if (chunkId === "fmt ") {
36
+ audioFormat = buf.readUInt16LE(offset);
37
+ numChannels = buf.readUInt16LE(offset + 2);
38
+ sampleRate = buf.readUInt32LE(offset + 4);
39
+ bitsPerSample = buf.readUInt16LE(offset + 14);
40
+ fmtFound = true;
41
+ } else if (chunkId === "data") {
42
+ dataOffset = offset;
43
+ dataSize = chunkSize;
44
+ break;
45
+ }
46
+ offset += chunkSize;
47
+ }
48
+
49
+ if (!fmtFound) throw new Error("No fmt chunk found");
50
+ if (audioFormat !== 1) throw new Error(`Unsupported audio format: ${audioFormat} (only PCM=1)`);
51
+ if (dataOffset === 0) throw new Error("No data chunk found");
52
+
53
+ const bytesPerSample = bitsPerSample / 8;
54
+ const totalSamples = Math.floor(dataSize / (bytesPerSample * numChannels));
55
+ const samples = new Float32Array(totalSamples);
56
+
57
+ for (let i = 0; i < totalSamples; i++) {
58
+ const pos = dataOffset + i * numChannels * bytesPerSample;
59
+ let val: number;
60
+ if (bitsPerSample === 16) {
61
+ val = buf.readInt16LE(pos) / 32768;
62
+ } else if (bitsPerSample === 32) {
63
+ val = buf.readInt32LE(pos) / 2147483648;
64
+ } else if (bitsPerSample === 8) {
65
+ val = (buf.readUInt8(pos) - 128) / 128;
66
+ } else {
67
+ throw new Error(`Unsupported bits per sample: ${bitsPerSample}`);
68
+ }
69
+ samples[i] = val;
70
+ }
71
+
72
+ return { sampleRate, samples };
73
+ }
74
+
75
+ function floatToInt16(sample: number): number {
76
+ const clamped = Math.max(-1, Math.min(1, sample));
77
+ return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff);
78
+ }
79
+
80
+ /**
81
+ * Write mono 16-bit PCM WAV file from normalized float samples (-1..1).
82
+ */
83
+ export function writeMono16WavFile(
84
+ filePath: string,
85
+ samples: Float32Array,
86
+ sampleRate: number,
87
+ ): void {
88
+ const numChannels = 1;
89
+ const bitsPerSample = 16;
90
+ const blockAlign = numChannels * (bitsPerSample / 8);
91
+ const dataSize = samples.length * blockAlign;
92
+ const wav = Buffer.alloc(44 + dataSize);
93
+
94
+ let offset = 0;
95
+ wav.write("RIFF", offset);
96
+ offset += 4;
97
+ wav.writeUInt32LE(36 + dataSize, offset);
98
+ offset += 4;
99
+ wav.write("WAVE", offset);
100
+ offset += 4;
101
+
102
+ wav.write("fmt ", offset);
103
+ offset += 4;
104
+ wav.writeUInt32LE(16, offset);
105
+ offset += 4; // PCM chunk size
106
+ wav.writeUInt16LE(1, offset);
107
+ offset += 2; // PCM format
108
+ wav.writeUInt16LE(numChannels, offset);
109
+ offset += 2;
110
+ wav.writeUInt32LE(sampleRate, offset);
111
+ offset += 4;
112
+ wav.writeUInt32LE(sampleRate * blockAlign, offset);
113
+ offset += 4;
114
+ wav.writeUInt16LE(blockAlign, offset);
115
+ offset += 2;
116
+ wav.writeUInt16LE(bitsPerSample, offset);
117
+ offset += 2;
118
+
119
+ wav.write("data", offset);
120
+ offset += 4;
121
+ wav.writeUInt32LE(dataSize, offset);
122
+ offset += 4;
123
+
124
+ for (let i = 0; i < samples.length; i++) {
125
+ wav.writeInt16LE(floatToInt16(samples[i]!), offset + i * 2);
126
+ }
127
+
128
+ writeFileSync(filePath, wav);
129
+ }
@@ -0,0 +1,120 @@
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;
5
+ const MODULATION_INDEX = 1.0;
6
+
7
+ export interface WaveformOptions {
8
+ sampleRate?: number;
9
+ samplesPerSymbol?: number;
10
+ bt?: number;
11
+ baseFrequency?: number;
12
+ }
13
+
14
+ function assertPositiveFinite(value: number, name: string): void {
15
+ if (!Number.isFinite(value) || value <= 0) {
16
+ throw new Error(`${name} must be a positive finite number`);
17
+ }
18
+ }
19
+
20
+ // Abramowitz and Stegun 7.1.26 approximation.
21
+ function erfApprox(x: number): number {
22
+ const sign = x < 0 ? -1 : 1;
23
+ const ax = Math.abs(x);
24
+ const t = 1 / (1 + 0.3275911 * ax);
25
+ const y =
26
+ 1 -
27
+ ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *
28
+ t *
29
+ Math.exp(-ax * ax);
30
+ return sign * y;
31
+ }
32
+
33
+ function gfskPulse(bt: number, tt: number): number {
34
+ // Same expression used by lib/ft2/gfsk_pulse.f90.
35
+ const scale = Math.PI * Math.sqrt(2 / Math.log(2)) * bt;
36
+ return 0.5 * (erfApprox(scale * (tt + 0.5)) - erfApprox(scale * (tt - 0.5)));
37
+ }
38
+
39
+ export function generateFT8Waveform(
40
+ tones: readonly number[],
41
+ options: WaveformOptions = {},
42
+ ): Float32Array {
43
+ // Mirrors the FT8 path in lib/ft8/gen_ft8wave.f90.
44
+ const nsym = tones.length;
45
+ if (nsym === 0) {
46
+ return new Float32Array(0);
47
+ }
48
+
49
+ const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
50
+ const nsps = options.samplesPerSymbol ?? DEFAULT_SAMPLES_PER_SYMBOL;
51
+ const bt = options.bt ?? DEFAULT_BT;
52
+ const f0 = options.baseFrequency ?? 0;
53
+
54
+ assertPositiveFinite(sampleRate, "sampleRate");
55
+ assertPositiveFinite(nsps, "samplesPerSymbol");
56
+ assertPositiveFinite(bt, "bt");
57
+ if (!Number.isFinite(f0)) {
58
+ throw new Error("baseFrequency must be finite");
59
+ }
60
+ if (!Number.isInteger(nsps)) {
61
+ throw new Error("samplesPerSymbol must be an integer");
62
+ }
63
+
64
+ const nwave = nsym * nsps;
65
+ const pulse = new Float64Array(3 * nsps);
66
+ for (let i = 0; i < pulse.length; i++) {
67
+ const tt = (i + 1 - 1.5 * nsps) / nsps;
68
+ pulse[i] = gfskPulse(bt, tt);
69
+ }
70
+
71
+ const dphi = new Float64Array((nsym + 2) * nsps);
72
+ const dphiPeak = (TWO_PI * MODULATION_INDEX) / nsps;
73
+
74
+ for (let j = 0; j < nsym; j++) {
75
+ const tone = tones[j]!;
76
+ const ib = j * nsps;
77
+ for (let i = 0; i < pulse.length; i++) {
78
+ dphi[ib + i]! += dphiPeak * pulse[i]! * tone;
79
+ }
80
+ }
81
+
82
+ const firstTone = tones[0]!;
83
+ const lastTone = tones[nsym - 1]!;
84
+ const tailBase = nsym * nsps;
85
+ for (let i = 0; i < 2 * nsps; i++) {
86
+ dphi[i]! += dphiPeak * firstTone * pulse[nsps + i]!;
87
+ dphi[tailBase + i]! += dphiPeak * lastTone * pulse[i]!;
88
+ }
89
+
90
+ const carrierDphi = (TWO_PI * f0) / sampleRate;
91
+ for (let i = 0; i < dphi.length; i++) {
92
+ dphi[i]! += carrierDphi;
93
+ }
94
+
95
+ const wave = new Float32Array(nwave);
96
+ let phi = 0;
97
+ for (let k = 0; k < nwave; k++) {
98
+ const j = nsps + k; // skip the leading dummy symbol
99
+ wave[k] = Math.sin(phi);
100
+ phi += dphi[j]!;
101
+ phi %= TWO_PI;
102
+ if (phi < 0) {
103
+ phi += TWO_PI;
104
+ }
105
+ }
106
+
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
+ }
112
+
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;
117
+ }
118
+
119
+ return wave;
120
+ }