Documentation/Source example
Example source
samples/worker/05. Spectrum Analyzer.js
Referenced by:Worker
'use strict';
// Worker sample 5: Spectrum Analyzer
// Demonstrates: the same real-time audio/FFT/render pipeline on the panel thread
// and in a Worker, offscreen GDI/Direct2D rendering and transferable Worker frames.
// Use the in-panel switches to compare execution mode, rendering backend, FFT size and rate limiting.
// Only one completed frame is allowed at a time; the next frame starts after the previous one is painted.
//
// GDI can show a higher maximum FPS than D2D in this sample. That is expected for this
// particular workload: every frame creates a small offscreen bitmap, draws only simple
// rectangles/lines, finishes it immediately and transfers it. GDI performs that tiny job
// directly in CPU memory, while D2D has additional per-frame resource, command-submission
// and synchronization overhead, so the fixed overhead can dominate. This is not a general
// GDI-vs-D2D benchmark; D2D benefits more from retained/reused resources and larger drawing
// or composition workloads. The difference is easiest to see in Max mode.
let engineMode = 'worker';
let drawMode = 1;
let fftSize = 4096;
let rateMode = 'max';
window.DrawMode = drawMode;
const TARGET_FPS = 120;
const FRAME_TIME = 1000 / TARGET_FPS;
const BAR_WIDTH = 5;
const BAR_GAP = 2;
const BAR_COLOUR = 0xFF42A5F5;
const SPECTRUM_TOP = 92;
const DT_LEFT = 0x0000;
const DT_CENTER = 0x0001;
const DT_VCENTER = 0x0004;
const DT_SINGLELINE = 0x0020;
const DT_NOPREFIX = 0x0800;
const DT_END_ELLIPSIS = 0x8000;
let INFO_FONT = gdi.Font('Segoe UI', 12);
let switchingRenderer = false;
let frame = null;
let timing = { audio: 0, analysis: 0, render: 0, total: 0, interval: 0 };
let frameSequence = 0;
let lastPaintedSequence = -1;
let frameFps = 0;
let paintFps = 0;
let frameCount = 0;
let paintCount = 0;
let frameWindowStart = performance.now();
let paintWindowStart = frameWindowStart;
function createSpectrumEngine(currentFftSize) {
const FFT_SIZE = currentFftSize;
const FLOOR_LEVEL = 0.04;
const IDLE_LEVEL = 0.015;
let width = 1;
let height = 1;
let bandCount = 1;
let bandSampleRate = 0;
let windowLength = 0;
const real = new Float64Array(FFT_SIZE);
const imag = new Float64Array(FFT_SIZE);
const magnitudes = new Float64Array((FFT_SIZE >> 1) + 1);
let targets = new Float64Array(1);
let smoothed = new Float64Array(1);
let peaks = new Float64Array(1);
let peakVelocity = new Float64Array(1);
let peakHold = new Float64Array(1);
let windowCoefficients = new Float64Array(1);
let bandStart = new Int32Array(1);
let bandEnd = new Int32Array(1);
function ensureWindow(length) {
length = Math.max(1, Math.min(FFT_SIZE, length | 0));
if (length === windowLength) return windowCoefficients;
windowLength = length;
windowCoefficients = new Float64Array(length);
if (length === 1) {
windowCoefficients[0] = 1;
} else {
const scale = 2 * Math.PI / (length - 1);
for (let i = 0; i < length; ++i) {
windowCoefficients[i] = 0.5 - 0.5 * Math.cos(scale * i);
}
}
return windowCoefficients;
}
function fft() {
let j = 0;
for (let i = 1; i < FFT_SIZE; ++i) {
let bit = FFT_SIZE >> 1;
while (j & bit) {
j ^= bit;
bit >>= 1;
}
j ^= bit;
if (i < j) {
let temp = real[i]; real[i] = real[j]; real[j] = temp;
temp = imag[i]; imag[i] = imag[j]; imag[j] = temp;
}
}
for (let length = 2; length <= FFT_SIZE; length <<= 1) {
const angle = -2 * Math.PI / length;
const wLenCos = Math.cos(angle);
const wLenSin = Math.sin(angle);
for (let i = 0; i < FFT_SIZE; i += length) {
let wCos = 1;
let wSin = 0;
const half = length >> 1;
for (let k = 0; k < half; ++k) {
const even = i + k;
const odd = even + half;
const oddReal = real[odd] * wCos - imag[odd] * wSin;
const oddImag = real[odd] * wSin + imag[odd] * wCos;
const evenReal = real[even];
const evenImag = imag[even];
real[even] = evenReal + oddReal;
imag[even] = evenImag + oddImag;
real[odd] = evenReal - oddReal;
imag[odd] = evenImag - oddImag;
const nextCos = wCos * wLenCos - wSin * wLenSin;
wSin = wCos * wLenSin + wSin * wLenCos;
wCos = nextCos;
}
}
}
}
function configureLayout() {
const count = Math.max(1, Math.floor((width + BAR_GAP) / (BAR_WIDTH + BAR_GAP)));
if (count === bandCount) return;
bandCount = count;
targets = new Float64Array(bandCount);
smoothed = new Float64Array(bandCount);
peaks = new Float64Array(bandCount);
peakVelocity = new Float64Array(bandCount);
peakHold = new Float64Array(bandCount);
bandStart = new Int32Array(bandCount);
bandEnd = new Int32Array(bandCount);
bandSampleRate = 0;
}
function configureBands(sampleRate) {
if (sampleRate === bandSampleRate) return;
bandSampleRate = sampleRate;
const nyquist = sampleRate * 0.5;
const minFrequency = Math.min(30, nyquist * 0.5);
const maxFrequency = Math.max(minFrequency + 1, Math.min(16000, nyquist * 0.96));
const ratio = maxFrequency / minFrequency;
const half = FFT_SIZE >> 1;
for (let band = 0; band < bandCount; ++band) {
const t0 = band / bandCount;
const t1 = (band + 1) / bandCount;
const f0 = minFrequency * Math.pow(ratio, t0);
const f1 = minFrequency * Math.pow(ratio, t1);
const bin0 = Math.max(1, Math.floor(f0 * FFT_SIZE / sampleRate));
const bin1 = Math.min(half + 1, Math.max(bin0 + 1, Math.ceil(f1 * FFT_SIZE / sampleRate)));
bandStart[band] = bin0;
bandEnd[band] = bin1;
}
}
function updateDynamics(dt, floorLevel) {
const attack = 1 - Math.exp(-dt / 0.018);
const release = 1 - Math.exp(-dt / 0.095);
const gravity = 3.0;
const peakHoldSeconds = 0.10;
for (let i = 0; i < bandCount; ++i) {
const target = targets[i];
let level = smoothed[i] || 0;
level += (target - level) * (target >= level ? attack : release);
level = Math.max(floorLevel, Math.min(0.98, level));
smoothed[i] = level;
let peak = peaks[i] || 0;
if (level >= peak) {
peak = level;
peakVelocity[i] = 0;
peakHold[i] = peakHoldSeconds;
} else {
const hold = Math.max(0, (peakHold[i] || 0) - dt);
peakHold[i] = hold;
if (hold <= 0) {
let velocity = (peakVelocity[i] || 0) + gravity * dt;
peak -= velocity * dt;
peakVelocity[i] = velocity;
if (peak <= level) {
peak = level;
peakVelocity[i] = 0;
}
}
}
peaks[i] = Math.max(level, Math.min(0.98, peak));
}
}
function analyse(chunk, dt) {
if (!chunk || !chunk.ChannelCount || !chunk.SampleRate || !chunk.SampleCount) {
targets.fill(IDLE_LEVEL);
updateDynamics(dt, IDLE_LEVEL);
return;
}
// Data materializes the PCM samples as a JS array. Read it once for this frame
// and then work only with that local array inside the hot loops below.
const data = chunk.Data;
if (!data || !data.length) {
targets.fill(IDLE_LEVEL);
updateDynamics(dt, IDLE_LEVEL);
return;
}
const channels = chunk.ChannelCount;
const sampleRate = chunk.SampleRate;
const sampleCount = chunk.SampleCount;
const used = Math.min(sampleCount, FFT_SIZE);
const firstFrame = sampleCount - used;
const window = ensureWindow(used);
real.fill(0);
imag.fill(0);
for (let frameIndex = 0; frameIndex < used; ++frameIndex) {
let mono = 0;
const sourceOffset = (firstFrame + frameIndex) * channels;
for (let channel = 0; channel < channels; ++channel) {
mono += Number(data[sourceOffset + channel]) || 0;
}
mono /= channels;
real[frameIndex] = mono * window[frameIndex];
}
fft();
configureBands(sampleRate);
const coherentGain = used > 1 ? 4 / used : 1;
const half = FFT_SIZE >> 1;
for (let bin = 0; bin <= half; ++bin) {
magnitudes[bin] = Math.hypot(real[bin], imag[bin]) * coherentGain;
}
for (let band = 0; band < bandCount; ++band) {
let energy = 0;
const bin0 = bandStart[band];
const bin1 = bandEnd[band];
for (let bin = bin0; bin < bin1; ++bin) {
const magnitude = magnitudes[bin];
energy += magnitude * magnitude;
}
const rms = Math.sqrt(energy / Math.max(1, bin1 - bin0));
const db = 20 * Math.log10(Math.max(1e-12, rms));
const level = Math.max(0, Math.min(1, (db + 72) / 72));
targets[band] = Math.max(FLOOR_LEVEL, Math.min(0.98, level));
}
updateDynamics(dt, FLOOR_LEVEL);
}
function renderSpectrum() {
const bitmap = gdi.CreateImage(width, height);
const gr = bitmap.GetGraphics();
gr.FillSolidRect(0, 0, width, height, 0xFF101319);
const plotTop = Math.min(Math.max(0, SPECTRUM_TOP), Math.max(0, height - 1));
const marginBottom = Math.max(8, Math.round(height * 0.04));
const plotBottom = Math.max(plotTop + 1, height - marginBottom);
const usableHeight = Math.max(1, plotBottom - plotTop);
const barWidth = Math.min(BAR_WIDTH, width);
const totalWidth = bandCount * barWidth + (bandCount - 1) * BAR_GAP;
const firstX = Math.floor((width - totalWidth) * 0.5);
for (let i = 0; i < bandCount; ++i) {
const x = firstX + i * (barWidth + BAR_GAP);
const barHeight = Math.max(1, smoothed[i] * usableHeight);
const y = plotBottom - barHeight;
gr.FillSolidRect(x, y, barWidth, barHeight, BAR_COLOUR);
const peakY = plotBottom - peaks[i] * usableHeight;
gr.DrawLine(x, peakY, x + barWidth - 1, peakY, 1, 0xFFE8EDF2);
}
bitmap.ReleaseGraphics(gr);
return bitmap;
}
return {
configure: function (newWidth, newHeight) {
width = Math.max(1, newWidth | 0);
height = Math.max(1, newHeight | 0);
configureLayout();
},
renderFrame: function (interval) {
const dt = Math.max(0.001, Math.min(0.2, (interval > 0 ? interval : FRAME_TIME) / 1000));
const audioStart = performance.now();
const chunk = fb.GetAudioChunk(0.06, -0.03);
const audioEnd = performance.now();
analyse(chunk, dt);
const analysisEnd = performance.now();
const bitmap = renderSpectrum();
const renderEnd = performance.now();
return {
bitmap,
timing: {
audio: audioEnd - audioStart,
analysis: analysisEnd - audioEnd,
render: renderEnd - analysisEnd,
total: renderEnd - audioStart,
interval
}
};
}
};
}
function makeWorkerSource(currentFftSize, uncapped) {
return String.raw`
'use strict';
const TARGET_FPS = ${TARGET_FPS};
const FRAME_TIME = 1000 / TARGET_FPS;
const UNCAPPED = ${uncapped};
const BAR_WIDTH = ${BAR_WIDTH};
const BAR_GAP = ${BAR_GAP};
const BAR_COLOUR = ${BAR_COLOUR};
const SPECTRUM_TOP = ${SPECTRUM_TOP};
const createSpectrumEngine = ${createSpectrumEngine.toString()};
const engine = createSpectrumEngine(${currentFftSize});
let configured = false;
let waitingForPanel = false;
let timerId = 0;
let lastFrameStart = 0;
let sequence = 0;
function scheduleNext() {
if (!configured || waitingForPanel || timerId) return;
const elapsed = performance.now() - lastFrameStart;
const delay = UNCAPPED ? 0 : Math.max(0, FRAME_TIME - elapsed);
timerId = setTimeout(function () {
timerId = 0;
renderFrame();
}, delay);
}
function renderFrame() {
if (!configured || waitingForPanel) return;
const frameStart = performance.now();
const interval = lastFrameStart ? frameStart - lastFrameStart : 0;
lastFrameStart = frameStart;
const result = engine.renderFrame(interval);
waitingForPanel = true;
postMessage({
bitmap: result.bitmap,
sequence: ++sequence,
timing: result.timing
}, [result.bitmap]);
}
onmessage = function (event) {
const message = event.data;
if (message.type === 'config') {
engine.configure(message.width, message.height);
configured = true;
scheduleNext();
return;
}
if (message.type === 'next') {
waitingForPanel = false;
scheduleNext();
}
};
`;
}
let worker = null;
let mainEngine = null;
let mainTimerId = 0;
let mainLastFrameStart = 0;
let mainSequence = 0;
let mainWaitingForPaint = false;
function resetCounters() {
frame = null;
timing = { audio: 0, analysis: 0, render: 0, total: 0, interval: 0 };
frameSequence = 0;
lastPaintedSequence = -1;
frameFps = 0;
paintFps = 0;
frameCount = 0;
paintCount = 0;
frameWindowStart = performance.now();
paintWindowStart = frameWindowStart;
}
function recordFrame(bitmap, sequence, nextTiming) {
frame = bitmap;
frameSequence = sequence;
timing = nextTiming;
++frameCount;
const now = performance.now();
const elapsed = now - frameWindowStart;
if (elapsed >= 500) {
frameFps = frameCount * 1000 / elapsed;
frameCount = 0;
frameWindowStart = now;
}
window.Repaint();
}
function stopEngine() {
if (worker) {
worker.terminate();
worker = null;
}
if (mainTimerId) {
clearTimeout(mainTimerId);
mainTimerId = 0;
}
mainEngine = null;
mainLastFrameStart = 0;
mainSequence = 0;
mainWaitingForPaint = false;
}
function scheduleMain() {
if (engineMode !== 'main' || !mainEngine || mainTimerId || mainWaitingForPaint) return;
const elapsed = performance.now() - mainLastFrameStart;
const delay = rateMode === 'max' ? 0 : Math.max(0, FRAME_TIME - elapsed);
mainTimerId = setTimeout(function () {
mainTimerId = 0;
renderMainFrame();
}, delay);
}
function renderMainFrame() {
if (engineMode !== 'main' || !mainEngine) return;
const frameStart = performance.now();
const interval = mainLastFrameStart ? frameStart - mainLastFrameStart : 0;
mainLastFrameStart = frameStart;
const result = mainEngine.renderFrame(interval);
mainWaitingForPaint = true;
recordFrame(result.bitmap, ++mainSequence, result.timing);
}
function startEngine() {
switchingRenderer = true;
stopEngine();
resetCounters();
try {
window.DrawMode = drawMode;
INFO_FONT = gdi.Font('Segoe UI', 12);
} finally {
switchingRenderer = false;
}
if (engineMode === 'worker') {
const currentWorker = new Worker(makeWorkerSource(fftSize, rateMode === 'max'), 'spectrum-analyzer');
worker = currentWorker;
currentWorker.onmessage = function (event) {
if (worker !== currentWorker) return;
recordFrame(event.data.bitmap, event.data.sequence, event.data.timing);
};
currentWorker.onerror = function (event) {
if (worker === currentWorker) console.log(`Worker error: ${event.message}`);
};
} else {
mainEngine = createSpectrumEngine(fftSize);
}
sendSize();
window.Repaint();
}
function sendSize() {
const width = Math.max(1, window.Width);
const height = Math.max(1, window.Height);
if (engineMode === 'worker') {
if (!worker) return;
worker.postMessage({ type: 'config', width, height });
} else {
if (!mainEngine) return;
mainEngine.configure(width, height);
scheduleMain();
}
}
function on_size() {
sendSize();
}
const SETTINGS_Y = 42;
const SETTINGS_H = 24;
const ENGINE_MAIN = { x: 66, y: SETTINGS_Y, w: 50, h: SETTINGS_H };
const ENGINE_WORKER = { x: 120, y: SETTINGS_Y, w: 62, h: SETTINGS_H };
const RENDERER_GDI = { x: 272, y: SETTINGS_Y, w: 46, h: SETTINGS_H };
const RENDERER_D2D = { x: 322, y: SETTINGS_Y, w: 52, h: SETTINGS_H };
const FFT_1024 = { x: 426, y: SETTINGS_Y, w: 52, h: SETTINGS_H };
const FFT_2048 = { x: 482, y: SETTINGS_Y, w: 52, h: SETTINGS_H };
const FFT_4096 = { x: 538, y: SETTINGS_Y, w: 52, h: SETTINGS_H };
const RATE_120 = { x: 644, y: SETTINGS_Y, w: 46, h: SETTINGS_H };
const RATE_MAX = { x: 694, y: SETTINGS_Y, w: 46, h: SETTINGS_H };
function contains(rect, x, y) {
return x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h;
}
function drawChoice(gr, rect, text, selected) {
gr.FillSolidRect(rect.x, rect.y, rect.w, rect.h, selected ? 0xFF355D7A : 0xFF20262E);
gr.DrawText(text, INFO_FONT, selected ? 0xFFFFFFFF : 0xFFB7C1CB,
rect.x, rect.y, rect.w, rect.h, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
}
function on_mouse_lbtn_up(x, y) {
let nextEngine = engineMode;
let nextMode = drawMode;
let nextFft = fftSize;
let nextRate = rateMode;
if (contains(ENGINE_MAIN, x, y)) nextEngine = 'main';
else if (contains(ENGINE_WORKER, x, y)) nextEngine = 'worker';
else if (contains(RENDERER_GDI, x, y)) nextMode = 0;
else if (contains(RENDERER_D2D, x, y)) nextMode = 1;
else if (contains(FFT_1024, x, y)) nextFft = 1024;
else if (contains(FFT_2048, x, y)) nextFft = 2048;
else if (contains(FFT_4096, x, y)) nextFft = 4096;
else if (contains(RATE_120, x, y)) nextRate = '120';
else if (contains(RATE_MAX, x, y)) nextRate = 'max';
else return;
if (nextEngine === engineMode && nextMode === drawMode && nextFft === fftSize && nextRate === rateMode) return;
engineMode = nextEngine;
drawMode = nextMode;
fftSize = nextFft;
rateMode = nextRate;
startEngine();
}
function on_paint(gr) {
gr.FillSolidRect(0, 0, window.Width, window.Height, 0xFF101319);
if (switchingRenderer) return;
if (frame) {
gr.DrawImage(frame, 0, 0, window.Width, window.Height, 0, 0, frame.Width, frame.Height);
if (frameSequence !== lastPaintedSequence) {
lastPaintedSequence = frameSequence;
++paintCount;
const now = performance.now();
const elapsed = now - paintWindowStart;
if (elapsed >= 500) {
paintFps = paintCount * 1000 / elapsed;
paintCount = 0;
paintWindowStart = now;
}
if (engineMode === 'worker') {
if (worker) worker.postMessage({ type: 'next' });
} else if (mainWaitingForPaint) {
mainWaitingForPaint = false;
scheduleMain();
}
}
}
const engine = engineMode === 'worker' ? 'Worker' : 'Main';
const backend = drawMode === 1 ? 'D2D' : 'GDI';
const overlay = `${engine}/${backend} frame ${frameFps.toFixed(1)} FPS paint ${paintFps.toFixed(1)} FPS interval ${timing.interval.toFixed(1)} ms audio ${timing.audio.toFixed(2)} FFT ${timing.analysis.toFixed(2)} render ${timing.render.toFixed(2)} total ${timing.total.toFixed(2)} ms`;
gr.FillSolidRect(8, 8, Math.max(1, Math.min(window.Width - 16, 760)), 28, 0xC0101319);
gr.DrawText(overlay, INFO_FONT, 0xFFE8EDF2, 14, 8, Math.max(1, window.Width - 28), 28,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
gr.DrawText('Engine:', INFO_FONT, 0xFFB7C1CB, 14, SETTINGS_Y, 50, SETTINGS_H,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
drawChoice(gr, ENGINE_MAIN, 'Main', engineMode === 'main');
drawChoice(gr, ENGINE_WORKER, 'Worker', engineMode === 'worker');
gr.DrawText('Renderer:', INFO_FONT, 0xFFB7C1CB, 196, SETTINGS_Y, 74, SETTINGS_H,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
drawChoice(gr, RENDERER_GDI, 'GDI', drawMode === 0);
drawChoice(gr, RENDERER_D2D, 'D2D', drawMode === 1);
gr.DrawText('FFT:', INFO_FONT, 0xFFB7C1CB, 388, SETTINGS_Y, 34, SETTINGS_H,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
drawChoice(gr, FFT_1024, '1024', fftSize === 1024);
drawChoice(gr, FFT_2048, '2048', fftSize === 2048);
drawChoice(gr, FFT_4096, '4096', fftSize === 4096);
gr.DrawText('Rate:', INFO_FONT, 0xFFB7C1CB, 604, SETTINGS_Y, 36, SETTINGS_H,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
drawChoice(gr, RATE_120, '120', rateMode === '120');
drawChoice(gr, RATE_MAX, 'Max', rateMode === 'max');
gr.DrawText('Tip: for finer timer granularity, enable "Use high-resolution timers" in Advanced > Tools > JSplitter > Performance (restart required).',
INFO_FONT, 0xFF7E8A96, 14, 68, Math.max(1, window.Width - 28), 18,
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS | DT_NOPREFIX);
}
startEngine();