@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.
- package/LICENSE +674 -0
- package/README.md +69 -0
- package/dist/ft8js.cjs +2119 -0
- package/dist/ft8js.cjs.map +1 -0
- package/dist/ft8js.mjs +2116 -0
- package/dist/ft8js.mjs.map +1 -0
- package/dist/ft8ts.cjs +2119 -0
- package/dist/ft8ts.cjs.map +1 -0
- package/dist/ft8ts.d.ts +36 -0
- package/dist/ft8ts.mjs +2116 -0
- package/dist/ft8ts.mjs.map +1 -0
- package/example/browser/index.html +251 -0
- package/example/decode-ft8-wav.ts +78 -0
- package/example/generate-ft8-wav.ts +82 -0
- package/package.json +53 -0
- package/src/__test__/190227_155815.wav +0 -0
- package/src/__test__/decode.test.ts +117 -0
- package/src/__test__/encode.test.ts +52 -0
- package/src/__test__/test_vectors.ts +221 -0
- package/src/__test__/wav.test.ts +45 -0
- package/src/__test__/waveform.test.ts +28 -0
- package/src/ft8/decode.ts +713 -0
- package/src/ft8/encode.ts +85 -0
- package/src/index.ts +2 -0
- package/src/util/constants.ts +118 -0
- package/src/util/crc.ts +39 -0
- package/src/util/decode174_91.ts +375 -0
- package/src/util/fft.ts +108 -0
- package/src/util/ldpc_tables.ts +91 -0
- package/src/util/pack_jt77.ts +531 -0
- package/src/util/unpack_jt77.ts +237 -0
- package/src/util/wav.ts +129 -0
- package/src/util/waveform.ts +120 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FT8 message packing – TypeScript port of packjt77.f90
|
|
3
|
+
*
|
|
4
|
+
* Implemented message types
|
|
5
|
+
* ─────────────────────────
|
|
6
|
+
* 0.0 Free text (≤13 chars from the 42-char FT8 alphabet)
|
|
7
|
+
* 1 Standard (two callsigns + grid/report/RR73/73)
|
|
8
|
+
* /R and /P suffixes on either callsign → ipa/ipb = 1 (triggers i3=2 for /P)
|
|
9
|
+
* 4 One nonstandard (<hash>) call + one standard call
|
|
10
|
+
* e.g. <YW18FIFA> KA1ABC 73
|
|
11
|
+
* KA1ABC <YW18FIFA> -11
|
|
12
|
+
* CQ YW18FIFA
|
|
13
|
+
*
|
|
14
|
+
* Reference: lib/77bit/packjt77.f90 (subroutines pack77, pack28, pack77_1,
|
|
15
|
+
* pack77_4, packtext77, ihashcall)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { A1, A2, A3, A4, C38, FTALPH, MAX22, MAX28, MAXGRID4, NTOKENS } from "./constants.js";
|
|
19
|
+
|
|
20
|
+
/** 9-limb big-integer (base 256, big-endian in limbs[0..8]) */
|
|
21
|
+
type MP = Uint8Array; // length 9
|
|
22
|
+
|
|
23
|
+
function mpZero(): MP {
|
|
24
|
+
return new Uint8Array(9);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** qa = 42 * qb + carry from high limbs, working with 9 limbs (indices 0..8) */
|
|
28
|
+
function mpMult42(a: MP): MP {
|
|
29
|
+
const b = mpZero();
|
|
30
|
+
let carry = 0;
|
|
31
|
+
for (let i = 8; i >= 0; i--) {
|
|
32
|
+
const v = 42 * (a[i] ?? 0) + carry;
|
|
33
|
+
b[i] = v & 0xff;
|
|
34
|
+
carry = v >>> 8;
|
|
35
|
+
}
|
|
36
|
+
return b;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** qa = qb + j */
|
|
40
|
+
function mpAdd(a: MP, j: number): MP {
|
|
41
|
+
const b = new Uint8Array(a);
|
|
42
|
+
let carry = j;
|
|
43
|
+
for (let i = 8; i >= 0 && carry > 0; i--) {
|
|
44
|
+
const v = (b[i] ?? 0) + carry;
|
|
45
|
+
b[i] = v & 0xff;
|
|
46
|
+
carry = v >>> 8;
|
|
47
|
+
}
|
|
48
|
+
return b;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pack a 13-char free-text string (42-char alphabet) into 71 bits.
|
|
53
|
+
* Mirrors Fortran packtext77 / mp_short_* logic.
|
|
54
|
+
* Alphabet: ' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?' (42 chars)
|
|
55
|
+
*/
|
|
56
|
+
function packtext77(c13: string): number[] {
|
|
57
|
+
// Right-justify in 13 chars
|
|
58
|
+
const w = c13.padStart(13, " ");
|
|
59
|
+
|
|
60
|
+
let qa = mpZero();
|
|
61
|
+
for (let i = 0; i < 13; i++) {
|
|
62
|
+
let j = FTALPH.indexOf(w[i] ?? " ");
|
|
63
|
+
if (j < 0) j = 0;
|
|
64
|
+
qa = mpMult42(qa);
|
|
65
|
+
qa = mpAdd(qa, j);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Extract 71 bits: first 7 then 8*8
|
|
69
|
+
const bits: number[] = [];
|
|
70
|
+
// limb 0 gives 7 bits (high), limbs 1..8 give 8 bits each → 7 + 64 = 71
|
|
71
|
+
// But we need exactly 71 bits. The Fortran writes b7.7 then 8*b8.8 for 71 total.
|
|
72
|
+
// That equals: 7 + 8*8 = 71 bits from the 9 bytes (72 bits), skipping the top bit of byte 0.
|
|
73
|
+
const byte0 = qa[0] ?? 0;
|
|
74
|
+
for (let b = 6; b >= 0; b--) bits.push((byte0 >> b) & 1);
|
|
75
|
+
for (let li = 1; li <= 8; li++) {
|
|
76
|
+
const byte = qa[li] ?? 0;
|
|
77
|
+
for (let b = 7; b >= 0; b--) bits.push((byte >> b) & 1);
|
|
78
|
+
}
|
|
79
|
+
return bits; // 71 bits
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* ihashcall(c0, m): compute a hash of c0 and return bits [m-1 .. 63-m] of
|
|
84
|
+
* (47055833459n * n8) shifted right by (64 - m).
|
|
85
|
+
*
|
|
86
|
+
* Fortran: ishft(47055833459_8 * n8, m - 64)
|
|
87
|
+
* → arithmetic right-shift of 64-bit product by (64 - m), keeping low m bits.
|
|
88
|
+
*
|
|
89
|
+
* Here we only ever call with m=22 (per pack28 for <...> callsigns).
|
|
90
|
+
*/
|
|
91
|
+
function ihashcall22(c0: string): number {
|
|
92
|
+
const C = C38;
|
|
93
|
+
let n8 = 0n;
|
|
94
|
+
const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
|
|
95
|
+
for (let i = 0; i < 11; i++) {
|
|
96
|
+
const j = C.indexOf(s[i] ?? " ");
|
|
97
|
+
n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
|
|
98
|
+
}
|
|
99
|
+
const MAGIC = 47055833459n;
|
|
100
|
+
const prod = BigInt.asUintN(64, MAGIC * n8);
|
|
101
|
+
// arithmetic right-shift by (64 - 22) = 42 bits → take top 22 bits
|
|
102
|
+
const result = Number(prod >> 42n) & 0x3fffff; // 22 bits
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Checks whether c0 is a valid standard callsign (may also have /R or /P suffix).
|
|
108
|
+
* Returns { basecall, isStandard, hasSuffix: '/R'|'/P'|null }
|
|
109
|
+
*/
|
|
110
|
+
function parseCallsign(raw: string): {
|
|
111
|
+
basecall: string;
|
|
112
|
+
isStandard: boolean;
|
|
113
|
+
suffix: "/R" | "/P" | null;
|
|
114
|
+
} {
|
|
115
|
+
let call = raw.trim().toUpperCase();
|
|
116
|
+
let suffix: "/R" | "/P" | null = null;
|
|
117
|
+
if (call.endsWith("/R")) {
|
|
118
|
+
suffix = "/R";
|
|
119
|
+
call = call.slice(0, -2);
|
|
120
|
+
}
|
|
121
|
+
if (call.endsWith("/P")) {
|
|
122
|
+
suffix = "/P";
|
|
123
|
+
call = call.slice(0, -2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isLetter = (c: string) => c >= "A" && c <= "Z";
|
|
127
|
+
const isDigit = (c: string) => c >= "0" && c <= "9";
|
|
128
|
+
|
|
129
|
+
// Find the call-area digit (last digit in the call)
|
|
130
|
+
let iarea = -1;
|
|
131
|
+
for (let i = call.length - 1; i >= 1; i--) {
|
|
132
|
+
if (isDigit(call[i] ?? "")) {
|
|
133
|
+
iarea = i;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (iarea < 1) return { basecall: call, isStandard: false, suffix };
|
|
138
|
+
|
|
139
|
+
// Count letters/digits before the call-area digit
|
|
140
|
+
let npdig = 0,
|
|
141
|
+
nplet = 0;
|
|
142
|
+
for (let i = 0; i < iarea; i++) {
|
|
143
|
+
if (isDigit(call[i] ?? "")) npdig++;
|
|
144
|
+
if (isLetter(call[i] ?? "")) nplet++;
|
|
145
|
+
}
|
|
146
|
+
// Count suffix letters after call-area digit
|
|
147
|
+
let nslet = 0;
|
|
148
|
+
for (let i = iarea + 1; i < call.length; i++) {
|
|
149
|
+
if (isLetter(call[i] ?? "")) nslet++;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const standard =
|
|
153
|
+
iarea >= 1 &&
|
|
154
|
+
iarea <= 2 && // Fortran: iarea (1-indexed) must be 2 or 3 → 0-indexed: 1 or 2
|
|
155
|
+
nplet >= 1 && // at least one letter before area digit
|
|
156
|
+
npdig < iarea && // not all digits before area
|
|
157
|
+
nslet >= 1 && // must have at least one letter after area digit
|
|
158
|
+
nslet <= 3; // at most 3 suffix letters
|
|
159
|
+
|
|
160
|
+
return { basecall: call, isStandard: standard, suffix };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* pack28: pack a single callsign/token to a 28-bit integer.
|
|
165
|
+
* Mirrors Fortran pack28 subroutine.
|
|
166
|
+
*/
|
|
167
|
+
function pack28(token: string): number {
|
|
168
|
+
const t = token.trim().toUpperCase();
|
|
169
|
+
|
|
170
|
+
// Special tokens
|
|
171
|
+
if (t === "DE") return 0;
|
|
172
|
+
if (t === "QRZ") return 1;
|
|
173
|
+
if (t === "CQ") return 2;
|
|
174
|
+
|
|
175
|
+
// CQ_nnn (CQ with frequency offset in kHz)
|
|
176
|
+
if (t.startsWith("CQ_")) {
|
|
177
|
+
const rest = t.slice(3);
|
|
178
|
+
const nqsy = parseInt(rest, 10);
|
|
179
|
+
if (!Number.isNaN(nqsy) && /^\d{3}$/.test(rest)) return 3 + nqsy;
|
|
180
|
+
// CQ_aaaa (up to 4 letters)
|
|
181
|
+
if (/^[A-Z]{1,4}$/.test(rest)) {
|
|
182
|
+
const padded = rest.padStart(4, " ");
|
|
183
|
+
let m = 0;
|
|
184
|
+
for (let i = 0; i < 4; i++) {
|
|
185
|
+
const c = padded[i] ?? " ";
|
|
186
|
+
const j = c >= "A" && c <= "Z" ? c.charCodeAt(0) - 64 : 0;
|
|
187
|
+
m = 27 * m + j;
|
|
188
|
+
}
|
|
189
|
+
return 3 + 1000 + m;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// <...> hash calls
|
|
194
|
+
if (t.startsWith("<") && t.endsWith(">")) {
|
|
195
|
+
const inner = t.slice(1, -1);
|
|
196
|
+
const n22 = ihashcall22(inner);
|
|
197
|
+
return (NTOKENS + n22) & (MAX28 - 1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Standard callsign
|
|
201
|
+
const { basecall, isStandard } = parseCallsign(t);
|
|
202
|
+
if (isStandard) {
|
|
203
|
+
const cs = basecall.length === 5 ? ` ${basecall}` : basecall;
|
|
204
|
+
const i1 = A1.indexOf(cs[0] ?? " ");
|
|
205
|
+
const i2 = A2.indexOf(cs[1] ?? "0");
|
|
206
|
+
const i3 = A3.indexOf(cs[2] ?? "0");
|
|
207
|
+
const i4 = A4.indexOf(cs[3] ?? " ");
|
|
208
|
+
const i5 = A4.indexOf(cs[4] ?? " ");
|
|
209
|
+
const i6 = A4.indexOf(cs[5] ?? " ");
|
|
210
|
+
const n28 =
|
|
211
|
+
36 * 10 * 27 * 27 * 27 * i1 +
|
|
212
|
+
10 * 27 * 27 * 27 * i2 +
|
|
213
|
+
27 * 27 * 27 * i3 +
|
|
214
|
+
27 * 27 * i4 +
|
|
215
|
+
27 * i5 +
|
|
216
|
+
i6;
|
|
217
|
+
return (n28 + NTOKENS + MAX22) & (MAX28 - 1);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Non-standard → 22-bit hash
|
|
221
|
+
const n22 = ihashcall22(basecall);
|
|
222
|
+
return (NTOKENS + n22) & (MAX28 - 1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function packgrid4(s: string): number {
|
|
226
|
+
if (s === "RRR") return MAXGRID4 + 2;
|
|
227
|
+
if (s === "73") return MAXGRID4 + 4;
|
|
228
|
+
// Numeric report (+NN / -NN)
|
|
229
|
+
const r = /^(R?)([+-]\d+)$/.exec(s);
|
|
230
|
+
if (r) {
|
|
231
|
+
let irpt = parseInt(r[2]!, 10);
|
|
232
|
+
if (irpt >= -50 && irpt <= -31) irpt += 101;
|
|
233
|
+
irpt += 35; // encode in range 5..85
|
|
234
|
+
return MAXGRID4 + irpt;
|
|
235
|
+
}
|
|
236
|
+
// 4-char grid locator
|
|
237
|
+
const j1 = (s.charCodeAt(0) - 65) * 18 * 10 * 10;
|
|
238
|
+
const j2 = (s.charCodeAt(1) - 65) * 10 * 10;
|
|
239
|
+
const j3 = (s.charCodeAt(2) - 48) * 10;
|
|
240
|
+
const j4 = s.charCodeAt(3) - 48;
|
|
241
|
+
return j1 + j2 + j3 + j4;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function appendBits(bits: number[], val: number, width: number): void {
|
|
245
|
+
for (let i = width - 1; i >= 0; i--) {
|
|
246
|
+
bits.push(Math.floor(val / 2 ** i) % 2);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Pack an FT8 message into 77 bits.
|
|
252
|
+
* Returns an array of 0/1 values, length 77.
|
|
253
|
+
*
|
|
254
|
+
* Supported message types:
|
|
255
|
+
* Type 1/2 Standard two-callsign messages including /R and /P suffixes
|
|
256
|
+
* Type 4 One nonstandard (<hash>) call + one standard or nonstandard call
|
|
257
|
+
* Type 0.0 Free text (≤13 chars from FTALPH)
|
|
258
|
+
*/
|
|
259
|
+
/**
|
|
260
|
+
* Preprocess a message in the same way as Fortran split77:
|
|
261
|
+
* - Collapse multiple spaces, force uppercase
|
|
262
|
+
* - If the first word is "CQ" and there are ≥3 words and the 3rd word is a
|
|
263
|
+
* valid base callsign, merge words 1+2 into "CQ_<word2>" and shift the rest.
|
|
264
|
+
*/
|
|
265
|
+
function split77(msg: string): string[] {
|
|
266
|
+
const parts = msg.trim().toUpperCase().replace(/\s+/g, " ").split(" ").filter(Boolean);
|
|
267
|
+
if (parts.length >= 3 && parts[0] === "CQ") {
|
|
268
|
+
// Check if word 3 (index 2) is a valid base callsign
|
|
269
|
+
const w3 = parts[2]!.replace(/\/[RP]$/, ""); // strip /R or /P for check
|
|
270
|
+
const { isStandard } = parseCallsign(w3);
|
|
271
|
+
if (isStandard) {
|
|
272
|
+
// merge CQ + word2 → CQ_word2
|
|
273
|
+
const merged = [`CQ_${parts[1]!}`, ...parts.slice(2)];
|
|
274
|
+
return merged;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return parts;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function pack77(msg: string): number[] {
|
|
281
|
+
const parts = split77(msg);
|
|
282
|
+
if (parts.length < 1) throw new Error("Empty message");
|
|
283
|
+
|
|
284
|
+
// ── Try Type 1/2: standard message ────────────────────────────────────────
|
|
285
|
+
const t1 = tryPackType1(parts);
|
|
286
|
+
if (t1) return t1;
|
|
287
|
+
|
|
288
|
+
// ── Try Type 4: one hash call ──────────────────────────────────────────────
|
|
289
|
+
const t4 = tryPackType4(parts);
|
|
290
|
+
if (t4) return t4;
|
|
291
|
+
|
|
292
|
+
// ── Default: Type 0.0 free text ───────────────────────────────────────────
|
|
293
|
+
return packFreeText(msg);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function tryPackType1(parts: string[]): number[] | null {
|
|
297
|
+
// Minimum 2 words, maximum 4
|
|
298
|
+
if (parts.length < 2 || parts.length > 4) return null;
|
|
299
|
+
|
|
300
|
+
const w1 = parts[0]!;
|
|
301
|
+
const w2 = parts[1]!;
|
|
302
|
+
const wLast = parts[parts.length - 1]!;
|
|
303
|
+
|
|
304
|
+
// Neither word may be a hash call if the other has a slash
|
|
305
|
+
if (w1.startsWith("<") && w2.includes("/")) return null;
|
|
306
|
+
if (w2.startsWith("<") && w1.includes("/")) return null;
|
|
307
|
+
|
|
308
|
+
// Parse callsign 1
|
|
309
|
+
let call1: string;
|
|
310
|
+
let ipa = 0;
|
|
311
|
+
let ok1: boolean;
|
|
312
|
+
|
|
313
|
+
if (w1 === "CQ" || w1 === "DE" || w1 === "QRZ" || w1.startsWith("CQ_")) {
|
|
314
|
+
call1 = w1;
|
|
315
|
+
ok1 = true;
|
|
316
|
+
ipa = 0;
|
|
317
|
+
} else if (w1.startsWith("<") && w1.endsWith(">")) {
|
|
318
|
+
call1 = w1;
|
|
319
|
+
ok1 = true;
|
|
320
|
+
ipa = 0;
|
|
321
|
+
} else {
|
|
322
|
+
const p1 = parseCallsign(w1);
|
|
323
|
+
call1 = p1.basecall;
|
|
324
|
+
ok1 = p1.isStandard;
|
|
325
|
+
if (p1.suffix === "/R" || p1.suffix === "/P") ipa = 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Parse callsign 2
|
|
329
|
+
let call2: string;
|
|
330
|
+
let ipb = 0;
|
|
331
|
+
let ok2: boolean;
|
|
332
|
+
|
|
333
|
+
if (w2.startsWith("<") && w2.endsWith(">")) {
|
|
334
|
+
call2 = w2;
|
|
335
|
+
ok2 = true;
|
|
336
|
+
ipb = 0;
|
|
337
|
+
} else {
|
|
338
|
+
const p2 = parseCallsign(w2);
|
|
339
|
+
call2 = p2.basecall;
|
|
340
|
+
ok2 = p2.isStandard;
|
|
341
|
+
if (p2.suffix === "/R" || p2.suffix === "/P") ipb = 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!ok1 || !ok2) return null;
|
|
345
|
+
|
|
346
|
+
// Determine message type (1 or 2)
|
|
347
|
+
const i1psfx = ipa === 1 && (w1.endsWith("/P") || w1.includes("/P "));
|
|
348
|
+
const i2psfx = ipb === 1 && (w2.endsWith("/P") || w2.includes("/P "));
|
|
349
|
+
const i3 = i1psfx || i2psfx ? 2 : 1;
|
|
350
|
+
|
|
351
|
+
// Decode the grid/report/special from the last word
|
|
352
|
+
let igrid4: number;
|
|
353
|
+
let ir = 0;
|
|
354
|
+
|
|
355
|
+
if (parts.length === 2) {
|
|
356
|
+
// Two-word message: <call1> <call2> → special irpt=1
|
|
357
|
+
igrid4 = MAXGRID4 + 1;
|
|
358
|
+
ir = 0;
|
|
359
|
+
} else {
|
|
360
|
+
// Check whether wLast is a grid, report, or special
|
|
361
|
+
const lastUpper = wLast.toUpperCase();
|
|
362
|
+
if (isGrid4(lastUpper)) {
|
|
363
|
+
igrid4 = packgrid4(lastUpper);
|
|
364
|
+
ir = parts.length === 4 && parts[2] === "R" ? 1 : 0;
|
|
365
|
+
} else if (lastUpper === "RRR") {
|
|
366
|
+
igrid4 = MAXGRID4 + 2;
|
|
367
|
+
ir = 0;
|
|
368
|
+
} else if (lastUpper === "RR73") {
|
|
369
|
+
igrid4 = MAXGRID4 + 3;
|
|
370
|
+
ir = 0;
|
|
371
|
+
} else if (lastUpper === "73") {
|
|
372
|
+
igrid4 = MAXGRID4 + 4;
|
|
373
|
+
ir = 0;
|
|
374
|
+
} else if (/^R[+-]\d+$/.test(lastUpper)) {
|
|
375
|
+
ir = 1;
|
|
376
|
+
const reportStr = lastUpper.slice(1); // strip leading R
|
|
377
|
+
let irpt = parseInt(reportStr, 10);
|
|
378
|
+
if (irpt >= -50 && irpt <= -31) irpt += 101;
|
|
379
|
+
irpt += 35;
|
|
380
|
+
igrid4 = MAXGRID4 + irpt;
|
|
381
|
+
} else if (/^[+-]\d+$/.test(lastUpper)) {
|
|
382
|
+
ir = 0;
|
|
383
|
+
let irpt = parseInt(lastUpper, 10);
|
|
384
|
+
if (irpt >= -50 && irpt <= -31) irpt += 101;
|
|
385
|
+
irpt += 35;
|
|
386
|
+
igrid4 = MAXGRID4 + irpt;
|
|
387
|
+
} else {
|
|
388
|
+
return null; // Not a valid Type 1 last word
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const n28a = pack28(call1);
|
|
393
|
+
const n28b = pack28(call2);
|
|
394
|
+
|
|
395
|
+
const bits: number[] = [];
|
|
396
|
+
appendBits(bits, n28a, 28);
|
|
397
|
+
appendBits(bits, ipa, 1);
|
|
398
|
+
appendBits(bits, n28b, 28);
|
|
399
|
+
appendBits(bits, ipb, 1);
|
|
400
|
+
appendBits(bits, ir, 1);
|
|
401
|
+
appendBits(bits, igrid4, 15);
|
|
402
|
+
appendBits(bits, i3, 3);
|
|
403
|
+
return bits;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isGrid4(s: string): boolean {
|
|
407
|
+
return (
|
|
408
|
+
s.length === 4 &&
|
|
409
|
+
s[0]! >= "A" &&
|
|
410
|
+
s[0]! <= "R" &&
|
|
411
|
+
s[1]! >= "A" &&
|
|
412
|
+
s[1]! <= "R" &&
|
|
413
|
+
s[2]! >= "0" &&
|
|
414
|
+
s[2]! <= "9" &&
|
|
415
|
+
s[3]! >= "0" &&
|
|
416
|
+
s[3]! <= "9"
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Type 4: one nonstandard (or hashed <...>) call + one standard call.
|
|
422
|
+
* Format: <HASH> CALL [RRR|RR73|73]
|
|
423
|
+
* CALL <HASH> [RRR|RR73|73]
|
|
424
|
+
* CQ NONSTDCALL
|
|
425
|
+
*
|
|
426
|
+
* Bit layout: n12(12) n58(58) iflip(1) nrpt(2) icq(1) i3=4(3) → 77 bits
|
|
427
|
+
*/
|
|
428
|
+
function tryPackType4(parts: string[]): number[] | null {
|
|
429
|
+
if (parts.length < 2 || parts.length > 3) return null;
|
|
430
|
+
|
|
431
|
+
const w1 = parts[0]!;
|
|
432
|
+
const w2 = parts[1]!;
|
|
433
|
+
const w3 = parts[2]; // optional
|
|
434
|
+
|
|
435
|
+
let icq = 0;
|
|
436
|
+
let iflip = 0;
|
|
437
|
+
let n12 = 0;
|
|
438
|
+
let n58 = 0n;
|
|
439
|
+
let nrpt = 0;
|
|
440
|
+
|
|
441
|
+
const parsedW1 = parseCallsign(w1);
|
|
442
|
+
const parsedW2 = parseCallsign(w2);
|
|
443
|
+
|
|
444
|
+
// If both are standard callsigns (no hash), type 4 doesn't apply
|
|
445
|
+
if (parsedW1.isStandard && parsedW2.isStandard && !w1.startsWith("<") && !w2.startsWith("<"))
|
|
446
|
+
return null;
|
|
447
|
+
|
|
448
|
+
if (w1 === "CQ") {
|
|
449
|
+
// CQ <nonstdcall>
|
|
450
|
+
if (w2.length <= 4) return null; // too short for type 4
|
|
451
|
+
icq = 1;
|
|
452
|
+
iflip = 0;
|
|
453
|
+
// save_hash_call updates n12 with ihashcall12 of the callsign
|
|
454
|
+
n12 = ihashcall12(w2);
|
|
455
|
+
const c11 = w2.padStart(11, " ");
|
|
456
|
+
n58 = encodeC11(c11);
|
|
457
|
+
nrpt = 0;
|
|
458
|
+
} else if (w1.startsWith("<") && w1.endsWith(">")) {
|
|
459
|
+
// <HASH> CALL [rpt]
|
|
460
|
+
iflip = 0;
|
|
461
|
+
const inner = w1.slice(1, -1);
|
|
462
|
+
n12 = ihashcall12(inner);
|
|
463
|
+
const c11 = w2.padStart(11, " ");
|
|
464
|
+
n58 = encodeC11(c11);
|
|
465
|
+
nrpt = decodeRpt(w3);
|
|
466
|
+
} else if (w2.startsWith("<") && w2.endsWith(">")) {
|
|
467
|
+
// CALL <HASH> [rpt]
|
|
468
|
+
iflip = 1;
|
|
469
|
+
const inner = w2.slice(1, -1);
|
|
470
|
+
n12 = ihashcall12(inner);
|
|
471
|
+
const c11 = w1.padStart(11, " ");
|
|
472
|
+
n58 = encodeC11(c11);
|
|
473
|
+
nrpt = decodeRpt(w3);
|
|
474
|
+
} else {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const i3 = 4;
|
|
479
|
+
|
|
480
|
+
const bits: number[] = [];
|
|
481
|
+
appendBits(bits, n12, 12);
|
|
482
|
+
// n58 is a BigInt, need 58 bits
|
|
483
|
+
for (let b = 57; b >= 0; b--) {
|
|
484
|
+
bits.push(Number((n58 >> BigInt(b)) & 1n));
|
|
485
|
+
}
|
|
486
|
+
appendBits(bits, iflip, 1);
|
|
487
|
+
appendBits(bits, nrpt, 2);
|
|
488
|
+
appendBits(bits, icq, 1);
|
|
489
|
+
appendBits(bits, i3, 3);
|
|
490
|
+
return bits;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function ihashcall12(c0: string): number {
|
|
494
|
+
let n8 = 0n;
|
|
495
|
+
const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
|
|
496
|
+
for (let i = 0; i < 11; i++) {
|
|
497
|
+
const j = C38.indexOf(s[i] ?? " ");
|
|
498
|
+
n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
|
|
499
|
+
}
|
|
500
|
+
const MAGIC = 47055833459n;
|
|
501
|
+
const prod = BigInt.asUintN(64, MAGIC * n8);
|
|
502
|
+
return Number(prod >> 52n) & 0xfff; // 12 bits
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function encodeC11(c11: string): bigint {
|
|
506
|
+
const padded = c11.padStart(11, " ");
|
|
507
|
+
let n = 0n;
|
|
508
|
+
for (let i = 0; i < 11; i++) {
|
|
509
|
+
const j = C38.indexOf(padded[i]!.toUpperCase());
|
|
510
|
+
n = n * 38n + BigInt(j < 0 ? 0 : j);
|
|
511
|
+
}
|
|
512
|
+
return n;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function decodeRpt(w: string | undefined): number {
|
|
516
|
+
if (!w) return 0;
|
|
517
|
+
if (w === "RRR") return 1;
|
|
518
|
+
if (w === "RR73") return 2;
|
|
519
|
+
if (w === "73") return 3;
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function packFreeText(msg: string): number[] {
|
|
524
|
+
// Truncate to 13 chars, only characters from FTALPH
|
|
525
|
+
const raw = msg.slice(0, 13).toUpperCase();
|
|
526
|
+
const bits71 = packtext77(raw);
|
|
527
|
+
|
|
528
|
+
// Type 0.0: n3=0, i3=0 → last 6 bits are 000 000
|
|
529
|
+
const bits: number[] = [...bits71, 0, 0, 0, 0, 0, 0];
|
|
530
|
+
return bits; // 77 bits
|
|
531
|
+
}
|