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),
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user