@e04/ft8ts 0.0.2 → 0.0.3
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 +33 -24
- package/dist/ft8ts.cjs +130 -11
- package/dist/ft8ts.cjs.map +1 -1
- package/dist/ft8ts.d.ts +57 -1
- package/dist/ft8ts.mjs +130 -12
- package/dist/ft8ts.mjs.map +1 -1
- package/example/browser/index.html +4 -4
- package/package.json +51 -51
- package/src/ft8/decode.ts +15 -4
- package/src/index.ts +1 -0
- package/src/util/hashcall.ts +110 -0
- package/src/util/unpack_jt77.ts +17 -8
package/src/ft8/decode.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "../util/constants.js";
|
|
14
14
|
import { decode174_91 } from "../util/decode174_91.js";
|
|
15
15
|
import { fftComplex, nextPow2 } from "../util/fft.js";
|
|
16
|
+
import type { HashCallBook } from "../util/hashcall.js";
|
|
16
17
|
import { unpack77 } from "../util/unpack_jt77.js";
|
|
17
18
|
|
|
18
19
|
export interface DecodedMessage {
|
|
@@ -34,6 +35,14 @@ export interface DecodeOptions {
|
|
|
34
35
|
depth?: number;
|
|
35
36
|
/** Maximum candidates to process */
|
|
36
37
|
maxCandidates?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Hash call book for resolving hashed callsigns.
|
|
40
|
+
* When provided, decoded standard callsigns are saved into the book,
|
|
41
|
+
* and hashed callsigns (e.g. `<...>`) are resolved from it.
|
|
42
|
+
* Pass the same instance across multiple `decode` calls to accumulate
|
|
43
|
+
* callsign knowledge over time.
|
|
44
|
+
*/
|
|
45
|
+
hashCallBook?: HashCallBook;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
|
@@ -50,6 +59,7 @@ export function decode(
|
|
|
50
59
|
const syncmin = options.syncMin ?? 1.2;
|
|
51
60
|
const depth = options.depth ?? 2;
|
|
52
61
|
const maxCandidates = options.maxCandidates ?? 300;
|
|
62
|
+
const book = options.hashCallBook;
|
|
53
63
|
|
|
54
64
|
// Resample to 12000 Hz if needed
|
|
55
65
|
let dd: Float64Array;
|
|
@@ -77,7 +87,7 @@ export function decode(
|
|
|
77
87
|
const seenMessages = new Set<string>();
|
|
78
88
|
|
|
79
89
|
for (const cand of candidates) {
|
|
80
|
-
const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth);
|
|
90
|
+
const result = ft8b(dd, cxRe, cxIm, cand.freq, cand.dt, sbase, depth, book);
|
|
81
91
|
if (!result) continue;
|
|
82
92
|
|
|
83
93
|
if (seenMessages.has(result.msg)) continue;
|
|
@@ -312,17 +322,18 @@ interface Ft8bResult {
|
|
|
312
322
|
}
|
|
313
323
|
|
|
314
324
|
function ft8b(
|
|
315
|
-
|
|
325
|
+
_dd0: Float64Array,
|
|
316
326
|
cxRe: Float64Array,
|
|
317
327
|
cxIm: Float64Array,
|
|
318
328
|
f1: number,
|
|
319
329
|
xdt: number,
|
|
320
330
|
_sbase: Float64Array,
|
|
321
331
|
depth: number,
|
|
332
|
+
book: HashCallBook | undefined,
|
|
322
333
|
): Ft8bResult | null {
|
|
323
334
|
const NFFT2 = 3200;
|
|
324
335
|
const NP2 = 2812;
|
|
325
|
-
const
|
|
336
|
+
const _NFFT1_LONG = 192000;
|
|
326
337
|
const fs2 = SAMPLE_RATE / NDOWN;
|
|
327
338
|
const dt2 = 1.0 / fs2;
|
|
328
339
|
const twopi = 2 * Math.PI;
|
|
@@ -541,7 +552,7 @@ function ft8b(
|
|
|
541
552
|
if (i3v === 0 && n3v === 2) return null;
|
|
542
553
|
|
|
543
554
|
// Unpack
|
|
544
|
-
const { msg, success } = unpack77(message77);
|
|
555
|
+
const { msg, success } = unpack77(message77, book);
|
|
545
556
|
if (!success || msg.trim().length === 0) return null;
|
|
546
557
|
|
|
547
558
|
// Estimate SNR
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash call table – TypeScript port of the hash call storage from packjt77.f90
|
|
3
|
+
*
|
|
4
|
+
* In FT8, nonstandard callsigns are transmitted as hashes (10-, 12-, or 22-bit).
|
|
5
|
+
* When a full callsign is decoded from a standard message, it is stored in this
|
|
6
|
+
* table so that future hashed references to it can be resolved.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors Fortran: save_hash_call, hash10, hash12, hash22, ihashcall
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { C38 } from "./constants.js";
|
|
12
|
+
|
|
13
|
+
const MAGIC = 47055833459n;
|
|
14
|
+
const MAX_HASH22_ENTRIES = 1000;
|
|
15
|
+
|
|
16
|
+
function ihashcall(c0: string, m: number): number {
|
|
17
|
+
const s = c0.padEnd(11, " ").slice(0, 11).toUpperCase();
|
|
18
|
+
let n8 = 0n;
|
|
19
|
+
for (let i = 0; i < 11; i++) {
|
|
20
|
+
const j = C38.indexOf(s[i] ?? " ");
|
|
21
|
+
n8 = 38n * n8 + BigInt(j < 0 ? 0 : j);
|
|
22
|
+
}
|
|
23
|
+
const prod = BigInt.asUintN(64, MAGIC * n8);
|
|
24
|
+
return Number(prod >> BigInt(64 - m)) & ((1 << m) - 1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maintains a callsign ↔ hash lookup table for resolving hashed FT8 callsigns.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* ```ts
|
|
32
|
+
* const book = new HashCallBook();
|
|
33
|
+
* const decoded = decodeFT8(samples, sampleRate, { hashCallBook: book });
|
|
34
|
+
* // `book` now contains callsigns learned from decoded messages.
|
|
35
|
+
* // Subsequent calls reuse the same book to resolve hashed callsigns:
|
|
36
|
+
* const decoded2 = decodeFT8(samples2, sampleRate, { hashCallBook: book });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* You can also pre-populate the book with known callsigns:
|
|
40
|
+
* ```ts
|
|
41
|
+
* book.save("W9XYZ");
|
|
42
|
+
* book.save("PJ4/K1ABC");
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export class HashCallBook {
|
|
46
|
+
private readonly calls10 = new Map<number, string>();
|
|
47
|
+
private readonly calls12 = new Map<number, string>();
|
|
48
|
+
private readonly hash22Entries: { hash: number; call: string }[] = [];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Store a callsign in all three hash tables (10, 12, 22-bit).
|
|
52
|
+
* Strips angle brackets if present. Ignores `<...>` and blank/short strings.
|
|
53
|
+
*/
|
|
54
|
+
save(callsign: string): void {
|
|
55
|
+
let cw = callsign.trim().toUpperCase();
|
|
56
|
+
if (cw === "" || cw === "<...>") return;
|
|
57
|
+
if (cw.startsWith("<")) cw = cw.slice(1);
|
|
58
|
+
const gt = cw.indexOf(">");
|
|
59
|
+
if (gt >= 0) cw = cw.slice(0, gt);
|
|
60
|
+
cw = cw.trim();
|
|
61
|
+
if (cw.length < 3) return;
|
|
62
|
+
|
|
63
|
+
const n10 = ihashcall(cw, 10);
|
|
64
|
+
if (n10 >= 0 && n10 <= 1023) this.calls10.set(n10, cw);
|
|
65
|
+
|
|
66
|
+
const n12 = ihashcall(cw, 12);
|
|
67
|
+
if (n12 >= 0 && n12 <= 4095) this.calls12.set(n12, cw);
|
|
68
|
+
|
|
69
|
+
const n22 = ihashcall(cw, 22);
|
|
70
|
+
const existing = this.hash22Entries.findIndex((e) => e.hash === n22);
|
|
71
|
+
if (existing >= 0) {
|
|
72
|
+
this.hash22Entries[existing]!.call = cw;
|
|
73
|
+
} else {
|
|
74
|
+
if (this.hash22Entries.length >= MAX_HASH22_ENTRIES) {
|
|
75
|
+
this.hash22Entries.pop();
|
|
76
|
+
}
|
|
77
|
+
this.hash22Entries.unshift({ hash: n22, call: cw });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Look up a callsign by its 10-bit hash. Returns `null` if not found. */
|
|
82
|
+
lookup10(n10: number): string | null {
|
|
83
|
+
if (n10 < 0 || n10 > 1023) return null;
|
|
84
|
+
return this.calls10.get(n10) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Look up a callsign by its 12-bit hash. Returns `null` if not found. */
|
|
88
|
+
lookup12(n12: number): string | null {
|
|
89
|
+
if (n12 < 0 || n12 > 4095) return null;
|
|
90
|
+
return this.calls12.get(n12) ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Look up a callsign by its 22-bit hash. Returns `null` if not found. */
|
|
94
|
+
lookup22(n22: number): string | null {
|
|
95
|
+
const entry = this.hash22Entries.find((e) => e.hash === n22);
|
|
96
|
+
return entry?.call ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Number of entries in the 22-bit hash table. */
|
|
100
|
+
get size(): number {
|
|
101
|
+
return this.hash22Entries.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remove all stored entries. */
|
|
105
|
+
clear(): void {
|
|
106
|
+
this.calls10.clear();
|
|
107
|
+
this.calls12.clear();
|
|
108
|
+
this.hash22Entries.length = 0;
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/util/unpack_jt77.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { A1, A2, A3, A4, C38, FTALPH, MAX22, MAXGRID4, NTOKENS } from "./constants.js";
|
|
12
|
+
import type { HashCallBook } from "./hashcall.js";
|
|
12
13
|
|
|
13
14
|
function bitsToUint(bits: number[], start: number, len: number): number {
|
|
14
15
|
let val = 0;
|
|
@@ -18,7 +19,7 @@ function bitsToUint(bits: number[], start: number, len: number): number {
|
|
|
18
19
|
return val;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
function unpack28(n28: number): { call: string; success: boolean } {
|
|
22
|
+
function unpack28(n28: number, book: HashCallBook | undefined): { call: string; success: boolean } {
|
|
22
23
|
if (n28 < 0 || n28 >= 268435456) return { call: "", success: false };
|
|
23
24
|
|
|
24
25
|
if (n28 === 0) return { call: "DE", success: true };
|
|
@@ -31,7 +32,6 @@ function unpack28(n28: number): { call: string; success: boolean } {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
if (n28 >= 1003 && n28 < NTOKENS) {
|
|
34
|
-
// CQ with 4-letter directed call
|
|
35
35
|
let m = n28 - 1003;
|
|
36
36
|
let chars = "";
|
|
37
37
|
for (let i = 3; i >= 0; i--) {
|
|
@@ -45,7 +45,9 @@ function unpack28(n28: number): { call: string; success: boolean } {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
if (n28 >= NTOKENS && n28 < NTOKENS + MAX22) {
|
|
48
|
-
|
|
48
|
+
const n22 = n28 - NTOKENS;
|
|
49
|
+
const resolved = book?.lookup22(n22);
|
|
50
|
+
if (resolved) return { call: `<${resolved}>`, success: true };
|
|
49
51
|
return { call: "<...>", success: true };
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -127,8 +129,11 @@ function unpackText77(bits71: number[]): string {
|
|
|
127
129
|
|
|
128
130
|
/**
|
|
129
131
|
* Unpack a 77-bit FT8 message into a human-readable string.
|
|
132
|
+
*
|
|
133
|
+
* When a {@link HashCallBook} is provided, hashed callsigns are resolved from
|
|
134
|
+
* the book, and newly decoded standard callsigns are saved into it.
|
|
130
135
|
*/
|
|
131
|
-
export function unpack77(bits77: number[]): { msg: string; success: boolean } {
|
|
136
|
+
export function unpack77(bits77: number[], book?: HashCallBook): { msg: string; success: boolean } {
|
|
132
137
|
const n3 = bitsToUint(bits77, 71, 3);
|
|
133
138
|
const i3 = bitsToUint(bits77, 74, 3);
|
|
134
139
|
|
|
@@ -148,8 +153,8 @@ export function unpack77(bits77: number[]): { msg: string; success: boolean } {
|
|
|
148
153
|
const ir = bits77[58]!;
|
|
149
154
|
const igrid4 = bitsToUint(bits77, 59, 15);
|
|
150
155
|
|
|
151
|
-
const { call: call1, success: ok1 } = unpack28(n28a);
|
|
152
|
-
const { call: call2Raw, success: ok2 } = unpack28(n28b);
|
|
156
|
+
const { call: call1, success: ok1 } = unpack28(n28a, book);
|
|
157
|
+
const { call: call2Raw, success: ok2 } = unpack28(n28b, book);
|
|
153
158
|
if (!ok1 || !ok2) return { msg: "", success: false };
|
|
154
159
|
|
|
155
160
|
let c1 = call1;
|
|
@@ -164,6 +169,8 @@ export function unpack77(bits77: number[]): { msg: string; success: boolean } {
|
|
|
164
169
|
if (c2.indexOf("<") < 0) {
|
|
165
170
|
if (ipb === 1 && i3 === 1 && c2.length >= 3) c2 += "/R";
|
|
166
171
|
if (ipb === 1 && i3 === 2 && c2.length >= 3) c2 += "/P";
|
|
172
|
+
// Save the "from" call (call_2) into the hash book
|
|
173
|
+
if (book && c2.length >= 3) book.save(c2);
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
if (igrid4 <= MAXGRID4) {
|
|
@@ -191,6 +198,7 @@ export function unpack77(bits77: number[]): { msg: string; success: boolean } {
|
|
|
191
198
|
|
|
192
199
|
if (i3 === 4) {
|
|
193
200
|
// Type 4: One nonstandard call
|
|
201
|
+
const n12 = bitsToUint(bits77, 0, 12);
|
|
194
202
|
let n58 = 0n;
|
|
195
203
|
for (let i = 0; i < 58; i++) {
|
|
196
204
|
n58 = n58 * 2n + BigInt(bits77[12 + i] ?? 0);
|
|
@@ -199,7 +207,6 @@ export function unpack77(bits77: number[]): { msg: string; success: boolean } {
|
|
|
199
207
|
const nrpt = bitsToUint(bits77, 71, 2);
|
|
200
208
|
const icq = bits77[73]!;
|
|
201
209
|
|
|
202
|
-
// Decode n58 to 11-char string using C38 alphabet
|
|
203
210
|
const c11chars: string[] = [];
|
|
204
211
|
let remain = n58;
|
|
205
212
|
for (let i = 10; i >= 0; i--) {
|
|
@@ -209,13 +216,15 @@ export function unpack77(bits77: number[]): { msg: string; success: boolean } {
|
|
|
209
216
|
}
|
|
210
217
|
const c11 = c11chars.join("").trim();
|
|
211
218
|
|
|
212
|
-
const
|
|
219
|
+
const resolved = book?.lookup12(n12);
|
|
220
|
+
const call3 = resolved ? `<${resolved}>` : "<...>";
|
|
213
221
|
|
|
214
222
|
let call1: string;
|
|
215
223
|
let call2: string;
|
|
216
224
|
if (iflip === 0) {
|
|
217
225
|
call1 = call3;
|
|
218
226
|
call2 = c11;
|
|
227
|
+
if (book) book.save(c11);
|
|
219
228
|
} else {
|
|
220
229
|
call1 = c11;
|
|
221
230
|
call2 = call3;
|