diff --git a/src/audio.rs b/src/audio.rs index 238802d..897a865 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -102,6 +102,8 @@ pub struct AudioWorkerDeps { pub actual_sample_rate: Arc, pub metrics_tx: tokio::sync::broadcast::Sender>, #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + pub spectro_subscribers: Arc, + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] pub restart_token: Arc, } @@ -437,6 +439,7 @@ fn capture_until_restart(deps: AudioWorkerDeps, generation: u64) -> anyhow::Resu &deps.seq, &mut ppm, &rta_config, + deps.spectro_subscribers.load(Ordering::Relaxed) > 0, ); let _ = deps.metrics_tx.send(Arc::new(frame)); } @@ -1078,6 +1081,7 @@ fn build_meter_frame( seq: &Arc, ppm_state: &mut PpmState, rta_config: &PhoenixRtaConfig, + spectro_requested: bool, ) -> MeterFrame { let mut rms_power_l = 0.0f32; let mut rms_power_r = 0.0f32; @@ -1172,10 +1176,14 @@ fn build_meter_frame( } let mut spectro_frame = None; 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); ppm_state.last_spectro = Some(frame.clone()); 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; } } diff --git a/src/routes.rs b/src/routes.rs index 2444742..df50b5f 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -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) { + 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(); loop { let mut latest = loop { diff --git a/src/state.rs b/src/state.rs index 965d5dd..7d180a8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -28,6 +28,7 @@ pub struct AppState { seq: Arc, actual_sample_rate: Arc, metrics_tx: broadcast::Sender>, + spectro_subscribers: Arc, restart_token: Arc, rta_config: Arc>, global_config: Arc>, @@ -105,6 +106,7 @@ impl AppState { seq: Arc::new(AtomicU64::new(0)), actual_sample_rate: Arc::new(AtomicU64::new(configured_sample_rate as u64)), metrics_tx, + spectro_subscribers: Arc::new(AtomicU64::new(0)), restart_token: Arc::new(AtomicU64::new(0)), rta_config: Arc::new(RwLock::new(initial_rta_config)), global_config: Arc::new(RwLock::new(initial_global_config)), @@ -117,6 +119,18 @@ impl AppState { 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 { *self.current_input.read().await } @@ -268,6 +282,7 @@ impl AppState { seq: self.seq.clone(), actual_sample_rate: self.actual_sample_rate.clone(), metrics_tx: self.metrics_tx.clone(), + spectro_subscribers: self.spectro_subscribers.clone(), restart_token: self.restart_token.clone(), }); } diff --git a/www/core/audio.js b/www/core/audio.js index a974c06..e77d182 100644 --- a/www/core/audio.js +++ b/www/core/audio.js @@ -9,6 +9,7 @@ import { getRtwCenters } from './rtw_centers.js'; let phoenixSocket = null; let phoenixSpectroSocket = null; let phoenixSpectroRetryTimer = null; +let phoenixSpectroDemanded = false; let pendingSpectroBuffer = null; let phoenixSpectroRaf = 0; let phoenixVisualsSocket = null; @@ -99,6 +100,7 @@ function closePhoenixSocket() { } catch (_) {} } phoenixSpectroSocket = null; + phoenixSpectroDemanded = false; pendingSpectroBuffer = null; if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf); phoenixSpectroRaf = 0; @@ -253,6 +255,7 @@ function copyPhoenixSpectroBins(audioState, spectro, frameDelta = 1) { } function openPhoenixSpectroSocket(baseUrl, env) { + if (!phoenixSpectroDemanded) return; if (phoenixSpectroSocket && ( phoenixSpectroSocket.readyState === WebSocket.OPEN || phoenixSpectroSocket.readyState === WebSocket.CONNECTING @@ -269,7 +272,7 @@ function openPhoenixSpectroSocket(baseUrl, env) { const retry = () => { if (phoenixSpectroSocket === socket) phoenixSpectroSocket = null; - if (!phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return; + if (!phoenixSpectroDemanded || !phoenixSocket || phoenixSocket.readyState !== WebSocket.OPEN) return; if (phoenixSpectroRetryTimer) clearTimeout(phoenixSpectroRetryTimer); phoenixSpectroRetryTimer = setTimeout(() => { phoenixSpectroRetryTimer = null; @@ -280,6 +283,31 @@ function openPhoenixSpectroSocket(baseUrl, env) { 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) { if (phoenixSpectroRaf || !pendingSpectroBuffer) return; phoenixSpectroRaf = requestAnimationFrame(() => { @@ -1043,6 +1071,7 @@ async function initPhoenixAudio(env) { ? cfg : ((typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null); const profile = rawProfile || {}; + setPhoenixSpectroDemand(baseUrl, env, profile.needSpectro); if (!profile.needXy) { env.audio.xyL = null; env.audio.xyR = null; @@ -1057,6 +1086,9 @@ async function initPhoenixAudio(env) { resetWaveformStateFallback(env.audio.waveformFallback); } }; + env.audio.updateProcessingConfig( + (typeof env?.getProcessingProfile === 'function') ? env.getProcessingProfile() : null, + ); const pushPhoenixGlobalConfig = async () => { const response = await requestPhoenixGlobalConfigUpdate(baseUrl, buildPhoenixGlobalConfigPayload()); @@ -1105,7 +1137,7 @@ async function initPhoenixAudio(env) { }; socket.onopen = () => { - openPhoenixSpectroSocket(baseUrl, env); + if (phoenixSpectroDemanded) openPhoenixSpectroSocket(baseUrl, env); openPhoenixVisualsSocket(baseUrl, env); finish(true); }; diff --git a/www/main.js b/www/main.js index 708cef1..5e3de9f 100644 --- a/www/main.js +++ b/www/main.js @@ -716,6 +716,7 @@ function buildProcessingProfile(viewId = style) { const profile = { needXy: false, needRta: false, + needSpectro: false, needVu: false, needPpmDin: false, needPpmEbu: false, @@ -728,6 +729,8 @@ function buildProcessingProfile(viewId = style) { for (const plotId of plotIds) { if (plotId === 'realtime') { profile.needRta = true; + } else if (plotId === 'spectrogram') { + profile.needSpectro = true; } else if (plotId === 'goniometer-rtw') { profile.needXy = true; profile.needRms = true; @@ -792,6 +795,7 @@ function setSlotForView(viewId, idx, value){ try { saveConfig?.(); } catch (_) {} try { env?.audio?.updatePhoenixGlobalConfig?.(); } catch (_) {} } + requestRender('processing-profile-change'); } function resetAllSlotsToDefaults() { @@ -804,6 +808,7 @@ function resetAllSlotsToDefaults() { saveSlotState(slotsState); saveSlotCustomizedState({}); refreshSlotEditors(); + requestRender('processing-profile-change'); } function initSlotSelect(sel, idx){ if (!sel) return; @@ -1007,11 +1012,13 @@ initSplitPlotSelect(splitLeftSel, (val) => { const clean = sanitizeSplitPlotId(val) ?? 'none'; CONFIG.SPLIT_VIEW_LEFT = clean; saveConfig(); + requestRender('processing-profile-change'); }); initSplitPlotSelect(splitRightSel, (val) => { const clean = sanitizeSplitPlotId(val) ?? 'none'; CONFIG.SPLIT_VIEW_RIGHT = clean; saveConfig(); + requestRender('processing-profile-change'); }); function clampMeterCount3(v) { @@ -1053,6 +1060,7 @@ function writeMeterToConfig(prefix, idx, val) { const key = getMeterKey(prefix, idx); CONFIG[key] = sanitizeSlotId(val) ?? 'vu'; saveConfig(); + requestRender('processing-profile-change'); } function setSplitMeterPopupOpen(open) { @@ -1098,10 +1106,10 @@ function syncQuadPlotPopup() { if (quadPlotSelBR) quadPlotSelBR.value = sanitizeSplitPlotId(CONFIG.QUAD_VIEW_BR) ?? 'none'; } -initSplitPlotSelect(quadPlotSelTL, (val) => { CONFIG.QUAD_VIEW_TL = sanitizeSplitPlotId(val) ?? 'phase-wheel'; saveConfig(); }); -initSplitPlotSelect(quadPlotSelTR, (val) => { CONFIG.QUAD_VIEW_TR = sanitizeSplitPlotId(val) ?? 'realtime'; saveConfig(); }); -initSplitPlotSelect(quadPlotSelBL, (val) => { CONFIG.QUAD_VIEW_BL = sanitizeSplitPlotId(val) ?? 'goniometer-rtw'; saveConfig(); }); -initSplitPlotSelect(quadPlotSelBR, (val) => { CONFIG.QUAD_VIEW_BR = sanitizeSplitPlotId(val) ?? 'none'; 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(); requestRender('processing-profile-change'); }); +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(); requestRender('processing-profile-change'); }); if (splitMeterBtn) { splitMeterBtn.addEventListener('click', () => { @@ -1857,10 +1865,12 @@ function handleSplitTapUp(x, y) { if (env?.splitPopup?.open) { env.splitPopup.open = false; env.splitPopup.viewId = null; + requestRender('processing-profile-change'); return; } env.splitPopup.open = true; env.splitPopup.viewId = target; + requestRender('processing-profile-change'); return; } env.switchView?.(target); @@ -1877,6 +1887,7 @@ function handleQuadTapUp(x, y) { // Popup ist offen: Tap innerhalb/außerhalb schließt env.quadPopup.open = false; env.quadPopup.viewId = null; + requestRender('processing-profile-change'); return; } if (isInAnyMeterHitRect(x, y)) return; @@ -1889,6 +1900,7 @@ function handleQuadTapUp(x, y) { if (env?.quadPopup) { env.quadPopup.open = true; env.quadPopup.viewId = target; + requestRender('processing-profile-change'); } return; } @@ -1912,6 +1924,7 @@ C.addEventListener('pointerdown', (e) => { if (!env?.splitPopup?.open) return; env.splitPopup.open = false; env.splitPopup.viewId = null; + requestRender('processing-profile-change'); try { e.preventDefault?.(); } catch (_) {} try { e.stopPropagation?.(); } catch (_) {} }, { capture: true, passive: false }); @@ -1968,6 +1981,7 @@ C.addEventListener('pointerdown', (e) => { if (pointInRect(x, y, box)) return; env.quadPopup.open = false; env.quadPopup.viewId = null; + requestRender('processing-profile-change'); try { e.preventDefault?.(); } catch (_) {} try { e.stopPropagation?.(); } catch (_) {} }, { capture: true, passive: false }); @@ -2612,9 +2626,14 @@ let audioRecoverInFlight = false; let lastAudioRecoverAt = 0; let renderDirty = true; let lastRenderedAudioSeq = 0; +let processingProfileDirty = true; +let appliedProcessingProfileView = ''; + +const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']); function requestRender(reason = 'ui') { renderDirty = true; + if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true; env.__lastRenderReason = reason; } @@ -2647,7 +2666,15 @@ async function loop(now){ const elapsed = now - lastFrameTime; const audioOk = env.audio.alive && !audioLost(env); 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 target = targetMs; const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0; diff --git a/www/views/phase_wheel.js b/www/views/phase_wheel.js index ea1c252..7348e2f 100644 --- a/www/views/phase_wheel.js +++ b/www/views/phase_wheel.js @@ -67,6 +67,12 @@ export function init() { ampBuffer: new Float32Array(0), filteredL: 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, currentRadius: 0, smoothPhase: null, @@ -100,8 +106,8 @@ export async function render(env, state) { drawStaticLayer(g, state, rect, layout, CONFIG, slots.length); const xyData = extractXYData(audio); - const gainCtrl = resolvePhaseGain(state, xyData, CONFIG); if (xyData.ready) { + const gainCtrl = resolvePhaseGain(state, xyData, CONFIG); const trace = buildWheelTrace(state, xyData, layout.wheel, gainCtrl.gain, CONFIG, audio); renderWheel(g, trace, layout.wheel, state, CONFIG, now); } else { @@ -330,6 +336,7 @@ function extractXYData(audio) { xyR, length: ready ? Math.min(xyL.length, xyR.length) : 0, sampleRate: audio?.sampleRate || 48000, + seq: Number(audio?.xySeq) || 0, }; } @@ -338,14 +345,12 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) { decayPhasePointer(state); return null; } - const filtered = preparePhaseFilteredBuffers(state, xyData); + const analysis = preparePhaseAnalysis(state, xyData); const amplitudeMode = getPhaseAmplitudeMode(CONFIG); const ringDbValues = getRingDbValues(CONFIG); const ppmRadiusNorm = amplitudeMode === 'ppm-din' ? computePpmDinRadiusNorm(audio, CONFIG, ringDbValues) : 0; - const targetPoints = Math.min(TARGET_POINTS, xyData.length); - const step = Math.max(1, Math.floor(xyData.length / targetPoints)); const radius = wheel.radius; let ampIdx = 0; let sumSin = 0; @@ -354,20 +359,9 @@ function buildWheelTrace(state, xyData, wheel, gain = 1, CONFIG, audio) { let levelAcc = 0; const gainLinear = Number.isFinite(gain) ? gain : 1; - 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 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)); + for (let i = 0; i < analysis.count; i++) { + const angle = analysis.angles[i]; + const amp = analysis.amplitudes[i]; const ampScaled = Math.min(1, amp * gainLinear); const radiusNorm = amplitudeMode === 'ppm-din' ? ppmRadiusNorm @@ -809,6 +803,63 @@ function preparePhaseFilteredBuffers(state, xyData) { 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) { const prevPhase = Number.isFinite(state.currentPhase) ? state.currentPhase : 0; const prevRadius = Number.isFinite(state.currentRadius) ? state.currentRadius : 0;