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 metrics_tx: tokio::sync::broadcast::Sender<Arc<MeterFrame>>,
#[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>,
}
@@ -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<AtomicU64>,
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;
}
}
+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) {
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 {
+15
View File
@@ -28,6 +28,7 @@ pub struct AppState {
seq: Arc<AtomicU64>,
actual_sample_rate: Arc<AtomicU64>,
metrics_tx: broadcast::Sender<Arc<MeterFrame>>,
spectro_subscribers: Arc<AtomicU64>,
restart_token: Arc<AtomicU64>,
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
@@ -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(),
});
}
+34 -2
View File
@@ -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);
};
+32 -5
View File
@@ -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;
+69 -18
View File
@@ -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;