755 lines
29 KiB
JavaScript
755 lines
29 KiB
JavaScript
// 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();
|
|
})();
|