@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
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@e04/ft8ts",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "FT8 encoder/decoder in pure TypeScript",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ft8",
|
|
7
|
+
"sdr",
|
|
8
|
+
"ham radio",
|
|
9
|
+
"amateur radio"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/e04/ft8ts.git"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/ft8ts.cjs",
|
|
16
|
+
"module": "dist/ft8ts.mjs",
|
|
17
|
+
"types": "./dist/ft8ts.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/ft8ts.d.ts",
|
|
21
|
+
"import": "./dist/ft8ts.mjs",
|
|
22
|
+
"require": "./dist/ft8ts.cjs"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "rollup -c",
|
|
28
|
+
"test": "vitest",
|
|
29
|
+
"lint": "biome check --write .",
|
|
30
|
+
"format": "biome format --write .",
|
|
31
|
+
"demo": "npx --yes serve . -p 3000"
|
|
32
|
+
},
|
|
33
|
+
"author": "e04",
|
|
34
|
+
"license": "GPL-3.0",
|
|
35
|
+
"type": "module",
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@biomejs/biome": "^2.4.4",
|
|
38
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
39
|
+
"@rollup/plugin-typescript": "^12.3.0",
|
|
40
|
+
"@types/node": "^25.3.0",
|
|
41
|
+
"rollup": "^4.59.0",
|
|
42
|
+
"rollup-plugin-dts": "^6.3.0",
|
|
43
|
+
"tslib": "^2.8.1",
|
|
44
|
+
"tsx": "^4.21.0",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"vitest": "^4.0.18"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"dist",
|
|
50
|
+
"src",
|
|
51
|
+
"example"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { decode } from "../ft8/decode.js";
|
|
6
|
+
import { encode174_91, getTones } from "../ft8/encode.js";
|
|
7
|
+
import { pack77 } from "../util/pack_jt77.js";
|
|
8
|
+
import { unpack77 } from "../util/unpack_jt77.js";
|
|
9
|
+
import { parseWavBuffer } from "../util/wav.js";
|
|
10
|
+
import { generateFT8Waveform } from "../util/waveform.js";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const SAMPLE_RATE = 12_000;
|
|
14
|
+
|
|
15
|
+
const ROUND_TRIP_MESSAGES = [
|
|
16
|
+
"CQ K1ABC FN42",
|
|
17
|
+
"K1ABC W9XYZ EN37",
|
|
18
|
+
"W9XYZ K1ABC -11",
|
|
19
|
+
"K1ABC W9XYZ R-09",
|
|
20
|
+
"W9XYZ K1ABC RRR",
|
|
21
|
+
"K1ABC W9XYZ 73",
|
|
22
|
+
"K1ABC W9XYZ RR73",
|
|
23
|
+
"CQ W9XYZ EN37",
|
|
24
|
+
"CQ JK1IFA PM95",
|
|
25
|
+
"TNX BOB 73 GL",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe("Unpack77", () => {
|
|
29
|
+
test.each(ROUND_TRIP_MESSAGES)('unpack matches original: "%s"', (msg) => {
|
|
30
|
+
const bits77 = pack77(msg);
|
|
31
|
+
const { msg: unpacked, success } = unpack77(bits77);
|
|
32
|
+
expect(success).toBe(true);
|
|
33
|
+
expect(unpacked.trim().toUpperCase()).toBe(msg.trim().toUpperCase());
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("FT8 Round Trip", () => {
|
|
38
|
+
test.each(ROUND_TRIP_MESSAGES)('encode then decode: "%s"', (msg) => {
|
|
39
|
+
const bits77 = pack77(msg);
|
|
40
|
+
const codeword = encode174_91(bits77);
|
|
41
|
+
const tones = getTones(codeword);
|
|
42
|
+
const baseFreq = 1000;
|
|
43
|
+
const waveform = generateFT8Waveform(tones, {
|
|
44
|
+
sampleRate: SAMPLE_RATE,
|
|
45
|
+
samplesPerSymbol: 1920,
|
|
46
|
+
bt: 2.0,
|
|
47
|
+
baseFrequency: baseFreq,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Place signal in a 15-second buffer at t=0.5s (standard FT8 timing)
|
|
51
|
+
const nmax = 15 * SAMPLE_RATE;
|
|
52
|
+
const fullBuffer = new Float32Array(nmax);
|
|
53
|
+
const offset = Math.round(0.5 * SAMPLE_RATE);
|
|
54
|
+
for (let i = 0; i < waveform.length && offset + i < nmax; i++) {
|
|
55
|
+
fullBuffer[offset + i] = waveform[i]!;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const decoded = decode(fullBuffer, SAMPLE_RATE, {
|
|
59
|
+
freqLow: 500,
|
|
60
|
+
freqHigh: 1500,
|
|
61
|
+
syncMin: 1.0,
|
|
62
|
+
depth: 2,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const found = decoded.find((d) => d.msg.trim().toUpperCase() === msg.trim().toUpperCase());
|
|
66
|
+
expect(found).toBeDefined();
|
|
67
|
+
if (found) {
|
|
68
|
+
expect(Math.abs(found.freq - baseFreq)).toBeLessThan(10);
|
|
69
|
+
}
|
|
70
|
+
}, 30_000);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
Downloaded from: https://sourceforge.net/projects/jtdx/files/samples/16bit_audio/FT8/190227_155815.wav/
|
|
75
|
+
*/
|
|
76
|
+
describe("WAV decode: 190227_155815.wav", () => {
|
|
77
|
+
test("decodes 17 messages matching expected results", () => {
|
|
78
|
+
const wavPath = join(__dirname, "190227_155815.wav");
|
|
79
|
+
const buf = readFileSync(wavPath);
|
|
80
|
+
const { sampleRate, samples } = parseWavBuffer(buf);
|
|
81
|
+
const decoded = decode(samples, sampleRate);
|
|
82
|
+
|
|
83
|
+
expect(decoded).toHaveLength(17);
|
|
84
|
+
|
|
85
|
+
/** Expected decode results for 190227_155815.wav */
|
|
86
|
+
const EXPECTED_190227_155815 = [
|
|
87
|
+
{ dt: 0.6, snr: -10, freq: 2568, msg: "UA6LIK SP3AVS R+00" },
|
|
88
|
+
{ dt: 0.7, snr: -11, freq: 822, msg: "YB3BBF SV2HXX R-20" },
|
|
89
|
+
{ dt: 0.6, snr: 2, freq: 200, msg: "R9AA IK5EEA 73" },
|
|
90
|
+
{ dt: 1.5, snr: -5, freq: 1149, msg: "UA3AIU UR5GCK -03" },
|
|
91
|
+
{ dt: 0.5, snr: -6, freq: 2512, msg: "GD3YUM UT7ZA KN57" },
|
|
92
|
+
{ dt: 0.6, snr: -11, freq: 523, msg: "CQ RD3FC KO95" },
|
|
93
|
+
{ dt: 0.7, snr: -7, freq: 424, msg: "R1BEQ UN7FU 73" },
|
|
94
|
+
{ dt: 0.7, snr: 3, freq: 1005, msg: "LB8ZH R2FAQ KO04" },
|
|
95
|
+
{ dt: 0.6, snr: -11, freq: 1892, msg: "EA7DGC IK8DYE 73" },
|
|
96
|
+
{ dt: 0.6, snr: 4, freq: 1659, msg: "UN7LZ RV3DQC -01" },
|
|
97
|
+
{ dt: 0.2, snr: 0, freq: 2378, msg: "CQ UA3QNE LO01" },
|
|
98
|
+
{ dt: 1.5, snr: -2, freq: 961, msg: "EA3HRU R6LCM RR73" },
|
|
99
|
+
{ dt: 0.7, snr: -3, freq: 2106, msg: "KA7RLM R7NW R-23" },
|
|
100
|
+
{ dt: 2.1, snr: -6, freq: 1294, msg: "G3VAO UA3GDJ KO92" },
|
|
101
|
+
{ dt: 0.8, snr: -15, freq: 1495, msg: "YD1GNL UA8CW MO07" },
|
|
102
|
+
{ dt: 0.4, snr: -11, freq: 2170, msg: "CQ ES6DO KO27" },
|
|
103
|
+
{ dt: 0.7, snr: -13, freq: 1433, msg: "YD1GNL R3KAB LN09" },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const exp of EXPECTED_190227_155815) {
|
|
107
|
+
const found = decoded.find(
|
|
108
|
+
(d) =>
|
|
109
|
+
d.msg === exp.msg &&
|
|
110
|
+
Math.round(d.freq) === exp.freq &&
|
|
111
|
+
Math.round(d.dt * 10) / 10 === exp.dt &&
|
|
112
|
+
Math.round(d.snr) === exp.snr,
|
|
113
|
+
);
|
|
114
|
+
expect(found, `Expected message not found: ${exp.msg}`).toBeDefined();
|
|
115
|
+
}
|
|
116
|
+
}, 15_000);
|
|
117
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full pipeline test: verifies that the TypeScript FT8 encoder matches the
|
|
3
|
+
* reference output produced by:
|
|
4
|
+
* /Applications/wsjtx.app/Contents/MacOS/ft8code "<message>"
|
|
5
|
+
*
|
|
6
|
+
* Run with:
|
|
7
|
+
* npx tsx src/test_full.ts
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { encode174_91, getTones } from "../ft8/encode.js";
|
|
11
|
+
import { pack77 } from "../util/pack_jt77.js";
|
|
12
|
+
import { FT8_VECTORS } from "./test_vectors.js";
|
|
13
|
+
|
|
14
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function bitsToString(bits: number[]): string {
|
|
17
|
+
return bits.join("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Format tones as the ft8code display line */
|
|
21
|
+
function formatTones(tones: number[]): string {
|
|
22
|
+
const sync = tones.slice(0, 7).join("");
|
|
23
|
+
const data1 = tones.slice(7, 36).join("");
|
|
24
|
+
const sync2 = tones.slice(36, 43).join("");
|
|
25
|
+
const data2 = tones.slice(43, 72).join("");
|
|
26
|
+
const sync3 = tones.slice(72, 79).join("");
|
|
27
|
+
return `${sync} ${data1} ${sync2} ${data2} ${sync3}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── test runner ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("FT8 Full Pipeline", () => {
|
|
33
|
+
test.each(FT8_VECTORS)('encodes message: "$msg"', (v) => {
|
|
34
|
+
// ── 1. pack77 → 77-bit source encoding ───────────────────────────────────
|
|
35
|
+
const bits77 = pack77(v.msg);
|
|
36
|
+
expect(bitsToString(bits77)).toBe(v.bits77);
|
|
37
|
+
|
|
38
|
+
// ── 2. encode174_91 → 91-bit (77 + 14-bit CRC) + 83 parity bits ─────────
|
|
39
|
+
const codeword = encode174_91(bits77);
|
|
40
|
+
const crc14 = codeword.slice(77, 91);
|
|
41
|
+
const parity83 = codeword.slice(91, 174);
|
|
42
|
+
|
|
43
|
+
expect(bitsToString(crc14)).toBe(v.crc14);
|
|
44
|
+
expect(bitsToString(parity83)).toBe(v.parity83);
|
|
45
|
+
|
|
46
|
+
// ── 3. getTones → 79 channel symbols ─────────────────────────────────────
|
|
47
|
+
const tones = getTones(codeword);
|
|
48
|
+
const tonesStr = formatTones(tones);
|
|
49
|
+
|
|
50
|
+
expect(tonesStr).toBe(v.tones);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ground-truth test vectors generated by running:
|
|
3
|
+
* /Applications/wsjtx.app/Contents/MacOS/ft8code "<message>"
|
|
4
|
+
* for each message INDIVIDUALLY to avoid data corruption.
|
|
5
|
+
*
|
|
6
|
+
* Each entry records the full pipeline output for one FT8 message:
|
|
7
|
+
* - bits77 : 77-bit source-encoded message (binary string)
|
|
8
|
+
* - crc14 : 14-bit CRC appended to form 91 bits (binary string)
|
|
9
|
+
* - parity83 : 83 LDPC parity bits (binary string)
|
|
10
|
+
* - tones : 79 channel symbols as the ft8code display line
|
|
11
|
+
* "3140652 <29 data tones> 3140652 <29 data tones> 3140652"
|
|
12
|
+
*/
|
|
13
|
+
export interface FT8Vector {
|
|
14
|
+
msg: string;
|
|
15
|
+
bits77: string;
|
|
16
|
+
crc14: string;
|
|
17
|
+
parity83: string;
|
|
18
|
+
tones: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const FT8_VECTORS: FT8Vector[] = [
|
|
22
|
+
// ── Type 1 – Standard (two callsigns + grid/RR73/73/report) ──────────────
|
|
23
|
+
{
|
|
24
|
+
msg: "CQ K1ABC FN42",
|
|
25
|
+
bits77: "00000000000000000000000000100000010011011110111100011010100010100001100110001",
|
|
26
|
+
crc14: "00101100101110",
|
|
27
|
+
parity83: "10101000001001000110111100001111000000111010010110111110100110100100001010010100110",
|
|
28
|
+
tones: "3140652 00000000100547670460602153343 3140652 73601104751700733474545513354 3140652",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
msg: "K1ABC W9XYZ EN37",
|
|
32
|
+
bits77: "00001001101111011110001101010000011000010100100111011100000010000101011001001",
|
|
33
|
+
crc14: "11000101111101",
|
|
34
|
+
parity83: "01110100100111100101001100101110111111000010000000010111101001111100110101011011010",
|
|
35
|
+
tones: "3140652 03224752350406114700513432537 3140652 46455756156477030037617546223 3140652",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
msg: "W9XYZ K1ABC -11",
|
|
39
|
+
bits77: "00001100001010010011101110000000010011011110111100011010100111111010101000001",
|
|
40
|
+
crc14: "11100001011000",
|
|
41
|
+
parity83: "10101010001101100001100111100001111000000000111111000110110010111111100000111011001",
|
|
42
|
+
tones: "3140652 02035572500547670461746302406 3140652 53631651575170007704437750721 3140652",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
msg: "K1ABC W9XYZ R-09",
|
|
46
|
+
bits77: "00001001101111011110001101010000011000010100100111011100001111111010101010001",
|
|
47
|
+
crc14: "11110000100100",
|
|
48
|
+
parity83: "11010110000101001010000011001010111110010011101111101010110110100010000110000101001",
|
|
49
|
+
tones: "3140652 03224752350406114702746352703 3140652 32340613021374326763445304061 3140652",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
msg: "W9XYZ K1ABC RRR",
|
|
53
|
+
bits77: "00001100001010010011101110000000010011011110111100011010100111111010010010001",
|
|
54
|
+
crc14: "00000100011001",
|
|
55
|
+
parity83: "01110010000100100010100001101001001001111100011110100011010001011111111100010011111",
|
|
56
|
+
tones: "3140652 02035572500547670461745553031 3140652 56430553516111752452312775327 3140652",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
msg: "K1ABC W9XYZ 73",
|
|
60
|
+
bits77: "00001001101111011110001101010000011000010100100111011100000111111010010100001",
|
|
61
|
+
crc14: "10101111000011",
|
|
62
|
+
parity83: "11101000111110001001010010101001100010010001011101000110110111001100101011101011111",
|
|
63
|
+
tones: "3140652 03224752350406114701745602375 3140652 17607411336153312604471562627 3140652",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
msg: "K1ABC W9XYZ RR73",
|
|
67
|
+
bits77: "00001001101111011110001101010000011000010100100111011100000111111001110101001",
|
|
68
|
+
crc14: "00111010010001",
|
|
69
|
+
parity83: "11001010000001001101001101000000010110101100001001001100001011011101110011110000011",
|
|
70
|
+
tones: "3140652 03224752350406114701742633261 3140652 07130116160034651115122642402 3140652",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
msg: "CQ FD K1ABC FN42",
|
|
74
|
+
bits77: "00000000000000000100100100010000010011011110111100011010100010100001100110001",
|
|
75
|
+
crc14: "00101111101001",
|
|
76
|
+
parity83: "00001111110110111000100010110101100110000001001111011101110010101111011010101110011",
|
|
77
|
+
tones: "3140652 00000111050547670460602153374 3140652 55174470534654011726436723642 3140652",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
msg: "CQ TEST K1ABC/R FN42",
|
|
81
|
+
bits77: "00000000011000010101111110010000010011011110111100011010110010100001100110001",
|
|
82
|
+
crc14: "10110111101110",
|
|
83
|
+
parity83: "01011001010001110100100000111001100101001011110010101110101001111111111010111111110",
|
|
84
|
+
tones: "3140652 00040627550547670465602152224 3140652 71213145507156124364617773774 3140652",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
msg: "K1ABC/R W9XYZ EN37",
|
|
88
|
+
bits77: "00001001101111011110001101011000011000010100100111011100000010000101011001001",
|
|
89
|
+
crc14: "00110011001010",
|
|
90
|
+
parity83: "11010111000111100001011011110001100000001100001010111101000011110111100011111001000",
|
|
91
|
+
tones: "3140652 03224752340406114700513433215 3140652 62370751224150151376024752710 3140652",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
msg: "W9XYZ K1ABC/R R FN42",
|
|
95
|
+
bits77: "00001100001010010011101110000000010011011110111100011010111010100001100110001",
|
|
96
|
+
crc14: "01100001011101",
|
|
97
|
+
parity83: "10111011010010010011010110100111111101110101010111100000101100001011010101111101011",
|
|
98
|
+
tones: "3140652 02035572500547670464602153406 3140652 44723332345776463750651236762 3140652",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
msg: "K1ABC/R W9XYZ RR73",
|
|
102
|
+
bits77: "00001001101111011110001101011000011000010100100111011100000111111001110101001",
|
|
103
|
+
crc14: "11001100100110",
|
|
104
|
+
parity83: "01101001100001001001011010011111001001100010000011100110100001010110100101010010001",
|
|
105
|
+
tones: "3140652 03224752340406114701742632543 3140652 21615111232711530254513456331 3140652",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
msg: "CQ TEST K1ABC FN42",
|
|
109
|
+
bits77: "00000000011000010101111110010000010011011110111100011010100010100001100110001",
|
|
110
|
+
crc14: "10000010100110",
|
|
111
|
+
parity83: "01011100000001100101000101001001111111001110000001101100011011010001000010100010110",
|
|
112
|
+
tones: "3140652 00040627550547670460602152013 3140652 21250156061177140165223103534 3140652",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
msg: "W9XYZ <PJ4/K1ABC> -11",
|
|
116
|
+
bits77: "00001100001010010011101110000000000110101001010110000101000111111010101000001",
|
|
117
|
+
crc14: "11000100101110",
|
|
118
|
+
parity83: "11001111000011010000100010101111111011101111110001100111111000110111000010111001101",
|
|
119
|
+
tones: "3140652 02035572500163365131746302533 3140652 72170230536772674157704703716 3140652",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
msg: "<PJ4/K1ABC> W9XYZ R-09",
|
|
123
|
+
bits77: "00000011010100101011000010100000011000010100100111011100001111111010101010001",
|
|
124
|
+
crc14: "10101100001110",
|
|
125
|
+
parity83: "00000011101101110011101111000010000111100010101001001001000001111010010110101011011",
|
|
126
|
+
tones: "3140652 00461340600406114702746352340 3140652 70026642670307536111017334622 3140652",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
msg: "CQ W9XYZ EN37",
|
|
130
|
+
bits77: "00000000000000000000000000100000011000010100100111011100000010000101011001001",
|
|
131
|
+
crc14: "11110000111000",
|
|
132
|
+
parity83: "11111110111101100111000100101001101101000101110000001000001010110101001100101101001",
|
|
133
|
+
tones: "3140652 00000000100406114700513432702 3140652 52747657056166064010134615661 3140652",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
msg: "<YW18FIFA> W9XYZ -11",
|
|
137
|
+
bits77: "00000010101101000010101011000000011000010100100111011100000111111010101000001",
|
|
138
|
+
crc14: "11000011110100",
|
|
139
|
+
parity83: "00001100000001011110000101010010100000110100010000110100101001000111111000001111000",
|
|
140
|
+
tones: "3140652 00623063400406114701746302517 3140652 30150124063350453045610770170 3140652",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
msg: "W9XYZ <YW18FIFA> R-09",
|
|
144
|
+
bits77: "00001100001010010011101110000000000101011010000101010110001111111010101010001",
|
|
145
|
+
crc14: "01001011111011",
|
|
146
|
+
parity83: "01101011110010000101001011101000001010101100111011001011001011111001010110100001011",
|
|
147
|
+
tones: "3140652 02035572500134513652746353567 3140652 66624306126013657212127134512 3140652",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
msg: "<YW18FIFA> KA1ABC",
|
|
151
|
+
bits77: "00000010101101000010101011000100101011100011001010010000100111111010010001001",
|
|
152
|
+
crc14: "11000001110010",
|
|
153
|
+
parity83: "01011010110101011000010100100010011001001100010110011111001011011000010100011100100",
|
|
154
|
+
tones: "3140652 00623063411370435511745532507 3140652 11234620355321153427122035255 3140652",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
msg: "KA1ABC <YW18FIFA> -11",
|
|
158
|
+
bits77: "10010101110001100101001000010000000101011010000101010110000111111010101000001",
|
|
159
|
+
crc14: "01001001011111",
|
|
160
|
+
parity83: "10100010010101100000000010100011111001000101101000011111001001001011110111010100110",
|
|
161
|
+
tones: "3140652 56252133050134513651746303556 3140652 74533650035271066027111247354 3140652",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
msg: "<YW18FIFA> KA1ABC R-17",
|
|
165
|
+
bits77: "00000010101101000010101011000100101011100011001010010000101111111010100010001",
|
|
166
|
+
crc14: "00000101101111",
|
|
167
|
+
parity83: "10101000110010001000001111110100110011001100101010100000000000100101110101100000101",
|
|
168
|
+
tones: "3140652 00623063411370435512746053034 3140652 74604310174542156350005646506 3140652",
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
msg: "<YW18FIFA> KA1ABC 73",
|
|
172
|
+
bits77: "00000010101101000010101011000100101011100011001010010000100111111010010100001",
|
|
173
|
+
crc14: "10000111011011",
|
|
174
|
+
parity83: "11010101101011001110100001100010110110100001100111001000011010001010100011111100001",
|
|
175
|
+
tones: "3140652 00623063411370435511745602026 3140652 67366214515344515710231352751 3140652",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
msg: "CQ G4ABC/P IO91",
|
|
179
|
+
bits77: "00000000000000000000000000100000010010000110000010110011010011111000010011010",
|
|
180
|
+
crc14: "01010111110100",
|
|
181
|
+
parity83: "01001111100010100100100111111010011001010011101101001000010011100110101000011001001",
|
|
182
|
+
tones: "3140652 00000000100551506545740545627 3140652 31175355577321326610325460211 3140652",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
msg: "G4ABC/P PA9XYZ JO22",
|
|
186
|
+
bits77: "00001001000011000001011001101101101111011101011000101010000100010011010110010",
|
|
187
|
+
crc14: "01011011110010",
|
|
188
|
+
parity83: "11100010101100011000110110001011110111010100010010010010001011110110010010100100011",
|
|
189
|
+
tones: "3140652 03304034222247341351054655667 3140652 12536520441247353333124433552 3140652",
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
msg: "PA9XYZ G4ABC/P RR73",
|
|
193
|
+
bits77: "10110111101110101100010101000000010010000110000010110011010111111001110101010",
|
|
194
|
+
crc14: "11011110000011",
|
|
195
|
+
parity83: "00100001111110111100000100111111100000110100000011000000101110010010101111011010110",
|
|
196
|
+
tones: "3140652 66726206300551506546742636670 3140652 15517475057750450200643367234 3140652",
|
|
197
|
+
},
|
|
198
|
+
// ── Type 4 – One nonstandard callsign (hash) + one standard call ──────────
|
|
199
|
+
{
|
|
200
|
+
msg: "CQ YW18FIFA",
|
|
201
|
+
bits77: "00101111000100000000000000001110111011100011100111111010101100001001110001100",
|
|
202
|
+
crc14: "01000000101100",
|
|
203
|
+
parity83: "10011010100101010101110100100001000110001111011011101010010110100010000101010100111",
|
|
204
|
+
tones: "3140652 12410000026470717462032520503 3140652 43235636455104172263345306357 3140652",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
msg: "<KA1ABC> YW18FIFA RR73",
|
|
208
|
+
bits77: "00101101001100000000000000001110111011100011100111111010101100001001110100100",
|
|
209
|
+
crc14: "01111001001110",
|
|
210
|
+
parity83: "10000110001000001101000000100000000010110001010110011101101101000011000110100111001",
|
|
211
|
+
tones: "3140652 12320000026470717462032610755 3140652 73041016005003413426660204571 3140652",
|
|
212
|
+
},
|
|
213
|
+
// ── Type 0.0 – Free text ──────────────────────────────────────────────────
|
|
214
|
+
{
|
|
215
|
+
msg: "TNX BOB 73 GL",
|
|
216
|
+
bits77: "01100011111011011100111011100010101001001010111000000111111101010000000000000",
|
|
217
|
+
crc14: "11111110001011",
|
|
218
|
+
parity83: "10101110011111010000101100110101000111011110110000100000010101111000001010000100010",
|
|
219
|
+
tones: "3140652 20744714706333640177350001770 3140652 64642730654607244050367013053 3140652",
|
|
220
|
+
},
|
|
221
|
+
];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { decode } from "../ft8/decode.js";
|
|
6
|
+
import { encode } from "../ft8/encode.js";
|
|
7
|
+
import { parseWavBuffer, writeMono16WavFile } from "../util/wav.js";
|
|
8
|
+
|
|
9
|
+
const SAMPLE_RATE = 12_000;
|
|
10
|
+
const BASE_FREQ = 1_000;
|
|
11
|
+
|
|
12
|
+
const ROUND_TRIP_MESSAGES = ["CQ K1ABC FN42", "CQ TEST KO01", "K1ABC W9XYZ EN37"];
|
|
13
|
+
|
|
14
|
+
describe("WAV round-trip", () => {
|
|
15
|
+
test.each(ROUND_TRIP_MESSAGES)('write WAV, read, decode: "%s"', (msg) => {
|
|
16
|
+
const waveform = encode(msg, {
|
|
17
|
+
sampleRate: SAMPLE_RATE,
|
|
18
|
+
samplesPerSymbol: 1_920,
|
|
19
|
+
bt: 2.0,
|
|
20
|
+
baseFrequency: BASE_FREQ,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const wavPath = join(tmpdir(), `ft8js2-roundtrip-${process.pid}-${Date.now()}.wav`);
|
|
24
|
+
writeMono16WavFile(wavPath, waveform, SAMPLE_RATE);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const buf = readFileSync(wavPath);
|
|
28
|
+
const { sampleRate, samples } = parseWavBuffer(buf);
|
|
29
|
+
const decoded = decode(samples, sampleRate, {
|
|
30
|
+
freqLow: 500,
|
|
31
|
+
freqHigh: 1500,
|
|
32
|
+
syncMin: 1.0,
|
|
33
|
+
depth: 2,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const found = decoded.find((d) => d.msg.trim().toUpperCase() === msg.trim().toUpperCase());
|
|
37
|
+
expect(found).toBeDefined();
|
|
38
|
+
if (found) {
|
|
39
|
+
expect(Math.abs(found.freq - BASE_FREQ)).toBeLessThan(10);
|
|
40
|
+
}
|
|
41
|
+
} finally {
|
|
42
|
+
unlinkSync(wavPath);
|
|
43
|
+
}
|
|
44
|
+
}, 30_000);
|
|
45
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { encode, encodeMessage } from "../ft8/encode.js";
|
|
3
|
+
import { generateFT8Waveform } from "../util/waveform.js";
|
|
4
|
+
|
|
5
|
+
describe("FT8 waveform generator", () => {
|
|
6
|
+
test("generates a Float32Array waveform with FT8 default length", () => {
|
|
7
|
+
const tones = encodeMessage("CQ K1ABC FN42");
|
|
8
|
+
const waveform = generateFT8Waveform(tones, { baseFrequency: 1000 });
|
|
9
|
+
|
|
10
|
+
expect(waveform).toBeInstanceOf(Float32Array);
|
|
11
|
+
expect(waveform.length).toBe(79 * 1920);
|
|
12
|
+
expect(waveform[0]).toBeCloseTo(0, 6);
|
|
13
|
+
expect(Number.isFinite(waveform[waveform.length - 1])).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("message helper produces the same waveform as manual two-step generation", () => {
|
|
17
|
+
const msg = "CQ K1ABC FN42";
|
|
18
|
+
const options = { baseFrequency: 1500 };
|
|
19
|
+
|
|
20
|
+
const manual = generateFT8Waveform(encodeMessage(msg), options);
|
|
21
|
+
const viaHelper = encode(msg, options);
|
|
22
|
+
|
|
23
|
+
expect(viaHelper.length).toBe(manual.length);
|
|
24
|
+
expect(viaHelper[0]).toBeCloseTo(manual[0]!, 7);
|
|
25
|
+
expect(viaHelper[12345]).toBeCloseTo(manual[12345]!, 7);
|
|
26
|
+
expect(viaHelper[viaHelper.length - 1]).toBeCloseTo(manual[manual.length - 1]!, 7);
|
|
27
|
+
});
|
|
28
|
+
});
|