The demo below resamples a pure tone from 44.1 kHz to 48 kHz in your browser. Choose Polyphase to hear the clean conversion, or Naive (drop/repeat) to hear what aliasing and imaging artifacts sound like. The waveform canvas shows the resampled output so you can see the smoothness — or the jagged edges.
<!-- Demo layout — method selector, frequency slider, waveform canvas, info row -->
<div class="controls">
<label class="ctrl-label">Resampling method</label>
<div class="btn-group">
<button id="btn-poly" class="method-btn active" data-method="polyphase">Polyphase (clean)</button>
<button id="btn-naive" class="method-btn" data-method="naive">Naive (drop/repeat)</button>
</div>
<label class="ctrl-label">Tone frequency</label>
<div class="freq-row">
<input id="freq-slider" type="range" min="200" max="8000" step="100" value="1000">
<span id="freq-display">1000 Hz</span>
</div>
</div>
<canvas id="wave" width="560" height="140"></canvas>
<div class="info-row">
<span class="badge in-badge">In: 44 100 Hz</span>
<span class="arrow">→</span>
<span class="badge out-badge">Out: 48 000 Hz</span>
<span id="status-msg" class="status-msg"></span>
</div>
<p class="hint-text">The polyphase filter uses 160 sub-filters (one per interpolated phase) each with 64 taps — matching the ratio 160/147. Naive resampling simply picks the nearest input sample, creating visible steps and audible aliasing.</p>
/* Styles for the sample-rate conversion demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
.ctrl-label { font-size: .8rem; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: .04em; }
.btn-group { display: flex; gap: .4rem; flex-wrap: wrap; }
.method-btn { font: 600 13px system-ui; padding: .35rem .75rem; border: 1.5px solid #1d3557;
background: #fff; color: #1d3557; border-radius: 6px; cursor: pointer; transition: all .15s; }
.method-btn.active { background: #1d3557; color: #fff; }
.freq-row { display: flex; align-items: center; gap: .6rem; }
#freq-slider { flex: 1; max-width: 280px; accent-color: #1d3557; }
#freq-display { font: 600 14px ui-monospace, monospace; color: #1d3557; min-width: 70px; }
canvas { display: block; width: 100%; border: 1px solid #dde3ea; border-radius: 8px;
background: #f7f9fb; margin-bottom: .6rem; }
.info-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
.badge { font: 600 12px ui-monospace; padding: .2rem .5rem; border-radius: 4px; }
.in-badge { background: #d1e8ff; color: #1a4a8a; }
.out-badge { background: #d4f4e2; color: #1a6640; }
.arrow { font-size: 1.1rem; color: #888; }
.status-msg { font-size: .88rem; font-weight: 600; color: #0a7d33; }
.hint-text { font-size: .84rem; color: #555; margin: 0; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Sample-rate conversion demo: polyphase vs naive resampling.
// Ratio: L/M = 160/147 (48000/44100 in lowest terms). Upsample by L, filter, downsample by M.
const FS_IN = 44100; // input sample rate (Hz)
const FS_OUT = 48000; // output sample rate (Hz)
const L = 160; // upsample factor (interpolation)
const M = 147; // downsample factor (decimation)
// Build a windowed-sinc low-pass FIR filter — cutoff at min(fs_in,fs_out)/2.
function sincLP(n, cutoff, N) {
// windowed sinc — Hamming window * sinc
const half = (N - 1) / 2;
const x = n - half;
const w = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / (N - 1)); // Hamming window coefficient
if (x === 0) return 2 * cutoff * w;
return w * Math.sin(2 * Math.PI * cutoff * x) / (Math.PI * x);
}
// Allocate and fill the polyphase filter bank
const TAPS = 64; // taps per polyphase sub-filter
const TOTAL = TAPS * L; // total FIR taps spread across all L phases
const CUT = 0.5 / Math.max(L, M); // cutoff = 0.5/max(L,M) in normalized frequency
const h = new Float32Array(TOTAL);
let norm = 0;
for (let i = 0; i < TOTAL; i++) {
h[i] = sincLP(i, CUT, TOTAL);
norm += h[i];
}
// normalize so passband gain = 1
for (let i = 0; i < TOTAL; i++) h[i] = h[i] * L / norm;
// Generate a pure sinusoidal input sample at the given frequency and index
function genInput(freq, n) {
return Math.sin(2 * Math.PI * freq * n / FS_IN);
}
// Polyphase resampling: for each output sample, pick the correct sub-filter phase and convolve.
function polyphaseResample(freq, numOut) {
const numIn = Math.ceil(numOut * M / L) + TAPS + 2;
const x = new Float32Array(numIn);
for (let i = 0; i < numIn; i++) x[i] = genInput(freq, i);
const out = new Float32Array(numOut);
for (let n = 0; n < numOut; n++) {
// compute fractional input position and phase index
const inPos = n * M / L;
const inIdx = Math.floor(inPos);
const phase = Math.round((inPos - inIdx) * L) % L;
let acc = 0;
// convolve with the chosen polyphase sub-filter
for (let k = 0; k < TAPS; k++) {
const xi = inIdx - k + TAPS - 1;
if (xi >= 0 && xi < numIn) acc += x[xi] * h[phase + k * L];
}
out[n] = acc;
}
return out;
}
// Naive resampling: nearest-neighbor — no anti-aliasing filter.
function naiveResample(freq, numOut) {
const out = new Float32Array(numOut);
for (let n = 0; n < numOut; n++) {
// round to nearest input index (creates aliasing)
const srcIdx = Math.round(n * M / L);
out[n] = genInput(freq, srcIdx);
}
return out;
}
// Draw the waveform on the canvas
function draw(samples, method) {
const canvas = document.getElementById('wave');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// draw background grid lines
ctx.strokeStyle = '#e0e6ed';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
for (let x = 0; x < W; x += 56) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// green for polyphase (clean), red for naive (aliased)
ctx.strokeStyle = method === 'polyphase' ? '#1a6640' : '#c92f3c';
ctx.lineWidth = 1.8;
ctx.beginPath();
const step = W / samples.length;
for (let i = 0; i < samples.length; i++) {
const px = i * step;
const py = H / 2 - samples[i] * (H / 2 - 10);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}
// Compute samples and redraw when method or frequency changes.
let currentMethod = 'polyphase';
let currentFreq = 1000;
function update() {
const numOut = 560; // number of output samples to render
const samples = currentMethod === 'polyphase'
? polyphaseResample(currentFreq, numOut)
: naiveResample(currentFreq, numOut);
draw(samples, currentMethod);
const msg = currentMethod === 'polyphase'
? i18nFmt('Polyphase — %freq% Hz tone resampled cleanly.', { freq: currentFreq })
: i18nFmt('Naive — %freq% Hz tone with aliasing artifacts.', { freq: currentFreq });
document.getElementById('status-msg').textContent = msg;
}
// Wire up button clicks and slider input
document.querySelectorAll('.method-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.method-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMethod = btn.dataset.method;
update();
});
});
document.getElementById('freq-slider').addEventListener('input', e => {
currentFreq = Number(e.target.value);
document.getElementById('freq-display').textContent = currentFreq + ' Hz';
update();
});
update();
<!-- Demo layout — method selector, frequency slider, waveform canvas, info row -->
<div class="controls">
<label class="ctrl-label">Resampling method</label>
<div class="btn-group">
<button id="btn-poly" class="method-btn active" data-method="polyphase">Polyphase (clean)</button>
<button id="btn-naive" class="method-btn" data-method="naive">Naive (drop/repeat)</button>
</div>
<label class="ctrl-label">Tone frequency</label>
<div class="freq-row">
<input id="freq-slider" type="range" min="200" max="8000" step="100" value="1000">
<span id="freq-display">1000 Hz</span>
</div>
</div>
<canvas id="wave" width="560" height="140"></canvas>
<div class="info-row">
<span class="badge in-badge">In: 44 100 Hz</span>
<span class="arrow">→</span>
<span class="badge out-badge">Out: 48 000 Hz</span>
<span id="status-msg" class="status-msg"></span>
</div>
<p class="hint-text">The polyphase filter uses 160 sub-filters (one per interpolated phase) each with 64 taps — matching the ratio 160/147. Naive resampling simply picks the nearest input sample, creating visible steps and audible aliasing.</p>
/* Styles for the sample-rate conversion demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
.ctrl-label { font-size: .8rem; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: .04em; }
.btn-group { display: flex; gap: .4rem; flex-wrap: wrap; }
.method-btn { font: 600 13px system-ui; padding: .35rem .75rem; border: 1.5px solid #1d3557;
background: #fff; color: #1d3557; border-radius: 6px; cursor: pointer; transition: all .15s; }
.method-btn.active { background: #1d3557; color: #fff; }
.freq-row { display: flex; align-items: center; gap: .6rem; }
#freq-slider { flex: 1; max-width: 280px; accent-color: #1d3557; }
#freq-display { font: 600 14px ui-monospace, monospace; color: #1d3557; min-width: 70px; }
canvas { display: block; width: 100%; border: 1px solid #dde3ea; border-radius: 8px;
background: #f7f9fb; margin-bottom: .6rem; }
.info-row { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
.badge { font: 600 12px ui-monospace; padding: .2rem .5rem; border-radius: 4px; }
.in-badge { background: #d1e8ff; color: #1a4a8a; }
.out-badge { background: #d4f4e2; color: #1a6640; }
.arrow { font-size: 1.1rem; color: #888; }
.status-msg { font-size: .88rem; font-weight: 600; color: #0a7d33; }
.hint-text { font-size: .84rem; color: #555; margin: 0; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Sample-rate conversion demo: polyphase vs naive resampling.
// Ratio: L/M = 160/147 (48000/44100 in lowest terms). Upsample by L, filter, downsample by M.
const FS_IN = 44100; // input sample rate (Hz)
const FS_OUT = 48000; // output sample rate (Hz)
const L = 160; // upsample factor (interpolation)
const M = 147; // downsample factor (decimation)
// Build a windowed-sinc low-pass FIR filter — cutoff at min(fs_in,fs_out)/2.
function sincLP(n, cutoff, N) {
// windowed sinc — Hamming window * sinc
const half = (N - 1) / 2;
const x = n - half;
const w = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / (N - 1)); // Hamming window coefficient
if (x === 0) return 2 * cutoff * w;
return w * Math.sin(2 * Math.PI * cutoff * x) / (Math.PI * x);
}
// Allocate and fill the polyphase filter bank
const TAPS = 64; // taps per polyphase sub-filter
const TOTAL = TAPS * L; // total FIR taps spread across all L phases
const CUT = 0.5 / Math.max(L, M); // cutoff = 0.5/max(L,M) in normalized frequency
const h = new Float32Array(TOTAL);
let norm = 0;
for (let i = 0; i < TOTAL; i++) {
h[i] = sincLP(i, CUT, TOTAL);
norm += h[i];
}
// normalize so passband gain = 1
for (let i = 0; i < TOTAL; i++) h[i] = h[i] * L / norm;
// Generate a pure sinusoidal input sample at the given frequency and index
function genInput(freq, n) {
return Math.sin(2 * Math.PI * freq * n / FS_IN);
}
// Polyphase resampling: for each output sample, pick the correct sub-filter phase and convolve.
function polyphaseResample(freq, numOut) {
const numIn = Math.ceil(numOut * M / L) + TAPS + 2;
const x = new Float32Array(numIn);
for (let i = 0; i < numIn; i++) x[i] = genInput(freq, i);
const out = new Float32Array(numOut);
for (let n = 0; n < numOut; n++) {
// compute fractional input position and phase index
const inPos = n * M / L;
const inIdx = Math.floor(inPos);
const phase = Math.round((inPos - inIdx) * L) % L;
let acc = 0;
// convolve with the chosen polyphase sub-filter
for (let k = 0; k < TAPS; k++) {
const xi = inIdx - k + TAPS - 1;
if (xi >= 0 && xi < numIn) acc += x[xi] * h[phase + k * L];
}
out[n] = acc;
}
return out;
}
// Naive resampling: nearest-neighbor — no anti-aliasing filter.
function naiveResample(freq, numOut) {
const out = new Float32Array(numOut);
for (let n = 0; n < numOut; n++) {
// round to nearest input index (creates aliasing)
const srcIdx = Math.round(n * M / L);
out[n] = genInput(freq, srcIdx);
}
return out;
}
// Draw the waveform on the canvas
function draw(samples, method) {
const canvas = document.getElementById('wave');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// draw background grid lines
ctx.strokeStyle = '#e0e6ed';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2); ctx.stroke();
for (let x = 0; x < W; x += 56) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// green for polyphase (clean), red for naive (aliased)
ctx.strokeStyle = method === 'polyphase' ? '#1a6640' : '#c92f3c';
ctx.lineWidth = 1.8;
ctx.beginPath();
const step = W / samples.length;
for (let i = 0; i < samples.length; i++) {
const px = i * step;
const py = H / 2 - samples[i] * (H / 2 - 10);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}
// Compute samples and redraw when method or frequency changes.
let currentMethod = 'polyphase';
let currentFreq = 1000;
function update() {
const numOut = 560; // number of output samples to render
const samples = currentMethod === 'polyphase'
? polyphaseResample(currentFreq, numOut)
: naiveResample(currentFreq, numOut);
draw(samples, currentMethod);
const msg = currentMethod === 'polyphase'
? i18nFmt('Polyphase — %freq% Hz tone resampled cleanly.', { freq: currentFreq })
: i18nFmt('Naive — %freq% Hz tone with aliasing artifacts.', { freq: currentFreq });
document.getElementById('status-msg').textContent = msg;
}
// Wire up button clicks and slider input
document.querySelectorAll('.method-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.method-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMethod = btn.dataset.method;
update();
});
});
document.getElementById('freq-slider').addEventListener('input', e => {
currentFreq = Number(e.target.value);
document.getElementById('freq-display').textContent = currentFreq + ' Hz';
update();
});
update();
Notice how the naive method introduces visible steps in the waveform and audible distortion, while the polyphase filter produces a smooth sinusoid at exactly the right frequency. The ratio 48000/44100=160/147 means that for every 147 input samples the algorithm must produce 160 output samples — it upsamples by 160, low-pass filters, then downsamples by 147, all folded into one efficient polyphase structure.
Comments
Loading comments...