Improve phase wheel averaging
This commit is contained in:
@@ -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');
|
||||
@@ -69,10 +69,12 @@ export function init() {
|
||||
filteredR: new Float32Array(0),
|
||||
phaseAngleBuffer: new Float32Array(0),
|
||||
phaseAmplitudeBuffer: new Float32Array(0),
|
||||
phaseWeightBuffer: new Float32Array(0),
|
||||
phaseAnalysisSeq: -1,
|
||||
phaseAnalysisLength: 0,
|
||||
phaseAnalysisSampleRate: 0,
|
||||
phaseAnalysisCount: 0,
|
||||
phaseConfidence: 0,
|
||||
currentPhase: null,
|
||||
currentRadius: 0,
|
||||
smoothPhase: null,
|
||||
@@ -353,8 +355,6 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
|
||||
: 0;
|
||||
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;
|
||||
@@ -367,28 +367,34 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
|
||||
? 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 phaseSummary = summarizeWeightedPhase(
|
||||
analysis.angles,
|
||||
analysis.weights,
|
||||
analysis.count,
|
||||
);
|
||||
const avgRadius = amplitudeMode === 'ppm-din' ? ppmRadiusNorm : (sumRadius * 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 prevRadius = Number.isFinite(state.smoothRadius) ? state.smoothRadius : avgRadius;
|
||||
state.currentPhase = avgAngle;
|
||||
state.currentRadius = avgRadius;
|
||||
state.phaseConfidence = phaseSummary.coherence;
|
||||
state.smoothPhase = smoothAngle(prevPhase, avgAngle, PHASE_PHASE_SMOOTH_ALPHA);
|
||||
state.smoothRadius = lerp(prevRadius, avgRadius, PHASE_RADIUS_SMOOTH_ALPHA);
|
||||
} else {
|
||||
state.phaseConfidence = 0;
|
||||
decayPhasePointer(state);
|
||||
}
|
||||
} else {
|
||||
state.phaseConfidence = 0;
|
||||
decayPhasePointer(state);
|
||||
}
|
||||
return { count: ampIdx, radius };
|
||||
@@ -810,6 +816,35 @@ function ensurePhaseAnalysisBuffers(state, length) {
|
||||
if (!state.phaseAmplitudeBuffer || state.phaseAmplitudeBuffer.length < 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) {
|
||||
@@ -823,6 +858,7 @@ function preparePhaseAnalysis(state, xyData) {
|
||||
return {
|
||||
angles: state.phaseAngleBuffer,
|
||||
amplitudes: state.phaseAmplitudeBuffer,
|
||||
weights: state.phaseWeightBuffer,
|
||||
count: state.phaseAnalysisCount,
|
||||
};
|
||||
}
|
||||
@@ -847,6 +883,10 @@ function preparePhaseAnalysis(state, xyData) {
|
||||
const magR = Math.min(1, Math.hypot(rRe, rIm));
|
||||
state.phaseAngleBuffer[count] = phaseDiff - Math.PI / 2;
|
||||
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++;
|
||||
}
|
||||
state.phaseAnalysisSeq = seq;
|
||||
@@ -856,6 +896,7 @@ function preparePhaseAnalysis(state, xyData) {
|
||||
return {
|
||||
angles: state.phaseAngleBuffer,
|
||||
amplitudes: state.phaseAmplitudeBuffer,
|
||||
weights: state.phaseWeightBuffer,
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user