Documentation/Source example
Example source

samples/worker/05. Spectrum Analyzer.js

Referenced by:Worker
'use strict';

// Worker sample 5: Spectrum Analyzer
// Demonstrates: real-time audio/FFT analysis in a Worker, direct panel rendering and
// transferable Float32Array frame ping-pong. The Worker sends only spectrum levels/peaks;
// on_paint() draws them directly with the panel graphics object, so no offscreen bitmap is
// created per frame. After a frame has painted, the previous Float32Array is transferred back
// to the Worker and reused for a later frame. After the first two frames, the same two buffers
// ping-pong between Worker and panel.
//
// Main mode runs the same analysis engine on the panel thread for comparison. GDI/D2D selects
// the panel rendering backend only; both modes use the same spectrum data and direct paint path.
// Use the in-panel switches to compare execution mode, rendering backend, FFT size and rate limit.
// Only one completed Worker frame is allowed in flight at a time; the next frame starts after
// the previous one has been painted and acknowledged.

let engineMode = 'worker';
let drawMode = 1;
let fftSize = 4096;
let rateMode = '120';
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 MIN_FREQUENCY = 30;
const MAX_FREQUENCY = 16000;

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 frameReturn = null;
let frameNeedsAck = false;
let timing = { audio: 0, analysis: 0, pack: 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, minFrequencyLimit, maxFrequencyLimit) {
    const FFT_SIZE = currentFftSize;
    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 pcm = new Float32Array(131072);
    const chunkInfo = { SampleCount: 0, ChannelCount: 0, SampleRate: 0, ChannelConfig: 0 };
    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);
    let bandPosition0 = new Float64Array(1);
    let bandPosition1 = new Float64Array(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 configureBands(sampleRate) {
        if (sampleRate === bandSampleRate) return;
        bandSampleRate = sampleRate;

        const nyquist = sampleRate * 0.5;
        const minFrequency = Math.min(minFrequencyLimit, nyquist * 0.5);
        const maxFrequency = Math.max(minFrequency + 1, Math.min(maxFrequencyLimit, 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 fftScale = FFT_SIZE / sampleRate;
            const binPosition0 = f0 * fftScale;
            const binPosition1 = f1 * fftScale;
            const bin0 = Math.max(1, Math.floor(binPosition0));
            const bin1 = Math.min(half + 1, Math.max(bin0 + 1, Math.ceil(binPosition1)));
            bandPosition0[band] = binPosition0;
            bandPosition1[band] = binPosition1;
            bandStart[band] = bin0;
            bandEnd[band] = bin1;
        }
    }

    function updateDynamics(dt, stopped = false) {
        const attack = 1 - Math.exp(-dt / 0.018);
        const release = 1 - Math.exp(-dt / 0.095);
        const gravity = 3.0;
        const peakHoldSeconds = 0.10;
        const inactiveEpsilon = 1e-3;

        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(0, 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;
                    }
                }
            }
            peak = Math.max(level, Math.min(0.98, peak));

            if (stopped) {
                if (level < inactiveEpsilon) {
                    level = 0;
                    smoothed[i] = 0;
                }
                if (level === 0 && peak < inactiveEpsilon) {
                    peak = 0;
                    peakVelocity[i] = 0;
                    peakHold[i] = 0;
                }
            }

            peaks[i] = peak;
        }
    }

    function analyse(data, sampleCount, channels, sampleRate, available, dt, stopped) {
        const required = sampleCount * channels;
        if (!data || !sampleCount || !channels || !sampleRate || available < required) {
            targets.fill(0);
            updateDynamics(dt, stopped);
            return;
        }

        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) {
            const binPosition0 = bandPosition0[band];
            const binPosition1 = bandPosition1[band];
            let bandMagnitude = 0;

            if (binPosition1 - binPosition0 < 1) {
                // A logarithmic band can be narrower than a single FFT bin at low frequencies.
                // Interpolate the magnitude at the geometric centre of such a band instead,
                // preserving independent motion without increasing the FFT size.
                const centrePosition = Math.sqrt(binPosition0 * binPosition1);
                const position = Math.max(1, Math.min(half, centrePosition));
                const bin = Math.floor(position);
                const nextBin = Math.min(half, bin + 1);
                const fraction = position - bin;
                bandMagnitude = magnitudes[bin] + (magnitudes[nextBin] - magnitudes[bin]) * fraction;
            } else {
                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;
                }

                bandMagnitude = Math.sqrt(energy / Math.max(1, bin1 - bin0));
            }

            const db = 20 * Math.log10(Math.max(1e-12, bandMagnitude));
            const level = Math.max(0, Math.min(1, (db + 72) / 72));
            targets[band] = Math.min(0.98, level);
        }

        updateDynamics(dt, stopped);
    }

    function makeSpectrumFrame() {
        const length = bandCount * 2;
        if (!(outputFrame instanceof Float32Array) || outputFrame.length !== length) {
            outputFrame = new Float32Array(length);
        }

        const frame = outputFrame;
        for (let i = 0; i < bandCount; ++i) {
            const at = i * 2;
            frame[at] = smoothed[i];
            frame[at + 1] = peaks[i];
        }
        return frame;
    }

    let outputFrame = null;

    return {
        configure: function (count) {
            const nextCount = Math.max(1, count | 0);
            if (nextCount === bandCount) return;
            bandCount = nextCount;
            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);
            bandPosition0 = new Float64Array(bandCount);
            bandPosition1 = new Float64Array(bandCount);
            bandSampleRate = 0;
            outputFrame = null;
        },

        setOutputFrame: function (frame) {
            const expectedLength = bandCount * 2;
            outputFrame = frame instanceof Float32Array && frame.length === expectedLength
                ? frame
                : null;
        },

        renderFrame: function (interval) {
            const dt = Math.max(0.001, Math.min(0.2, (interval > 0 ? interval : FRAME_TIME) / 1000));
            const audioStart = performance.now();
            let data = null;
            let sampleCount = 0;
            let channels = 0;
            let sampleRate = 0;
            let available = 0;

            if (typeof fb.GetAudioChunkTo === 'function') {
                available = fb.GetAudioChunkTo(pcm, 0.06, -0.03, chunkInfo);
                data = pcm;
                sampleCount = chunkInfo.SampleCount;
                channels = chunkInfo.ChannelCount;
                sampleRate = chunkInfo.SampleRate;
            } else {
                const chunk = fb.GetAudioChunk(0.06, -0.03);
                if (chunk) {
                    sampleCount = chunk.SampleCount;
                    channels = chunk.ChannelCount;
                    sampleRate = chunk.SampleRate;
                    const required = sampleCount * channels;
                    if (typeof chunk.CopyDataTo === 'function') {
                        if (pcm.length < required) pcm = new Float32Array(required);
                        available = chunk.CopyDataTo(pcm);
                        data = pcm;
                    } else {
                        data = chunk.Data;
                        available = data ? data.length : 0;
                    }
                }
            }
            const audioEnd = performance.now();

            analyse(data, sampleCount, channels, sampleRate, available, dt, !fb.IsPlaying);
            const analysisEnd = performance.now();

            const frame = makeSpectrumFrame();
            const packEnd = performance.now();

            return {
                frame,
                timing: {
                    audio: audioEnd - audioStart,
                    analysis: analysisEnd - audioEnd,
                    pack: packEnd - analysisEnd,
                    total: packEnd - 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 createSpectrumEngine = ${createSpectrumEngine.toString()};
const engine = createSpectrumEngine(${currentFftSize}, ${MIN_FREQUENCY}, ${MAX_FREQUENCY});

let configured = false;
let waitingForPanel = false;
let timerId = 0;
let lastFrameStart = 0;
let sequence = 0;
const frameMessage = { frame: null, sequence: 0, timing: null };

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;
        produceFrame();
    }, delay);
}

