Optimize realtime display processing

This commit is contained in:
Mikei386
2026-07-21 22:04:50 +02:00
parent a123539023
commit c067c73024
6 changed files with 165 additions and 26 deletions
+9 -1
View File
@@ -102,6 +102,8 @@ pub struct AudioWorkerDeps {
pub actual_sample_rate: Arc<AtomicU64>, pub actual_sample_rate: Arc<AtomicU64>,
pub metrics_tx: tokio::sync::broadcast::Sender<Arc<MeterFrame>>, pub metrics_tx: tokio::sync::broadcast::Sender<Arc<MeterFrame>>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))] #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub spectro_subscribers: Arc<AtomicU64>,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub restart_token: Arc<AtomicU64>, pub restart_token: Arc<AtomicU64>,
} }
@@ -437,6 +439,7 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu
&deps.seq, &deps.seq,
&mut ppm, &mut ppm,
&rta_config, &rta_config,
deps.spectro_subscribers.load(Ordering::Relaxed) > 0,
); );
let _ = deps.metrics_tx.send(Arc::new(frame)); let _ = deps.metrics_tx.send(Arc::new(frame));
} }
@@ -1078,6 +1081,7 @@ fn build_meter_frame(
seq: &Arc<AtomicU64>, seq: &Arc<AtomicU64>,
ppm_state: &mut PpmState, ppm_state: &mut PpmState,
rta_config: &PhoenixRtaConfig, rta_config: &PhoenixRtaConfig,
spectro_requested: bool,
) -> MeterFrame { ) -> MeterFrame {
let mut rms_power_l = 0.0f32; let mut rms_power_l = 0.0f32;
let mut rms_power_r = 0.0f32; let mut rms_power_r = 0.0f32;
@@ -1172,10 +1176,14 @@ fn build_meter_frame(
} }
let mut spectro_frame = None; let mut spectro_frame = None;
if let Some(state) = ppm_state.spectro_state.as_mut() { if let Some(state) = ppm_state.spectro_state.as_mut() {
if finalize_spectro_state(state, sample_rate) { if spectro_requested && finalize_spectro_state(state, sample_rate) {
let frame = build_spectro_frame(state, sample_rate); let frame = build_spectro_frame(state, sample_rate);
ppm_state.last_spectro = Some(frame.clone()); ppm_state.last_spectro = Some(frame.clone());
spectro_frame = Some(frame); spectro_frame = Some(frame);
} else if !spectro_requested && state.ring_fill >= state.fft_size {
// Keep the current audio window warm, but do not accumulate a
// transform backlog while no client displays the spectrogram.
state.samples_since = state.fft_step_samples;
} }
} }
+6
View File
@@ -959,6 +959,12 @@ async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppSt
} }
async fn spectro_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) { async fn spectro_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
state.spectro_subscriber_connected();
spectro_ws_session(&mut socket, &state).await;
state.spectro_subscriber_disconnected();
}
async fn spectro_ws_session(socket: &mut axum::extract::ws::WebSocket, state: &AppState) {
let mut rx = state.subscribe_metrics(); let mut rx = state.subscribe_metrics();
loop { loop {
let mut latest = loop { let mut latest = loop {
+15
View File
@@ -28,6 +28,7 @@ pub struct AppState {
seq: Arc<AtomicU64>, seq: Arc<AtomicU64>,
actual_sample_rate: Arc<AtomicU64>, actual_sample_rate: Arc<AtomicU64>,
metrics_tx: broadcast::Sender<Arc<MeterFrame>>, metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
spectro_subscribers: Arc<AtomicU64>,
restart_token: Arc<AtomicU64>, restart_token: Arc<AtomicU64>,
rta_config: Arc<RwLock<PhoenixRtaConfig>>, rta_config: Arc<RwLock<PhoenixRtaConfig>>,
global_config: Arc<RwLock<PhoenixGlobalConfig>>, global_config: Arc<RwLock<PhoenixGlobalConfig>>,
@@ -105,6 +106,7 @@ impl AppState {
seq: Arc::new(AtomicU64::new(0)), seq: Arc::new(AtomicU64::new(0)),
actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)), actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)),
metrics_tx, metrics_tx,
spectro_subscribers: Arc::new(AtomicU64::new(0)),
restart_token: Arc::new(AtomicU64::new(0)), restart_token: Arc::new(AtomicU64::new(0)),
rta_config: Arc::new(RwLock::new(initial_rta_config)), rta_config: Arc::new(RwLock::new(initial_rta_config)),
global_config: Arc::new(RwLock::new(initial_global_config)), global_config: Arc::new(RwLock::new(initial_global_config)),
@@ -117,6 +119,18 @@ impl AppState {
self.metrics_tx.subscribe() self.metrics_tx.subscribe()
} }
pub fn spectro_subscriber_connected(&self) {
self.spectro_subscribers.fetch_add(1, Ordering::SeqCst);
}
pub fn spectro_subscriber_disconnected(&self) {
let _ =
self.spectro_subscribers
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
Some(count.saturating_sub(1))
});
}
pub async fn input(&self) -> InputSource { pub async fn input(&self) -> InputSource {
*self.current_input.read().await *self.current_input.read().await
} }
@@ -268,6 +282,7 @@ impl AppState {
seq: self.seq.clone(), seq: self.seq.clone(),
actual_sample_rate: self.actual_sample_rate.clone(), actual_sample_rate: self.actual_sample_rate.clone(),
metrics_tx: self.metrics_tx.clone(), metrics_tx: self.metrics_tx.clone(),
spectro_subscribers: self.spectro_subscribers.clone(),
restart_token: self.restart_token.clone(), restart_token: self.restart_token.clone(),
}); });
} }
+34 -2
View File
@@ -9,6 +9,7 @@ import { getRtwCenters } from './rtw_centers.js';
let phoenixSocket = null; let phoenixSocket = null;
let phoenixSpectroSocket = null; let phoenixSpectroSocket = null;
let phoenixSpectroRetryTimer = null; let phoenixSpectroRetryTimer = null;
let phoenixSpectroDemanded = false;
let pendingSpectroBuffer = null; let pendingSpectroBuffer = null;
let phoenixSpectroRaf = 0; let phoenixSpectroRaf = 0;
let phoenixVisualsSocket = null; let phoenixVisualsSocket = null;
@@ -99,6 +100,7 @@ function closePhoenixSocket() {
} catch (_) {} } catch (_) {}
} }
phoenixSpectroSocket = null; phoenixSpectroSocket = null;
phoenixSpectroDemanded = false;
pendingSpectroBuffer = null; pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf); if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0; phoenixSpectroRaf = 0;
@@ -253,6 +255,7 @@ function copyPhoenixSpectroBins(audioState, spectro, frameDelta = 1) {
} }
function openPhoenixSpectroSocket(baseUrl, env) { function openPhoenixSpectroSocket(baseUrl, env) {
if (!phoenixSpectroDemanded) return;
if (phoenixSpectroSocket && ( if (phoenixSpectroSocket && (
phoenixSpectroSocket.readyState === WebSocket.OPEN phoenixSpectroSocket.readyState === WebSocket.OPEN
|| phoenixSpectroSocket.readyState === WebSocket.CONNECTING || phoenixSpectroSocket.readyState === WebSocket.CONNECTING
@@ -269,7 +272,7 @@ function openPhoenixSpectroSocket(baseUrl, env) {
const retry = () => { const retry = () => {
if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null; if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null;
if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return; if (!phoenixSpectroDemanded || !phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return;
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer); if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
phoenixSpectroRetryTimer = setTimeout(() => { phoenixSpectroRetryTimer = setTimeout(() => {
phoenixSpectroRetryTimer = null; phoenixSpectroRetryTimer = null;
@@ -280,6 +283,31 @@ function openPhoenixSpectroSocket(baseUrl, env) {
socket.onclose = retry; socket.onclose = retry;
} }
function setPhoenixSpectroDemand(baseUrl, env, demanded) {
const next = !!demanded;
phoenixSpectroDemanded = next;
if (next) {
if (phoenixSocket?.readyState === WebSocket.OPEN) openPhoenixSpectroSocket(baseUrl, env);
return;
}
if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer);
phoenixSpectroRetryTimer = null;
if (phoenixSpectroSocket) {
const socket = phoenixSpectroSocket;
phoenixSpectroSocket = null;
try {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close();
} catch (_) {}
}
pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0;
}
function scheduleSpectroBufferPump(env) { function scheduleSpectroBufferPump(env) {
if (phoenixSpectroRaf || !pendingSpectroBuffer) return; if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
phoenixSpectroRaf = requestAnimationFrame(() => { phoenixSpectroRaf = requestAnimationFrame(() => {
@@ -1043,6 +1071,7 @@ async function initPhoenixAudio(env) {
? cfg ? cfg
: ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null); : ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null);
const profile = rawProfile || {}; const profile = rawProfile || {};
setPhoenixSpectroDemand(baseUrl, env, profile.needSpectro);
if (!profile.needXy) { if (!profile.needXy) {
env.audio.xyL = null; env.audio.xyL = null;
env.audio.xyR = null; env.audio.xyR = null;
@@ -1057,6 +1086,9 @@ async function initPhoenixAudio(env) {
resetWaveformStateFallback(env.audio.waveformFallback); resetWaveformStateFallback(env.audio.waveformFallback);
} }
}; };
env.audio.updateProcessingConfig(
(typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null,
);
const pushPhoenixGlobalConfig = async () => { const pushPhoenixGlobalConfig = async () => {
const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload()); const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload());
@@ -1105,7 +1137,7 @@ async function initPhoenixAudio(env) {
}; };
socket.onopen = () => { socket.onopen = () => {
openPhoenixSpectroSocket(baseUrl, env); if (phoenixSpectroDemanded) openPhoenixSpectroSocket(baseUrl, env);
openPhoenixVisualsSocket(baseUrl, env); openPhoenixVisualsSocket(baseUrl, env);
finish(true); finish(true);
}; };
+32 -5
View File
@@ -716,6 +716,7 @@ function buildProcessingProfile(viewId = style) {
const profile = { const profile = {
needXy: false, needXy: false,
needRta: false, needRta: false,
needSpectro: false,
needVu: false, needVu: false,
needPpmDin: false, needPpmDin: false,
needPpmEbu: false, needPpmEbu: false,
@@ -728,6 +729,8 @@ function buildProcessingProfile(viewId = style) {
for (const plotId of plotIds) { for (const plotId of plotIds) {
if (plotId === 'realtime') { if (plotId === 'realtime') {
profile.needRta = true; profile.needRta = true;
} else if (plotId === 'spectrogram') {
profile.needSpectro = true;
} else if (plotId === 'goniometer-rtw') { } else if (plotId === 'goniometer-rtw') {
profile.needXy = true; profile.needXy = true;
profile.needRms = true; profile.needRms = true;
@@ -792,6 +795,7 @@ function setSlotForView(viewId, idx, value){
try { saveConfig?.(); } catch (_) {} try { saveConfig?.(); } catch (_) {}
try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {} try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {}
} }
requestRender('processing-profile-change');
} }
function resetAllSlotsToDefaults() { function resetAllSlotsToDefaults() {
@@ -804,6 +808,7 @@ function resetAllSlotsToDefaults() {
saveSlotState(slotsState); saveSlotState(slotsState);
saveSlotCustomizedState({}); saveSlotCustomizedState({});
refreshSlotEditors(); refreshSlotEditors();
requestRender('processing-profile-change');
} }
function initSlotSelect(sel, idx){ function initSlotSelect(sel, idx){
if (!sel) return; if (!sel) return;
@@ -1007,11 +1012,13 @@ initSplitPlotSelect(splitLeftSel, (val) => {
const clean = sanitizeSplitPlotId(val) ?? 'none'; const clean = sanitizeSplitPlotId(val) ?? 'none';
CONFIG.SPLIT_VIEW_LEFT = clean; CONFIG.SPLIT_VIEW_LEFT = clean;
saveConfig(); saveConfig();
requestRender('processing-profile-change');
}); });
initSplitPlotSelect(splitRightSel, (val) => { initSplitPlotSelect(splitRightSel, (val) => {
const clean = sanitizeSplitPlotId(val) ?? 'none'; const clean = sanitizeSplitPlotId(val) ?? 'none';
CONFIG.SPLIT_VIEW_RIGHT = clean; CONFIG.SPLIT_VIEW_RIGHT = clean;
saveConfig(); saveConfig();
requestRender('processing-profile-change');
}); });
function clampMeterCount3(v) { function clampMeterCount3(v) {
@@ -1053,6 +1060,7 @@ function writeMeterToConfig(prefix, idx, val) {
const key = getMeterKey(prefix, idx); const key = getMeterKey(prefix, idx);
CONFIG[key] = sanitizeSlotId(val) ?? 'vu'; CONFIG[key] = sanitizeSlotId(val) ?? 'vu';
saveConfig(); saveConfig();
requestRender('processing-profile-change');
} }
function setSplitMeterPopupOpen(open) { function setSplitMeterPopupOpen(open) {
@@ -1098,10 +1106,10 @@ function syncQuadPlotPopup() {
if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none'; if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none';
} }
initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); }); initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); requestRender('processing-profile-change'); });
initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); }); initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); requestRender('processing-profile-change'); });
initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); }); initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); requestRender('processing-profile-change'); });
initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); }); initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; saveConfig(); requestRender('processing-profile-change'); });
if (splitMeterBtn) { if (splitMeterBtn) {
splitMeterBtn.addEventListener('click', () => { splitMeterBtn.addEventListener('click', () => {
@@ -1857,10 +1865,12 @@ function handleSplitTapUp(x, y) {
if (env?.splitPopup?.open) { if (env?.splitPopup?.open) {
env.splitPopup.open = false; env.splitPopup.open = false;
env.splitPopup.viewId = null; env.splitPopup.viewId = null;
requestRender('processing-profile-change');
return; return;
} }
env.splitPopup.open = true; env.splitPopup.open = true;
env.splitPopup.viewId = target; env.splitPopup.viewId = target;
requestRender('processing-profile-change');
return; return;
} }
env.switchView?.(target); env.switchView?.(target);
@@ -1877,6 +1887,7 @@ function handleQuadTapUp(x, y) {
// Popup ist offen: Tap innerhalb/außerhalb schließt // Popup ist offen: Tap innerhalb/außerhalb schließt
env.quadPopup.open = false; env.quadPopup.open = false;
env.quadPopup.viewId = null; env.quadPopup.viewId = null;
requestRender('processing-profile-change');
return; return;
} }
if (isInAnyMeterHitRect(x, y)) return; if (isInAnyMeterHitRect(x, y)) return;
@@ -1889,6 +1900,7 @@ function handleQuadTapUp(x, y) {
if (env?.quadPopup) { if (env?.quadPopup) {
env.quadPopup.open = true; env.quadPopup.open = true;
env.quadPopup.viewId = target; env.quadPopup.viewId = target;
requestRender('processing-profile-change');
} }
return; return;
} }
@@ -1912,6 +1924,7 @@ C.addEventListener('pointerdown', (e) => {
if (!env?.splitPopup?.open) return; if (!env?.splitPopup?.open) return;
env.splitPopup.open = false; env.splitPopup.open = false;
env.splitPopup.viewId = null; env.splitPopup.viewId = null;
requestRender('processing-profile-change');
try { e.preventDefault?.(); } catch (_) {} try { e.preventDefault?.(); } catch (_) {}
try { e.stopPropagation?.(); } catch (_) {} try { e.stopPropagation?.(); } catch (_) {}
}, { capture: true, passive: false }); }, { capture: true, passive: false });
@@ -1968,6 +1981,7 @@ C.addEventListener('pointerdown', (e) => {
if (pointInRect(x, y, box)) return; if (pointInRect(x, y, box)) return;
env.quadPopup.open = false; env.quadPopup.open = false;
env.quadPopup.viewId = null; env.quadPopup.viewId = null;
requestRender('processing-profile-change');
try { e.preventDefault?.(); } catch (_) {} try { e.preventDefault?.(); } catch (_) {}
try { e.stopPropagation?.(); } catch (_) {} try { e.stopPropagation?.(); } catch (_) {}
}, { capture: true, passive: false }); }, { capture: true, passive: false });
@@ -2612,9 +2626,14 @@ let audioRecoverInFlight = false;
let lastAudioRecoverAt = 0; let lastAudioRecoverAt = 0;
let renderDirty = true; let renderDirty = true;
let lastRenderedAudioSeq = 0; let lastRenderedAudioSeq = 0;
let processingProfileDirty = true;
let appliedProcessingProfileView = '';
const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']);
function requestRender(reason = 'ui') { function requestRender(reason = 'ui') {
renderDirty = true; renderDirty = true;
if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true;
env.__lastRenderReason = reason; env.__lastRenderReason = reason;
} }
@@ -2647,7 +2666,15 @@ async function loop(now){
const elapsed = now - lastFrameTime; const elapsed = now - lastFrameTime;
const audioOk = env.audio.alive && !audioLost(env); const audioOk = env.audio.alive && !audioLost(env);
const renderStyle = getRenderableStyle(); const renderStyle = getRenderableStyle();
try { env.audio?.updateProcessingConfig?.(buildProcessingProfile(renderStyle)); } catch (_) {} if (processingProfileDirty || appliedProcessingProfileView !== renderStyle) {
try {
if (typeof env.audio?.updateProcessingConfig === 'function') {
env.audio.updateProcessingConfig(buildProcessingProfile(renderStyle));
processingProfileDirty = false;
appliedProcessingProfileView = renderStyle;
}
} catch (_) {}
}
const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20); const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
const target = targetMs; const target = targetMs;
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0; const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
+69 -18
View File
@@ -67,6 +67,12 @@ export function init() {
ampBuffer: new Float32Array(0), ampBuffer: new Float32Array(0),
filteredL: new Float32Array(0), filteredL: new Float32Array(0),
filteredR: new Float32Array(0), filteredR: new Float32Array(0),
phaseAngleBuffer: new Float32Array(0),
phaseAmplitudeBuffer: new Float32Array(0),
phaseAnalysisSeq: -1,
phaseAnalysisLength: 0,
phaseAnalysisSampleRate: 0,
phaseAnalysisCount: 0,
currentPhase: null, currentPhase: null,
currentRadius: 0, currentRadius: 0,
smoothPhase: null, smoothPhase: null,
@@ -100,8 +106,8 @@ export async function render(env, state) {
drawStaticLayer(g, state, rect, layout, CONFIG, slots.length); drawStaticLayer(g, state, rect, layout, CONFIG, slots.length);
const xyData = extractXYData(audio); const xyData = extractXYData(audio);
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
if (xyData.ready) { if (xyData.ready) {
const gainCtrl = resolvePhaseGain(state, xyData, CONFIG);
const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio); const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio);
renderWheel(g, trace, layout.wheel, state, CONFIG, now); renderWheel(g, trace, layout.wheel, state, CONFIG, now);
} else { } else {
@@ -330,6 +336,7 @@ function extractXYData(audio) {
xyR, xyR,
length: ready ? Math.min(xyL.length, xyR.length) : 0, length: ready ? Math.min(xyL.length, xyR.length) : 0,
sampleRate: audio?.sampleRate || 48000, sampleRate: audio?.sampleRate || 48000,
seq: Number(audio?.xySeq) || 0,
}; };
} }
@@ -338,14 +345,12 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
decayPhasePointer(state); decayPhasePointer(state);
return null; return null;
} }
const filtered = preparePhaseFilteredBuffers(state, xyData); const analysis = preparePhaseAnalysis(state, xyData);
const amplitudeMode = getPhaseAmplitudeMode(CONFIG); const amplitudeMode = getPhaseAmplitudeMode(CONFIG);
const ringDbValues = getRingDbValues(CONFIG); const ringDbValues = getRingDbValues(CONFIG);
const ppmRadiusNorm = amplitudeMode === 'ppm-din' const ppmRadiusNorm = amplitudeMode === 'ppm-din'
? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues) ? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues)
: 0; : 0;
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
const radius = wheel.radius; const radius = wheel.radius;
let ampIdx = 0; let ampIdx = 0;
let sumSin = 0; let sumSin = 0;
@@ -354,20 +359,9 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) {
let levelAcc = 0; let levelAcc = 0;
const gainLinear = Number.isFinite(gain) ? gain : 1; const gainLinear = Number.isFinite(gain) ? gain : 1;
for (let i = 0; i < xyData.length; i += step) { for (let i = 0; i < analysis.count; i++) {
const lRe = clamp1(filtered.L[i]); const angle = analysis.angles[i];
const rRe = clamp1(filtered.R[i]); const amp = analysis.amplitudes[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 ampScaled = Math.min(1, amp * gainLinear);
const radiusNorm = amplitudeMode === 'ppm-din' const radiusNorm = amplitudeMode === 'ppm-din'
? ppmRadiusNorm ? ppmRadiusNorm
@@ -809,6 +803,63 @@ function preparePhaseFilteredBuffers(state, xyData) {
return filtered; return filtered;
} }
function ensurePhaseAnalysisBuffers(state, length) {
if (!state.phaseAngleBuffer || state.phaseAngleBuffer.length < length) {
state.phaseAngleBuffer = new Float32Array(length);
}
if (!state.phaseAmplitudeBuffer || state.phaseAmplitudeBuffer.length < length) {
state.phaseAmplitudeBuffer = new Float32Array(length);
}
}
function preparePhaseAnalysis(state, xyData) {
const sampleRate = Math.max(1, Math.round(xyData.sampleRate) || 48000);
const seq = Number(xyData.seq) || 0;
const canReuse = seq > 0
&& state.phaseAnalysisSeq === seq
&& state.phaseAnalysisLength === xyData.length
&& state.phaseAnalysisSampleRate === sampleRate;
if (canReuse) {
return {
angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer,
count: state.phaseAnalysisCount,
};
}
const filtered = preparePhaseFilteredBuffers(state, xyData);
const targetPoints = Math.min(TARGET_POINTS, xyData.length);
const step = Math.max(1, Math.floor(xyData.length / targetPoints));
const sampleCount = Math.ceil(xyData.length / step);
ensurePhaseAnalysisBuffers(state, sampleCount);
let count = 0;
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 magL = Math.min(1, Math.hypot(lRe, lIm));
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));
count++;
}
state.phaseAnalysisSeq = seq;
state.phaseAnalysisLength = xyData.length;
state.phaseAnalysisSampleRate = sampleRate;
state.phaseAnalysisCount = count;
return {
angles: state.phaseAngleBuffer,
amplitudes: state.phaseAmplitudeBuffer,
count,
};
}
function decayPhasePointer(state) { function decayPhasePointer(state) {
const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0; const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0;
const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0; const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;