ft8 article
This commit is contained in:
parent
ee71ba5b7f
commit
3b7f7b39c9
3 changed files with 899 additions and 0 deletions
102
content/blog/ft8-second-by-second.md
Normal file
102
content/blog/ft8-second-by-second.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
+++
|
||||
title = "An FT8 transmission, second by second"
|
||||
date = 2026-04-27
|
||||
+++
|
||||
|
||||
<link rel="stylesheet" href="/css/scene.css">
|
||||
|
||||
<div class="scene-host" data-scene-host>
|
||||
|
||||
The most popular digital mode in amateur radio sounds, to a human ear, like a small chorus of out-of-tune piccolos warming up. It runs for exactly twelve and a bit seconds, repeats every fifteen, and somehow squeezes a complete contact between two stations into less audio than a ringtone. Unlike the dial-up handshake, which serenaded a generation of pre-broadband households with a sequence whose every wail and chirp meant something specific, FT8 is too quiet, too brief, and too dense to dissect by ear. The information is in there. Your auditory cortex just doesn't have the right decoder.
|
||||
|
||||
What follows is what those tones are actually doing, second by second, from the moment the operator clicks "Enable TX" to the moment a similar-looking string of characters lights up on someone else's screen 8000 km away.
|
||||
|
||||
<div id="scene-ft8-spectrum"></div>
|
||||
|
||||
The whole transmission, laid out as the spectrogram a receiver would see. Time runs left to right across 12.64 seconds. The vertical axis is the eight 8-FSK tones, spaced 6.25 Hz apart for a total signal bandwidth of about 50 Hz. The 79 tones are not pseudo-random fill; they are the actual output of an FT8 encoder running in the browser on the message "CQ W5ISP EM13", carrying 91 bits of source code + CRC and 83 bits of LDPC parity. Drag the time cursor (or flip auto-play) to step through symbol by symbol; the readout below tells you which phase you're in and what those three bits, or that sync symbol, are doing. The amber Costas-array pattern (3, 1, 4, 0, 6, 5, 2) appears three times, at start, middle, and end, and is the scaffold the rest of the protocol hangs on.
|
||||
|
||||
## Before t = 0: the computer does the hard part
|
||||
|
||||
Long before any RF leaves the antenna, your computer takes the message you want to send and beats it into a shape the radio knows how to handle. FT8 messages are 77 bits. That's the entire payload budget. There is no negotiation. Want to send "CQ W5ISP EM13"? That fits, with room to spare. Want to send a paragraph of pleasantries? You cannot. The protocol is constitutionally incapable of small talk.
|
||||
|
||||
The 77 bits are produced by a [source encoder](https://en.wikipedia.org/wiki/Source_coding) that knows the structure of an amateur QSO and exploits it ruthlessly. Standard callsigns get hashed into compact integer indices. Grid squares get packed into 15 bits. The signal report is a small integer offset from a known base. The full taxonomy of FT8 message types (standard, contest, EU VHF, ARRL Field Day, free-text, telemetry) is encoded in the first three bits, with three more bits for sub-type, so the decoder knows how to interpret the rest.
|
||||
|
||||
Once the message is 77 bits of densely packed binary, three more steps happen in quick succession:
|
||||
|
||||
1. A [CRC-14](https://en.wikipedia.org/wiki/Cyclic_redundancy_check) is computed over the 77 bits and appended, giving 91 bits. The CRC is purely a sanity check; it does not protect against errors, it only detects them.
|
||||
2. The 91 bits get fed through a rate-1/2 [LDPC(174,91) encoder](https://en.wikipedia.org/wiki/Low-density_parity-check_code). 174 bits come out. The 83 added bits are parity, distributed sparsely across the codeword in a pattern that lets the decoder recover the original even when many of the 174 bits arrive wrong. LDPC is the reason FT8 decodes down at -24 dB SNR while you can't hear a thing in the speaker.
|
||||
3. The 174 bits get grouped three at a time into 58 [octal symbols](https://en.wikipedia.org/wiki/Octal), each one a number from 0 to 7. Each symbol becomes one of eight tones. The mapping is [Gray-coded](https://en.wikipedia.org/wiki/Gray_code) so an error to an adjacent tone (the most common kind) only flips one bit.
|
||||
|
||||
Then the symbol stream gets sandwiched between three copies of the famous [Costas 7x7 array](https://en.wikipedia.org/wiki/Costas_array): the sequence [3, 1, 4, 0, 6, 5, 2]. One copy at the start, one in the middle, one at the end. These 21 sync symbols carry no payload data. Their sole job is to be unambiguously findable by a receiver that doesn't know exactly when the transmitter started.
|
||||
|
||||
Total: 7 + 29 + 7 + 29 + 7 = 79 symbols. At 160 ms per symbol, that's 12.64 seconds of audio. The numbers are not coincidences; the symbol rate (6.25 baud) and the tone spacing (6.25 Hz) are deliberately equal so the eight tones are mutually orthogonal at the symbol boundaries. This means a matched filter looking at any one tone gets zero contribution from the other seven, which is the cleanest possible situation for a demodulator working at sub-noise-floor SNRs.
|
||||
|
||||
<div id="scene-ft8-pipeline"></div>
|
||||
|
||||
The same pipeline that produced the spectrogram above, with bit counts laid out so the geometry tells the story. "CQ W5ISP EM13" packs to 77 bits, gains 14 bits of CRC, gets doubled to 174 bits by LDPC, packs into 58 octal symbols, and finally has three Costas arrays spliced in to make the 79-symbol on-air stream. The bottom row is the same spectrogram as above, miniaturized.
|
||||
|
||||
## t = 0 ms: the carrier appears
|
||||
|
||||
The transmission is timed to start exactly 500 ms after the start of an even or odd UTC 15-second slot, depending on which slot you've chosen. The first 160 ms contains the first sync symbol, but with one twist: the PA's drive level ramps up from zero over a small fraction of that first symbol to avoid splattering keyclicks across the band. Aggressive ramping makes nearby operators love you. Lazy ramping makes them complain about your signal on the next 25 Hz bin over.
|
||||
|
||||
A receiver listening on the right slot sees the first tone arrive, and starts the long process of figuring out where exactly in time and frequency it landed.
|
||||
|
||||
## t = 0 ms to 1.12 s: first Costas array
|
||||
|
||||
Seven symbols of [3, 1, 4, 0, 6, 5, 2]. To a spectrogram, this looks like seven brightly lit pixels in a staircase pattern that doesn't repeat under any combination of time or frequency shifts. Costas arrays are mathematical objects with the specific property that their auto-correlation has exactly one peak, and that peak is sharp.
|
||||
|
||||
The receiver doesn't know your transmit frequency to within 6.25 Hz. It doesn't know your start time to within 160 ms. The Costas array exists to fix both at once. The receiver slides a Costas template across the entire spectrogram in both axes, computing a correlation score at every (frequency, time) offset. There will be exactly one place where the score is high. That's your signal. Within milliseconds the receiver has nailed down where to start counting symbols and what frequency to mix down to.
|
||||
|
||||
The reason Costas arrays beat simpler sync patterns is that the mode is meant to work when the signal is well below the noise floor. A repetitive sync sequence (like a tone burst) would be impossible to find when buried in noise because the auto-correlation has multiple peaks at every offset corresponding to the period. The Costas property guarantees a single, unambiguous peak even in heavy noise, which is the only way the rest of the protocol gets to do its job.
|
||||
|
||||
## t = 1.12 to 5.76 s: first data block
|
||||
|
||||
29 symbols, each one of eight tones, each carrying 3 bits, each lasting 160 ms. To a spectrogram, this looks like a sequence of 29 horizontal bars hopping seemingly at random between the eight available tone slots. To the receiver, this is the first half of the LDPC(174,91) codeword: 87 bits worth of "your message, plus parity, plus more parity, all interleaved."
|
||||
|
||||
The bits are not in obvious payload order. The LDPC encoder produces a codeword where the original 91 message bits are mixed with 83 parity bits in a pattern designed to maximize how spread out the parity protection is. Even if a contiguous chunk of the data is destroyed by a noise burst, the remaining bits carry enough redundancy to reconstruct the original. The encoder's matrix is sparse (the "low density" part of LDPC), which is what makes the decoder fast enough to run in software on a typical computer.
|
||||
|
||||
While this block transmits, the receiver is already demodulating it. Each 160 ms symbol gets fed through a bank of eight matched filters, one per tone, and the receiver computes a soft-decision likelihood for each of the three bits the symbol represents. Hard decisions (just picking the loudest tone) lose 2-3 dB of decoding margin. FT8 always keeps the soft values around because LDPC's whole superpower is using probabilistic information from the channel.
|
||||
|
||||
## t = 5.76 to 6.88 s: middle Costas array
|
||||
|
||||
Another seven symbols of [3, 1, 4, 0, 6, 5, 2]. The receiver already found the signal during the first Costas, so why repeat it? Two reasons.
|
||||
|
||||
First, the second Costas lets the receiver re-estimate timing and frequency drift. If the transmitter's clock is a few ppm off, or yours is, or there's a slow Doppler shift on the path because the ionosphere is doing one of its slower acrobatic routines, the symbols later in the transmission will arrive a bit early or late. The middle Costas gives the demodulator a fresh fix.
|
||||
|
||||
Second, in heavy noise, the first Costas might have been noise-destroyed and missed entirely. The middle one offers a second chance for initial sync. Robustness through redundancy.
|
||||
|
||||
The receiver's symbol clock recovery is now locked in for the next half of the transmission.
|
||||
|
||||
## t = 6.88 to 11.52 s: second data block
|
||||
|
||||
Another 29 symbols, the remaining 87 bits of the LDPC codeword. The pattern looks just as random as the first block because, statistically, it is.
|
||||
|
||||
Here's the elegant part: by the time the second block finishes, the receiver has 174 noisy bit estimates, each with a soft confidence value. It hands these to the LDPC decoder, which begins running its [belief propagation](https://en.wikipedia.org/wiki/Belief_propagation) algorithm even before the transmission is over. Belief propagation works by passing messages between bit nodes and parity-check nodes in the LDPC graph, iteratively refining each bit's estimate based on what its parity-check neighbors think it should be.
|
||||
|
||||
After typically 20 to 50 iterations, the decoder either converges on a valid codeword (one where every parity check is satisfied) or gives up. If it converges, you have your 91 candidate bits: 77 of message, 14 of CRC.
|
||||
|
||||
## t = 11.52 to 12.64 s: final Costas array, then silence
|
||||
|
||||
Seven more sync symbols, then the PA ramps down to zero over the last few milliseconds. The transmission is over.
|
||||
|
||||
The receiver doesn't strictly need the final Costas. By now, sync is locked in. But it's there for symmetric design and as a final cross-check, and it lets the receiver attempt forensic recovery of transmissions where the first one or two Costas arrays got blown away by interference. With three Costas arrays distributed across the message, FT8 can typically still decode if any one of them survives and at least one of the data blocks is mostly intact.
|
||||
|
||||
## t > 12.64 s: the receiver does the rest
|
||||
|
||||
The transmission is done, but the work isn't. The receiver runs the CRC check on the decoded 91 bits. If the 14-bit CRC matches what the source said it should be, the message is real. If it doesn't, the LDPC decoder's apparent success was a false positive (which happens, rarely, when the noise happens to land on a valid codeword that isn't the one transmitted) and the message gets thrown out.
|
||||
|
||||
If the CRC checks out, the source decoder reverses the message-type encoding: pulls the three i3 bits, decides what kind of message this is, and unpacks the corresponding fields. Callsign hashes get looked up in the receiver's running database of recently-heard callsigns. Grids get expanded back to four characters. The result is a string like "CQ W5ISP EM13" that gets dropped into the WSJT-X main window with a frequency offset and an SNR estimate.
|
||||
|
||||
The whole decode, from end-of-transmission to displayed message, typically takes a fraction of a second on modern hardware. WSJT-X scans the entire 2500 Hz audio passband for FT8 signals at every 6.25 Hz offset, in parallel, and surfaces every one that decodes successfully. On a busy 20-meter evening at solar maximum, a single 15-second cycle might produce 30 or 40 decoded messages from stations all over the planet.
|
||||
|
||||
## Why this works at -24 dB SNR
|
||||
|
||||
The math, done bluntly: FT8 transmits 174 bits of LDPC codeword in 12.64 seconds across 50 Hz of bandwidth. Channel capacity in that bandwidth at -24 dB SNR (using Shannon's formula) is around 6 bits/sec, or 76 bits in 12.64 seconds. FT8 sends 91 effective bits (77 message + 14 CRC) of useful information in that same time, slightly above Shannon's theoretical limit for the assumed AWGN channel.
|
||||
|
||||
It cheats slightly by counting the Costas overhead as separate from "data" and by relying on the receiver's coherent detection of orthogonal tones, but the bottom line is that FT8 is operating very close to the absolute information-theoretic limit of what's possible in that channel. There is not much room left for improvement without sacrificing something else (longer transmissions, narrower bandwidth, simpler messages).
|
||||
|
||||
The dial-up modem worked for the same reason any modulation scheme works: it found a clever way to push data through a channel with known constraints. The constraints there were 4 kHz of phone-line bandwidth, ~30 dB SNR, and analog hand-off equipment. The constraints for FT8 are 50 Hz of HF bandwidth, sometimes -24 dB SNR, and a propagation channel that occasionally swallows entire seconds whole. Different problem, similar elegance, both worth pulling apart to see what each beep is for.
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/ft8.js" defer></script>
|
||||
|
|
@ -125,6 +125,48 @@
|
|||
}
|
||||
.scene-control input[type="range"]:focus { outline: none; }
|
||||
|
||||
/* Toggle button: short, pill-shaped on/off control. */
|
||||
.scene-toggle {
|
||||
align-self: end;
|
||||
justify-self: start;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--s-panel);
|
||||
color: var(--s-fg);
|
||||
border: 1px solid var(--s-grid-strong);
|
||||
border-radius: 999px;
|
||||
padding: 0.32rem 0.85rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||
user-select: none;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scene-toggle:hover {
|
||||
border-color: var(--s-cyan);
|
||||
color: var(--s-cyan);
|
||||
}
|
||||
|
||||
.scene-toggle[data-on="1"] {
|
||||
background: var(--s-cyan);
|
||||
border-color: var(--s-cyan);
|
||||
color: var(--s-bg);
|
||||
}
|
||||
|
||||
.scene-toggle[data-on="1"]:hover {
|
||||
filter: brightness(1.05);
|
||||
color: var(--s-bg);
|
||||
}
|
||||
|
||||
.scene-toggle:focus-visible {
|
||||
outline: 2px solid var(--s-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.scene-box .scene-caption {
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
|
|
|
|||
755
static/js/ft8.js
Normal file
755
static/js/ft8.js
Normal file
|
|
@ -0,0 +1,755 @@
|
|||
// FT8 transmission anatomy scenes. Vanilla JS + 2D canvas. Scoped to one post.
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const C = {
|
||||
bg: '#1a1d24',
|
||||
panel: '#242932',
|
||||
fg: '#d8dce4',
|
||||
dim: '#8189a0',
|
||||
grid: '#2d323d',
|
||||
gridStrong: '#3a414e',
|
||||
blue: '#78b5f3',
|
||||
cyan: '#6dc5d3',
|
||||
green: '#9dca83',
|
||||
amber: '#d6a86a',
|
||||
red: '#e58089',
|
||||
yellow: '#e2c37d',
|
||||
magenta: '#c78de0',
|
||||
};
|
||||
|
||||
// ---- framework ----
|
||||
function setupCanvas(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
const fit = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
};
|
||||
fit();
|
||||
new ResizeObserver(fit).observe(canvas);
|
||||
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
|
||||
}
|
||||
|
||||
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
|
||||
const root = document.getElementById(id);
|
||||
if (!root) return null;
|
||||
root.classList.add('scene-box');
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = `${height}px`;
|
||||
root.appendChild(canvas);
|
||||
|
||||
const readoutEl = document.createElement('div');
|
||||
readoutEl.className = 'scene-readout';
|
||||
const readoutSpans = {};
|
||||
readout.forEach(r => {
|
||||
const item = document.createElement('span');
|
||||
item.className = 'scene-readout-item';
|
||||
const label = document.createElement('span'); label.textContent = r.label + ':';
|
||||
const val = document.createElement('span'); val.textContent = r.init ?? '';
|
||||
item.appendChild(label); item.appendChild(val);
|
||||
readoutEl.appendChild(item);
|
||||
readoutSpans[r.key] = val;
|
||||
});
|
||||
if (readout.length) root.appendChild(readoutEl);
|
||||
|
||||
const controlsEl = document.createElement('div');
|
||||
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
|
||||
const values = {};
|
||||
const inputs = {};
|
||||
controls.forEach(c => {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'scene-control';
|
||||
|
||||
if (c.type === 'toggle') {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'scene-toggle';
|
||||
let on = !!c.value;
|
||||
values[c.key] = on ? 1 : 0;
|
||||
const render = () => {
|
||||
btn.textContent = `${c.label}: ${on ? (c.onLabel || 'on') : (c.offLabel || 'off')}`;
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
btn.dataset.on = on ? '1' : '0';
|
||||
};
|
||||
render();
|
||||
btn.addEventListener('click', () => {
|
||||
on = !on;
|
||||
values[c.key] = on ? 1 : 0;
|
||||
render();
|
||||
});
|
||||
inputs[c.key] = {
|
||||
set: v => { on = !!v; values[c.key] = on ? 1 : 0; render(); },
|
||||
};
|
||||
wrap.appendChild(btn);
|
||||
controlsEl.appendChild(wrap);
|
||||
return;
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
const name = document.createElement('span'); name.textContent = c.label;
|
||||
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
|
||||
label.appendChild(name); label.appendChild(valSpan);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'range';
|
||||
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
|
||||
input.value = c.value;
|
||||
values[c.key] = parseFloat(input.value);
|
||||
const fmt = c.format || (v => v.toFixed(2));
|
||||
valSpan.textContent = fmt(values[c.key]);
|
||||
input.addEventListener('input', () => {
|
||||
values[c.key] = parseFloat(input.value);
|
||||
valSpan.textContent = fmt(values[c.key]);
|
||||
});
|
||||
inputs[c.key] = { input, valSpan, fmt };
|
||||
wrap.appendChild(label); wrap.appendChild(input);
|
||||
controlsEl.appendChild(wrap);
|
||||
});
|
||||
if (controls.length) root.appendChild(controlsEl);
|
||||
|
||||
if (caption) {
|
||||
const cap = document.createElement('div');
|
||||
cap.className = 'scene-caption';
|
||||
cap.textContent = caption;
|
||||
root.appendChild(cap);
|
||||
}
|
||||
|
||||
const { ctx, getSize } = setupCanvas(canvas);
|
||||
const setReadout = (key, txt) => {
|
||||
if (readoutSpans[key]) readoutSpans[key].textContent = txt;
|
||||
};
|
||||
const setControl = (key, v) => {
|
||||
const c = inputs[key];
|
||||
if (!c) return;
|
||||
if (c.set) { c.set(v); return; }
|
||||
c.input.value = v;
|
||||
values[key] = parseFloat(c.input.value);
|
||||
c.valSpan.textContent = c.fmt(values[key]);
|
||||
};
|
||||
return { canvas, ctx, getSize, values, setReadout, setControl, root };
|
||||
}
|
||||
|
||||
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
|
||||
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12, family = 'system-ui, sans-serif') {
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = `${size}px ${family}`;
|
||||
ctx.textAlign = align;
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillText(str, x, y);
|
||||
ctx.restore();
|
||||
}
|
||||
function roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
|
||||
else ctx.rect(x, y, w, h);
|
||||
}
|
||||
|
||||
// ---- FT8 protocol constants ----
|
||||
const COSTAS = [3, 1, 4, 0, 6, 5, 2];
|
||||
const SYM_DUR = 0.16; // seconds per symbol
|
||||
const TONE_HZ = 6.25; // tone spacing
|
||||
const N_SYM = 79;
|
||||
const TOTAL = N_SYM * SYM_DUR; // 12.64 s
|
||||
|
||||
// ---- Real FT8 encoder for "CQ W5ISP EM13" ----
|
||||
//
|
||||
// Direct port of kgoba/ft8_lib's encoder. Source-encodes a standard i3=1
|
||||
// message ("call_to call_de grid"), appends CRC-14, runs LDPC(174,91), and
|
||||
// maps to 79 8-FSK tones with Costas sync arrays at start/middle/end.
|
||||
// Output is the literal sequence of tones a real WSJT-X transmit produces.
|
||||
|
||||
const FT8_GRAY_MAP = [0, 1, 3, 2, 5, 6, 4, 7];
|
||||
const FT8_CRC_POLY = 0x2757;
|
||||
const NTOKENS = 2063592;
|
||||
const MAX22 = 4194304;
|
||||
|
||||
// 83x12-byte LDPC parity generator matrix (lifted from kFTX_LDPC_generator).
|
||||
// Each row is one parity row's bitmask over the 91-bit message (MSB first).
|
||||
const FT8_LDPC_GEN = [
|
||||
[0x83,0x29,0xce,0x11,0xbf,0x31,0xea,0xf5,0x09,0xf2,0x7f,0xc0],
|
||||
[0x76,0x1c,0x26,0x4e,0x25,0xc2,0x59,0x33,0x54,0x93,0x13,0x20],
|
||||
[0xdc,0x26,0x59,0x02,0xfb,0x27,0x7c,0x64,0x10,0xa1,0xbd,0xc0],
|
||||
[0x1b,0x3f,0x41,0x78,0x58,0xcd,0x2d,0xd3,0x3e,0xc7,0xf6,0x20],
|
||||
[0x09,0xfd,0xa4,0xfe,0xe0,0x41,0x95,0xfd,0x03,0x47,0x83,0xa0],
|
||||
[0x07,0x7c,0xcc,0xc1,0x1b,0x88,0x73,0xed,0x5c,0x3d,0x48,0xa0],
|
||||
[0x29,0xb6,0x2a,0xfe,0x3c,0xa0,0x36,0xf4,0xfe,0x1a,0x9d,0xa0],
|
||||
[0x60,0x54,0xfa,0xf5,0xf3,0x5d,0x96,0xd3,0xb0,0xc8,0xc3,0xe0],
|
||||
[0xe2,0x07,0x98,0xe4,0x31,0x0e,0xed,0x27,0x88,0x4a,0xe9,0x00],
|
||||
[0x77,0x5c,0x9c,0x08,0xe8,0x0e,0x26,0xdd,0xae,0x56,0x31,0x80],
|
||||
[0xb0,0xb8,0x11,0x02,0x8c,0x2b,0xf9,0x97,0x21,0x34,0x87,0xc0],
|
||||
[0x18,0xa0,0xc9,0x23,0x1f,0xc6,0x0a,0xdf,0x5c,0x5e,0xa3,0x20],
|
||||
[0x76,0x47,0x1e,0x83,0x02,0xa0,0x72,0x1e,0x01,0xb1,0x2b,0x80],
|
||||
[0xff,0xbc,0xcb,0x80,0xca,0x83,0x41,0xfa,0xfb,0x47,0xb2,0xe0],
|
||||
[0x66,0xa7,0x2a,0x15,0x8f,0x93,0x25,0xa2,0xbf,0x67,0x17,0x00],
|
||||
[0xc4,0x24,0x36,0x89,0xfe,0x85,0xb1,0xc5,0x13,0x63,0xa1,0x80],
|
||||
[0x0d,0xff,0x73,0x94,0x14,0xd1,0xa1,0xb3,0x4b,0x1c,0x27,0x00],
|
||||
[0x15,0xb4,0x88,0x30,0x63,0x6c,0x8b,0x99,0x89,0x49,0x72,0xe0],
|
||||
[0x29,0xa8,0x9c,0x0d,0x3d,0xe8,0x1d,0x66,0x54,0x89,0xb0,0xe0],
|
||||
[0x4f,0x12,0x6f,0x37,0xfa,0x51,0xcb,0xe6,0x1b,0xd6,0xb9,0x40],
|
||||
[0x99,0xc4,0x72,0x39,0xd0,0xd9,0x7d,0x3c,0x84,0xe0,0x94,0x00],
|
||||
[0x19,0x19,0xb7,0x51,0x19,0x76,0x56,0x21,0xbb,0x4f,0x1e,0x80],
|
||||
[0x09,0xdb,0x12,0xd7,0x31,0xfa,0xee,0x0b,0x86,0xdf,0x6b,0x80],
|
||||
[0x48,0x8f,0xc3,0x3d,0xf4,0x3f,0xbd,0xee,0xa4,0xea,0xfb,0x40],
|
||||
[0x82,0x74,0x23,0xee,0x40,0xb6,0x75,0xf7,0x56,0xeb,0x5f,0xe0],
|
||||
[0xab,0xe1,0x97,0xc4,0x84,0xcb,0x74,0x75,0x71,0x44,0xa9,0xa0],
|
||||
[0x2b,0x50,0x0e,0x4b,0xc0,0xec,0x5a,0x6d,0x2b,0xdb,0xdd,0x00],
|
||||
[0xc4,0x74,0xaa,0x53,0xd7,0x02,0x18,0x76,0x16,0x69,0x36,0x00],
|
||||
[0x8e,0xba,0x1a,0x13,0xdb,0x33,0x90,0xbd,0x67,0x18,0xce,0xc0],
|
||||
[0x75,0x38,0x44,0x67,0x3a,0x27,0x78,0x2c,0xc4,0x20,0x12,0xe0],
|
||||
[0x06,0xff,0x83,0xa1,0x45,0xc3,0x70,0x35,0xa5,0xc1,0x26,0x80],
|
||||
[0x3b,0x37,0x41,0x78,0x58,0xcc,0x2d,0xd3,0x3e,0xc3,0xf6,0x20],
|
||||
[0x9a,0x4a,0x5a,0x28,0xee,0x17,0xca,0x9c,0x32,0x48,0x42,0xc0],
|
||||
[0xbc,0x29,0xf4,0x65,0x30,0x9c,0x97,0x7e,0x89,0x61,0x0a,0x40],
|
||||
[0x26,0x63,0xae,0x6d,0xdf,0x8b,0x5c,0xe2,0xbb,0x29,0x48,0x80],
|
||||
[0x46,0xf2,0x31,0xef,0xe4,0x57,0x03,0x4c,0x18,0x14,0x41,0x80],
|
||||
[0x3f,0xb2,0xce,0x85,0xab,0xe9,0xb0,0xc7,0x2e,0x06,0xfb,0xe0],
|
||||
[0xde,0x87,0x48,0x1f,0x28,0x2c,0x15,0x39,0x71,0xa0,0xa2,0xe0],
|
||||
[0xfc,0xd7,0xcc,0xf2,0x3c,0x69,0xfa,0x99,0xbb,0xa1,0x41,0x20],
|
||||
[0xf0,0x26,0x14,0x47,0xe9,0x49,0x0c,0xa8,0xe4,0x74,0xce,0xc0],
|
||||
[0x44,0x10,0x11,0x58,0x18,0x19,0x6f,0x95,0xcd,0xd7,0x01,0x20],
|
||||
[0x08,0x8f,0xc3,0x1d,0xf4,0xbf,0xbd,0xe2,0xa4,0xea,0xfb,0x40],
|
||||
[0xb8,0xfe,0xf1,0xb6,0x30,0x77,0x29,0xfb,0x0a,0x07,0x8c,0x00],
|
||||
[0x5a,0xfe,0xa7,0xac,0xcc,0xb7,0x7b,0xbc,0x9d,0x99,0xa9,0x00],
|
||||
[0x49,0xa7,0x01,0x6a,0xc6,0x53,0xf6,0x5e,0xcd,0xc9,0x07,0x60],
|
||||
[0x19,0x44,0xd0,0x85,0xbe,0x4e,0x7d,0xa8,0xd6,0xcc,0x7d,0x00],
|
||||
[0x25,0x1f,0x62,0xad,0xc4,0x03,0x2f,0x0e,0xe7,0x14,0x00,0x20],
|
||||
[0x56,0x47,0x1f,0x87,0x02,0xa0,0x72,0x1e,0x00,0xb1,0x2b,0x80],
|
||||
[0x2b,0x8e,0x49,0x23,0xf2,0xdd,0x51,0xe2,0xd5,0x37,0xfa,0x00],
|
||||
[0x6b,0x55,0x0a,0x40,0xa6,0x6f,0x47,0x55,0xde,0x95,0xc2,0x60],
|
||||
[0xa1,0x8a,0xd2,0x8d,0x4e,0x27,0xfe,0x92,0xa4,0xf6,0xc8,0x40],
|
||||
[0x10,0xc2,0xe5,0x86,0x38,0x8c,0xb8,0x2a,0x3d,0x80,0x75,0x80],
|
||||
[0xef,0x34,0xa4,0x18,0x17,0xee,0x02,0x13,0x3d,0xb2,0xeb,0x00],
|
||||
[0x7e,0x9c,0x0c,0x54,0x32,0x5a,0x9c,0x15,0x83,0x6e,0x00,0x00],
|
||||
[0x36,0x93,0xe5,0x72,0xd1,0xfd,0xe4,0xcd,0xf0,0x79,0xe8,0x60],
|
||||
[0xbf,0xb2,0xce,0xc5,0xab,0xe1,0xb0,0xc7,0x2e,0x07,0xfb,0xe0],
|
||||
[0x7e,0xe1,0x82,0x30,0xc5,0x83,0xcc,0xcc,0x57,0xd4,0xb0,0x80],
|
||||
[0xa0,0x66,0xcb,0x2f,0xed,0xaf,0xc9,0xf5,0x26,0x64,0x12,0x60],
|
||||
[0xbb,0x23,0x72,0x5a,0xbc,0x47,0xcc,0x5f,0x4c,0xc4,0xcd,0x20],
|
||||
[0xde,0xd9,0xdb,0xa3,0xbe,0xe4,0x0c,0x59,0xb5,0x60,0x9b,0x40],
|
||||
[0xd9,0xa7,0x01,0x6a,0xc6,0x53,0xe6,0xde,0xcd,0xc9,0x03,0x60],
|
||||
[0x9a,0xd4,0x6a,0xed,0x5f,0x70,0x7f,0x28,0x0a,0xb5,0xfc,0x40],
|
||||
[0xe5,0x92,0x1c,0x77,0x82,0x25,0x87,0x31,0x6d,0x7d,0x3c,0x20],
|
||||
[0x4f,0x14,0xda,0x82,0x42,0xa8,0xb8,0x6d,0xca,0x73,0x35,0x20],
|
||||
[0x8b,0x8b,0x50,0x7a,0xd4,0x67,0xd4,0x44,0x1d,0xf7,0x70,0xe0],
|
||||
[0x22,0x83,0x1c,0x9c,0xf1,0x16,0x94,0x67,0xad,0x04,0xb6,0x80],
|
||||
[0x21,0x3b,0x83,0x8f,0xe2,0xae,0x54,0xc3,0x8e,0xe7,0x18,0x00],
|
||||
[0x5d,0x92,0x6b,0x6d,0xd7,0x1f,0x08,0x51,0x81,0xa4,0xe1,0x20],
|
||||
[0x66,0xab,0x79,0xd4,0xb2,0x9e,0xe6,0xe6,0x95,0x09,0xe5,0x60],
|
||||
[0x95,0x81,0x48,0x68,0x2d,0x74,0x8a,0x38,0xdd,0x68,0xba,0xa0],
|
||||
[0xb8,0xce,0x02,0x0c,0xf0,0x69,0xc3,0x2a,0x72,0x3a,0xb1,0x40],
|
||||
[0xf4,0x33,0x1d,0x6d,0x46,0x16,0x07,0xe9,0x57,0x52,0x74,0x60],
|
||||
[0x6d,0xa2,0x3b,0xa4,0x24,0xb9,0x59,0x61,0x33,0xcf,0x9c,0x80],
|
||||
[0xa6,0x36,0xbc,0xbc,0x7b,0x30,0xc5,0xfb,0xea,0xe6,0x7f,0xe0],
|
||||
[0x5c,0xb0,0xd8,0x6a,0x07,0xdf,0x65,0x4a,0x90,0x89,0xa2,0x00],
|
||||
[0xf1,0x1f,0x10,0x68,0x48,0x78,0x0f,0xc9,0xec,0xdd,0x80,0xa0],
|
||||
[0x1f,0xbb,0x53,0x64,0xfb,0x8d,0x2c,0x9d,0x73,0x0d,0x5b,0xa0],
|
||||
[0xfc,0xb8,0x6b,0xc7,0x0a,0x50,0xc9,0xd0,0x2a,0x5d,0x03,0x40],
|
||||
[0xa5,0x34,0x43,0x30,0x29,0xea,0xc1,0x5f,0x32,0x2e,0x34,0xc0],
|
||||
[0xc9,0x89,0xd9,0xc7,0xc3,0xd3,0xb8,0xc5,0x5d,0x75,0x13,0x00],
|
||||
[0x7b,0xb3,0x8b,0x2f,0x01,0x86,0xd4,0x66,0x43,0xae,0x96,0x20],
|
||||
[0x26,0x44,0xeb,0xad,0xeb,0x44,0xb9,0x46,0x7d,0x1f,0x42,0xc0],
|
||||
[0x60,0x8c,0xc8,0x57,0x59,0x4b,0xfb,0xb5,0x5d,0x69,0x60,0x00],
|
||||
];
|
||||
|
||||
const ALPHANUM_SPACE = ' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const ALPHANUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const NUMERIC = '0123456789';
|
||||
const LETTERS_SPACE = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
function isDigit(c) { return c >= '0' && c <= '9'; }
|
||||
|
||||
function packBasecall(call) {
|
||||
const length = call.length;
|
||||
if (length <= 2) return -1;
|
||||
const c6 = [' ', ' ', ' ', ' ', ' ', ' '];
|
||||
if (isDigit(call[2]) && length <= 6) {
|
||||
// AB0XYZ form: left-aligned
|
||||
for (let i = 0; i < length; i++) c6[i] = call[i];
|
||||
} else if (isDigit(call[1]) && length <= 5) {
|
||||
// A0XYZ form: shift right by 1
|
||||
for (let i = 0; i < length; i++) c6[i + 1] = call[i];
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
const i0 = ALPHANUM_SPACE.indexOf(c6[0]);
|
||||
const i1 = ALPHANUM.indexOf(c6[1]);
|
||||
const i2 = NUMERIC.indexOf(c6[2]);
|
||||
const i3 = LETTERS_SPACE.indexOf(c6[3]);
|
||||
const i4 = LETTERS_SPACE.indexOf(c6[4]);
|
||||
const i5 = LETTERS_SPACE.indexOf(c6[5]);
|
||||
if (i0 < 0 || i1 < 0 || i2 < 0 || i3 < 0 || i4 < 0 || i5 < 0) return -1;
|
||||
let n = i0;
|
||||
n = n * 36 + i1;
|
||||
n = n * 10 + i2;
|
||||
n = n * 27 + i3;
|
||||
n = n * 27 + i4;
|
||||
n = n * 27 + i5;
|
||||
return n;
|
||||
}
|
||||
|
||||
function pack28(call) {
|
||||
if (call === 'DE') return 0;
|
||||
if (call === 'QRZ') return 1;
|
||||
if (call === 'CQ') return 2;
|
||||
const n = packBasecall(call);
|
||||
if (n < 0) return -1;
|
||||
return NTOKENS + MAX22 + n;
|
||||
}
|
||||
|
||||
function packGrid4(grid) {
|
||||
const a = grid.charCodeAt(0) - 65;
|
||||
const b = grid.charCodeAt(1) - 65;
|
||||
const c = grid.charCodeAt(2) - 48;
|
||||
const d = grid.charCodeAt(3) - 48;
|
||||
return ((a * 18 + b) * 10 + c) * 10 + d;
|
||||
}
|
||||
|
||||
// Pack a standard i3=1 message into a 10-byte (77-bit) payload.
|
||||
// Last 3 bits of byte 9 are zero, ready for CRC injection.
|
||||
function packStdMessage(callTo, callDe, grid) {
|
||||
const n28a = pack28(callTo);
|
||||
const n28b = pack28(callDe);
|
||||
const igrid4 = packGrid4(grid);
|
||||
const i3 = 1;
|
||||
const n29a = n28a * 2;
|
||||
const n29b = n28b * 2;
|
||||
const p = new Uint8Array(10);
|
||||
p[0] = Math.floor(n29a / (1 << 21)) & 0xff;
|
||||
p[1] = Math.floor(n29a / (1 << 13)) & 0xff;
|
||||
p[2] = Math.floor(n29a / (1 << 5)) & 0xff;
|
||||
p[3] = ((n29a * 8) & 0xff) | (Math.floor(n29b / (1 << 26)) & 0x07);
|
||||
p[4] = Math.floor(n29b / (1 << 18)) & 0xff;
|
||||
p[5] = Math.floor(n29b / (1 << 10)) & 0xff;
|
||||
p[6] = Math.floor(n29b / (1 << 2)) & 0xff;
|
||||
p[7] = ((n29b * 64) & 0xff) | ((igrid4 >>> 10) & 0x3f);
|
||||
p[8] = (igrid4 >>> 2) & 0xff;
|
||||
p[9] = ((igrid4 * 64) & 0xff) | ((i3 * 8) & 0x38);
|
||||
return p;
|
||||
}
|
||||
|
||||
// CRC-14 over numBits of msg (MSB first), poly 0x2757, no init/xor.
|
||||
function computeCRC14(msg, numBits) {
|
||||
let r = 0;
|
||||
let i = 0;
|
||||
for (let b = 0; b < numBits; b++) {
|
||||
if (b % 8 === 0) {
|
||||
r ^= (msg[i] << 6);
|
||||
i++;
|
||||
}
|
||||
if (r & 0x2000) r = (r << 1) ^ FT8_CRC_POLY;
|
||||
else r = (r << 1);
|
||||
}
|
||||
return r & 0x3fff;
|
||||
}
|
||||
|
||||
// Append CRC-14 to a 77-bit payload, producing 91 bits in 12 bytes.
|
||||
function addCRC(payload) {
|
||||
const a = new Uint8Array(12);
|
||||
for (let i = 0; i < 10; i++) a[i] = payload[i];
|
||||
a[9] &= 0xf8;
|
||||
a[10] = 0;
|
||||
a[11] = 0;
|
||||
const crc = computeCRC14(a, 96 - 14);
|
||||
a[9] |= (crc >>> 11) & 0x07;
|
||||
a[10] = (crc >>> 3) & 0xff;
|
||||
a[11] = (crc << 5) & 0xff;
|
||||
return a;
|
||||
}
|
||||
|
||||
function parity8(x) {
|
||||
x ^= x >>> 4;
|
||||
x ^= x >>> 2;
|
||||
x ^= x >>> 1;
|
||||
return x & 1;
|
||||
}
|
||||
|
||||
// LDPC(174, 91) systematic encoder. First 91 bits of cw = a91; last 83 bits
|
||||
// are parity computed via FT8_LDPC_GEN.
|
||||
function encodeLDPC(a91) {
|
||||
const cw = new Uint8Array(22);
|
||||
for (let j = 0; j < 22; j++) cw[j] = j < 12 ? a91[j] : 0;
|
||||
let mask = 0x80 >>> (91 % 8);
|
||||
let idx = 11;
|
||||
for (let i = 0; i < 83; i++) {
|
||||
let s = 0;
|
||||
for (let j = 0; j < 12; j++) s ^= parity8(a91[j] & FT8_LDPC_GEN[i][j]);
|
||||
if (s & 1) cw[idx] |= mask;
|
||||
mask >>>= 1;
|
||||
if (mask === 0) { mask = 0x80; idx++; }
|
||||
}
|
||||
return cw;
|
||||
}
|
||||
|
||||
// Map the 174-bit codeword to 79 8-FSK tones, splicing in three Costas
|
||||
// arrays at positions 0..6, 36..42, 72..78. Each data tone is the Gray-coded
|
||||
// value of three consecutive codeword bits (MSB first).
|
||||
function tonesFromCodeword(cw) {
|
||||
const tones = new Array(N_SYM);
|
||||
let mask = 0x80, b = 0;
|
||||
for (let t = 0; t < N_SYM; t++) {
|
||||
if (t < 7) tones[t] = COSTAS[t];
|
||||
else if (t >= 36 && t < 43) tones[t] = COSTAS[t - 36];
|
||||
else if (t >= 72) tones[t] = COSTAS[t - 72];
|
||||
else {
|
||||
let v = 0;
|
||||
if (cw[b] & mask) v |= 4;
|
||||
mask >>>= 1; if (mask === 0) { mask = 0x80; b++; }
|
||||
if (cw[b] & mask) v |= 2;
|
||||
mask >>>= 1; if (mask === 0) { mask = 0x80; b++; }
|
||||
if (cw[b] & mask) v |= 1;
|
||||
mask >>>= 1; if (mask === 0) { mask = 0x80; b++; }
|
||||
tones[t] = FT8_GRAY_MAP[v];
|
||||
}
|
||||
}
|
||||
return tones;
|
||||
}
|
||||
|
||||
function buildSymbols() {
|
||||
const payload = packStdMessage('CQ', 'W5ISP', 'EM13');
|
||||
const a91 = addCRC(payload);
|
||||
const cw = encodeLDPC(a91);
|
||||
return tonesFromCodeword(cw);
|
||||
}
|
||||
|
||||
function phaseOf(idx) {
|
||||
if (idx < 7) return 'costas1';
|
||||
if (idx < 36) return 'data1';
|
||||
if (idx < 43) return 'costas2';
|
||||
if (idx < 72) return 'data2';
|
||||
return 'costas3';
|
||||
}
|
||||
|
||||
const PHASES = [
|
||||
{ from: 0, to: 7, kind: 'costas', label: 'Costas 1', sub: 'initial sync (7 syms)' },
|
||||
{ from: 7, to: 36, kind: 'data', label: 'Data block 1', sub: '29 syms = 87 LDPC bits' },
|
||||
{ from: 36, to: 43, kind: 'costas', label: 'Costas 2', sub: 're-sync, drift fix' },
|
||||
{ from: 43, to: 72, kind: 'data', label: 'Data block 2', sub: '29 syms = 87 LDPC bits' },
|
||||
{ from: 72, to: 79, kind: 'costas', label: 'Costas 3', sub: 'final sync' },
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// Scene 1: Spectrogram timeline
|
||||
// ============================================================
|
||||
function sceneSpectrum() {
|
||||
const symbols = buildSymbols();
|
||||
|
||||
const s = scene('scene-ft8-spectrum', {
|
||||
height: 440,
|
||||
controls: [
|
||||
{ key: 'cursor', label: 'time cursor', min: 0, max: TOTAL, step: 0.01, value: 0,
|
||||
format: v => v < 1 ? `${Math.round(v * 1000)} ms` : `${v.toFixed(2)} s` },
|
||||
{ key: 'play', type: 'toggle', label: 'auto-play', value: 0 },
|
||||
],
|
||||
readout: [
|
||||
{ key: 'sym', label: 'symbol', init: '1 / 79' },
|
||||
{ key: 'phase', label: 'phase', init: 'Costas 1' },
|
||||
{ key: 'tone', label: 'tone', init: '3 (+18.75 Hz)' },
|
||||
{ key: 'mean', label: 'meaning', init: 'sync 1/7 (Costas[0]=3)' },
|
||||
],
|
||||
caption: 'The literal 79 tones a real WSJT-X transmit produces for "CQ W5ISP EM13", computed in the browser by the actual FT8 source coder + CRC-14 + LDPC(174,91) encoder + Gray map. Each cell is one 160 ms symbol; eight rows are the eight FT8 tones at 6.25 Hz spacing. Costas sync symbols are amber, data symbols cyan. The block of zeros at the start of data block 1 is real: "CQ" packs to n28a = 2, so the high bits of the codeword are essentially all zero.',
|
||||
});
|
||||
if (!s) return;
|
||||
|
||||
let last = performance.now();
|
||||
|
||||
function draw() {
|
||||
const now = performance.now();
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
|
||||
if (s.values.play > 0.5) {
|
||||
let t = s.values.cursor + dt * 1.5;
|
||||
if (t > TOTAL) t = 0;
|
||||
s.setControl('cursor', t);
|
||||
}
|
||||
|
||||
const { w, h } = s.getSize();
|
||||
clear(s.ctx, w, h);
|
||||
const ctx = s.ctx;
|
||||
|
||||
const cursor = s.values.cursor;
|
||||
const cursorSym = Math.min(N_SYM - 1, Math.floor(cursor / SYM_DUR));
|
||||
|
||||
const padL = 56, padR = 64, padT = 78, padB = 50;
|
||||
const plotW = w - padL - padR;
|
||||
const plotH = h - padT - padB;
|
||||
const cellW = plotW / N_SYM;
|
||||
const cellH = plotH / 8;
|
||||
|
||||
// Plot panel
|
||||
ctx.fillStyle = '#171b22';
|
||||
ctx.fillRect(padL, padT, plotW, plotH);
|
||||
|
||||
// Phase background bands and brackets
|
||||
PHASES.forEach(p => {
|
||||
const x1 = padL + p.from * cellW;
|
||||
const x2 = padL + p.to * cellW;
|
||||
ctx.fillStyle = p.kind === 'costas' ? 'rgba(214, 168, 106, 0.07)' : 'rgba(109, 197, 211, 0.05)';
|
||||
ctx.fillRect(x1, padT, x2 - x1, plotH);
|
||||
|
||||
const color = p.kind === 'costas' ? C.amber : C.cyan;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 + 1, padT - 22);
|
||||
ctx.lineTo(x1 + 1, padT - 14);
|
||||
ctx.lineTo(x2 - 1, padT - 14);
|
||||
ctx.lineTo(x2 - 1, padT - 22);
|
||||
ctx.stroke();
|
||||
|
||||
const cx = (x1 + x2) / 2;
|
||||
text(ctx, p.label, cx, padT - 42, color, 'center', 'alphabetic', 11, 'system-ui, sans-serif');
|
||||
text(ctx, p.sub, cx, padT - 28, C.dim, 'center', 'alphabetic', 9, 'system-ui, sans-serif');
|
||||
});
|
||||
|
||||
// Faint cell grid
|
||||
ctx.strokeStyle = 'rgba(45, 50, 61, 0.35)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 0; i <= N_SYM; i += 1) {
|
||||
const x = padL + i * cellW;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padT);
|
||||
ctx.lineTo(x, padT + plotH);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let t = 0; t <= 8; t++) {
|
||||
const y = padT + t * cellH;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(padL + plotW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Tone-row labels (left: index, right: Hz offset)
|
||||
for (let t = 0; t < 8; t++) {
|
||||
const y = padT + (7 - t) * cellH + cellH / 2;
|
||||
text(ctx, `t${t}`, padL - 8, y, C.dim, 'right', 'middle', 10, 'ui-monospace, monospace');
|
||||
text(ctx, `+${(t * TONE_HZ).toFixed(2)} Hz`, padL + plotW + 6, y, '#5d6470', 'left', 'middle', 9, 'ui-monospace, monospace');
|
||||
}
|
||||
|
||||
// Symbol cells
|
||||
symbols.forEach((tone, i) => {
|
||||
const isCostas = phaseOf(i).startsWith('costas');
|
||||
const x = padL + i * cellW + 0.5;
|
||||
const y = padT + (7 - tone) * cellH + 0.5;
|
||||
const sw = cellW - 1;
|
||||
const sh = cellH - 1;
|
||||
|
||||
ctx.fillStyle = isCostas ? C.amber : C.cyan;
|
||||
ctx.fillRect(x, y, sw, sh);
|
||||
|
||||
if (i === cursorSym) {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x - 0.5, y - 0.5, sw + 1, sh + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Cursor line
|
||||
const cx = padL + (cursor / TOTAL) * plotW;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, padT - 6);
|
||||
ctx.lineTo(cx, padT + plotH + 6);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Time axis ticks. Sub-second values render in ms.
|
||||
const ticks = [0, 1.12, 5.76, 6.88, 11.52, 12.64];
|
||||
ticks.forEach(t => {
|
||||
const x = padL + (t / TOTAL) * plotW;
|
||||
ctx.strokeStyle = C.gridStrong;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padT + plotH);
|
||||
ctx.lineTo(x, padT + plotH + 5);
|
||||
ctx.stroke();
|
||||
const label = t < 1 ? `${Math.round(t * 1000)} ms` : `${t.toFixed(2)} s`;
|
||||
text(ctx, label, x, padT + plotH + 8, C.dim, 'center', 'top', 9, 'ui-monospace, monospace');
|
||||
});
|
||||
|
||||
// Y-axis label
|
||||
ctx.save();
|
||||
ctx.translate(14, padT + plotH / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
text(ctx, 'tone (8-FSK)', 0, 0, C.dim, 'center', 'middle', 11);
|
||||
ctx.restore();
|
||||
|
||||
// X-axis label
|
||||
text(ctx, 'time within transmission (s)', padL + plotW / 2, padT + plotH + 30, C.dim, 'center', 'top', 11);
|
||||
|
||||
// Readouts
|
||||
s.setReadout('sym', `${cursorSym + 1} / ${N_SYM}`);
|
||||
const ph = phaseOf(cursorSym);
|
||||
const phLabel = {
|
||||
costas1: 'Costas 1 (initial sync)',
|
||||
data1: 'Data block 1',
|
||||
costas2: 'Costas 2 (re-sync)',
|
||||
data2: 'Data block 2',
|
||||
costas3: 'Costas 3 (final sync)',
|
||||
}[ph];
|
||||
s.setReadout('phase', phLabel);
|
||||
const tone = symbols[cursorSym];
|
||||
s.setReadout('tone', `${tone} (+${(tone * TONE_HZ).toFixed(2)} Hz)`);
|
||||
|
||||
let mean;
|
||||
if (ph.startsWith('costas')) {
|
||||
const ci = ph === 'costas1' ? cursorSym : ph === 'costas2' ? cursorSym - 36 : cursorSym - 72;
|
||||
mean = `sync ${ci + 1}/7 (Costas[${ci}] = ${COSTAS[ci]})`;
|
||||
} else {
|
||||
const di = ph === 'data1' ? cursorSym - 7 : cursorSym - 14;
|
||||
const b0 = di * 3;
|
||||
mean = `LDPC bits ${b0}-${b0 + 2} (3 of 174)`;
|
||||
}
|
||||
s.setReadout('mean', mean);
|
||||
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Scene 2: Encoding pipeline
|
||||
// ============================================================
|
||||
function scenePipeline() {
|
||||
const symbols = buildSymbols();
|
||||
|
||||
const s = scene('scene-ft8-pipeline', {
|
||||
height: 480,
|
||||
caption: 'The actual encoder pipeline that produced the spectrogram above, run on "CQ W5ISP EM13". Each row is a stage; bar widths are proportional to bit (or symbol) count. The LDPC step nearly doubles the stream, and the Costas step splices 21 sync symbols on top of 58 data symbols. The bottom row is the same 79 tones as the spectrogram, miniaturized.',
|
||||
});
|
||||
if (!s) return;
|
||||
|
||||
const stages = [
|
||||
{ kind: 'msg', label: 'Message', sub: 'human-readable, variable length' },
|
||||
{ kind: 'bar', label: 'Source-coded', sub: '77 bits (i3.n3 type + packed fields)',
|
||||
segments: [{ count: 77, color: C.green, tag: '77' }], total: 77, of: 174 },
|
||||
{ kind: 'bar', label: '+ CRC-14', sub: '91 bits = 77 message + 14 CRC',
|
||||
segments: [
|
||||
{ count: 77, color: C.green, tag: '77 msg' },
|
||||
{ count: 14, color: C.yellow, tag: '14 CRC' },
|
||||
], total: 91, of: 174 },
|
||||
{ kind: 'bar', label: 'LDPC(174,91)', sub: '174 bits = 91 + 83 sparse parity',
|
||||
segments: [
|
||||
{ count: 91, color: C.green, tag: '91 msg+CRC' },
|
||||
{ count: 83, color: C.magenta, tag: '83 parity' },
|
||||
], total: 174, of: 174 },
|
||||
{ kind: 'bar', label: 'Symbol-mapped', sub: '58 octal symbols (3 bits each, Gray-coded 8-FSK)',
|
||||
segments: [{ count: 58, color: C.cyan, tag: '58 sym' }], total: 58, of: 79, units: 'sym' },
|
||||
{ kind: 'bar', label: '+ 3 Costas arrays', sub: '79 symbols = 7 + 29 + 7 + 29 + 7',
|
||||
segments: [
|
||||
{ count: 7, color: C.amber, tag: '7' },
|
||||
{ count: 29, color: C.cyan, tag: '29' },
|
||||
{ count: 7, color: C.amber, tag: '7' },
|
||||
{ count: 29, color: C.cyan, tag: '29' },
|
||||
{ count: 7, color: C.amber, tag: '7' },
|
||||
], total: 79, of: 79, units: 'sym' },
|
||||
{ kind: 'air', label: 'On the air', sub: '12.64 s, 8-FSK, 6.25 baud, 50 Hz wide' },
|
||||
];
|
||||
|
||||
function draw() {
|
||||
const { w, h } = s.getSize();
|
||||
clear(s.ctx, w, h);
|
||||
const ctx = s.ctx;
|
||||
|
||||
const padL = 14, padR = 14, padT = 12, padB = 8;
|
||||
const labelW = 170;
|
||||
const barL = padL + labelW + 8;
|
||||
const barW = w - barL - padR;
|
||||
const rowH = (h - padT - padB) / stages.length;
|
||||
|
||||
stages.forEach((st, idx) => {
|
||||
const cy = padT + idx * rowH + rowH / 2;
|
||||
|
||||
// Labels (left column)
|
||||
text(ctx, st.label, padL, cy - 6, C.fg, 'left', 'alphabetic', 12, 'system-ui, sans-serif');
|
||||
text(ctx, st.sub, padL, cy + 9, C.dim, 'left', 'alphabetic', 10, 'system-ui, sans-serif');
|
||||
|
||||
if (st.kind === 'msg') {
|
||||
const bh = 30;
|
||||
const by = cy - bh / 2;
|
||||
ctx.fillStyle = C.panel;
|
||||
roundRect(ctx, barL, by, barW, bh, 6);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = C.gridStrong;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
text(ctx, 'CQ W5ISP EM13', barL + barW / 2, cy, C.fg, 'center', 'middle', 14, 'ui-monospace, monospace');
|
||||
} else if (st.kind === 'bar') {
|
||||
const bh = 26;
|
||||
const by = cy - bh / 2;
|
||||
// Faint full-width track
|
||||
ctx.fillStyle = 'rgba(45, 50, 61, 0.45)';
|
||||
ctx.fillRect(barL, by, barW, bh);
|
||||
// Segments scaled by st.of
|
||||
let cx = barL;
|
||||
const px = barW / st.of;
|
||||
st.segments.forEach(seg => {
|
||||
const segW = seg.count * px;
|
||||
ctx.fillStyle = seg.color;
|
||||
ctx.fillRect(cx, by, segW, bh);
|
||||
if (segW > 32) {
|
||||
text(ctx, seg.tag, cx + segW / 2, cy, '#1a1d24', 'center', 'middle', 10, 'ui-monospace, monospace');
|
||||
}
|
||||
cx += segW;
|
||||
});
|
||||
// Right-edge total
|
||||
const totalLabel = st.units ? `${st.total} ${st.units}` : `${st.total} bits`;
|
||||
text(ctx, totalLabel, barL + barW + 0, cy, C.dim, 'right', 'middle', 10, 'ui-monospace, monospace');
|
||||
} else if (st.kind === 'air') {
|
||||
const bh = 36;
|
||||
const by = cy - bh / 2;
|
||||
ctx.fillStyle = '#171b22';
|
||||
ctx.fillRect(barL, by, barW, bh);
|
||||
// Mini spectrogram: 79 columns, 8 rows compressed
|
||||
const cellW = barW / N_SYM;
|
||||
const rowsH = bh - 4;
|
||||
for (let i = 0; i < N_SYM; i++) {
|
||||
const isCostas = phaseOf(i).startsWith('costas');
|
||||
ctx.fillStyle = isCostas ? C.amber : C.cyan;
|
||||
const tone = symbols[i];
|
||||
const cx2 = barL + i * cellW;
|
||||
const cy2 = by + 2 + (7 - tone) * rowsH / 7;
|
||||
ctx.fillRect(cx2, cy2, Math.max(cellW - 0.6, 1), 3.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Connector arrow between stages
|
||||
if (idx < stages.length - 1) {
|
||||
const ay = cy + rowH / 2;
|
||||
const ax = padL + 60;
|
||||
ctx.strokeStyle = C.gridStrong;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, ay - 5);
|
||||
ctx.lineTo(ax, ay + 5);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax - 3, ay + 1);
|
||||
ctx.lineTo(ax, ay + 6);
|
||||
ctx.lineTo(ax + 3, ay + 1);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loop() { draw(); requestAnimationFrame(loop); }
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// ---- init ----
|
||||
function init() {
|
||||
sceneSpectrum();
|
||||
scenePipeline();
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue