<p class="hint">Type a binary message (e.g. <code>10110</code>), then <b>Encode</b> it with a rate-1/2 convolutional code. Use the slider to add noise, then <b>Decode (Viterbi)</b> to recover the original.</p>
<div class="row">
<label>Input bits: <input id="inp" type="text" value="10110" maxlength="12" spellcheck="false"/></label>
</div>
<div class="row">
<label>Noise (bits to flip): <input id="noise" type="range" min="0" max="6" value="1"/> <span id="noise-val">1</span></label>
</div>
<div class="btns">
<button id="encode-btn">Encode</button>
<button id="decode-btn">Decode (Viterbi)</button>
<button id="reset-btn" class="ghost">Reset</button>
</div>
<div id="panel"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .93rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
code { background: #eef1f4; padding: 0 3px; border-radius: 3px; font-size: .85rem; }
.row { display: flex; align-items: center; gap: .5rem; margin: .35rem 0; flex-wrap: wrap; }
label { display: flex; align-items: center; gap: .4rem; font-size: .88rem; }
input[type=text] { font: 600 15px ui-monospace, monospace; width: 130px; padding: .3rem .5rem;
border: 1px solid #cdd9e3; border-radius: 6px; }
input[type=range] { width: 120px; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin: .5rem 0; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#panel { margin-top: .6rem; }
.section-label { font-size: .78rem; font-weight: 700; color: #6b7280; text-transform: uppercase;
letter-spacing: .05em; margin: .7rem 0 .2rem; }
.bits { display: flex; flex-wrap: wrap; gap: 3px; margin: .2rem 0 .5rem; }
.bit { display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 5px; font: 700 13px ui-monospace, monospace;
border: 1px solid #cdd9e3; }
.bit-in { background: #dbeafe; border-color: #93c5fd; color: #1e40af; }
.bit-enc { background: #dcfce7; border-color: #86efac; color: #166534; }
.bit-rx { background: #fef3c7; border-color: #fcd34d; color: #92400e; }
.bit-rx.flipped { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.bit-dec { background: #ede9fe; border-color: #c4b5fd; color: #5b21b6; }
.result-ok { color: #166534; font-weight: 700; margin: .3rem 0; }
.result-err { color: #991b1b; font-weight: 700; margin: .3rem 0; }
.note { font-size: .82rem; color: #4b5563; margin: .4rem 0 0; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Rate-1/2 convolutional code, constraint length k=3.
// Generators: g1=110 (0x6), g2=111 (0x7) — standard NASA/GSM polynomials.
// State = last 2 input bits (2 bits, 4 states).
// Each input bit produces 2 output bits; encoded length = 2 * (n + k - 1).
// We tail-bite by appending k-1=2 zero bits to flush the encoder.
const G1 = [1,1,0]; // taps for output 1
const G2 = [1,1,1]; // taps for output 2
const K = 3; // constraint length
const STATES = 1 << (K - 1); // 4 states
function dot(reg, taps) {
return reg.reduce((s, b, i) => s ^ (b & taps[i]), 0);
}
// Encode: input is array of bits; appends (K-1) zeros to flush.
function encode(bits) {
const msg = [...bits, ...new Array(K - 1).fill(0)];
const reg = new Array(K).fill(0);
const out = [];
for (const b of msg) {
reg.unshift(b); reg.pop();
out.push(dot(reg, G1), dot(reg, G2));
}
return out;
}
// Viterbi decoder: received is array of bits (possibly noisy).
// Returns decoded bits (original length, without the flush zeros).
function viterbi(received, origLen) {
const totalSteps = origLen + K - 1;
// paths[state] = { cost, bits[] }
let paths = Array.from({length: STATES}, (_, s) => ({
cost: s === 0 ? 0 : Infinity, bits: []
}));
const rx = received;
for (let t = 0; t < totalSteps; t++) {
const r0 = rx[2 * t], r1 = rx[2 * t + 1];
const next = Array.from({length: STATES}, () => ({ cost: Infinity, bits: null }));
for (let s = 0; s < STATES; s++) {
if (paths[s].cost === Infinity) continue;
for (const b of [0, 1]) {
const reg = [(b << (K-1)) | s >> 0]; // reconstruct shift register
// reg[0]=current input bit, reg[1..K-1]=previous state bits
const fullReg = [b, ...toBits(s, K-1)];
const c0 = dot(fullReg, G1);
const c1 = dot(fullReg, G2);
const hammingDist = (c0 ^ r0) + (c1 ^ r1);
const newCost = paths[s].cost + hammingDist;
const nstate = ((s >> 1) | (b << (K-2))); // next state
if (newCost < next[nstate].cost) {
next[nstate] = { cost: newCost, bits: [...paths[s].bits, b] };
}
}
}
paths = next;
}
// Find state with lowest cost (should be 0 if flush zeros worked)
let best = paths[0];
for (const p of paths) if (p.cost < best.cost) best = p;
return best.bits.slice(0, origLen);
}
function toBits(n, len) {
const b = [];
for (let i = len - 1; i >= 0; i--) b.push((n >> i) & 1);
return b;
}
function addNoise(encoded, nFlips) {
const rx = [...encoded];
// randomly pick nFlips distinct positions
const idx = [];
while (idx.length < Math.min(nFlips, rx.length)) {
const p = Math.floor(Math.random() * rx.length);
if (!idx.includes(p)) idx.push(p);
}
const flipped = new Set(idx);
for (const i of idx) rx[i] ^= 1;
return { rx, flipped };
}
function parseBits(s) {
return [...s.trim()].filter(c => c==='0'||c==='1').map(Number);
}
function renderBits(bits, cls, flippedSet) {
return bits.map((b, i) => {
const f = flippedSet && flippedSet.has(i) ? ' flipped' : '';
return `<span class="bit ${cls}${f}">${b}</span>`;
}).join('');
}
const noiseSlider = document.getElementById('noise');
const noiseVal = document.getElementById('noise-val');
noiseSlider.addEventListener('input', () => { noiseVal.textContent = noiseSlider.value; });
let lastInput = null, lastEncoded = null, lastRx = null, lastFlipped = null;
document.getElementById('encode-btn').addEventListener('click', () => {
const bits = parseBits(document.getElementById('inp').value);
if (bits.length === 0) { document.getElementById('panel').innerHTML = '<p class="result-err">Enter at least one bit (0 or 1).</p>'; return; }
lastInput = bits;
lastEncoded = encode(bits);
const nFlips = parseInt(noiseSlider.value);
const { rx, flipped } = addNoise(lastEncoded, nFlips);
lastRx = rx; lastFlipped = flipped;
document.getElementById('panel').innerHTML = "\n " + fmt(`<div class="section-label">Input (%length% bits)</div>
<div class="bits">%renderBits%</div>
<div class="section-label">Encoded (%length2% bits) — rate 1/2, generators 110 & 111</div>
<div class="bits">%renderBits2%</div>
<div class="section-label">Received after channel (%nFlips% bit%v% flipped, shown in red)</div>
<div class="bits">%renderBits3%</div>
<p class="note">Press <b>Decode (Viterbi)</b> to recover the original.</p>`, { length: bits.length, renderBits: renderBits(bits, 'bit-in', null), length2: lastEncoded.length, renderBits2: renderBits(lastEncoded, 'bit-enc', null), nFlips: nFlips, v: nFlips!==1?`s`:'', renderBits3: renderBits(rx, 'bit-rx', flipped) });
});
document.getElementById('decode-btn').addEventListener('click', () => {
if (!lastInput || !lastRx) { document.getElementById('panel').innerHTML = '<p class="result-err">Click Encode first.</p>'; return; }
const decoded = viterbi(lastRx, lastInput.length);
const ok = decoded.every((b, i) => b === lastInput[i]);
document.getElementById('panel').innerHTML += "\n " + fmt(`<div class="section-label">Decoded (Viterbi)</div>
<div class="bits">%renderBits%</div>
<p class="%v%">%v2%</p>
<p class="note">The Viterbi algorithm traces the most-likely path through %STATES% states × %v3% steps = %v4% trellis nodes.</p>`, { renderBits: renderBits(decoded, 'bit-dec', null), v: ok ? 'result-ok' : 'result-err', v2: ok ? `✓ Perfect recovery — Viterbi found the original.` : `✗ Decoding error — too many flipped bits for this code.`, STATES: STATES, v3: lastInput.length + K - 1, v4: STATES * (lastInput.length + K - 1) });
console.log(`Input:`, lastInput.join(''), '| Encoded:', lastEncoded.join(''), '| Flipped positions:', [...lastFlipped].sort((a,b)=>a-b).join(','), '| Decoded:', decoded.join(''), '| Match:', ok);
});
document.getElementById('reset-btn').addEventListener('click', () => {
document.getElementById('inp').value = '10110';
noiseSlider.value = 1; noiseVal.textContent = '1';
lastInput = lastEncoded = lastRx = lastFlipped = null;
document.getElementById('panel').innerHTML = '';
});
<p class="hint">Type a binary message (e.g. <code>10110</code>), then <b>Encode</b> it with a rate-1/2 convolutional code. Use the slider to add noise, then <b>Decode (Viterbi)</b> to recover the original.</p>
<div class="row">
<label>Input bits: <input id="inp" type="text" value="10110" maxlength="12" spellcheck="false"/></label>
</div>
<div class="row">
<label>Noise (bits to flip): <input id="noise" type="range" min="0" max="6" value="1"/> <span id="noise-val">1</span></label>
</div>
<div class="btns">
<button id="encode-btn">Encode</button>
<button id="decode-btn">Decode (Viterbi)</button>
<button id="reset-btn" class="ghost">Reset</button>
</div>
<div id="panel"></div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .93rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
code { background: #eef1f4; padding: 0 3px; border-radius: 3px; font-size: .85rem; }
.row { display: flex; align-items: center; gap: .5rem; margin: .35rem 0; flex-wrap: wrap; }
label { display: flex; align-items: center; gap: .4rem; font-size: .88rem; }
input[type=text] { font: 600 15px ui-monospace, monospace; width: 130px; padding: .3rem .5rem;
border: 1px solid #cdd9e3; border-radius: 6px; }
input[type=range] { width: 120px; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin: .5rem 0; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
#panel { margin-top: .6rem; }
.section-label { font-size: .78rem; font-weight: 700; color: #6b7280; text-transform: uppercase;
letter-spacing: .05em; margin: .7rem 0 .2rem; }
.bits { display: flex; flex-wrap: wrap; gap: 3px; margin: .2rem 0 .5rem; }
.bit { display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 5px; font: 700 13px ui-monospace, monospace;
border: 1px solid #cdd9e3; }
.bit-in { background: #dbeafe; border-color: #93c5fd; color: #1e40af; }
.bit-enc { background: #dcfce7; border-color: #86efac; color: #166534; }
.bit-rx { background: #fef3c7; border-color: #fcd34d; color: #92400e; }
.bit-rx.flipped { background: #fee2e2; border-color: #fca5a5; color: #991b1b; }
.bit-dec { background: #ede9fe; border-color: #c4b5fd; color: #5b21b6; }
.result-ok { color: #166534; font-weight: 700; margin: .3rem 0; }
.result-err { color: #991b1b; font-weight: 700; margin: .3rem 0; }
.note { font-size: .82rem; color: #4b5563; margin: .4rem 0 0; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Rate-1/2 convolutional code, constraint length k=3.
// Generators: g1=110 (0x6), g2=111 (0x7) — standard NASA/GSM polynomials.
// State = last 2 input bits (2 bits, 4 states).
// Each input bit produces 2 output bits; encoded length = 2 * (n + k - 1).
// We tail-bite by appending k-1=2 zero bits to flush the encoder.
const G1 = [1,1,0]; // taps for output 1
const G2 = [1,1,1]; // taps for output 2
const K = 3; // constraint length
const STATES = 1 << (K - 1); // 4 states
function dot(reg, taps) {
return reg.reduce((s, b, i) => s ^ (b & taps[i]), 0);
}
// Encode: input is array of bits; appends (K-1) zeros to flush.
function encode(bits) {
const msg = [...bits, ...new Array(K - 1).fill(0)];
const reg = new Array(K).fill(0);
const out = [];
for (const b of msg) {
reg.unshift(b); reg.pop();
out.push(dot(reg, G1), dot(reg, G2));
}
return out;
}
// Viterbi decoder: received is array of bits (possibly noisy).
// Returns decoded bits (original length, without the flush zeros).
function viterbi(received, origLen) {
const totalSteps = origLen + K - 1;
// paths[state] = { cost, bits[] }
let paths = Array.from({length: STATES}, (_, s) => ({
cost: s === 0 ? 0 : Infinity, bits: []
}));
const rx = received;
for (let t = 0; t < totalSteps; t++) {
const r0 = rx[2 * t], r1 = rx[2 * t + 1];
const next = Array.from({length: STATES}, () => ({ cost: Infinity, bits: null }));
for (let s = 0; s < STATES; s++) {
if (paths[s].cost === Infinity) continue;
for (const b of [0, 1]) {
const reg = [(b << (K-1)) | s >> 0]; // reconstruct shift register
// reg[0]=current input bit, reg[1..K-1]=previous state bits
const fullReg = [b, ...toBits(s, K-1)];
const c0 = dot(fullReg, G1);
const c1 = dot(fullReg, G2);
const hammingDist = (c0 ^ r0) + (c1 ^ r1);
const newCost = paths[s].cost + hammingDist;
const nstate = ((s >> 1) | (b << (K-2))); // next state
if (newCost < next[nstate].cost) {
next[nstate] = { cost: newCost, bits: [...paths[s].bits, b] };
}
}
}
paths = next;
}
// Find state with lowest cost (should be 0 if flush zeros worked)
let best = paths[0];
for (const p of paths) if (p.cost < best.cost) best = p;
return best.bits.slice(0, origLen);
}
function toBits(n, len) {
const b = [];
for (let i = len - 1; i >= 0; i--) b.push((n >> i) & 1);
return b;
}
function addNoise(encoded, nFlips) {
const rx = [...encoded];
// randomly pick nFlips distinct positions
const idx = [];
while (idx.length < Math.min(nFlips, rx.length)) {
const p = Math.floor(Math.random() * rx.length);
if (!idx.includes(p)) idx.push(p);
}
const flipped = new Set(idx);
for (const i of idx) rx[i] ^= 1;
return { rx, flipped };
}
function parseBits(s) {
return [...s.trim()].filter(c => c==='0'||c==='1').map(Number);
}
function renderBits(bits, cls, flippedSet) {
return bits.map((b, i) => {
const f = flippedSet && flippedSet.has(i) ? ' flipped' : '';
return `<span class="bit ${cls}${f}">${b}</span>`;
}).join('');
}
const noiseSlider = document.getElementById('noise');
const noiseVal = document.getElementById('noise-val');
noiseSlider.addEventListener('input', () => { noiseVal.textContent = noiseSlider.value; });
let lastInput = null, lastEncoded = null, lastRx = null, lastFlipped = null;
document.getElementById('encode-btn').addEventListener('click', () => {
const bits = parseBits(document.getElementById('inp').value);
if (bits.length === 0) { document.getElementById('panel').innerHTML = '<p class="result-err">Enter at least one bit (0 or 1).</p>'; return; }
lastInput = bits;
lastEncoded = encode(bits);
const nFlips = parseInt(noiseSlider.value);
const { rx, flipped } = addNoise(lastEncoded, nFlips);
lastRx = rx; lastFlipped = flipped;
document.getElementById('panel').innerHTML = "\n " + fmt(`<div class="section-label">Input (%length% bits)</div>
<div class="bits">%renderBits%</div>
<div class="section-label">Encoded (%length2% bits) — rate 1/2, generators 110 & 111</div>
<div class="bits">%renderBits2%</div>
<div class="section-label">Received after channel (%nFlips% bit%v% flipped, shown in red)</div>
<div class="bits">%renderBits3%</div>
<p class="note">Press <b>Decode (Viterbi)</b> to recover the original.</p>`, { length: bits.length, renderBits: renderBits(bits, 'bit-in', null), length2: lastEncoded.length, renderBits2: renderBits(lastEncoded, 'bit-enc', null), nFlips: nFlips, v: nFlips!==1?`s`:'', renderBits3: renderBits(rx, 'bit-rx', flipped) });
});
document.getElementById('decode-btn').addEventListener('click', () => {
if (!lastInput || !lastRx) { document.getElementById('panel').innerHTML = '<p class="result-err">Click Encode first.</p>'; return; }
const decoded = viterbi(lastRx, lastInput.length);
const ok = decoded.every((b, i) => b === lastInput[i]);
document.getElementById('panel').innerHTML += "\n " + fmt(`<div class="section-label">Decoded (Viterbi)</div>
<div class="bits">%renderBits%</div>
<p class="%v%">%v2%</p>
<p class="note">The Viterbi algorithm traces the most-likely path through %STATES% states × %v3% steps = %v4% trellis nodes.</p>`, { renderBits: renderBits(decoded, 'bit-dec', null), v: ok ? 'result-ok' : 'result-err', v2: ok ? `✓ Perfect recovery — Viterbi found the original.` : `✗ Decoding error — too many flipped bits for this code.`, STATES: STATES, v3: lastInput.length + K - 1, v4: STATES * (lastInput.length + K - 1) });
console.log(`Input:`, lastInput.join(''), '| Encoded:', lastEncoded.join(''), '| Flipped positions:', [...lastFlipped].sort((a,b)=>a-b).join(','), '| Decoded:', decoded.join(''), '| Match:', ok);
});
document.getElementById('reset-btn').addEventListener('click', () => {
document.getElementById('inp').value = '10110';
noiseSlider.value = 1; noiseVal.textContent = '1';
lastInput = lastEncoded = lastRx = lastFlipped = null;
document.getElementById('panel').innerHTML = '';
});
Notice two things. First, the encoded sequence is exactly twice as long as the input — each input bit becomes two output bits. Second, even with several bit-flips the Viterbi decoder recovers the original perfectly, because the redundancy gives it enough information to correct errors. Only when the noise exceeds the code's error-correcting capacity does it start to fail.
Comments
Loading comments...