335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
import { createPeakHoldState, stepPeakHold } from '../core/utils.js';
|
|
import { drawHairlineGrid, METER_HEADER_FONT } from './scale_helpers.js';
|
|
import { HEADER_BG, LABEL_COLOR, MID_COLOR, WARN_COLOR } from '../core/theme.js';
|
|
|
|
export const id = 'hifi-peak';
|
|
|
|
const HIFI_BOTTOM = -20;
|
|
const HIFI_TOP = 6;
|
|
const HIFI_RED_START = 3;
|
|
const DEFAULT_ATTACK_MS = 8;
|
|
const DEFAULT_RELEASE_DB_PER_S = 26;
|
|
const DEFAULT_HOLD_MS = 320;
|
|
const DEFAULT_HOLD_DECAY_DB_PER_S = 40;
|
|
const MAJOR_TICKS = [-20, -10, -5, 0, 3, 6];
|
|
const MINOR_TICKS = [-15, -8, -7, -6, -4, -3, -2, -1, 1, 2, 4, 5];
|
|
|
|
export function initShared(CONFIG = {}) {
|
|
const now = performance.now();
|
|
const refDbfsFor0 = resolveHifiRefDbfsFor0(CONFIG);
|
|
return {
|
|
target: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
|
values: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
|
hold: { L: HIFI_BOTTOM, R: HIFI_BOTTOM },
|
|
refDbfsFor0,
|
|
lastTs: now,
|
|
_holdState: {
|
|
L: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS),
|
|
R: createPeakHoldState(HIFI_BOTTOM, now, DEFAULT_HOLD_MS),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function update(packet, shared) {
|
|
if (!packet || !shared) return;
|
|
const now = performance.now();
|
|
const rawL = Number.isFinite(packet.tpL) ? packet.tpL : -120;
|
|
const rawR = Number.isFinite(packet.tpR) ? packet.tpR : -120;
|
|
const refDbfsFor0 = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : -14;
|
|
const deckL = rawL - refDbfsFor0;
|
|
const deckR = rawR - refDbfsFor0;
|
|
const targetL = clamp(deckL, HIFI_BOTTOM, HIFI_TOP);
|
|
const targetR = clamp(deckR, HIFI_BOTTOM, HIFI_TOP);
|
|
const dt = Math.max(1 / 240, (now - (shared.lastTs || now)) / 1000);
|
|
shared.lastTs = now;
|
|
|
|
const attackTau = Math.max(0.001, DEFAULT_ATTACK_MS / 1000);
|
|
const attackAlpha = 1 - Math.exp(-dt / attackTau);
|
|
const releaseStep = DEFAULT_RELEASE_DB_PER_S * dt;
|
|
|
|
shared.target.L = targetL;
|
|
shared.target.R = targetR;
|
|
shared.values.L = applyBallistics(shared.values.L, targetL, attackAlpha, releaseStep);
|
|
shared.values.R = applyBallistics(shared.values.R, targetR, attackAlpha, releaseStep);
|
|
}
|
|
|
|
export function draw(g, rect, CONFIG = {}, shared) {
|
|
if (!g || !rect || !shared) return;
|
|
|
|
syncAlignment(shared, CONFIG);
|
|
|
|
const colNorm = CONFIG.TP_COLOR_NORMAL || MID_COLOR;
|
|
const colWarn = CONFIG.TP_COLOR_WARN || WARN_COLOR;
|
|
const redOnly = CONFIG.TP_RED_BAR_ONLY !== false;
|
|
const mapY = (db) => rect.y + (1 - norm(db)) * rect.h;
|
|
|
|
const innerPad = 8;
|
|
const scaleW = 28;
|
|
const gap = 8;
|
|
const avail = rect.w - innerPad * 2 - scaleW - gap * 2;
|
|
let barW = Math.max(10, Math.floor(avail / 2));
|
|
barW = Math.floor(barW * (CONFIG.METER_BAR_THIN || 0.55));
|
|
|
|
const used = 2 * barW + scaleW + 2 * gap;
|
|
const extra = Math.max(0, (rect.w - innerPad * 2) - used);
|
|
const leftX = rect.x + innerPad + extra / 2;
|
|
const scaleX = leftX + barW + gap;
|
|
const rightX = scaleX + scaleW + gap;
|
|
const centerX = scaleX + scaleW / 2;
|
|
const centerLeft = leftX + barW / 2;
|
|
const centerRight = rightX + barW / 2;
|
|
const innerW = Math.max(2, barW - 2);
|
|
const rawL = clamp(shared.values.L, HIFI_BOTTOM, HIFI_TOP);
|
|
const rawR = clamp(shared.values.R, HIFI_BOTTOM, HIFI_TOP);
|
|
const smooth = smoothHeader(shared, rawL, rawR);
|
|
|
|
g.save();
|
|
const prevFont = g.font;
|
|
g.font = 'bold 12px ui-monospace, monospace';
|
|
g.fillStyle = HEADER_BG;
|
|
g.fillRect(rect.x, rect.y - 24, rect.w, 24);
|
|
g.textAlign = 'center';
|
|
g.fillStyle = (rawL > HIFI_RED_START || rawR > HIFI_RED_START) ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
|
const yText = rect.y - 12;
|
|
g.fillStyle = rawL > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
|
g.fillText(formatHeaderValue(smooth.L), centerLeft, yText);
|
|
g.fillStyle = colNorm;
|
|
g.fillText('|', centerX, yText);
|
|
g.fillStyle = rawR > HIFI_RED_START ? WARN_COLOR : (CONFIG.HEADER_TEXT_COLOR || MID_COLOR);
|
|
g.fillText(formatHeaderValue(smooth.R), centerRight, yText);
|
|
g.font = prevFont;
|
|
g.restore();
|
|
|
|
drawBar(g, leftX, innerW, rawL, mapY, colNorm, colWarn, redOnly);
|
|
drawBar(g, rightX, innerW, rawR, mapY, colNorm, colWarn, redOnly);
|
|
|
|
const now = performance.now();
|
|
const holdOpts = {
|
|
holdMs: DEFAULT_HOLD_MS,
|
|
decayDbPerS: DEFAULT_HOLD_DECAY_DB_PER_S,
|
|
floor: HIFI_BOTTOM,
|
|
riseThreshold: 0.15,
|
|
};
|
|
shared.hold.L = stepPeakHold(shared.values.L, shared._holdState.L, now, holdOpts);
|
|
shared.hold.R = stepPeakHold(shared.values.R, shared._holdState.R, now, holdOpts);
|
|
|
|
g.save();
|
|
g.fillStyle = '#ffffff';
|
|
g.fillRect(leftX + 1, mapY(shared.hold.L) - 1, innerW, 2);
|
|
g.fillRect(rightX + 1, mapY(shared.hold.R) - 1, innerW, 2);
|
|
g.restore();
|
|
|
|
const yTop = mapY(HIFI_TOP);
|
|
const yRed = mapY(HIFI_RED_START);
|
|
drawWarningEdges(g, leftX, barW, rightX, centerX, yRed, yTop, colWarn);
|
|
drawBarTicks(g, leftX, rightX, innerW, mapY, colWarn);
|
|
drawScale(g, { x: scaleX, y: rect.y, w: scaleW, h: rect.h }, centerX, mapY);
|
|
|
|
g.save();
|
|
g.fillStyle = LABEL_COLOR;
|
|
g.textAlign = 'center';
|
|
const baseY = rect.y + rect.h + 16;
|
|
g.fillText('L', leftX + barW / 2, baseY);
|
|
g.fillText('R', rightX + barW / 2, baseY);
|
|
const prevFooterFont = g.font;
|
|
g.fillStyle = '#ffffff';
|
|
g.font = 'bold 8.4px ui-monospace, monospace';
|
|
g.fillText('dB', centerX, baseY);
|
|
g.font = prevFooterFont;
|
|
g.restore();
|
|
}
|
|
|
|
function applyBallistics(current, target, attackAlpha, releaseStep) {
|
|
const prev = Number.isFinite(current) ? current : HIFI_BOTTOM;
|
|
if (target >= prev) {
|
|
return clamp(prev + (target - prev) * attackAlpha, HIFI_BOTTOM, HIFI_TOP);
|
|
}
|
|
return clamp(Math.max(target, prev - releaseStep), HIFI_BOTTOM, HIFI_TOP);
|
|
}
|
|
|
|
function drawBar(g, x, width, value, mapY, colNorm, colWarn, redOnly) {
|
|
const yVal = mapY(value);
|
|
const yFloor = mapY(HIFI_BOTTOM);
|
|
const yRed = mapY(HIFI_RED_START);
|
|
|
|
if (value > HIFI_RED_START) {
|
|
if (redOnly) {
|
|
const normTop = Math.min(yRed, yFloor);
|
|
if (yFloor - normTop > 0) {
|
|
g.fillStyle = colNorm;
|
|
g.fillRect(x + 1, normTop, width, yFloor - normTop);
|
|
}
|
|
const warnTop = Math.min(yVal, yRed);
|
|
if (yRed - warnTop > 0) {
|
|
g.fillStyle = colWarn;
|
|
g.fillRect(x + 1, warnTop, width, yRed - warnTop);
|
|
}
|
|
} else {
|
|
g.fillStyle = colWarn;
|
|
g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal));
|
|
}
|
|
} else {
|
|
g.fillStyle = colNorm;
|
|
g.fillRect(x + 1, yVal, width, Math.max(0, yFloor - yVal));
|
|
}
|
|
|
|
g.globalAlpha = 0.12;
|
|
g.fillStyle = '#ffffff';
|
|
g.fillRect(x + 1, yVal, width, 2);
|
|
g.globalAlpha = 1;
|
|
}
|
|
|
|
function drawScale(g, rect, centerX, mapY) {
|
|
drawHairlineGrid(g, rect, mapY, HIFI_TOP, HIFI_BOTTOM, 1);
|
|
|
|
g.save();
|
|
const prevFont = g.font;
|
|
g.font = METER_HEADER_FONT;
|
|
g.strokeStyle = 'rgba(143, 211, 212, 0.22)';
|
|
g.lineWidth = 1;
|
|
for (const db of MINOR_TICKS) {
|
|
const y = Math.round(mapY(db)) + 0.5;
|
|
g.beginPath();
|
|
g.moveTo(centerX - 4, y);
|
|
g.lineTo(centerX + 4, y);
|
|
g.stroke();
|
|
}
|
|
for (const db of MAJOR_TICKS) {
|
|
const y = Math.round(mapY(db)) + 0.5;
|
|
g.beginPath();
|
|
g.moveTo(centerX - 8, y);
|
|
g.lineTo(centerX + 8, y);
|
|
g.stroke();
|
|
}
|
|
g.fillStyle = LABEL_COLOR;
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'alphabetic';
|
|
for (const db of MAJOR_TICKS) {
|
|
const y = mapY(db);
|
|
const label = db > 0 ? `+${db}` : `${db}`;
|
|
g.fillText(label, centerX, y + 5);
|
|
}
|
|
g.font = prevFont;
|
|
g.restore();
|
|
}
|
|
|
|
function drawWarningEdges(g, leftX, barW, rightX, centerX, yWarn, yTop, color) {
|
|
const innerRight = leftX + barW;
|
|
const gapL = Math.abs(centerX - innerRight);
|
|
const insetL = Math.max(1, Math.floor(gapL * 0.35));
|
|
const xL = Math.round(innerRight + insetL) + 0.5;
|
|
|
|
const innerLeft = rightX;
|
|
const gapR = Math.abs(centerX - innerLeft);
|
|
const insetR = Math.max(1, Math.floor(gapR * 0.35));
|
|
const xR = Math.round(innerLeft - insetR) + 0.5;
|
|
|
|
const y1 = Math.min(yWarn, yTop);
|
|
const y2 = Math.max(yWarn, yTop);
|
|
|
|
g.save();
|
|
g.strokeStyle = color;
|
|
g.lineWidth = 1;
|
|
g.beginPath(); g.moveTo(xL, y1); g.lineTo(xL, y2); g.stroke();
|
|
g.beginPath(); g.moveTo(xR, y1); g.lineTo(xR, y2); g.stroke();
|
|
g.restore();
|
|
}
|
|
|
|
function drawBarTicks(g, leftX, rightX, widthPx, mapY, warnColor) {
|
|
if (!g) return;
|
|
|
|
g.save();
|
|
g.lineWidth = 1;
|
|
const defaultColor = 'rgb(0,0,255)';
|
|
const majorInset = 2;
|
|
const minorFrac = 0.45;
|
|
const majorWidth = Math.max(1, widthPx - 4 * majorInset);
|
|
const minorWidth = Math.max(1, Math.floor(widthPx * minorFrac));
|
|
const cxL = leftX + 1 + widthPx / 2;
|
|
const cxR = rightX + 1 + widthPx / 2;
|
|
|
|
const drawPair = (yPix, width, color = defaultColor) => {
|
|
g.strokeStyle = color;
|
|
const x1L = Math.round(cxL - width / 2) + 0.5;
|
|
const x2L = Math.round(cxL + width / 2) + 0.5;
|
|
g.beginPath(); g.moveTo(x1L, yPix); g.lineTo(x2L, yPix); g.stroke();
|
|
const x1R = Math.round(cxR - width / 2) + 0.5;
|
|
const x2R = Math.round(cxR + width / 2) + 0.5;
|
|
g.beginPath(); g.moveTo(x1R, yPix); g.lineTo(x2R, yPix); g.stroke();
|
|
};
|
|
|
|
for (const db of MAJOR_TICKS) {
|
|
const y = Math.round(mapY(db)) + 0.5;
|
|
const color = db === HIFI_RED_START ? warnColor : defaultColor;
|
|
drawPair(y, majorWidth, color);
|
|
}
|
|
for (const db of MINOR_TICKS) {
|
|
const y = Math.round(mapY(db)) + 0.5;
|
|
drawPair(y, minorWidth, defaultColor);
|
|
}
|
|
|
|
g.restore();
|
|
}
|
|
|
|
function formatHeaderValue(v) {
|
|
const n = Number.isFinite(v) ? v : HIFI_BOTTOM;
|
|
const sign = n >= 0 ? '+' : '-';
|
|
return `${sign}${Math.abs(n).toFixed(1)}`;
|
|
}
|
|
|
|
function resolveHifiRefDbfsFor0(CONFIG = {}) {
|
|
const base = Number.isFinite(CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU)
|
|
? CONFIG.PPM_REF_DBFS_PEAK_FOR_0_DBU
|
|
: -15;
|
|
const modeOffset = (CONFIG.PPM_DIN_MODE === 'al_minus6') ? -6 : -9;
|
|
const trim = Number(CONFIG.PPM_DIN_TRIM_DB) || 0;
|
|
const effOff = modeOffset + trim;
|
|
const ppmDin0Dbfs = base - effOff;
|
|
return (CONFIG.HIFI_PEAK_ALIGNMENT === 'ppm_din_zero')
|
|
? ppmDin0Dbfs
|
|
: (ppmDin0Dbfs - 5);
|
|
}
|
|
|
|
function syncAlignment(shared, CONFIG = {}) {
|
|
const nextRef = resolveHifiRefDbfsFor0(CONFIG);
|
|
const prevRef = Number.isFinite(shared.refDbfsFor0) ? shared.refDbfsFor0 : nextRef;
|
|
const shift = prevRef - nextRef;
|
|
if (!Number.isFinite(shift) || Math.abs(shift) < 1e-6) {
|
|
shared.refDbfsFor0 = nextRef;
|
|
return;
|
|
}
|
|
|
|
shared.refDbfsFor0 = nextRef;
|
|
shared.target.L = clamp((shared.target.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
shared.target.R = clamp((shared.target.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
shared.values.L = clamp((shared.values.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
shared.values.R = clamp((shared.values.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
shared.hold.L = clamp((shared.hold.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
shared.hold.R = clamp((shared.hold.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
if (shared._holdState?.L) shared._holdState.L.value = clamp((shared._holdState.L.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
if (shared._holdState?.R) shared._holdState.R.value = clamp((shared._holdState.R.value ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
if (shared._header) {
|
|
shared._header.L = clamp((shared._header.L ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
shared._header.R = clamp((shared._header.R ?? HIFI_BOTTOM) + shift, HIFI_BOTTOM, HIFI_TOP);
|
|
}
|
|
}
|
|
|
|
function smoothHeader(shared, rawL, rawR, alpha = 0.2) {
|
|
if (!shared._header) {
|
|
shared._header = { L: rawL, R: rawR };
|
|
return shared._header;
|
|
}
|
|
shared._header.L += alpha * (rawL - shared._header.L);
|
|
shared._header.R += alpha * (rawR - shared._header.R);
|
|
return shared._header;
|
|
}
|
|
|
|
function norm(db) {
|
|
const clamped = clamp(db, HIFI_BOTTOM, HIFI_TOP);
|
|
return (clamped - HIFI_BOTTOM) / (HIFI_TOP - HIFI_BOTTOM);
|
|
}
|
|
|
|
function clamp(v, lo, hi) {
|
|
return Math.max(lo, Math.min(hi, v));
|
|
}
|