function produceFrame() {
    if (!configured || waitingForPanel) return;

    const frameStart = performance.now();
    const interval = lastFrameStart ? frameStart - lastFrameStart : 0;
    lastFrameStart = frameStart;

    const result = engine.renderFrame(interval);
    const frame = result.frame;

    // Ownership of this ArrayBuffer moves to the panel. The Worker does not retain another
    // output buffer; the panel returns its previous displayed frame with the ACK after paint.
    engine.setOutputFrame(null);
    waitingForPanel = true;
    frameMessage.frame = frame;
    frameMessage.sequence = ++sequence;
    frameMessage.timing = result.timing;
    try {
        postMessage(frameMessage, [frame.buffer]);
    } finally {
        frameMessage.frame = null;
        frameMessage.timing = null;
    }
}

onmessage = function (event) {
    const message = event.data;

    if (message.type === 'config') {
        engine.configure(message.count);
        configured = true;
        scheduleNext();
        return;
    }

    if (message.type === 'next') {
        waitingForPanel = false;
        engine.setOutputFrame(message.frame || null);
        scheduleNext();
    }
};
`;
}

let worker = null;
let mainEngine = null;
let mainTimerId = 0;
let mainLastFrameStart = 0;
let mainSequence = 0;
let mainWaitingForPaint = false;
const workerNextMessage = { type: 'next', frame: null };

function resetCounters() {
    frame = null;
    frameReturn = null;
    frameNeedsAck = false;
    timing = { audio: 0, analysis: 0, pack: 0, total: 0, interval: 0 };
    frameSequence = 0;
    lastPaintedSequence = -1;
    frameFps = 0;
    paintFps = 0;
    frameCount = 0;
    paintCount = 0;
    frameWindowStart = performance.now();
    paintWindowStart = frameWindowStart;
}

function recordFrame(nextFrame, sequence, nextTiming) {
    const previousFrame = frame;
    frame = nextFrame instanceof Float32Array ? nextFrame : previousFrame;
    frameReturn = frame !== previousFrame ? previousFrame : null;
    frameNeedsAck = engineMode === 'worker';
    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.frame, ++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.frame, event.data.sequence, event.data.timing);
        };

        currentWorker.onerror = function (event) {
            if (worker === currentWorker) console.log(`Worker error: ${event.message}`);
        };
    } else {
        mainEngine = createSpectrumEngine(fftSize, MIN_FREQUENCY, MAX_FREQUENCY);
    }

    sendSize();
    window.Repaint();
}

function sendSize() {
    const width = Math.max(1, window.Width);
    const count = Math.max(1, Math.floor((width + BAR_GAP) / (BAR_WIDTH + BAR_GAP)));

    if (engineMode === 'worker') {
        if (!worker) return;
        worker.postMessage({ type: 'config', count });
    } else {
        if (!mainEngine) return;
        mainEngine.configure(count);
        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 drawSpectrumFrame(gr, spectrumFrame) {
    if (!(spectrumFrame instanceof Float32Array) || spectrumFrame.length < 2) return;

    const width = Math.max(1, window.Width);
    const height = Math.max(1, window.Height);
    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 count = spectrumFrame.length >> 1;
    const totalWidth = count * barWidth + (count - 1) * BAR_GAP;
    const firstX = Math.floor((width - totalWidth) * 0.5);

    for (let i = 0; i < count; ++i) {
        const x = firstX + i * (barWidth + BAR_GAP);
        const level = spectrumFrame[i * 2] || 0;
        const peak = spectrumFrame[i * 2 + 1] || 0;
        const barHeight = level * usableHeight;

        if (barHeight > 0) {
            const y = plotBottom - barHeight;
            gr.FillSolidRect(x, y, barWidth, barHeight, BAR_COLOUR);
        }

        const peakHeight = peak * usableHeight;
        if (peakHeight >= 1) {
            const peakY = plotBottom - peakHeight;
            gr.DrawLine(x, peakY, x + barWidth - 1, peakY, 1, 0xFFE8EDF2);
        }
    }
}

function on_paint(gr) {
    gr.FillSolidRect(0, 0, window.Width, window.Height, 0xFF101319);
    if (switchingRenderer) return;

    if (frame) {
        drawSpectrumFrame(gr, frame);

        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' && frameNeedsAck) {
                const returnFrame = frameReturn;
                frameReturn = null;
                frameNeedsAck = false;
                if (worker) {
                    workerNextMessage.frame = returnFrame || null;
                    try {
                        if (returnFrame && returnFrame.buffer) {
                            worker.postMessage(workerNextMessage, [returnFrame.buffer]);
                        } else {
                            worker.postMessage(workerNextMessage);
                        }
                    } finally {
                        workerNextMessage.frame = null;
                    }
                }
            } else if (engineMode === 'main' && 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)}   pack ${timing.pack.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();