Improve phase wheel averaging

This commit is contained in:
Mikei386
2026-07-22 09:40:49 +02:00
parent 4379b78b8f
commit 97352ecfce
2 changed files with 91 additions and 6 deletions
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../www/views/phase_wheel.js', import.meta.url), 'utf8');
const functionSource = source.match(/export function summarizeWeightedPhase[\s\S]*?\n\}/)?.[0];
assert.ok(functionSource, 'weighted phase summary must remain testable');
const context = vm.createContext({ Number, Math });
vm.runInContext(functionSource.replace('export function', 'function'), context);
const dominant = context.summarizeWeightedPhase(
new Float32Array([0, Math.PI / 2]),
new Float32Array([100, 1]),
2,
);
assert.ok(dominant, 'valid weighted phases must produce a summary');
assert.ok(Math.abs(dominant.angle) < 0.02,
'a quiet phase component must not pull a dominant component toward an unweighted mean');
const wrapped = context.summarizeWeightedPhase(
new Float32Array([179 * Math.PI / 180, -179 * Math.PI / 180]),
new Float32Array([1, 1]),
2,
);
assert.ok(Math.abs(Math.abs(wrapped.angle) - Math.PI) < 0.02,
'circular averaging must preserve the wrap around at 180 degrees');
assert.ok(wrapped.coherence > 0.99,
'nearly aligned phase observations must report high coherence');
const opposed = context.summarizeWeightedPhase(
new Float32Array([0, Math.PI]),
new Float32Array([1, 1]),
2,
);
assert.ok(opposed.coherence < 1e-6,
'opposing phase observations must report that their mean direction is ambiguous');
assert.equal(context.summarizeWeightedPhase(
new Float32Array([0]),
new Float32Array([0]),
1,
), null, 'samples without common L/R energy must not invent a phase angle');
console.log('phase wheel regression tests passed');
+47 -6
View File
@@ -69,10 +69,12 @@ export function init() {
filteredR: new Float32Array(0), filteredR: new Float32Array(0),
phaseAngleBuffer: new Float32Array(0), phaseAngleBuffer: new Float32Array(0),
phaseAmplitudeBuffer: new Float32Array(0), phaseAmplitudeBuffer: new Float32Array(0),
phaseWeightBuffer: new Float32Array(0),
phaseAnalysisSeq: -1, phaseAnalysisSeq: -1,
phaseAnalysisLength: 0, phaseAnalysisLength: 0,
phaseAnalysisSampleRate: 0, phaseAnalysisSampleRate: 0,
phaseAnalysisCount: 0, phaseAnalysisCount: 0,
phaseConfidence: 0,
currentPhase: null, currentPhase: null,
currentRadius: 0, currentRadius: 0,
smoothPhase: null, smoothPhase: null,
@@ -353,8 +355,6 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
: 0; : 0;
const radius = wheel.radius; const radius = wheel.radius;
let ampIdx = 0; let ampIdx = 0;
let sumSin = 0;
let sumCos = 0;
let sumRadius = 0; let sumRadius = 0;
let levelAcc = 0; let levelAcc = 0;
const gainLinear = Number.isFinite(gain) ? gain : 1; const gainLinear = Number.isFinite(gain) ? gain : 1;
@@ -367,28 +367,34 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
? ppmRadiusNorm ? ppmRadiusNorm
: linearToRadiusNorm(ampScaled, ringDbValues); : linearToRadiusNorm(ampScaled, ringDbValues);
ampIdx++; ampIdx++;
sumSin += Math.sin(angle);
sumCos += Math.cos(angle);
sumRadius += radiusNorm; sumRadius += radiusNorm;
levelAcc += amp * amp; levelAcc += amp * amp;
} }
if (ampIdx > 0) { if (ampIdx > 0) {
const invCount = 1 / ampIdx; const invCount = 1 / ampIdx;
const avgAngle = Math.atan2(sumSin * invCount, sumCos * invCount); const phaseSummary = summarizeWeightedPhase(
analysis.angles,
analysis.weights,
analysis.count,
);
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount); const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * invCount);
const blockLevel = Math.sqrt(levelAcc * invCount); const blockLevel = Math.sqrt(levelAcc * invCount);
if (blockLevel >= PHASE_LEVEL_THRESHOLD) { if (blockLevel >= PHASE_LEVEL_THRESHOLD && phaseSummary) {
const avgAngle = phaseSummary.angle;
const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle; const prevPhase = Number.isFinite(state.smoothPhase) ? state.smoothPhase : avgAngle;
const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius; const prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
state.currentPhase = avgAngle; state.currentPhase = avgAngle;
state.currentRadius = avgRadius; state.currentRadius = avgRadius;
state.phaseConfidence = phaseSummary.coherence;
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA); state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA); state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
} else { } else {
state.phaseConfidence = 0;
decayPhasePointer(state); decayPhasePointer(state);
} }
} else { } else {
state.phaseConfidence = 0;
decayPhasePointer(state); decayPhasePointer(state);
} }
return { count: ampIdx, radius }; return { count: ampIdx, radius };
@@ -810,6 +816,35 @@ function ensurePhaseAnalysisBuffers(state, length) {
if (!state.phaseAmplitudeBuffer || state.phaseAmplitudeBuffer.length < length) { if (!state.phaseAmplitudeBuffer || state.phaseAmplitudeBuffer.length < length) {
state.phaseAmplitudeBuffer = new Float32Array(length); state.phaseAmplitudeBuffer = new Float32Array(length);
} }
if (!state.phaseWeightBuffer || state.phaseWeightBuffer.length < length) {
state.phaseWeightBuffer = new Float32Array(length);
}
}
export function summarizeWeightedPhase(angles, weights, count) {
const limit = Math.max(0, Math.min(
Number.isFinite(count) ? Math.floor(count) : 0,
angles?.length || 0,
weights?.length || 0,
));
let sumSin = 0;
let sumCos = 0;
let sumWeight = 0;
for (let i = 0; i < limit; i++) {
const angle = Number(angles[i]);
const weight = Number(weights[i]);
if (!Number.isFinite(angle) || !Number.isFinite(weight) || weight <= 0) continue;
sumSin += Math.sin(angle) * weight;
sumCos += Math.cos(angle) * weight;
sumWeight += weight;
}
if (!(sumWeight > 1e-12)) return null;
const resultant = Math.hypot(sumSin, sumCos);
return {
angle: Math.atan2(sumSin, sumCos),
coherence: Math.max(0, Math.min(1, resultant / sumWeight)),
weight: sumWeight,
};
} }
function preparePhaseAnalysis(state, xyData) { function preparePhaseAnalysis(state, xyData) {
@@ -823,6 +858,7 @@ function preparePhaseAnalysis(state, xyData) {
return { return {
angles: state.phaseAngleBuffer, angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer, amplitudes: state.phaseAmplitudeBuffer,
weights: state.phaseWeightBuffer,
count: state.phaseAnalysisCount, count: state.phaseAnalysisCount,
}; };
} }
@@ -847,6 +883,10 @@ function preparePhaseAnalysis(state, xyData) {
const magR = Math.min(1, Math.hypot(rRe, rIm)); const magR = Math.min(1, Math.hypot(rRe, rIm));
state.phaseAngleBuffer[count] = phaseDiff - Math.PI / 2; state.phaseAngleBuffer[count] = phaseDiff - Math.PI / 2;
state.phaseAmplitudeBuffer[count] = Math.min(1, 0.5 * (magL + magR)); state.phaseAmplitudeBuffer[count] = Math.min(1, 0.5 * (magL + magR));
// The phase of a strong component must contribute more than the phase of
// a quiet or one-sided component. magL * magR is the magnitude of the
// complex L/R cross power for this analytic sample.
state.phaseWeightBuffer[count] = magL * magR;
count++; count++;
} }
state.phaseAnalysisSeq = seq; state.phaseAnalysisSeq = seq;
@@ -856,6 +896,7 @@ function preparePhaseAnalysis(state, xyData) {
return { return {
angles: state.phaseAngleBuffer, angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer, amplitudes: state.phaseAmplitudeBuffer,
weights: state.phaseWeightBuffer,
count, count,
}; };
} }