972 lines
32 KiB
JavaScript
972 lines
32 KiB
JavaScript
// IN USE
|
|
// views/phase_wheel.js — Phase/Frequency Wheel (polar phase display) + meter panel
|
|
|
|
import { DEFAULT_TOP_INSET, FRAME_COLOR, PANEL_BG } from '../core/theme.js';
|
|
|
|
export const id = 'phase-wheel';
|
|
|
|
const FRAME = { left: 0, top: DEFAULT_TOP_INSET, right: 0, bottom: 0 };
|
|
const WHEEL_METER_GAP = 8;
|
|
const METER_WIDTH = 420;
|
|
const METER_GAP = 8;
|
|
const METER_SLOTS = 3;
|
|
const METER_PAD_TOP = 30;
|
|
const METER_PAD_BOTTOM = 20;
|
|
const METER_EXTRA_BOTTOM_PAD = 6;
|
|
const TARGET_POINTS = 1024;
|
|
const COLOR_STOPS = [
|
|
{ t: 0.0, color: [0, 0, 50] },
|
|
{ t: 0.3, color: [0, 120, 180] },
|
|
{ t: 0.6, color: [0, 205, 120] },
|
|
{ t: 0.8, color: [210, 220, 0] },
|
|
{ t: 1.0, color: [255, 120, 0] },
|
|
];
|
|
const RING_DBFS = [0, -8, -16, -24, -32, -40];
|
|
const RING_PPM_DIN = [+5, 0, -10, -20, -30, -40, -50];
|
|
const PHASE_GAIN_MIN_DB = -35;
|
|
const PHASE_GAIN_MAX_DB = 35;
|
|
const PHASE_ALIGN_TARGET = Math.pow(10, -15 / 20);
|
|
const PHASE_AGC_TARGET_DB = linearToDb(PHASE_ALIGN_TARGET);
|
|
const PHASE_AGC_BASE_GAIN = 1 / PHASE_ALIGN_TARGET;
|
|
const PHASE_AGC_ATTACK_S = 0.02;
|
|
const PHASE_AGC_RELEASE_DB_PER_S = 12;
|
|
const PHASE_LEVEL_THRESHOLD_DB = -60;
|
|
const PHASE_LEVEL_THRESHOLD = dbToLinear(PHASE_LEVEL_THRESHOLD_DB);
|
|
const PHASE_IDLE_DECAY = 0.85;
|
|
const PHASE_PHASE_SMOOTH_ALPHA = 0.08;
|
|
const PHASE_RADIUS_SMOOTH_ALPHA = 0.18;
|
|
const PHASE_BANDPASS_LOW_HZ = 300;
|
|
const PHASE_BANDPASS_HIGH_HZ = 5000;
|
|
const PHASE_TRAIL_FADE_MS = 2000;
|
|
const PHASE_TRAIL_MIN_STEP_MS = 1000 / 45;
|
|
const PHASE_TRAIL_MIN_DIST_PX = 1.5;
|
|
const PHASE_SECTORS = [
|
|
{ startDeg: -30, endDeg: 30, color: 'rgba(72,210,150,0.3)' },
|
|
{ startDeg: 30, endDeg: 60, color: 'rgba(255,214,120,0.25)' },
|
|
{ startDeg: -60, endDeg: -30, color: 'rgba(255,214,120,0.25)' },
|
|
{ startDeg: 60, endDeg: 180, color: 'rgba(255,120,120,0.25)' },
|
|
{ startDeg: -180, endDeg: -60, color: 'rgba(255,120,120,0.25)' },
|
|
];
|
|
|
|
// Numerisch stabilere Hilbert-Kernel-Erstellung
|
|
const HILBERT_KERNEL = buildHilbertKernel(33);
|
|
const HILBERT_HALF = (HILBERT_KERNEL.length - 1) / 2;
|
|
|
|
// Lookup-Tables für häufig verwendete Werte
|
|
const ANGLE_COS = new Float32Array(360);
|
|
const ANGLE_SIN = new Float32Array(360);
|
|
for (let i = 0; i < 360; i++) {
|
|
const rad = (i * Math.PI) / 180;
|
|
ANGLE_COS[i] = Math.cos(rad);
|
|
ANGLE_SIN[i] = Math.sin(rad);
|
|
}
|
|
|
|
export function init() {
|
|
return {
|
|
traceBuffer: new Float32Array(0),
|
|
ampBuffer: new Float32Array(0),
|
|
filteredL: new Float32Array(0),
|
|
filteredR: new Float32Array(0),
|
|
currentPhase: null,
|
|
currentRadius: 0,
|
|
smoothPhase: null,
|
|
smoothRadius: 0,
|
|
phaseAgcEnv: 1e-3,
|
|
phaseAgcGainDb: 0,
|
|
phaseAgcLastTs: 0,
|
|
bandpass: createBandpassState(),
|
|
bufferGrowthCount: 0,
|
|
maxBufferSize: 0,
|
|
phaseTrail: [],
|
|
staticLayerCanvas: null,
|
|
staticLayerKey: '',
|
|
};
|
|
}
|
|
|
|
export function destroy() {
|
|
// Cleanup - Setze Referenzen für Garbage Collection frei
|
|
return null;
|
|
}
|
|
|
|
export function resize() {}
|
|
|
|
export async function render(env, state) {
|
|
const { ctx: g, rect, config: CONFIG, audio, utils, meters } = env;
|
|
const slotsRaw = env.slots?.(id) || ['vu', 'ppm-ebu', 'tp'];
|
|
const slots = slotsRaw.filter((v) => v && v !== 'none');
|
|
const topInset = Number.isFinite(env?.topInset) ? Number(env.topInset) : FRAME.top;
|
|
const layout = computeLayout(rect, slots.length, topInset);
|
|
const now = getNow();
|
|
|
|
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
|
|
const xyData = extractXYData(audio);
|
|
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
|
|
if (xyData.ready) {
|
|
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
|
|
renderWheel(g, trace, layout.wheel, state, CONFIG, now);
|
|
} else {
|
|
decayPhasePointer(state);
|
|
trimPhaseTrail(state, CONFIG, now);
|
|
drawPhaseTrail(g, layout.wheel, state, CONFIG, now);
|
|
drawPhasePointer(g, layout.wheel, state);
|
|
drawIdleMessage(g, layout.wheel);
|
|
}
|
|
|
|
if (slots.length) {
|
|
const panelSlotWidth = computePanelSlotWidth(layout.meter.w, slots.length);
|
|
await renderMeterPanel(g, layout.meter, slots, CONFIG, meters, panelSlotWidth);
|
|
drawMeterOutline(g, layout.meter);
|
|
}
|
|
drawPhaseAngleLabel(g, layout.wheelPanel, layout.wheel, state, now);
|
|
}
|
|
|
|
function computeLayout(rect, slotCount = METER_SLOTS, topInset = FRAME.top) {
|
|
const plotX = FRAME.left;
|
|
const plotY = Number.isFinite(topInset) ? topInset : FRAME.top;
|
|
const innerWidth = Math.max(200, rect.w - plotX - FRAME.right);
|
|
const minPlotW = 200;
|
|
const n = Math.max(0, Math.min(METER_SLOTS, slotCount | 0));
|
|
let meterW = n > 0 ? resolveMeterWidth(rect.w, n) : 0;
|
|
let plotW = innerWidth - (n > 0 ? (meterW + METER_GAP) : 0);
|
|
if (plotW < minPlotW) {
|
|
plotW = minPlotW;
|
|
meterW = n > 0 ? Math.max(0, innerWidth - plotW - METER_GAP) : 0;
|
|
}
|
|
const plotH = Math.max(160, rect.h - plotY - FRAME.bottom);
|
|
const wheelSize = Math.min(plotW, plotH);
|
|
const wheelPanel = {
|
|
x: plotX,
|
|
y: plotY,
|
|
w: plotW,
|
|
h: plotH,
|
|
};
|
|
const meterX = plotX + plotW + (n > 0 ? METER_GAP : 0);
|
|
const wheel = {
|
|
x: wheelPanel.x + (wheelPanel.w - wheelSize) / 2,
|
|
y: plotY + (plotH - wheelSize) / 2,
|
|
w: wheelSize,
|
|
h: wheelSize,
|
|
cx: wheelPanel.x + wheelPanel.w / 2,
|
|
cy: plotY + plotH / 2,
|
|
radius: wheelSize / 2 - 8,
|
|
};
|
|
const meter = {
|
|
x: meterX,
|
|
y: plotY,
|
|
w: meterW,
|
|
h: plotH,
|
|
};
|
|
return { wheelPanel, wheel, meter };
|
|
}
|
|
|
|
function drawStaticLayer(g, state, rect, layout, CONFIG, slotCount) {
|
|
const layer = ensureStaticLayer(state, rect, layout, CONFIG, slotCount);
|
|
if (!layer) {
|
|
drawWheelBackground(g, layout.wheelPanel, layout.wheel, CONFIG);
|
|
if (slotCount) drawMeterBackground(g, layout.meter);
|
|
return;
|
|
}
|
|
g.drawImage(layer, 0, 0, rect.w, rect.h);
|
|
}
|
|
|
|
function ensureStaticLayer(state, rect, layout, CONFIG, slotCount) {
|
|
const w = Math.max(1, rect.w | 0);
|
|
const h = Math.max(1, rect.h | 0);
|
|
const key = [
|
|
w, h,
|
|
layout.wheelPanel.x, layout.wheelPanel.y, layout.wheelPanel.w, layout.wheelPanel.h,
|
|
layout.wheel.x, layout.wheel.y, layout.wheel.w, layout.wheel.h, layout.wheel.radius,
|
|
layout.meter.x, layout.meter.y, layout.meter.w, layout.meter.h,
|
|
slotCount,
|
|
!!CONFIG?.PANEL_DIVIDERS_ENABLED,
|
|
CONFIG?.PHASE_AMPLITUDE_MODE || 'bandpass',
|
|
].join('|');
|
|
if (state.staticLayerCanvas && state.staticLayerKey === key) {
|
|
return state.staticLayerCanvas;
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d', { alpha: true });
|
|
if (!ctx) return null;
|
|
ctx.textBaseline = 'alphabetic';
|
|
ctx.font = 'bold 14px ui-monospace, monospace';
|
|
drawWheelBackground(ctx, layout.wheelPanel, layout.wheel, CONFIG);
|
|
if (slotCount) {
|
|
drawMeterBackground(ctx, layout.meter);
|
|
drawMeterPanelDividers(ctx, layout.meter, slotCount, CONFIG, computePanelSlotWidth(layout.meter.w, slotCount));
|
|
}
|
|
state.staticLayerCanvas = canvas;
|
|
state.staticLayerKey = key;
|
|
return canvas;
|
|
}
|
|
|
|
function resolveMeterWidth(totalWidth, slotCount) {
|
|
if (slotCount >= 3) return Math.max(METER_WIDTH, Math.round(totalWidth * 0.35));
|
|
return slotCount === 2 ? 280 : 160;
|
|
}
|
|
|
|
function drawWheelBackground(g, panel, wheel, CONFIG) {
|
|
const ringDbValues = getRingDbValues(CONFIG);
|
|
g.save();
|
|
g.fillStyle = PANEL_BG;
|
|
g.fillRect(panel.x, panel.y, panel.w, panel.h);
|
|
g.strokeStyle = FRAME_COLOR;
|
|
g.lineWidth = 2;
|
|
g.strokeRect(panel.x, panel.y, panel.w, panel.h);
|
|
g.translate(wheel.cx, wheel.cy);
|
|
drawPhaseSectors(g, wheel);
|
|
const rings = ringDbValues.length;
|
|
for (let i = 0; i < rings; i++) {
|
|
const frac = ringFraction(i, rings);
|
|
const r = wheel.radius * frac;
|
|
g.strokeStyle = 'rgba(0,231,255,0.2)';
|
|
g.lineWidth = i === 0 ? 1.5 : 1;
|
|
g.setLineDash(i === 0 ? [] : [4, 4]);
|
|
g.beginPath();
|
|
g.arc(0, 0, r, 0, Math.PI * 2);
|
|
g.stroke();
|
|
}
|
|
g.setLineDash([]);
|
|
g.strokeStyle = 'rgba(0,231,255,0.35)';
|
|
for (let i = 0; i < 4; i++) {
|
|
const angle = (Math.PI / 2) * i;
|
|
g.beginPath();
|
|
g.moveTo(0, 0);
|
|
g.lineTo(Math.cos(angle) * wheel.radius, Math.sin(angle) * wheel.radius);
|
|
g.stroke();
|
|
}
|
|
drawAmplitudeScale(g, wheel, ringDbValues, CONFIG);
|
|
g.fillStyle = '#bcd';
|
|
g.font = '12px ui-monospace, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
const labels = [
|
|
{ text: '0°', angle: -Math.PI / 2 },
|
|
{ text: '-90°', angle: Math.PI },
|
|
{ text: '+90°', angle: 0 },
|
|
{ text: '180°', angle: Math.PI / 2 },
|
|
];
|
|
for (const lbl of labels) {
|
|
const inset = (lbl.text === '+90°' || lbl.text === '-90°') ? 34 : 24;
|
|
const x = Math.cos(lbl.angle) * (wheel.radius - inset);
|
|
const y = Math.sin(lbl.angle) * (wheel.radius - inset);
|
|
g.fillText(lbl.text, x, y);
|
|
}
|
|
g.restore();
|
|
}
|
|
|
|
function drawPhaseSectors(g, wheel) {
|
|
const radius = Math.max(10, wheel.radius - 6);
|
|
const width = 12;
|
|
for (const sector of PHASE_SECTORS) {
|
|
g.save();
|
|
g.strokeStyle = sector.color;
|
|
g.lineWidth = width;
|
|
g.beginPath();
|
|
g.arc(
|
|
0,
|
|
0,
|
|
radius,
|
|
degToCanvas(sector.startDeg),
|
|
degToCanvas(sector.endDeg),
|
|
false
|
|
);
|
|
g.stroke();
|
|
g.restore();
|
|
}
|
|
}
|
|
|
|
function drawIdleMessage(g, wheel) {
|
|
g.save();
|
|
g.fillStyle = '#bcd';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
g.font = 'bold 16px ui-monospace, monospace';
|
|
g.fillText('Waiting for audio…', wheel.cx, wheel.cy);
|
|
g.restore();
|
|
}
|
|
|
|
function drawAmplitudeScale(g, wheel, ringDbValues, CONFIG) {
|
|
const axisAngle = (3 * Math.PI) / 4;
|
|
const mode = getPhaseAmplitudeMode(CONFIG);
|
|
g.save();
|
|
g.fillStyle = '#9fe';
|
|
g.font = '11px ui-monospace, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
const rings = Array.isArray(ringDbValues) ? ringDbValues.length : 0;
|
|
for (let i = 0; i < rings; i++) {
|
|
const db = ringDbValues[i];
|
|
const r = wheel.radius * ringFraction(i, rings);
|
|
const x = Math.cos(axisAngle) * (r + 14);
|
|
const y = Math.sin(axisAngle) * (r + 14);
|
|
g.fillText(formatAmplitudeLabel(db, mode), x, y);
|
|
}
|
|
const titleX = Math.cos(axisAngle) * (wheel.radius + 34);
|
|
const titleY = Math.sin(axisAngle) * (wheel.radius + 34);
|
|
g.fillStyle = 'rgba(170,220,220,0.9)';
|
|
g.font = 'bold 11px ui-monospace, monospace';
|
|
g.fillText(mode === 'ppm-din' ? 'PPM DIN' : 'dBFS', titleX, titleY);
|
|
g.restore();
|
|
}
|
|
|
|
function degToCanvas(deg) {
|
|
return (deg * Math.PI) / 180 - Math.PI / 2;
|
|
}
|
|
|
|
function radToDeg(rad) {
|
|
return (rad * 180) / Math.PI;
|
|
}
|
|
|
|
function extractXYData(audio) {
|
|
const xyL = audio?.xyL;
|
|
const xyR = audio?.xyR;
|
|
const isVec = (v) => v && (Array.isArray(v) || ArrayBuffer.isView(v));
|
|
const ready = !!(audio?.alive && isVec(xyL) && isVec(xyR) && xyL.length && xyR.length);
|
|
return {
|
|
ready,
|
|
xyL,
|
|
xyR,
|
|
length: ready ? Math.min(xyL.length, xyR.length) : 0,
|
|
sampleRate: audio?.sampleRate || 48000,
|
|
};
|
|
}
|
|
|
|
function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
|
|
if (!xyData.ready || !xyData.length) {
|
|
decayPhasePointer(state);
|
|
return null;
|
|
}
|
|
const filtered = preparePhaseFilteredBuffers(state, xyData);
|
|
const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
|
|
const ringDbValues = getRingDbValues(CONFIG);
|
|
const ppmRadiusNorm = amplitudeMode === 'ppm-din'
|
|
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
|
|
: 0;
|
|
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
|
|
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
|
|
const radius = wheel.radius;
|
|
let ampIdx = 0;
|
|
let sumSin = 0;
|
|
let sumCos = 0;
|
|
let sumRadius = 0;
|
|
let levelAcc = 0;
|
|
const gainLinear = Number.isFinite(gain) ? gain : 1;
|
|
|
|
for (let i = 0; i < xyData.length; i += step) {
|
|
const lRe = clamp1(filtered.L[i]);
|
|
const rRe = clamp1(filtered.R[i]);
|
|
const lIm = hilbertAt(filtered.L, i);
|
|
const rIm = hilbertAt(filtered.R, i);
|
|
const phaseL = Math.atan2(lIm, lRe);
|
|
const phaseR = Math.atan2(rIm, rRe);
|
|
let phaseDiff = phaseL - phaseR;
|
|
if (!Number.isFinite(phaseDiff)) continue;
|
|
phaseDiff = wrapAngle(phaseDiff);
|
|
const angle = phaseDiff - Math.PI / 2;
|
|
const magL = Math.min(1, Math.hypot(lRe, lIm));
|
|
const magR = Math.min(1, Math.hypot(rRe, rIm));
|
|
const amp = Math.min(1, 0.5 * (magL + magR));
|
|
const ampScaled = Math.min(1, amp * gainLinear);
|
|
const radiusNorm = amplitudeMode === 'ppm-din'
|
|
? ppmRadiusNorm
|
|
: linearToRadiusNorm(ampScaled, ringDbValues);
|
|
ampIdx++;
|
|
sumSin += Math.sin(angle);
|
|
sumCos += Math.cos(angle);
|
|
sumRadius += radiusNorm;
|
|
levelAcc += amp * amp;
|
|
}
|
|
|
|
if (ampIdx > 0) {
|
|
const invCount = 1 / ampIdx;
|
|
const avgAngle = Math.atan2(sumSin * invCount, sumCos * invCount);
|
|
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
|
|
const blockLevel = Math.sqrt(levelAcc * invCount);
|
|
if (blockLevel >= PHASE_LEVEL_THRESHOLD) {
|
|
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
|
|
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
|
|
state.currentPhase = avgAngle;
|
|
state.currentRadius = avgRadius;
|
|
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
|
|
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
|
|
} else {
|
|
decayPhasePointer(state);
|
|
}
|
|
} else {
|
|
decayPhasePointer(state);
|
|
}
|
|
return { count: ampIdx, radius };
|
|
}
|
|
|
|
function renderWheel(g, trace, wheel, state, CONFIG, timestamp) {
|
|
g.save();
|
|
g.lineWidth = 1.5;
|
|
g.globalAlpha = 0.95;
|
|
if (trace && trace.count > 1) {
|
|
// reserved for future trace rendering
|
|
}
|
|
g.restore();
|
|
updatePhaseTrail(state, wheel, CONFIG, timestamp);
|
|
drawPhaseTrail(g, wheel, state, CONFIG, timestamp);
|
|
drawPhasePointer(g, wheel, state);
|
|
}
|
|
|
|
function drawPhasePointer(g, wheel, state) {
|
|
if (!Number.isFinite(state?.smoothPhase) || !Number.isFinite(state?.smoothRadius)) return;
|
|
const phase = state.smoothPhase;
|
|
const radiusNorm = Math.max(0, state.smoothRadius);
|
|
if (radiusNorm <= 0.002) return;
|
|
const length = radiusNorm * wheel.radius;
|
|
g.save();
|
|
g.translate(wheel.cx, wheel.cy);
|
|
g.strokeStyle = '#ffe36e';
|
|
g.fillStyle = '#ffe36e';
|
|
g.lineWidth = 3;
|
|
g.beginPath();
|
|
g.moveTo(0, 0);
|
|
g.lineTo(Math.cos(phase) * length, Math.sin(phase) * length);
|
|
g.stroke();
|
|
g.beginPath();
|
|
g.arc(0, 0, 4, 0, Math.PI * 2);
|
|
g.fill();
|
|
g.restore();
|
|
}
|
|
|
|
function drawPhaseAngleLabel(g, wheelPanel, wheel, state, nowMs) {
|
|
const hasPhase = Number.isFinite(state?.smoothPhase) && Number.isFinite(state?.smoothRadius) && state.smoothRadius > 0.002;
|
|
const now = Number.isFinite(nowMs) ? nowMs : getNow();
|
|
let degText = '--°';
|
|
if (hasPhase) {
|
|
const prevTs = Number.isFinite(state?._phaseLabelLastTs) ? state._phaseLabelLastTs : now;
|
|
const dt = Math.max(0, Math.min(0.25, (now - prevTs) / 1000));
|
|
// Die Zahl soll dem Zeiger folgen. Der Zeiger ist bereits geglättet (smoothPhase).
|
|
// Hier nur eine leichte, adaptive Glättung + Snap bei großen Sprüngen, damit Zahl und Zeiger
|
|
// bei schnellen Änderungen nicht auseinanderlaufen.
|
|
const targetPhase = state.smoothPhase;
|
|
const prevPhase = Number.isFinite(state?._phaseLabelPhase) ? state._phaseLabelPhase : targetPhase;
|
|
const diff = wrapAngle(targetPhase - prevPhase);
|
|
const absDiff = Math.abs(diff);
|
|
const snap = absDiff > (Math.PI / 3); // >60°: sofort folgen
|
|
const tau = 0.18; // schneller als vorher, damit der Wert "mitkommt"
|
|
const alpha = (snap || dt <= 0) ? 1 : (1 - Math.exp(-dt / tau));
|
|
const labelPhase = smoothAngle(prevPhase, targetPhase, alpha);
|
|
state._phaseLabelPhase = labelPhase;
|
|
state._phaseLabelLastTs = now;
|
|
|
|
let deg = radToDeg(labelPhase) + 90;
|
|
while (deg <= -180) deg += 360;
|
|
while (deg > 180) deg -= 360;
|
|
const minIntervalMs = 1000 / 30; // bis zu 30 updates/s (sonst wirkt es "hinterher")
|
|
const prevText = typeof state?._phaseLabelText === 'string' ? state._phaseLabelText : null;
|
|
const prevDisplayTs = Number.isFinite(state?._phaseLabelDisplayTs) ? state._phaseLabelDisplayTs : 0;
|
|
const nextText = `${deg > 0 ? '+' : ''}${deg.toFixed(0)}°`;
|
|
if (!prevText || snap || (now - prevDisplayTs) >= minIntervalMs) {
|
|
degText = nextText;
|
|
state._phaseLabelText = degText;
|
|
state._phaseLabelDisplayTs = now;
|
|
} else {
|
|
degText = prevText;
|
|
}
|
|
} else {
|
|
if (state) {
|
|
state._phaseLabelPhase = null;
|
|
state._phaseLabelLastTs = now;
|
|
state._phaseLabelText = '--°';
|
|
state._phaseLabelDisplayTs = now;
|
|
}
|
|
}
|
|
const boxPadding = 7;
|
|
const boxW = 96;
|
|
const boxH = 42;
|
|
const panelX = Number.isFinite(wheelPanel?.x) ? wheelPanel.x : 0;
|
|
const panelY = Number.isFinite(wheelPanel?.y) ? wheelPanel.y : wheel.y;
|
|
const boxX = panelX + 12;
|
|
const boxY = panelY + 12;
|
|
g.save();
|
|
g.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
g.strokeStyle = 'rgba(0, 231, 255, 0.65)';
|
|
g.lineWidth = 1;
|
|
g.fillRect(boxX, boxY, boxW, boxH);
|
|
g.strokeRect(boxX, boxY, boxW, boxH);
|
|
g.fillStyle = '#ffe36e';
|
|
g.font = 'bold 15px ui-monospace, monospace';
|
|
g.textAlign = 'center';
|
|
g.textBaseline = 'middle';
|
|
g.fillText('Phase', boxX + boxW / 2, boxY + boxPadding + 4);
|
|
g.font = 'bold 19px ui-monospace, monospace';
|
|
g.fillText(degText, boxX + boxW / 2, boxY + boxH - boxPadding - 2);
|
|
g.restore();
|
|
}
|
|
|
|
function computePanelSlotWidth(canvasWidth, slotCount = METER_SLOTS) {
|
|
const n = Math.max(1, Math.min(METER_SLOTS, slotCount | 0));
|
|
const avail = Math.max(200, canvasWidth);
|
|
const totalGap = (n - 1) * 12;
|
|
const usable = avail - totalGap;
|
|
return Math.floor(usable / n);
|
|
}
|
|
|
|
async function renderMeterPanel(g, meterLayout, slots, CONFIG, meters, panelSlotWidth) {
|
|
const slotCount = Array.isArray(slots) ? slots.length : 0;
|
|
if (!slotCount || meterLayout?.w <= 0) return;
|
|
const gap = 12;
|
|
const slotW = panelSlotWidth;
|
|
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
|
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
|
|
|
for (let i = 0; i < slotCount; i++) {
|
|
const rectM = {
|
|
x: startX + i * (slotW + gap),
|
|
y: meterLayout.y + METER_PAD_TOP,
|
|
w: slotW,
|
|
h: Math.max(40, meterLayout.h - METER_PAD_TOP - METER_PAD_BOTTOM - METER_EXTRA_BOTTOM_PAD),
|
|
};
|
|
|
|
try {
|
|
await meters.draw(g, rectM, slots[i], CONFIG);
|
|
} catch (e) {
|
|
console.warn(`Meter ${slots[i]} draw error:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawMeterPanelDividers(g, meterLayout, slotCount, CONFIG, panelSlotWidth) {
|
|
if (!meterLayout?.w || !slotCount || !CONFIG?.PANEL_DIVIDERS_ENABLED) return;
|
|
const gap = 12;
|
|
const slotW = panelSlotWidth;
|
|
const totalSlotW = slotW * slotCount + gap * (slotCount - 1);
|
|
const startX = meterLayout.x + Math.max(0, Math.floor((meterLayout.w - totalSlotW) / 2));
|
|
for (let i = 1; i < slotCount; i++) {
|
|
const dividerX = startX + i * slotW + (i - 1) * gap + gap / 2;
|
|
g.save();
|
|
g.strokeStyle = 'rgba(0,231,255,0.4)';
|
|
g.lineWidth = 1;
|
|
g.setLineDash([4, 3]);
|
|
g.beginPath();
|
|
g.moveTo(dividerX, meterLayout.y + 6);
|
|
g.lineTo(dividerX, meterLayout.y + meterLayout.h - 6);
|
|
g.stroke();
|
|
g.restore();
|
|
}
|
|
}
|
|
|
|
function drawMeterOutline(g, meterRect) {
|
|
g.save();
|
|
g.strokeStyle = FRAME_COLOR;
|
|
g.lineWidth = 2;
|
|
g.strokeRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
|
g.restore();
|
|
}
|
|
|
|
function drawMeterBackground(g, meterRect) {
|
|
g.save();
|
|
g.fillStyle = PANEL_BG;
|
|
g.fillRect(meterRect.x, meterRect.y, meterRect.w, meterRect.h);
|
|
g.restore();
|
|
}
|
|
|
|
function updatePhaseTrail(state, wheel, CONFIG, now) {
|
|
if (!state.phaseTrail) state.phaseTrail = [];
|
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
|
state.phaseTrail.length = 0;
|
|
return;
|
|
}
|
|
const radiusNorm = Math.max(0, Number(state?.smoothRadius) || 0);
|
|
if (radiusNorm > 0.002 && Number.isFinite(state?.smoothPhase)) {
|
|
const length = radiusNorm * wheel.radius;
|
|
const x = wheel.cx + Math.cos(state.smoothPhase) * length;
|
|
const y = wheel.cy + Math.sin(state.smoothPhase) * length;
|
|
const color = trailColorForAngle(state.smoothPhase);
|
|
const last = state.phaseTrail.length ? state.phaseTrail[state.phaseTrail.length - 1] : null;
|
|
if (!last) {
|
|
state.phaseTrail.push({ x, y, t: now, color });
|
|
} else {
|
|
const dt = now - last.t;
|
|
const dx = x - last.x;
|
|
const dy = y - last.y;
|
|
const dist2 = dx * dx + dy * dy;
|
|
if (dt >= PHASE_TRAIL_MIN_STEP_MS || dist2 >= PHASE_TRAIL_MIN_DIST_PX * PHASE_TRAIL_MIN_DIST_PX || last.color !== color) {
|
|
state.phaseTrail.push({ x, y, t: now, color });
|
|
} else {
|
|
last.x = x;
|
|
last.y = y;
|
|
last.t = now;
|
|
last.color = color;
|
|
}
|
|
}
|
|
}
|
|
trimPhaseTrail(state, CONFIG, now);
|
|
}
|
|
|
|
function trimPhaseTrail(state, CONFIG, now) {
|
|
if (!state.phaseTrail) state.phaseTrail = [];
|
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) {
|
|
state.phaseTrail.length = 0;
|
|
return;
|
|
}
|
|
const cutoff = now - PHASE_TRAIL_FADE_MS;
|
|
let write = 0;
|
|
for (let i = 0; i < state.phaseTrail.length; i++) {
|
|
const pt = state.phaseTrail[i];
|
|
if (pt.t >= cutoff) {
|
|
state.phaseTrail[write++] = pt;
|
|
}
|
|
}
|
|
state.phaseTrail.length = write;
|
|
}
|
|
|
|
function drawPhaseTrail(g, wheel, state, CONFIG, now) {
|
|
if (!CONFIG?.PHASE_TRAIL_ENABLED) return;
|
|
const pts = state.phaseTrail;
|
|
if (!pts || pts.length < 2) return;
|
|
g.save();
|
|
g.lineCap = 'round';
|
|
g.lineWidth = 2.5;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const prev = pts[i - 1];
|
|
const curr = pts[i];
|
|
const age = Math.max(0, now - curr.t);
|
|
const alpha = Math.max(0, 1 - age / PHASE_TRAIL_FADE_MS);
|
|
if (alpha <= 0) continue;
|
|
g.globalAlpha = alpha;
|
|
g.strokeStyle = curr.color;
|
|
g.beginPath();
|
|
g.moveTo(prev.x, prev.y);
|
|
g.lineTo(curr.x, curr.y);
|
|
g.stroke();
|
|
}
|
|
g.restore();
|
|
}
|
|
|
|
function trailColorForAngle(angle) {
|
|
let deg = radToDeg(angle) + 90;
|
|
while (deg <= -180) deg += 360;
|
|
while (deg > 180) deg -= 360;
|
|
const centered = deg;
|
|
if (Math.abs(centered) <= 30) return '#48d296';
|
|
if (Math.abs(centered) <= 60) return '#ffd678';
|
|
return '#ff7a78';
|
|
}
|
|
|
|
function clamp1(value) {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.max(-1, Math.min(1, value));
|
|
}
|
|
|
|
function ringFraction(index, ringCount) {
|
|
const n = Math.max(1, ringCount | 0);
|
|
const idx = Math.max(0, Math.min(n - 1, index | 0));
|
|
return (n - idx) / n;
|
|
}
|
|
|
|
function getPhaseAmplitudeMode(CONFIG) {
|
|
return CONFIG?.PHASE_AMPLITUDE_MODE === 'ppm-din' ? 'ppm-din' : 'bandpass';
|
|
}
|
|
|
|
function getRingDbValues(CONFIG) {
|
|
return getPhaseAmplitudeMode(CONFIG) === 'ppm-din' ? RING_PPM_DIN : RING_DBFS;
|
|
}
|
|
|
|
function formatAmplitudeLabel(db, mode) {
|
|
const num = Number(db);
|
|
if (!Number.isFinite(num)) return '';
|
|
if (mode === 'ppm-din') return num > 0 ? `+${num}` : `${num}`;
|
|
return `${num} dB`;
|
|
}
|
|
|
|
function dbToRadiusNorm(db, ringDbValues) {
|
|
if (!Array.isArray(ringDbValues) || !ringDbValues.length) return 0;
|
|
const n = ringDbValues.length;
|
|
const maxDb = ringDbValues[0];
|
|
const minDb = ringDbValues[n - 1];
|
|
if (!Number.isFinite(db)) return ringFraction(n - 1, n);
|
|
if (db >= maxDb) return ringFraction(0, n);
|
|
if (db <= minDb) return ringFraction(n - 1, n);
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const hiDb = ringDbValues[i];
|
|
const loDb = ringDbValues[i + 1];
|
|
if (db <= hiDb && db >= loDb) {
|
|
const span = Math.max(1e-10, hiDb - loDb);
|
|
const t = (db - loDb) / span;
|
|
const hiFrac = ringFraction(i, n);
|
|
const loFrac = ringFraction(i + 1, n);
|
|
return loFrac + (hiFrac - loFrac) * t;
|
|
}
|
|
}
|
|
return ringFraction(n - 1, n);
|
|
}
|
|
|
|
function linearToRadiusNorm(amp, ringDbValues) {
|
|
const ampDb = linearToDb(Math.max(1e-10, Math.min(1, amp)));
|
|
return dbToRadiusNorm(ampDb, ringDbValues);
|
|
}
|
|
|
|
function mapPpmRawToDin(raw, cfg) {
|
|
const minDb = Number.isFinite(cfg?.PPM_DIN_BOTTOM) ? cfg.PPM_DIN_BOTTOM : -50;
|
|
const maxDb = Number.isFinite(cfg?.PPM_DIN_TOP) ? cfg.PPM_DIN_TOP : +5;
|
|
if (!Number.isFinite(raw)) return minDb;
|
|
const base = Number.isFinite(cfg?.PPM_REF_DBFS_PEAK_FOR_0_DBU) ? cfg.PPM_REF_DBFS_PEAK_FOR_0_DBU : -15;
|
|
const effOff = (cfg?.PPM_DIN_MODE === 'al_minus6' ? -6 : -9) + (Number(cfg?.PPM_DIN_TRIM_DB) || 0);
|
|
const mapped = (raw - base) + effOff;
|
|
return Math.max(minDb, Math.min(maxDb, mapped));
|
|
}
|
|
|
|
function computePpmDinRadiusNorm(audio, cfg, ringDbValues) {
|
|
const rawL = Number.isFinite(audio?.ppmDinL) ? audio.ppmDinL : audio?.ppmL;
|
|
const rawR = Number.isFinite(audio?.ppmDinR) ? audio.ppmDinR : audio?.ppmR;
|
|
const l = mapPpmRawToDin(rawL, cfg);
|
|
const r = mapPpmRawToDin(rawR, cfg);
|
|
const db = Math.max(l, r);
|
|
return dbToRadiusNorm(db, ringDbValues);
|
|
}
|
|
|
|
function createBandpassState() {
|
|
return {
|
|
sampleRate: 0,
|
|
hpAlpha: 0,
|
|
lpAlpha: 0,
|
|
channels: {
|
|
L: { hpX: 0, hpY: 0, lpY: 0 },
|
|
R: { hpX: 0, hpY: 0, lpY: 0 },
|
|
},
|
|
};
|
|
}
|
|
|
|
function ensureBandpassCoeffs(state, sampleRate) {
|
|
if (!state.bandpass) state.bandpass = createBandpassState();
|
|
const sr = Math.max(8000, Math.round(sampleRate) || 48000);
|
|
if (state.bandpass.sampleRate === sr) return;
|
|
state.bandpass.sampleRate = sr;
|
|
state.bandpass.hpAlpha = computeHighpassAlpha(sr, PHASE_BANDPASS_LOW_HZ);
|
|
state.bandpass.lpAlpha = computeLowpassAlpha(sr, PHASE_BANDPASS_HIGH_HZ);
|
|
}
|
|
|
|
function computeHighpassAlpha(sampleRate, cutoff) {
|
|
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
|
|
const dt = 1 / Math.max(1, sampleRate);
|
|
return Math.max(0, Math.min(1, rc / (rc + dt)));
|
|
}
|
|
|
|
function computeLowpassAlpha(sampleRate, cutoff) {
|
|
const rc = 1 / (2 * Math.PI * Math.max(1, cutoff));
|
|
const dt = 1 / Math.max(1, sampleRate);
|
|
return Math.max(0, Math.min(1, dt / (rc + dt)));
|
|
}
|
|
|
|
function ensureFilteredBuffers(state, length) {
|
|
const neededLength = Math.ceil(length * 1.1); // 10% Puffer für Stabilität
|
|
if (!state.filteredL || state.filteredL.length < neededLength) {
|
|
state.filteredL = new Float32Array(neededLength);
|
|
}
|
|
if (!state.filteredR || state.filteredR.length < neededLength) {
|
|
state.filteredR = new Float32Array(neededLength);
|
|
}
|
|
return { L: state.filteredL, R: state.filteredR };
|
|
}
|
|
|
|
function applyBandpassSample(sample, channelState, bandpassState) {
|
|
const hpAlpha = bandpassState.hpAlpha;
|
|
const lpAlpha = bandpassState.lpAlpha;
|
|
|
|
if (!Number.isFinite(sample)) sample = 0;
|
|
|
|
const hpY = hpAlpha * (channelState.hpY + sample - channelState.hpX);
|
|
channelState.hpY = Number.isFinite(hpY) ? hpY : 0;
|
|
channelState.hpX = sample;
|
|
|
|
const lpY = lpAlpha * hpY + (1 - lpAlpha) * channelState.lpY;
|
|
channelState.lpY = Number.isFinite(lpY) ? lpY : 0;
|
|
|
|
return lpY;
|
|
}
|
|
|
|
function preparePhaseFilteredBuffers(state, xyData) {
|
|
ensureBandpassCoeffs(state, xyData.sampleRate || 48000);
|
|
const filtered = ensureFilteredBuffers(state, xyData.length);
|
|
|
|
// Reset channel states if they contain NaN/Infinity
|
|
if (!Number.isFinite(state.bandpass.channels.L.hpY)) {
|
|
state.bandpass.channels.L = { hpX: 0, hpY: 0, lpY: 0 };
|
|
}
|
|
if (!Number.isFinite(state.bandpass.channels.R.hpY)) {
|
|
state.bandpass.channels.R = { hpX: 0, hpY: 0, lpY: 0 };
|
|
}
|
|
|
|
for (let i = 0; i < xyData.length; i++) {
|
|
filtered.L[i] = applyBandpassSample(
|
|
xyData.xyL[i],
|
|
state.bandpass.channels.L,
|
|
state.bandpass
|
|
);
|
|
filtered.R[i] = applyBandpassSample(
|
|
xyData.xyR[i],
|
|
state.bandpass.channels.R,
|
|
state.bandpass
|
|
);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function decayPhasePointer(state) {
|
|
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
|
|
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;
|
|
const decayedRadius = prevRadius * PHASE_IDLE_DECAY;
|
|
state.currentPhase = prevPhase;
|
|
state.currentRadius = decayedRadius;
|
|
const prevSmoothPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : prevPhase;
|
|
const prevSmoothRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : decayedRadius;
|
|
state.smoothPhase = smoothAngle(prevSmoothPhase, prevPhase, PHASE_PHASE_SMOOTH_ALPHA * 0.5);
|
|
state.smoothRadius = lerp(prevSmoothRadius, decayedRadius, PHASE_RADIUS_SMOOTH_ALPHA);
|
|
}
|
|
|
|
function resolvePhaseGain(state, xyData, CONFIG) {
|
|
const gainDb = clampPhaseGain(CONFIG?.PHASE_DISPLAY_GAIN_DB ?? 0);
|
|
const allowAgc = CONFIG?.PHASE_AGC_ENABLED && getPhaseAmplitudeMode(CONFIG) !== 'ppm-din';
|
|
if (allowAgc && xyData?.ready) {
|
|
const auto = computePhaseAgcGain(state, xyData);
|
|
return { gainDb: auto.gainDb, gain: auto.gain * PHASE_AGC_BASE_GAIN };
|
|
}
|
|
state.phaseAgcGainDb = gainDb;
|
|
return { gainDb, gain: dbToLinear(gainDb) };
|
|
}
|
|
|
|
function wrapAngle(rad) {
|
|
if (!Number.isFinite(rad)) return 0;
|
|
|
|
// Verbesserte Winkel-Normalisierung mit besserer numerischer Stabilität
|
|
const TWO_PI = Math.PI * 2;
|
|
const normalized = ((rad % TWO_PI) + TWO_PI) % TWO_PI;
|
|
|
|
// Sicherstellen, dass der Winkel im Bereich [-π, π] liegt
|
|
return normalized > Math.PI ? normalized - TWO_PI : normalized;
|
|
}
|
|
|
|
function smoothAngle(prev, next, alpha) {
|
|
if (!Number.isFinite(prev)) return next;
|
|
if (!Number.isFinite(next)) return prev;
|
|
const diff = wrapAngle(next - prev);
|
|
return wrapAngle(prev + diff * Math.max(0, Math.min(1, alpha)));
|
|
}
|
|
|
|
function lerp(a, b, t) {
|
|
if (!Number.isFinite(a)) return b;
|
|
if (!Number.isFinite(b)) return a;
|
|
const clampedT = Math.max(0, Math.min(1, t));
|
|
return a + (b - a) * clampedT;
|
|
}
|
|
|
|
function hilbertAt(buffer, idx) {
|
|
if (!buffer || idx < 0 || idx >= buffer.length) return 0;
|
|
|
|
let acc = 0;
|
|
for (let k = 0; k < HILBERT_KERNEL.length; k++) {
|
|
const src = idx + k - HILBERT_HALF;
|
|
if (src < 0 || src >= buffer.length) continue;
|
|
const sample = buffer[src];
|
|
const kernel = HILBERT_KERNEL[k];
|
|
if (Number.isFinite(sample) && Number.isFinite(kernel)) {
|
|
acc += sample * kernel;
|
|
}
|
|
}
|
|
return Number.isFinite(acc) ? acc : 0;
|
|
}
|
|
|
|
function buildHilbertKernel(size = 33) {
|
|
const taps = size % 2 === 0 ? size + 1 : size;
|
|
const mid = (taps - 1) / 2;
|
|
const kernel = new Float32Array(taps);
|
|
|
|
for (let n = 0; n < taps; n++) {
|
|
const k = n - mid;
|
|
|
|
// Verbesserte numerische Stabilität + even k = 0 wie idealer Hilbert-Kernel
|
|
if (Math.abs(k) < 1e-10 || k % 2 === 0) {
|
|
kernel[n] = 0;
|
|
continue;
|
|
}
|
|
|
|
const window = 0.54 - 0.46 * Math.cos((2 * Math.PI * n) / Math.max(1, taps - 1));
|
|
const value = (2 / (Math.PI * k)) * window;
|
|
|
|
// Sicherstellen, dass der Wert finite ist
|
|
kernel[n] = Number.isFinite(value) ? value : 0;
|
|
}
|
|
return kernel;
|
|
}
|
|
|
|
function computePhaseAgcGain(state, xyData) {
|
|
const now = getNow();
|
|
const dt = state.phaseAgcLastTs ? Math.max(0, (now - state.phaseAgcLastTs) / 1000) : 0;
|
|
state.phaseAgcLastTs = now;
|
|
|
|
const peak = measurePhasePeak(xyData);
|
|
let env = Number.isFinite(state.phaseAgcEnv) && state.phaseAgcEnv > 0 ? state.phaseAgcEnv : 1e-3;
|
|
|
|
if (peak >= env) {
|
|
const alpha = 1 - Math.exp(-Math.max(dt, 0) / Math.max(0.001, PHASE_AGC_ATTACK_S));
|
|
env = env + (peak - env) * Math.min(1, alpha || 1);
|
|
} else {
|
|
const releaseFactor = Math.pow(10, -PHASE_AGC_RELEASE_DB_PER_S * Math.max(dt, 0) / 20);
|
|
env = Math.max(peak, env * releaseFactor);
|
|
}
|
|
|
|
env = Math.max(1e-8, env); // Verbesserter minimaler Wert
|
|
state.phaseAgcEnv = env;
|
|
|
|
const envDb = linearToDb(env);
|
|
let gainDb = PHASE_AGC_TARGET_DB - envDb;
|
|
|
|
if (gainDb > PHASE_GAIN_MAX_DB) gainDb = PHASE_GAIN_MAX_DB;
|
|
if (gainDb < PHASE_GAIN_MIN_DB) gainDb = PHASE_GAIN_MIN_DB;
|
|
|
|
state.phaseAgcGainDb = gainDb;
|
|
return { gainDb, gain: dbToLinear(gainDb) };
|
|
}
|
|
|
|
function measurePhasePeak(xyData) {
|
|
if (!xyData || !xyData.ready) return 1e-4; // Verbesserter Default-Wert
|
|
|
|
let peak = 1e-8; // Höhere Präzision
|
|
const len = Math.min(xyData.length, 1000); // Begrenzung für Performance
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
const sample = Math.max(
|
|
Math.abs(xyData.xyL[i] || 0),
|
|
Math.abs(xyData.xyR[i] || 0)
|
|
);
|
|
if (sample > peak) peak = sample;
|
|
}
|
|
|
|
return Math.max(1e-8, peak); // Sicherstellen, dass nicht 0 zurückgegeben wird
|
|
}
|
|
|
|
function clampPhaseGain(db) {
|
|
let val = Number(db);
|
|
if (!Number.isFinite(val)) val = 0;
|
|
if (val < PHASE_GAIN_MIN_DB) val = PHASE_GAIN_MIN_DB;
|
|
if (val > PHASE_GAIN_MAX_DB) val = PHASE_GAIN_MAX_DB;
|
|
return val;
|
|
}
|
|
|
|
function getNow() {
|
|
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
|
return performance.now();
|
|
}
|
|
return Date.now();
|
|
}
|
|
|
|
function linearToDb(value) {
|
|
if (value <= 1e-10) return -200; // Explizite Behandlung sehr kleiner Werte
|
|
const result = 20 * Math.log10(value);
|
|
return Number.isFinite(result) ? result : -200;
|
|
}
|
|
|
|
function dbToLinear(db) {
|
|
if (!Number.isFinite(db)) return 0;
|
|
if (db < -200) return 0;
|
|
const result = Math.pow(10, db / 20);
|
|
return Number.isFinite(result) ? result : 0;
|
|
}
|