Documentation/Source example
Example source
samples/vis/JS - Spectrogram.js
Referenced by:vis.Create
include('docs/Flags.js');
// JSplitter vis sample: Spectrogram | JavaScript callback.
// Source: scrolling FFT history; render options affect only the presentation.
// JSplitter vis: a scrolling Spectrogram drawn in JavaScript (works with either GDI or Direct2D).
// The callback receives raw 16 ms FFT columns. This sample converts them to the
// same 20 ms physical display columns as the built-in renderer (50 px/s), then
// keeps those pixels in a circular offscreen bitmap.
vis.UpdateIntervalMs = 16; // Default: 16 ms; allowed: unsigned integer 1..4294967295 ms.
const backgroundColor = 0xff202020;
const foregroundColor = 0xffc2c2c2;
const displayMsPerPixel = 20; // 50 physical pixels per second, like the built-in renderer.
const source = {
channel: "mix", // Default: "mix"; also accepts a vis.AudioFormat channel name or unsigned PCM index.
fftSize: 4096, // Default: 4096; allowed: power of two, 256..16384.
columnIntervalMs: 16, // Default: 16 ms; allowed: integer 8..250 ms.
minFrequency: 50, // Default: 50 Hz; allowed: 0 < value < maxFrequency.
maxFrequency: 20000, // Default: 20000 Hz; allowed: value > minFrequency; clamped to Nyquist.
scale: "log", // Default: "log"; allowed: "log" or "linear".
minDb: -78, // Default: -78 dBFS; allowed: -160 <= value < maxDb.
maxDb: 0, // Default: 0 dBFS; allowed: minDb < value <= +24.
gain: 1 // Default: 1; allowed: > 0.
};
let image = null;
let imageColumns = null;
let sourceCapacity = 0;
let imageBins = 0;
let imageRate = 0;
let rasterHeight = 0;
let displayCapacity = 0;
let displayNext = 0;
let displayValid = 0;
let phaseMs = 0;
let bestOverlapMs = 0;
let lastNext = 0;
let lastValid = 0;
let lastSourceAt = 0;
let frequencyRows = [];
let rasterPixels = new Uint32Array(0);
let candidatePixels = new Uint32Array(0);
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
// A row maps to either two interpolated FFT bins (narrow frequencies) or
// a weighted average of bin powers (wide frequency bands), matching the
// built-in Spectrogram frequency rasterization.
function makeFrequencyRows(height, fftBins, sampleRate) {
const nyquist = sampleRate * 0.5;
const minimum = Math.min(source.minFrequency, nyquist * 0.5);
const maximum = Math.max(minimum + 1, Math.min(source.maxFrequency, nyquist * 0.96));
const half = fftBins - 1;
const fftScale = 2 * half / sampleRate;
const rows = [];
function frequency(t) {
return source.scale === "log"
? minimum * Math.pow(maximum / minimum, t)
: minimum + (maximum - minimum) * t;
}
for (let y = 0; y < height; ++y) {
const f0 = frequency(1 - (y + 1) / height);
const f1 = frequency(1 - y / height);
const p0 = f0 * fftScale, p1 = f1 * fftScale;
if (p1 - p0 < 1) {
const center = source.scale === "log" ? Math.sqrt(f0 * f1) : (f0 + f1) * 0.5;
const pos = clamp(center * fftScale, 1, half);
const first = Math.floor(pos);
const fraction = pos - first;
const sharpness = clamp(Math.log(Math.max(1, center) / 250) / Math.log(4), 0, 1);
const smooth = fraction * fraction * (3 - 2 * fraction);
rows.push({ first, last: Math.min(half, first + 1),
mix: fraction + (smooth - fraction) * sharpness });
} else {
const first = clamp(Math.floor(Math.max(0, p0) + 0.5), 1, half);
const last = clamp(Math.ceil(Math.max(0, p1) + 0.5), first + 1, half + 1);
const coverage = center => clamp(Math.min(p1, center + 0.5) -
Math.max(p0, center - 0.5), 0, 1);
rows.push({ first, last, firstWeight: coverage(first),
lastWeight: coverage(last - 1) });
}
}
return rows;
}
function binLevel(columns, offset, row) {
const gain = source.gain;
const minDb = source.minDb;
const spanDb = source.maxDb - minDb;
let db;
if (row.mix !== undefined) {
const a = 20 * Math.log10(Math.max(1e-12, columns[offset + row.first] * gain));
const b = 20 * Math.log10(Math.max(1e-12, columns[offset + row.last] * gain));
db = a + (b - a) * row.mix;
} else {
let power = 0, weight = 0;
for (let bin = row.first; bin < row.last; ++bin) {
const w = bin === row.first ? row.firstWeight :
bin === row.last - 1 ? row.lastWeight : 1;
const magnitude = Math.max(0, columns[offset + bin]);
power += w * magnitude * magnitude;
weight += w;
}
db = 20 * Math.log10(Math.max(1e-12,
Math.sqrt(power / Math.max(weight, 1e-12)) * gain));
}
return clamp((db - minDb) / spanDb, 0, 1);
}
function rasterizeSourceColumn(columns, offset) {
for (let row = 0; row < rasterHeight; ++row) {
const level = binLevel(columns, offset, frequencyRows[row]);
const shade = 32 + Math.round(level * (194 - 32));
rasterPixels[row] = (0xff000000 | (shade << 16) | (shade << 8) | shade) >>> 0;
}
}
function writeDisplayColumn(bitmapGraphics, column, pixels) {
// Merge equal vertical shades into runs. This keeps the JS example simple
// without issuing two FillSolidRect calls for every individual pixel.
let first = 0;
let color = pixels[0];
for (let row = 1; row <= rasterHeight; ++row) {
if (row !== rasterHeight && pixels[row] === color) continue;
const count = row - first;
bitmapGraphics.FillSolidRect(column, first, 1, count, color);
bitmapGraphics.FillSolidRect(column + displayCapacity, first, 1, count, color);
if (row !== rasterHeight) {
first = row;
color = pixels[row];
}
}
}
function appendSourceColumn(bitmapGraphics, columns, offset) {
rasterizeSourceColumn(columns, offset);
let remaining = source.columnIntervalMs;
while (remaining > 1e-8) {
const take = Math.min(remaining, displayMsPerPixel - phaseMs);
// Like the built-in display, choose one real FFT frame for each
// finished physical pixel: the frame with the greatest time overlap.
if (take > bestOverlapMs) {
candidatePixels.set(rasterPixels);
bestOverlapMs = take;
}
phaseMs += take;
remaining -= take;
if (phaseMs + 1e-7 >= displayMsPerPixel) {
writeDisplayColumn(bitmapGraphics, displayNext, candidatePixels);
displayNext = (displayNext + 1) % displayCapacity;
displayValid = Math.min(displayValid + 1, displayCapacity);
phaseMs = 0;
bestOverlapMs = 0;
}
}
}
// Callback data (owned and reused by JSplitter):
// data.columns: Float32Array of linear FFT magnitudes, column-major.
// data.fftBins: Number of frequency bins per FFT column.
// data.sampleRate: Audio sample rate in Hz.
// data.capacityColumns: Number of slots in the circular source history.
// data.validColumns: Number of filled slots (0..capacityColumns).
// data.nextColumn: Next source write slot (newest is nextColumn - 1, modulo capacity).
// data.full: The source history was rebuilt; rebuild retained display pixels.
const spectrogram = vis.Create(VisSourceType.Spectrogram, source, (gr, x, y, w, h, data) => {
const { columns, fftBins, sampleRate, capacityColumns, validColumns, nextColumn, full } = data;
if (!capacityColumns || !fftBins || !sampleRate || !validColumns || w <= 0 || h <= 0) return;
const height = Math.max(1, Math.round(h));
const neededDisplayCapacity = Math.max(Math.ceil(w) + 2,
Math.ceil(capacityColumns * source.columnIntervalMs / displayMsPerPixel) + 2);
const rebuild = !image || columns !== imageColumns || capacityColumns !== sourceCapacity ||
fftBins !== imageBins || sampleRate !== imageRate || height !== rasterHeight ||
neededDisplayCapacity > displayCapacity || validColumns < lastValid ||
(full && (nextColumn !== lastNext || validColumns !== lastValid));
if (rebuild) {
rasterHeight = height;
displayCapacity = neededDisplayCapacity;
image = gdi.CreateImage(displayCapacity * 2, rasterHeight);
imageColumns = columns;
sourceCapacity = capacityColumns;
imageBins = fftBins;
imageRate = sampleRate;
displayNext = 0;
displayValid = 0;
phaseMs = 0;
bestOverlapMs = 0;
frequencyRows = makeFrequencyRows(rasterHeight, fftBins, sampleRate);
rasterPixels = new Uint32Array(rasterHeight);
candidatePixels = new Uint32Array(rasterHeight);
const bitmapGraphics = image.GetGraphics();
try {
bitmapGraphics.FillSolidRect(0, 0, displayCapacity * 2, rasterHeight, backgroundColor);
const first = (nextColumn + capacityColumns - validColumns) % capacityColumns;
for (let c = 0; c < validColumns; ++c) {
const column = (first + c) % capacityColumns;
appendSourceColumn(bitmapGraphics, columns, column * fftBins);
}
} finally {
image.ReleaseGraphics(bitmapGraphics);
}
lastSourceAt = Date.now();
} else {
let count = (nextColumn + capacityColumns - lastNext) % capacityColumns;
if (validColumns > lastValid && count === 0) count = validColumns - lastValid;
count = Math.min(count, validColumns);
if (count > 0) {
const bitmapGraphics = image.GetGraphics();
try {
const first = (nextColumn + capacityColumns - count) % capacityColumns;
for (let c = 0; c < count; ++c) {
const column = (first + c) % capacityColumns;
appendSourceColumn(bitmapGraphics, columns, column * fftBins);
}
} finally {
image.ReleaseGraphics(bitmapGraphics);
}
lastSourceAt = Date.now();
}
}
lastNext = nextColumn;
lastValid = validColumns;
if (!displayValid) return;
const pixelWidth = Math.max(1, Math.floor(w));
const visible = Math.min(displayValid, pixelWidth);
const blank = pixelWidth - visible;
const elapsedMs = lastSourceAt ? Math.min(Date.now() - lastSourceAt, source.columnIntervalMs) : 0;
const scrollPixels = (phaseMs + elapsedMs) / displayMsPerPixel;
const shift = Math.min(pixelWidth - 1, Math.max(0, Math.floor(scrollPixels + 0.5)));
const firstScreenColumn = Math.max(shift, blank);
const destinationStart = firstScreenColumn - shift;
const dataCount = pixelWidth - firstScreenColumn;
gr.SetInterpolationMode(InterpolationMode.NearestNeighbor);
try {
if (dataCount > 0) {
const sourceStart = (displayNext + displayCapacity - pixelWidth + firstScreenColumn) % displayCapacity;
gr.DrawImage(image, x + destinationStart, y, dataCount, rasterHeight,
sourceStart, 0, dataCount, rasterHeight);
}
if (shift > 0) {
const newest = (displayNext + displayCapacity - 1) % displayCapacity;
gr.DrawImage(image, x + pixelWidth - shift, y, shift, rasterHeight,
newest, 0, 1, rasterHeight);
}
} finally {
gr.SetInterpolationMode(InterpolationMode.Default);
}
});
function on_paint(gr) {
const w = window.Width, h = window.Height;
gr.FillSolidRect(0, 0, w, h, backgroundColor);
if (w > 0 && h > 0) spectrogram.Draw(gr, 0, 0, w, h);
}
setInterval(() => window.Repaint(), 16.6667);