@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,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
+ }