diff --git a/scripts/test_phase_wheel.mjs b/scripts/test_phase_wheel.mjs new file mode 100644 index 0000000..69a922f --- /dev/null +++ b/scripts/test_phase_wheel.mjs @@ -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'); diff --git a/www/views/phase_wheel.js b/www/views/phase_wheel.js index 7348e2f..f774ffa 100644 --- a/www/views/phase_wheel.js +++ b/www/views/phase_wheel.js @@ -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, }; }