Documentation/Source example
Example source
samples/vis/Builtin - Channel Explorer.js
Referenced by:vis.AudioFormat
include('docs/Flags.js');
// JSplitter vis sample: current PCM channel map and interactive source routing.
//
// vis.AudioFormat describes the post-processing stream received by the
// visualization engine. Click a channel card to route it to the highlighted
// Peak Meter. The active route alternates A/B after every assignment; clicking
// a meter row selects that route explicitly.
//
// Named channels use Names[0], the canonical selector. A channel whose flag is
// unknown to JSplitter has Names=[] and remains selectable by its numeric Index.
const INITIAL_UPDATE_INTERVAL = vis.UpdateIntervalMs;
vis.UpdateIntervalMs = 16;
const COLOR = {
background: 0xff0b0e13,
header: 0xff111722,
panel: 0xff101620,
card: 0xff151d29,
cardHover: 0xff1c2837,
border: 0xff2b3747,
text: 0xffedf3f8,
muted: 0xff91a0b2,
faint: 0xff647286,
routeA: 0xff38d6c5,
routeB: 0xffa987ff,
green: 0xff5bd982,
yellow: 0xffffcf5c,
red: 0xffff6577,
white: 0xffffffff
};
const FONT_TITLE = gdi.Font('Segoe UI', 18, 1);
const FONT_SUBTITLE = gdi.Font('Segoe UI', 10);
const FONT_CARD = gdi.Font('Segoe UI', 11, 1);
const FONT_META = gdi.Font('Segoe UI', 9);
const FONT_LANE = gdi.Font('Segoe UI', 15, 1);
const METER_RANGES = [
{to: -18, color: COLOR.green},
{to: -6, color: COLOR.yellow},
{to: 0, color: COLOR.red}
];
const meters = [
vis.Create(VisSourceType.PeakMeter, {
channel: 'mix', minDb: -60, maxDb: 0, attackMs: 0,
releaseMs: 170, peakHoldMs: 500, peakGravity: 3, clipHoldMs: 1000
}, {
ranges: METER_RANGES,
segmentSize: 6, segmentGap: 2, colorMode: 'gradient',
peakColor: COLOR.white, peakSize: 2, showPeak: true,
showClip: true, clipWidth: 4, clipColor: COLOR.red
}),
vis.Create(VisSourceType.PeakMeter, {
channel: 'mix', minDb: -60, maxDb: 0, attackMs: 0,
releaseMs: 170, peakHoldMs: 500, peakGravity: 3, clipHoldMs: 1000
}, {
ranges: METER_RANGES,
segmentSize: 6, segmentGap: 2, colorMode: 'gradient',
peakColor: COLOR.white, peakSize: 2, showPeak: true,
showClip: true, clipWidth: 4, clipColor: COLOR.red
})
];
let audioFormat = null;
let formatSignature = '';
let selectors = ['mix', 'mix'];
let autoSelectionDone = false;
let activeRoute = 0;
let hoveredCard = -1;
let hoveredLane = -1;
let channelRects = [];
let laneRects = [];
function hex32(value) {
return '0x' + ('00000000' + (value >>> 0).toString(16).toUpperCase()).slice(-8);
}
function rateText(rate) {
const khz = rate / 1000;
return (Math.round(khz * 10) / 10).toFixed(khz % 1 ? 1 : 0) + ' kHz';
}
function selectorText(value) {
if (value === 'mix') return 'mix';
return typeof value === 'number' ? 'index ' + value : value;
}
function selectorCode(value) {
return typeof value === 'string' ? 'Channel = "' + value + '"' : 'Channel = ' + value;
}
function selectorFor(channel) {
return channel.Names && channel.Names.length ? channel.Names[0] : channel.Index;
}
function selectorMatches(channel, selector) {
if (typeof selector === 'number') return selector === channel.Index;
return channel.Names && channel.Names.indexOf(selector) !== -1;
}
function selectorAvailable(format, selector) {
if (selector === 'mix') return true;
return format.Channels.some(channel => selectorMatches(channel, selector));
}
function setRoute(slot, selector) {
meters[slot].Channel = selector;
// The getter demonstrates canonicalization: "left" would read back as
// "front-left". Unknown flags retain their numeric index.
selectors[slot] = meters[slot].Channel;
}
function defaultSelector(format, slot) {
if (!format.Channels.length) return 'mix';
return selectorFor(format.Channels[Math.min(slot, format.Channels.length - 1)]);
}
function reconcileRoutes(format) {
if (!autoSelectionDone) {
setRoute(0, defaultSelector(format, 0));
setRoute(1, defaultSelector(format, 1));
autoSelectionDone = true;
return;
}
for (let slot = 0; slot < meters.length; ++slot) {
if (!selectorAvailable(format, selectors[slot])) {
setRoute(slot, defaultSelector(format, slot));
}
}
}
function signatureOf(format) {
if (!format) return '';
return [format.SampleRate, format.ChannelCount, format.ChannelConfig]
.concat(format.Channels.map(channel =>
channel.Index + ':' + channel.Flag + ':' + channel.Names.join(',')))
.join('|');
}
function syncAudioFormat() {
const next = vis.AudioFormat;
const nextSignature = signatureOf(next);
if (nextSignature === formatSignature && Boolean(next) === Boolean(audioFormat)) return;
audioFormat = next;
formatSignature = nextSignature;
if (audioFormat) reconcileRoutes(audioFormat);
window.Repaint();
}
function contains(rect, x, y) {
return x >= rect.x && y >= rect.y && x < rect.x + rect.w && y < rect.y + rect.h;
}
function updateLayout() {
const w = window.Width;
const h = window.Height;
const margin = 14;
const gap = 8;
const headerH = 94;
const meterAreaH = Math.min(174, Math.max(118, h * 0.29));
const metersTop = Math.max(headerH + 48, h - meterAreaH);
laneRects = [];
const laneGap = 9;
const laneH = Math.max(42, (h - metersTop - margin - laneGap) / 2);
for (let i = 0; i < 2; ++i) {
laneRects.push({
x: margin,
y: metersTop + i * (laneH + laneGap),
w: Math.max(0, w - margin * 2),
h: laneH
});
}
channelRects = [];
if (!audioFormat || !audioFormat.Channels.length) return;
const areaTop = headerH + 8;
const areaBottom = metersTop - 18;
const areaW = Math.max(0, w - margin * 2);
const areaH = Math.max(0, areaBottom - areaTop);
const count = audioFormat.Channels.length;
const columns = Math.max(1, Math.min(count, Math.floor((areaW + gap) / (158 + gap))));
const rows = Math.ceil(count / columns);
const cardW = (areaW - gap * (columns - 1)) / columns;
const cardH = Math.max(30, Math.min(72, (areaH - gap * (rows - 1)) / rows));
for (let i = 0; i < count; ++i) {
const column = i % columns;
const row = Math.floor(i / columns);
channelRects.push({
x: margin + column * (cardW + gap),
y: areaTop + row * (cardH + gap),
w: cardW,
h: cardH
});
}
}
function hitTest(rects, x, y) {
for (let i = 0; i < rects.length; ++i) {
if (contains(rects[i], x, y)) return i;
}
return -1;
}
function selectedCard(slot) {
if (!audioFormat) return -1;
for (let i = 0; i < audioFormat.Channels.length; ++i) {
if (selectorMatches(audioFormat.Channels[i], selectors[slot])) return i;
}
return -1;
}
function drawStatus(gr) {
const w = window.Width;
const state = audioFormat ? (fb.IsPaused ? 'PAUSED' : 'LIVE')
: (fb.IsPlaying ? 'SYNCING' : 'NO STREAM');
const stateColor = audioFormat ? (fb.IsPaused ? COLOR.yellow : COLOR.green) : COLOR.faint;
const statusW = 86;
const statusX = Math.max(14, w - statusW - 14);
gr.FillSolidRect(statusX, 14, statusW, 24, COLOR.card);
gr.DrawRect(statusX + 0.5, 14.5, statusW - 1, 23, 1, stateColor);
const textW = gr.CalcTextWidth(state, FONT_META);
gr.DrawString(state, FONT_META, stateColor,
statusX + (statusW - textW) / 2, 19, textW + 2, 16);
}
function drawHeader(gr) {
const w = window.Width;
gr.FillSolidRect(0, 0, w, 94, COLOR.header);
gr.FillSolidRect(0, 93, w, 1, COLOR.border);
gr.DrawString('PCM channel explorer', FONT_TITLE, COLOR.text,
14, 10, Math.max(0, w - 120), 28);
drawStatus(gr);
if (audioFormat) {
const summary = rateText(audioFormat.SampleRate) + ' · ' +
audioFormat.ChannelCount + (audioFormat.ChannelCount === 1 ? ' channel' : ' channels') +
' · ChannelConfig ' + hex32(audioFormat.ChannelConfig);
gr.DrawString(summary, FONT_SUBTITLE, COLOR.muted,
15, 43, Math.max(0, w - 30), 18);
gr.DrawString('Click a PCM position to route it to the highlighted meter. Names[0] is used as the selector.',
FONT_META, COLOR.faint, 15, 67, Math.max(0, w - 30), 17);
} else {
gr.DrawString('vis.AudioFormat → null', FONT_SUBTITLE, COLOR.muted,
15, 43, Math.max(0, w - 30), 18);
gr.DrawString('Start playback to inspect the post-processing stream.',
FONT_META, COLOR.faint, 15, 67, Math.max(0, w - 30), 17);
}
}
function drawRoutes(gr) {
for (let slot = 0; slot < 2; ++slot) {
const card = selectedCard(slot);
if (card < 0 || !channelRects[card] || !laneRects[slot]) continue;
const source = channelRects[card];
const target = laneRects[slot];
const color = slot === 0 ? COLOR.routeA : COLOR.routeB;
const startX = source.x + source.w / 2;
const startY = source.y + source.h;
const bridgeY = target.y - 7 + slot * 3;
const endX = target.x + 27;
gr.DrawLine(startX, startY, startX, bridgeY, 2, color);
gr.DrawLine(startX, bridgeY, endX, bridgeY, 2, color);
gr.DrawLine(endX, bridgeY, endX, target.y, 2, color);
}
}
function drawBadge(gr, text, x, y, color) {
gr.FillSolidRect(x, y, 20, 18, color);
const width = gr.CalcTextWidth(text, FONT_META);
gr.DrawString(text, FONT_META, COLOR.background,
x + (20 - width) / 2, y + 2, width + 2, 14);
}
function drawChannelCard(gr, channel, rect, index) {
if (!rect || rect.w <= 2 || rect.h <= 2) return;
const hover = index === hoveredCard;
const selectedA = selectorMatches(channel, selectors[0]);
const selectedB = selectorMatches(channel, selectors[1]);
gr.FillSolidRect(rect.x, rect.y, rect.w, rect.h,
hover ? COLOR.cardHover : COLOR.card);
gr.DrawRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1,
1, hover ? COLOR.text : COLOR.border);
if (selectedA) gr.DrawRect(rect.x + 2, rect.y + 2, rect.w - 4, rect.h - 4,
2, COLOR.routeA);
if (selectedB) gr.DrawRect(rect.x + 5, rect.y + 5, rect.w - 10, rect.h - 10,
2, COLOR.routeB);
const names = channel.Names || [];
const canonical = names.length ? names[0] : 'unknown position';
const aliases = names.length > 1 ? names.slice(1).join(' · ') : 'index-only selector';
const textX = rect.x + 10;
const badgeSpace = (selectedA ? 25 : 0) + (selectedB ? 25 : 0);
gr.DrawString('#' + channel.Index + ' ' + canonical, FONT_CARD, COLOR.text,
textX, rect.y + 7, Math.max(0, rect.w - 20 - badgeSpace), 19);
if (rect.h >= 52) {
gr.DrawString(aliases, FONT_META, COLOR.muted,
textX, rect.y + 28, Math.max(0, rect.w - 20), 16);
gr.DrawString('Flag ' + hex32(channel.Flag), FONT_META, COLOR.faint,
textX, rect.y + rect.h - 18, Math.max(0, rect.w - 20), 15);
} else {
gr.DrawString(hex32(channel.Flag), FONT_META, COLOR.faint,
textX, rect.y + rect.h - 17, Math.max(0, rect.w - 20), 14);
}
let badgeX = rect.x + rect.w - 26;
if (selectedB) {
drawBadge(gr, 'B', badgeX, rect.y + 6, COLOR.routeB);
badgeX -= 25;
}
if (selectedA) drawBadge(gr, 'A', badgeX, rect.y + 6, COLOR.routeA);
}
function drawEmptyState(gr) {
const top = 110;
const bottom = laneRects.length ? laneRects[0].y - 18 : window.Height - 20;
const height = Math.max(30, bottom - top);
const title = fb.IsPlaying ? 'Waiting for the first PCM chunk…' : 'No current audio stream';
const width = gr.CalcTextWidth(title, FONT_CARD);
gr.DrawString(title, FONT_CARD, COLOR.muted,
Math.max(14, (window.Width - width) / 2), top + height / 2 - 10,
Math.min(width + 4, Math.max(0, window.Width - 28)), 22);
}
function drawLane(gr, slot, rect) {
if (!rect || rect.w <= 2 || rect.h <= 2) return;
const active = slot === activeRoute;
const color = slot === 0 ? COLOR.routeA : COLOR.routeB;
gr.FillSolidRect(rect.x, rect.y, rect.w, rect.h,
slot === hoveredLane ? COLOR.cardHover : COLOR.panel);
gr.DrawRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1,
active ? 2 : 1, active ? color : COLOR.border);
gr.FillSolidRect(rect.x + 9, rect.y + 9, 35, Math.max(20, rect.h - 18), color);
gr.DrawString(slot === 0 ? 'A' : 'B', FONT_LANE, COLOR.background,
rect.x + 18, rect.y + Math.max(8, (rect.h - 25) / 2), 20, 25);
const labelX = rect.x + 55;
const meterX = rect.x + Math.min(205, Math.max(150, rect.w * 0.42));
const labelW = Math.max(0, meterX - labelX - 8);
gr.DrawString(selectorText(selectors[slot]), FONT_CARD, COLOR.text,
labelX, rect.y + 8, labelW, 20);
gr.DrawString(selectorCode(selectors[slot]), FONT_META, active ? color : COLOR.muted,
labelX, rect.y + 29, labelW, Math.max(14, rect.h - 33));
const meterW = Math.max(0, rect.x + rect.w - meterX - 12);
const meterY = rect.y + 12;
const meterH = Math.max(0, rect.h - 24);
if (meterW > 0 && meterH > 0)
meters[slot].Draw(gr, meterX, meterY, meterW, meterH);
}
function on_paint(gr) {
gr.FillSolidRect(0, 0, window.Width, window.Height, COLOR.background);
updateLayout();
drawHeader(gr);
drawRoutes(gr);
if (audioFormat && audioFormat.Channels.length) {
for (let i = 0; i < audioFormat.Channels.length; ++i) {
drawChannelCard(gr, audioFormat.Channels[i], channelRects[i], i);
}
} else {
drawEmptyState(gr);
}
for (let i = 0; i < laneRects.length; ++i) drawLane(gr, i, laneRects[i]);
}
function on_mouse_move(x, y) {
updateLayout();
const nextCard = hitTest(channelRects, x, y);
const nextLane = nextCard === -1 ? hitTest(laneRects, x, y) : -1;
if (nextCard !== hoveredCard || nextLane !== hoveredLane) {
hoveredCard = nextCard;
hoveredLane = nextLane;
window.Repaint();
}
}
function on_mouse_leave() {
if (hoveredCard !== -1 || hoveredLane !== -1) {
hoveredCard = -1;
hoveredLane = -1;
window.Repaint();
}
}
function on_mouse_lbtn_up(x, y) {
updateLayout();
const card = hitTest(channelRects, x, y);
if (card !== -1 && audioFormat) {
setRoute(activeRoute, selectorFor(audioFormat.Channels[card]));
activeRoute = 1 - activeRoute;
window.Repaint();
return;
}
const lane = hitTest(laneRects, x, y);
if (lane !== -1) {
activeRoute = lane;
window.Repaint();
}
}
function on_playback_new_track() { syncAudioFormat(); }
function on_playback_pause() { syncAudioFormat(); }
function on_playback_stop() { syncAudioFormat(); }
const formatTimer = setInterval(syncAudioFormat, 200);
const paintTimer = setInterval(() => window.Repaint(), 33);
function on_script_unload() {
clearInterval(formatTimer);
clearInterval(paintTimer);
vis.UpdateIntervalMs = INITIAL_UPDATE_INTERVAL;
}
syncAudioFormat();