The canvas below shows a tone whose amplitude is modulated by a slow sinusoid. The blue curve is the raw signal x(t); the orange curve is the envelope ∣z(t)∣ recovered via the Hilbert transform. Use the sliders to change the carrier frequency and the modulation depth — watch the envelope cling to the peaks of the carrier no matter how you tune it.
<!-- Canvas demo: AM-modulated tone + Hilbert envelope -->
<div class="controls">
<label>Carrier frequency: <span id="fc-val"></span> Hz
<input type="range" id="fc" min="4" max="20" value="10" step="1">
</label>
<label>Modulation depth: <span id="mod-val"></span>%
<input type="range" id="mod" min="0" max="100" value="60" step="5">
</label>
</div>
<canvas id="cv" width="560" height="280"></canvas>
<div class="legend">
<span class="dot blue"></span> Signal x(t)
<span class="dot orange"></span> Envelope |z(t)|
</div>
<p class="note" id="note"></p>
/* Styles for the Hilbert transform envelope demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .4rem; margin-bottom: .6rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .5rem; }
input[type=range] { flex: 1; accent-color: #1d3557; }
canvas { display: block; width: 100%; max-width: 560px; border: 1px solid #cdd9e3;
border-radius: 8px; background: #f8fafc; }
.legend { font-size: .82rem; display: flex; gap: 1rem; margin-top: .4rem; align-items: center; }
.dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; }
.dot.blue { background: #1d6fa5; }
.dot.orange { background: #e07020; }
.note { font-size: .82rem; color: #555; margin: .4rem 0 0; min-height: 1.2em; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Discrete Hilbert transform via FFT — builds the analytic signal and extracts the envelope
const N = 512; // number of samples (must be a power of 2 for the FFT)
const T = 1.0; // signal duration in seconds
const dt = T / N;
// In-place radix-2 Cooley-Tukey FFT (forward, unnormalized)
function fft(re, im) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
[re[i], re[j]] = [re[j], re[i]];
[im[i], im[j]] = [im[j], im[i]];
}
}
for (let len = 2; len <= n; len <<= 1) {
const ang = -2 * Math.PI / len;
const wr = Math.cos(ang), wi = Math.sin(ang);
for (let i = 0; i < n; i += len) {
let cr = 1, ci = 0;
for (let k = 0; k < len / 2; k++) {
const ur = re[i + k], ui = im[i + k];
const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
re[i + k] = ur + vr; im[i + k] = ui + vi;
re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi;
const tmp = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = tmp;
}
}
}
}
// Inverse FFT: swap re/im, forward FFT, then normalize
function ifft(re, im) {
fft(im, re);
for (let i = 0; i < re.length; i++) { re[i] /= re.length; im[i] /= re.length; }
}
// Compute the Hilbert envelope via the analytic signal
function hilbertEnvelope(x) {
const re = x.slice(), im = new Array(N).fill(0);
fft(re, im);
// double positive frequencies, zero out negative frequencies
for (let k = 1; k < N / 2; k++) { re[k] *= 2; im[k] *= 2; }
for (let k = N / 2 + 1; k < N; k++) { re[k] = 0; im[k] = 0; }
ifft(re, im);
// magnitude of the analytic signal = instantaneous amplitude
return re.map((r, i) => Math.sqrt(r * r + im[i] * im[i]));
}
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const fcSlider = document.getElementById('fc');
const modSlider = document.getElementById('mod');
const fcVal = document.getElementById('fc-val');
const modVal = document.getElementById('mod-val');
const noteEl = document.getElementById('note');
function draw() {
const fc = Number(fcSlider.value);
const m = Number(modSlider.value) / 100;
fcVal.textContent = fc;
modVal.textContent = Math.round(m * 100);
// build AM-modulated signal: (1-m+m*cos(2*pi*2*t)) * cos(2*pi*fc*t)
const x = [];
for (let n = 0; n < N; n++) {
const t = n * dt;
const env = 1 - m + m * Math.cos(2 * Math.PI * 2 * t);
x.push(env * Math.cos(2 * Math.PI * fc * t));
}
const env = hilbertEnvelope(x);
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
const mid = H / 2;
const scale = mid * 0.88;
// draw the raw signal in blue
ctx.beginPath();
ctx.strokeStyle = '#1d6fa5';
ctx.lineWidth = 1.5;
for (let i = 0; i < N; i++) {
const px = (i / N) * W;
const py = mid - x[i] * scale;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
// draw the positive envelope in orange
ctx.beginPath();
ctx.strokeStyle = '#e07020';
ctx.lineWidth = 2;
for (let i = 0; i < N; i++) {
const px = (i / N) * W;
const py = mid - env[i] * scale;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
// draw the negative mirror envelope as a dashed orange line
ctx.beginPath();
ctx.strokeStyle = '#e07020';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
for (let i = 0; i < N; i++) {
const px = (i / N) * W;
const py = mid + env[i] * scale;
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
}
ctx.stroke();
ctx.setLineDash([]);
// update the explanatory note below the canvas
if (m >= 0.99) {
noteEl.textContent = '100% modulation — the signal touches zero; the instantaneous phase jumps by π at each zero crossing.';
} else {
noteEl.textContent = i18nFmt('Carrier %fc% Hz · modulation %mod%% — the envelope (orange) tracks the amplitude peaks of the carrier (blue).', { fc: fc, mod: Math.round(m * 100) });
}
}
fcSlider.addEventListener('input', draw);
modSlider.addEventListener('input', draw);
draw();
Notice that the envelope never dips below zero and always touches the absolute peaks of the carrier. When modulation depth is 100 % the signal briefly touches zero — complete suppression. That moment corresponds to a π-radian jump in the instantaneous phase, a signature the Hilbert transform detects cleanly.
Comments
Loading comments...