Compare commits

..
8 Commits
Author SHA1 Message Date
michael ec3b6c3059 correlation: hold last value and ease to zero on silence
The XY correlation cube snapped to center the moment the audio dropped
below the silence threshold, because process() hard-set value to 0.0 in
the silent branch. Now the silent branch eases the displayed value back
toward zero using the configured response time, so the cube holds its
last reading and then settles instead of jumping. The active path is
unchanged, so all existing response-time and peak tests still pass.

- add `display` (f64) that tracks the live correlation while active
- silent branch: display += alpha * (0 - display)
- update configurable_silence_threshold test for the eased decay
- add correlation_holds_then_decays_to_zero_on_silence test
2026-09-04 12:44:28 +02:00
Mikei386 684c5fe3d3 Install Volumio integration during updates 2026-08-11 09:06:50 +02:00
Mikei386 07563c3b11 Prevent kiosk and getty tty1 race 2026-08-11 08:59:23 +02:00
Mikei386 48578c5919 Enable low-frequency RTW analyzer range 2026-08-06 20:53:30 +02:00
Mikei386 cbbeac5b0f Revert "Fix skipped realtime analyzer frames"
This reverts commit afcceec986.
2026-08-05 20:54:55 +02:00
Mikei386 ab19798a2d Revert "Smooth realtime display frame pacing"
This reverts commit a8e9729b3a.
2026-08-05 20:54:33 +02:00
Mikei386 96d323c32d Revert "Decouple realtime snapshots from transport cadence"
This reverts commit feec2bac56.
2026-08-05 20:53:58 +02:00
Mikei386 dba83a2d98 Revert "Synchronize realtime data with display frames"
This reverts commit c6a1037611.
2026-08-05 20:53:18 +02:00
21 changed files with 300 additions and 226 deletions

No files matched your search

+7 -2
View File
@@ -2,8 +2,13 @@ Mini-Dokumentation: Analyzer Kiosk auf Raspberry Pi OS
1. Dienste deaktivieren
sudo systemctl disable --now lightdm.service
sudo systemctl disable --now getty@tty1.service
sudo systemctl reset-failed getty@tty1.service
sudo systemctl mask --now getty@tty1.service
Hinweis:
- tty1 ist exklusiv fuer Xorg/Chromium reserviert. Ein blosses `disable` reicht
nicht, weil systemd den Getty bei einem spaeteren Boot erneut erzeugen kann.
- Fuer einen lokalen Login-Prompt auf tty1 den Kiosk zuerst stoppen und Getty
anschliessend mit `sudo systemctl unmask --now getty@tty1.service` reaktivieren.
2. systemd neu laden
sudo systemctl daemon-reload
+19 -2
View File
@@ -124,8 +124,25 @@ zugehörige sudo-Regel werden einmalig als root installiert:
sudo bash /home/volumio/Phoenix/scripts/install_volumio_services.sh
```
Der Installer verändert keine Debian-/Analyzer-Units und startet laufende
Dienste nicht automatisch neu.
Der Installer überschreibt keine Debian-/Analyzer-Unitdateien und startet
laufende Dienste nicht automatisch neu. Er maskiert `getty@tty1.service`, da der
Kiosk-X-Server `tty1` exklusiv verwendet. Ohne die Maskierung können Getty und
Xorg beim Booten um dasselbe virtuelle Terminal konkurrieren und eine
Kiosk-Neustartschleife auslösen.
Nach dieser einmaligen Grundinstallation bringt der privilegierte
Volumio-Online-Updater bei jedem erfolgreichen Phoenix-Update auch die
mitgelieferten systemd-Units, den Update-Helfer und die sudo-Regel aus. Fehlen
diese Integrationsdateien in einem Update, wird es vor dem Austausch des
laufenden Projektbaums abgelehnt.
Um den lokalen Login-Prompt auf `tty1` wiederherzustellen, zuerst den Kiosk
stoppen und Getty anschließend wieder freigeben:
```bash
sudo systemctl stop analyzer-kiosk-volumio.service
sudo systemctl unmask --now getty@tty1.service
```
Wenn `toggle-ext` genutzt werden soll, sollte es also unter diesem Pfad liegen:
@@ -1,5 +1,7 @@
[Unit]
Description=Analyzer Kiosk Volumio (Raspberry Pi OS)
Conflicts=getty@tty1.service
After=systemd-user-sessions.service getty-pre.target
After=network-online.target
After=analyzer-web-volumio.service
Wants=analyzer-web-volumio.service
@@ -1,5 +1,7 @@
[Unit]
Description=Analyzer Kiosk (Raspberry Pi OS)
Conflicts=getty@tty1.service
After=systemd-user-sessions.service getty-pre.target
After=network-online.target
After=analyzer-web.service
Wants=analyzer-web.service
+5
View File
@@ -51,8 +51,13 @@ install -o root -g root -m 0440 \
"$SUDOERS_SOURCE" \
/etc/sudoers.d/phoenix-update-volumio
# tty1 is reserved for Xorg by analyzer-kiosk-volumio.service. Disabling the
# generated getty is insufficient because systemd may create/start it again on
# a later boot. Mask it so the kiosk and agetty can never race for the VT.
systemctl mask --now getty@tty1.service
systemctl daemon-reload
systemctl enable analyzer-web-volumio.service analyzer-kiosk-volumio.service
echo "Volumio integration installed"
echo "getty@tty1.service masked; run 'systemctl unmask getty@tty1.service' to restore the tty1 login console"
echo "Existing services were not restarted"
+58 -7
View File
@@ -209,6 +209,46 @@ build_volumio_candidate() {
echo "staged Volumio build completed"
}
validate_volumio_integration_candidate() {
local phoenix_dir="${1:-$STAGE_DIR}"
local required_path
local required_paths=(
"scripts/install_volumio_services.sh"
"scripts/restart_phoenix_services.sh"
"etc/systemd/system/analyzer-kiosk-volumio.service"
"etc/systemd/system/analyzer-web-volumio.service"
"etc/systemd/system/phoenix-update-volumio.service"
"etc/sudoers.d/phoenix-update-volumio"
)
for required_path in "${required_paths[@]}"; do
if [ ! -f "$phoenix_dir/$required_path" ]; then
echo "staged Volumio integration file missing: $phoenix_dir/$required_path" >&2
return 1
fi
done
bash -n \
"$phoenix_dir/scripts/install_volumio_services.sh" \
"$phoenix_dir/scripts/restart_phoenix_services.sh"
}
install_volumio_integration() {
local phoenix_dir="${1:-$PHOENIX_ROOT}"
local installer="$phoenix_dir/scripts/install_volumio_services.sh"
if [ "$(id -u)" -ne 0 ]; then
echo "Volumio integration update requires root" >&2
return 1
fi
if [ ! -f "$installer" ]; then
echo "Volumio integration installer missing: $installer" >&2
return 1
fi
echo "installing Volumio system integration from $phoenix_dir"
/bin/bash "$installer"
}
start_all_services() {
if is_volumio_install; then
restart_if_present analyzer-web-volumio.service
@@ -248,6 +288,12 @@ rollback_to_backup() {
return 1
fi
mv "$BACKUP_DIR" "$PHOENIX_ROOT"
if is_volumio_install && [ -f "$PHOENIX_ROOT/scripts/install_volumio_services.sh" ]; then
if ! install_volumio_integration "$PHOENIX_ROOT"; then
echo "backup files were restored but their Volumio integration could not be installed" >&2
return 1
fi
fi
if ! wait_for_candidate_health "$PHOENIX_ROOT" "${PHOENIX_ROLLBACK_HEALTH_ATTEMPTS:-120}" 1; then
echo "backup rollback restored files but Phoenix health did not recover" >&2
return 1
@@ -289,6 +335,7 @@ if [ ! -f "$STAGE_DIR/Cargo.toml" ] || [ ! -d "$STAGE_DIR/src" ] || [ ! -d "$STA
fi
if is_volumio_install; then
validate_volumio_integration_candidate "$STAGE_DIR"
build_volumio_candidate "$STAGE_DIR"
fi
@@ -303,6 +350,17 @@ mv "$STAGE_DIR" "$PHOENIX_ROOT"
health_attempts=45
health_delay=1
if is_volumio_install; then
if ! install_volumio_integration "$PHOENIX_ROOT"; then
if ! rollback_to_backup; then
echo "new Volumio integration failed and rollback failed" >&2
exit 1
fi
echo "new Volumio integration failed; rolled back to backup" >&2
exit 1
fi
fi
if ! wait_for_candidate_health "$PHOENIX_ROOT" "$health_attempts" "$health_delay"; then
if ! rollback_to_backup; then
echo "new Phoenix version failed health check and rollback failed" >&2
@@ -312,11 +370,4 @@ if ! wait_for_candidate_health "$PHOENIX_ROOT" "$health_attempts" "$health_delay
exit 1
fi
if is_volumio_install && [ "$(id -u)" -eq 0 ] && [ -f "$PHOENIX_ROOT/scripts/restart_phoenix_services.sh" ]; then
install -o root -g root -m 0755 \
"$PHOENIX_ROOT/scripts/restart_phoenix_services.sh" \
/usr/local/libexec/phoenix/restart_phoenix_services.sh
echo "Volumio privileged update helper refreshed"
fi
echo "=== $(date): restart_phoenix_services.sh OK ==="
+5 -19
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
const html = fs.readFileSync(new URL('../www/index.html', import.meta.url), 'utf8');
const main = fs.readFileSync(new URL('../www/main.js', import.meta.url), 'utf8');
const audio = fs.readFileSync(new URL('../www/core/audio.js', import.meta.url), 'utf8');
const options = fs.readFileSync(new URL('../www/ui/options.js', import.meta.url), 'utf8');
const realtime = fs.readFileSync(new URL('../www/views/realtime.js', import.meta.url), 'utf8');
const styleSelect = html.match(/<select\b[^>]*\bid="styleSel"[^>]*>/)?.[0] || '';
@@ -12,29 +12,15 @@ assert(!/\bopt-w-\d+\b/.test(styleSelect), 'style selector must not have a fixed
assert(/adjustStyleDropdownWidth\(\)/.test(main), 'dynamic style selector sizing is missing');
assert(/styleSel\.style\.width\s*=/.test(main), 'dynamic style selector width assignment is missing');
assert(/currentMetricsSeq\s*=\s*Number\.isFinite\(env\.audio\?\.phoenixMetricsSeq\)/.test(main),
'render loop must track the metrics sequence used by the RTA');
assert(/renderRequestGeneration\s*\+=\s*1/.test(main),
'render requests must carry a generation that survives asynchronous rendering');
assert(/renderRequestGeneration\s*===\s*renderGeneration\)\s*renderDirty\s*=\s*false/.test(main),
'an in-flight render must not clear a newer render request');
assert(/const frameDue\s*=\s*audioOk\s*\|\|\s*elapsed\s*>=\s*target/.test(main),
'live displays must use requestAnimationFrame directly instead of a second 60 Hz gate');
assert(/await flushPendingPhoenixData\(env, CONFIG\);[^]*?const currentMetricsSeq/.test(main),
'the render loop must publish buffered Phoenix data before reading frame sequences');
assert(/await updateActiveMeters\(env, d, CONFIG\);\s*[^]*?env\.requestRender\?\.\('audio'\)/.test(audio),
'audio rendering must be requested after active meters finish updating');
assert(/export async function flushPendingPhoenixData\(env, CONFIG\)/.test(audio),
'Phoenix data paths must expose one display-synchronized presentation pump');
assert(!/requestAnimationFrame/.test(audio),
'audio transport must not create requestAnimationFrame loops that compete with the canvas loop');
assert(!/while\s*\(pendingPhoenixFrame\)/.test(audio),
'metrics transport must coalesce bursts instead of draining every queued state');
assert(!/syncNativePeakHold/.test(realtime),
'native RTA peak anchors must not be copied directly into the visible hold line');
assert((realtime.match(/applyPeakHold\(state, integrated, CONFIG, range\)/g) || []).length >= 3,
'every RTA engine must advance peak hold on the display clock');
assert(/useIirEngine[^]*?applyRealtimeBarBallistics\(state, displayBase, CONFIG, range\)/.test(realtime),
'native IIR bars must advance their visible release on the display clock');
assert(/h\('opt_rtaFreq', v => \{\s*CONFIG\.RTA_FREQ_RANGE = \(v === 'lf'\) \? 'lf' : 'norm'/.test(options),
'the low-frequency range must remain selectable in the RTW layout');
assert(!/RTA_FREQ_RANGE\s*=\s*CONFIG\.RTA_BAR_LAYOUT\s*===\s*'rtw'\s*\?\s*'norm'/.test(options),
'the RTW layout must not force the normal frequency range');
console.log('GUI contract regression tests passed');
+7 -5
View File
@@ -31,9 +31,9 @@ const context = vm.createContext({
Number,
normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds,
getRtwCenters(mode) {
if (mode === '1_12') return new Array(121).fill(0);
if (mode === '1_6') return new Array(61).fill(0);
return new Array(31).fill(0);
if (mode === '1_12') return new Array(145).fill(0);
if (mode === '1_6') return new Array(73).fill(0);
return new Array(37).fill(0);
},
});
vm.runInContext(functionSource.replace('export function', 'function'), context);
@@ -56,7 +56,7 @@ const forced = context.buildRtaRuntimeConfig({
});
assert.equal(forced.engine, 'iir');
assert.equal(forced.bpo, '1_12');
assert.equal(forced.freqRange, 'norm');
assert.equal(forced.freqRange, 'lf');
assert.equal(forced.order, 6);
assert.equal(forced.detector, 'peak');
assert.equal(forced.rtaPeakHoldMode, 'fall');
@@ -64,7 +64,7 @@ assert.equal(forced.rtaPeakHoldSeconds, 4);
assert.equal(forced.rtaPeakDecayDbPerSecond, 20);
assert.equal(forced.rtaPeakResetToken, 9);
assert.equal(forced.tauFast, 0.125);
assert.equal(forced.rtwCenters.length, 121);
assert.equal(forced.rtwCenters.length, 145);
assert.equal(forced.correlationResponseS, 2.5);
assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50);
assert.equal(forced.correlationResetToken, 7);
@@ -99,6 +99,8 @@ assert.equal(selectionContext.applyRtaBpoSelection('1_3'), '1_3');
assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
const twelfthCenters = getRtwCenters('1_12');
assert.equal(twelfthCenters[0], 5);
assert.equal(twelfthCenters.at(-1), 20000);
const staleTwelfthPacket = {
engine: 'iir',
bpo: '1_12',
+1 -1
View File
@@ -37,5 +37,5 @@ metrics.addEventListener('message', (event) => {
await new Promise((resolve) => setTimeout(resolve, 1100));
sockets.forEach((socket) => socket.close());
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads');
assert.ok(count >= 110 && count <= 145, `expected about 120 metrics/s, received ${count}`);
assert.ok(count >= 50 && count <= 75, `expected about 60 metrics/s, received ${count}`);
console.log(`runtime websocket test passed (${count} metrics in 1.1 s; max frame age ${maxFrameAgeMs} ms; all three streams opened)`);
+30 -20
View File
@@ -14,7 +14,7 @@ use tracing::warn;
#[cfg(target_os = "linux")]
use crate::correlation::CorrelationMeter;
#[cfg(target_os = "linux")]
use crate::goniometer::selected_sample_indices;
use crate::goniometer::{selected_sample_indices, GoniometerClock};
#[cfg(target_os = "linux")]
use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame};
#[cfg(target_os = "linux")]
@@ -40,8 +40,8 @@ use crate::{
model::{InputSource, MeterFrame, PhoenixRtaConfig},
};
#[cfg(not(target_os = "linux"))]
const PLACEHOLDER_UPDATES_PER_SECOND: u32 = 120;
#[cfg(target_os = "linux")]
const METRICS_TARGET_UPDATES_PER_SECOND: u32 = 60;
#[derive(Clone, Copy, Debug, Default)]
struct TransportPeaks {
@@ -90,24 +90,26 @@ const BOX_MAX_DB: f32 = 9.0;
const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256;
#[cfg(target_os = "linux")]
const RTW_CENTERS_1_6: &[f32] = &[
20.0, 22.4, 25.0, 28.0, 31.5, 35.5, 40.0, 45.0, 50.0, 56.0, 63.0, 70.8, 80.0, 90.0, 100.0,
112.0, 125.0, 141.0, 160.0, 180.0, 200.0, 224.0, 250.0, 282.0, 315.0, 355.0, 400.0, 450.0,
500.0, 560.0, 630.0, 708.0, 800.0, 900.0, 1000.0, 1120.0, 1250.0, 1410.0, 1600.0, 1800.0,
2000.0, 2240.0, 2500.0, 2820.0, 3150.0, 3550.0, 4000.0, 4500.0, 5000.0, 5600.0, 6300.0, 7080.0,
8000.0, 9000.0, 10000.0, 11200.0, 12500.0, 14100.0, 16000.0, 18000.0, 20000.0,
5.0, 5.6, 6.3, 7.08, 8.0, 9.0, 10.0, 11.2, 12.5, 14.1, 16.0, 18.0, 20.0, 22.4, 25.0, 28.0,
31.5, 35.5, 40.0, 45.0, 50.0, 56.0, 63.0, 70.8, 80.0, 90.0, 100.0, 112.0, 125.0, 141.0, 160.0,
180.0, 200.0, 224.0, 250.0, 282.0, 315.0, 355.0, 400.0, 450.0, 500.0, 560.0, 630.0, 708.0,
800.0, 900.0, 1000.0, 1120.0, 1250.0, 1410.0, 1600.0, 1800.0, 2000.0, 2240.0, 2500.0, 2820.0,
3150.0, 3550.0, 4000.0, 4500.0, 5000.0, 5600.0, 6300.0, 7080.0, 8000.0, 9000.0, 10000.0,
11200.0, 12500.0, 14100.0, 16000.0, 18000.0, 20000.0,
];
#[cfg(target_os = "linux")]
const RTW_CENTERS_1_12: &[f32] = &[
20.0, 21.2, 22.4, 23.8, 25.0, 26.5, 28.0, 29.8, 31.5, 33.4, 35.5, 37.6, 40.0, 42.4, 45.0, 47.5,
50.0, 53.0, 56.0, 59.5, 63.0, 67.0, 71.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0, 106.0, 112.0,
118.0, 125.0, 132.0, 140.0, 150.0, 160.0, 170.0, 180.0, 190.0, 200.0, 212.0, 224.0, 238.0,
250.0, 265.0, 280.0, 298.0, 315.0, 334.0, 355.0, 376.0, 400.0, 424.0, 450.0, 475.0, 500.0,
530.0, 560.0, 595.0, 630.0, 670.0, 710.0, 750.0, 800.0, 850.0, 900.0, 950.0, 1000.0, 1060.0,
1120.0, 1180.0, 1250.0, 1320.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0, 2000.0, 2120.0,
2240.0, 2380.0, 2500.0, 2650.0, 2800.0, 2980.0, 3150.0, 3340.0, 3550.0, 3760.0, 4000.0, 4240.0,
4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, 6300.0, 6700.0, 7100.0, 7500.0, 8000.0, 8500.0,
9000.0, 9500.0, 10_000.0, 10_600.0, 11_200.0, 11_800.0, 12_500.0, 13_200.0, 14_000.0, 15_000.0,
16_000.0, 17_000.0, 18_000.0, 19_000.0, 20_000.0,
5.0, 5.3, 5.6, 5.95, 6.3, 6.7, 7.1, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 10.6, 11.2, 11.8, 12.5,
13.2, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.2, 22.4, 23.8, 25.0, 26.5, 28.0, 29.8, 31.5,
33.4, 35.5, 37.6, 40.0, 42.4, 45.0, 47.5, 50.0, 53.0, 56.0, 59.5, 63.0, 67.0, 71.0, 75.0, 80.0,
85.0, 90.0, 95.0, 100.0, 106.0, 112.0, 118.0, 125.0, 132.0, 140.0, 150.0, 160.0, 170.0, 180.0,
190.0, 200.0, 212.0, 224.0, 238.0, 250.0, 265.0, 280.0, 298.0, 315.0, 334.0, 355.0, 376.0,
400.0, 424.0, 450.0, 475.0, 500.0, 530.0, 560.0, 595.0, 630.0, 670.0, 710.0, 750.0, 800.0,
850.0, 900.0, 950.0, 1000.0, 1060.0, 1120.0, 1180.0, 1250.0, 1320.0, 1400.0, 1500.0, 1600.0,
1700.0, 1800.0, 1900.0, 2000.0, 2120.0, 2240.0, 2380.0, 2500.0, 2650.0, 2800.0, 2980.0, 3150.0,
3340.0, 3550.0, 3760.0, 4000.0, 4240.0, 4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, 6300.0,
6700.0, 7100.0, 7500.0, 8000.0, 8500.0, 9000.0, 9500.0, 10_000.0, 10_600.0, 11_200.0, 11_800.0,
12_500.0, 13_200.0, 14_000.0, 15_000.0, 16_000.0, 17_000.0, 18_000.0, 19_000.0, 20_000.0,
];
#[derive(Clone)]
@@ -415,8 +417,7 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
tokio::spawn(async move {
warn!("Phoenix ALSA capture is only available on Linux; emitting placeholder frames on this host");
let tick_ns = 1_000_000_000u64 / u64::from(PLACEHOLDER_UPDATES_PER_SECOND);
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
let mut ticker = tokio::time::interval(Duration::from_millis(16));
deps.actual_sample_rate
.store(deps.config.sample_rate as u64, Ordering::SeqCst);
loop {
@@ -594,6 +595,7 @@ struct PpmState {
lr_delay_y1_r: f32,
true_peak_l: TruePeakDetector,
true_peak_r: TruePeakDetector,
transport_clock: GoniometerClock,
transport_peaks: TransportPeaks,
correlation: CorrelationMeter,
phase_wheel: PhaseWheelAnalyzer,
@@ -626,6 +628,7 @@ impl Default for PpmState {
lr_delay_y1_r: 0.0,
true_peak_l: TruePeakDetector::default(),
true_peak_r: TruePeakDetector::default(),
transport_clock: GoniometerClock::default(),
transport_peaks: TransportPeaks::default(),
correlation: CorrelationMeter::new(48_000, 1.0, -75.0, 0),
phase_wheel: PhaseWheelAnalyzer::new(48_000),
@@ -1251,6 +1254,13 @@ fn process_audio_block(
None
};
if !ppm_state
.transport_clock
.advance(frames, sample_rate, METRICS_TARGET_UPDATES_PER_SECOND)
{
return (None, spectro_frame);
}
let rta = ppm_state.rta_state.as_ref().and_then(|state| match state {
RtaEngineState::Iir(bank) => Some(build_iir_rta_frame(bank, sample_rate, rta_config)),
RtaEngineState::Fft(fft) if fft.ring_fill >= fft.fft_size => {
+67 -8
View File
@@ -17,6 +17,7 @@ pub struct CorrelationMeter {
cross_power: f64,
gate_power: f64,
value: f32,
display: f64,
negative_peak: f32,
reset_token: u64,
}
@@ -40,6 +41,7 @@ impl CorrelationMeter {
cross_power: 0.0,
gate_power: 0.0,
value: 0.0,
display: 0.0,
negative_peak: 0.0,
reset_token,
};
@@ -93,18 +95,24 @@ impl CorrelationMeter {
* (0.5 * (l * l + r * r) - self.gate_power);
// Below the configured mono RMS threshold the correlation indication
// settles at its neutral position.
// eases back to its neutral position instead of snapping there.
const MIN_POWER: f64 = 1.0e-10;
let denominator = (self.power_l * self.power_r).sqrt();
self.value = if self.gate_power >= self.silence_threshold_power
let active = self.gate_power >= self.silence_threshold_power
&& self.power_l > MIN_POWER
&& self.power_r > MIN_POWER
&& denominator > 0.0
{
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32
&& denominator > 0.0;
if active {
// Audio present: track the live correlation exactly as before.
self.display = (self.cross_power / denominator).clamp(-1.0, 1.0);
self.value = self.display as f32;
} else {
0.0
};
// Silent: hold the last value, then decay it back to zero using
// the configured response time, so the XY cube settles instead of
// jumping to center.
self.display += self.alpha * (0.0 - self.display);
self.value = self.display as f32;
}
if self.value < self.negative_peak {
self.negative_peak = self.value;
}
@@ -255,7 +263,26 @@ mod tests {
};
(sample, sample)
});
assert_eq!(meter.value(), 0.0);
// Raising the threshold above the signal level neutralizes the meter.
// With the eased display it decays toward zero rather than snapping.
assert!(
meter.value() < 0.2,
"meter did not neutralize: {}",
meter.value()
);
run_signal(&mut meter, 3, |index| {
let sample = if index & 1 == 0 {
amplitude
} else {
-amplitude
};
(sample, sample)
});
assert!(
meter.value().abs() < 0.05,
"meter did not settle to zero: {}",
meter.value()
);
}
#[test]
@@ -278,4 +305,36 @@ mod tests {
});
assert_eq!(meter.value(), 0.0);
}
#[test]
fn correlation_holds_then_decays_to_zero_on_silence() {
let sample_rate = 48_000;
let mut meter = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0);
// Drive a strongly correlated signal so the meter settles near +1.
for index in 0..sample_rate as usize * 3 {
let sample = if index & 1 == 0 { 0.5 } else { -0.5 };
meter.process(sample, sample);
}
assert!(meter.value() > 0.9, "expected near +1, got {}", meter.value());
// Cut the audio: the value must hold (not snap to 0) right away...
for _ in 0..10 {
meter.process(0.0, 0.0);
}
assert!(
meter.value() > 0.5,
"value snapped to zero on silence: {}",
meter.value()
);
// ...and then decay back toward zero over the response time.
for _ in 0..sample_rate as usize * 4 {
meter.process(0.0, 0.0);
}
assert!(
meter.value().abs() < 0.05,
"value did not decay to zero: {}",
meter.value()
);
}
}
-2
View File
@@ -1,12 +1,10 @@
//! Timing and sample-selection helpers for the realtime goniometer stream.
#[cfg(test)]
#[derive(Clone, Debug, Default)]
pub struct GoniometerClock {
phase: u64,
}
#[cfg(test)]
impl GoniometerClock {
/// Returns true at the first capture boundary after the next display tick.
/// The fractional phase is retained, so the average rate is independent
+10 -76
View File
@@ -14,7 +14,7 @@ use std::{
process::Stdio,
sync::atomic::{AtomicU64, Ordering},
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
time::{SystemTime, UNIX_EPOCH},
};
use tokio::{fs, io::AsyncWriteExt, process::Command};
use tracing::warn;
@@ -27,8 +27,6 @@ use crate::{
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
const DEVICE_BACKUP_KIND: &str = "phoenix-device-backup";
const DEVICE_BACKUP_SCHEMA_VERSION: u32 = 1;
const REALTIME_WS_UPDATES_PER_SECOND: u64 = 120;
const MAX_MERGED_XY_POINTS: usize = 2048;
static JSON_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1);
fn stable_script_command(program: &str) -> Command {
@@ -1198,11 +1196,7 @@ fn normalize_frontend_layout_id(raw: &str) -> String {
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics();
let tick_ns = 1_000_000_000u64 / REALTIME_WS_UPDATES_PER_SECOND;
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
break;
};
@@ -1242,11 +1236,7 @@ fn strip_visual_payloads(frame: &mut MeterFrame) {
async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics();
let tick_ns = 1_000_000_000u64 / REALTIME_WS_UPDATES_PER_SECOND;
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
return;
};
@@ -1426,14 +1416,19 @@ fn drain_visual_meter_frames(
latest: &mut Arc<MeterFrame>,
) -> (Option<WaveEnvFrame>, Option<(Vec<f32>, Vec<f32>)>) {
let mut combined_wave_env = latest.wave_env.clone();
let mut combined_xy = None;
append_xy_samples(&mut combined_xy, &latest.xy_l, &latest.xy_r);
let mut latest_xy = if latest.xy_l.is_empty() || latest.xy_r.is_empty() {
None
} else {
Some((latest.xy_l.clone(), latest.xy_r.clone()))
};
loop {
match rx.try_recv() {
Ok(newer) => {
merge_wave_env(&mut combined_wave_env, newer.wave_env.clone());
append_xy_samples(&mut combined_xy, &newer.xy_l, &newer.xy_r);
if !newer.xy_l.is_empty() && !newer.xy_r.is_empty() {
latest_xy = Some((newer.xy_l.clone(), newer.xy_r.clone()));
}
*latest = newer;
}
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
@@ -1444,31 +1439,7 @@ fn drain_visual_meter_frames(
}
}
(combined_wave_env, combined_xy)
}
fn append_xy_samples(
target: &mut Option<(Vec<f32>, Vec<f32>)>,
incoming_l: &[f32],
incoming_r: &[f32],
) {
let count = incoming_l.len().min(incoming_r.len());
if count == 0 {
return;
}
let (left, right) = target.get_or_insert_with(|| {
(
Vec::with_capacity(count.min(MAX_MERGED_XY_POINTS)),
Vec::with_capacity(count.min(MAX_MERGED_XY_POINTS)),
)
});
left.extend_from_slice(&incoming_l[..count]);
right.extend_from_slice(&incoming_r[..count]);
if left.len() > MAX_MERGED_XY_POINTS {
let excess = left.len() - MAX_MERGED_XY_POINTS;
left.drain(..excess);
right.drain(..excess);
}
(combined_wave_env, latest_xy)
}
fn merge_wave_env(target: &mut Option<WaveEnvFrame>, incoming: Option<WaveEnvFrame>) {
@@ -1718,43 +1689,6 @@ mod tests {
assert_eq!(tp_r, -3.0);
}
#[test]
fn visual_drain_keeps_xy_samples_in_chronological_order() {
let (tx, mut rx) = tokio::sync::broadcast::channel(8);
for (seq, left, right) in [
(1, vec![1.0, 2.0], vec![-1.0, -2.0]),
(2, vec![3.0, 4.0], vec![-3.0, -4.0]),
(3, vec![5.0], vec![-5.0]),
] {
let mut frame = meter_frame();
frame.seq = seq;
frame.xy_l = left;
frame.xy_r = right;
tx.send(Arc::new(frame)).unwrap();
}
let mut latest = rx.try_recv().unwrap();
let (_, xy) = drain_visual_meter_frames(&mut rx, &mut latest);
let (left, right) = xy.unwrap();
assert_eq!(latest.seq, 3);
assert_eq!(left, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
assert_eq!(right, vec![-1.0, -2.0, -3.0, -4.0, -5.0]);
}
#[test]
fn merged_xy_keeps_the_newest_bounded_window() {
let mut xy = None;
let first: Vec<f32> = (0..1500).map(|value| value as f32).collect();
let second: Vec<f32> = (1500..2500).map(|value| value as f32).collect();
append_xy_samples(&mut xy, &first, &first);
append_xy_samples(&mut xy, &second, &second);
let (left, right) = xy.unwrap();
assert_eq!(left.len(), MAX_MERGED_XY_POINTS);
assert_eq!(right.len(), MAX_MERGED_XY_POINTS);
assert_eq!(left[0], 452.0);
assert_eq!(left[MAX_MERGED_XY_POINTS - 1], 2499.0);
}
#[test]
fn wave_envelopes_merge_without_losing_columns() {
let mut target = Some(WaveEnvFrame {
+4 -4
View File
@@ -6,9 +6,9 @@
use std::collections::VecDeque;
pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[
20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0,
500.0, 630.0, 800.0, 1_000.0, 1_250.0, 1_600.0, 2_000.0, 2_500.0, 3_150.0, 4_000.0, 5_000.0,
6_300.0, 8_000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0,
5.0, 6.3, 8.0, 10.0, 12.5, 16.0, 20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0,
200.0, 250.0, 315.0, 400.0, 500.0, 630.0, 800.0, 1_000.0, 1_250.0, 1_600.0, 2_000.0, 2_500.0,
3_150.0, 4_000.0, 5_000.0, 6_300.0, 8_000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.0,
];
/// Maps a rounded preferred label to its exact IEC base-ten center frequency.
@@ -448,7 +448,7 @@ mod tests {
#[test]
fn third_octave_butterworth_has_correct_center_and_edges() {
assert_eq!(RTW_THIRD_OCTAVE_CENTERS.len(), 31);
assert_eq!(RTW_THIRD_OCTAVE_CENTERS.len(), 37);
for &nominal_center in RTW_THIRD_OCTAVE_CENTERS {
let center = exact_fractional_octave_center(nominal_center, 3);
let (lower, upper) = fractional_octave_edges(center, 3);
+1 -2
View File
@@ -426,7 +426,6 @@ fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
if config.layout == "rtw" {
config.engine = "iir".to_string();
config.order = 6;
config.freq_range = "norm".to_string();
}
// Fast and Slow are named standard responses, not free tuning controls.
@@ -853,7 +852,7 @@ mod tests {
assert_eq!(config.filterbank, "legacy");
assert_eq!(config.bpo, "1_12");
assert_eq!(config.order, 6);
assert_eq!(config.freq_range, "norm");
assert_eq!(config.freq_range, "lf");
}
#[test]
+70 -43
View File
@@ -16,14 +16,18 @@ let phoenixSpectroSocket = null;
let phoenixSpectroRetryTimer = null;
let phoenixSpectroDemanded = false;
let pendingSpectroBuffer = null;
let phoenixSpectroRaf = 0;
let phoenixVisualsSocket = null;
let phoenixVisualsRetryTimer = null;
let pendingVisualsBuffer = null;
let phoenixVisualsRaf = 0;
let envRef = null;
let lifecycleHandlersBound = false;
let recoverTimer = null;
let lastHardRecoverAt = 0;
let pendingPhoenixFrame = null;
let phoenixPacketRaf = 0;
let phoenixPacketBusy = false;
const RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 };
const WAVEFORM_RING_SECONDS = 20;
@@ -103,6 +107,8 @@ function closePhoenixSocket() {
phoenixSpectroSocket = null;
phoenixSpectroDemanded = false;
pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0;
if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer);
phoenixVisualsRetryTimer = null;
if (phoenixVisualsSocket) {
@@ -116,7 +122,12 @@ function closePhoenixSocket() {
}
phoenixVisualsSocket = null;
pendingVisualsBuffer = null;
if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf);
phoenixVisualsRaf = 0;
pendingPhoenixFrame = null;
if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf);
phoenixPacketRaf = 0;
phoenixPacketBusy = false;
}
async function requestPhoenixRtaConfig(baseUrl, config) {
@@ -261,6 +272,7 @@ function openPhoenixSpectroSocket(baseUrl, env) {
socket.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer)) return;
pendingSpectroBuffer = event.data;
scheduleSpectroBufferPump(env);
};
const retry = () => {
@@ -297,6 +309,27 @@ function setPhoenixSpectroDemand(baseUrl, env, demanded) {
} catch (_) {}
}
pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0;
}
function scheduleSpectroBufferPump(env) {
if (phoenixSpectroRaf || !pendingSpectroBuffer) return;
phoenixSpectroRaf = requestAnimationFrame(() => {
phoenixSpectroRaf = 0;
const buffer = pendingSpectroBuffer;
pendingSpectroBuffer = null;
try {
const decoded = decodePhoenixSpectroBuffer(buffer);
if (!decoded) return;
if (!copyPhoenixSpectroBins(env.audio, decoded)) return;
env.requestRender?.('spectro');
} catch (err) {
console.warn('Phoenix spectrogram packet error:', err);
} finally {
if (pendingSpectroBuffer) scheduleSpectroBufferPump(env);
}
});
}
function openPhoenixVisualsSocket(baseUrl, env) {
@@ -310,6 +343,7 @@ function openPhoenixVisualsSocket(baseUrl, env) {
socket.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer)) return;
pendingVisualsBuffer = event.data;
scheduleVisualsBufferPump(env);
};
const retry = () => {
if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null;
@@ -324,6 +358,22 @@ function openPhoenixVisualsSocket(baseUrl, env) {
socket.onclose = retry;
}
function scheduleVisualsBufferPump(env) {
if (phoenixVisualsRaf || !pendingVisualsBuffer) return;
phoenixVisualsRaf = requestAnimationFrame(() => {
phoenixVisualsRaf = 0;
const buffer = pendingVisualsBuffer;
pendingVisualsBuffer = null;
try {
applyVisualsBuffer(env, buffer);
} catch (err) {
console.warn('Phoenix visual packet error:', err);
} finally {
if (pendingVisualsBuffer) scheduleVisualsBufferPump(env);
}
});
}
function applyVisualsBuffer(env, buffer) {
const decoded = decodePhoenixVisualsBuffer(buffer);
if (!decoded) return;
@@ -382,6 +432,7 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
const previousSeq = Number(env.audio.phoenixMetricsSeq || 0);
if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return;
if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq;
env.requestRender?.('audio');
env.audio.lastSampleTs = sampleTs;
env.audio.alive = true;
if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) {
@@ -436,10 +487,6 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
}
await updateActiveMeters(env, d, CONFIG);
// Publish the frame only after every active meter has consumed the packet.
// Otherwise the canvas loop can draw between the data update and the meter
// update, producing an old frame followed by a visible catch-up step.
env.requestRender?.('audio');
}
function buildPhoenixMeterPacket(frame) {
@@ -579,49 +626,29 @@ function mergePendingPhoenixFrame(previous, next) {
return merged;
}
function enqueuePhoenixFrame(frame) {
pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame);
}
// WebSocket delivery and the display's VSync are independent clocks. Keep the
// callbacks allocation-light and publish one coherent newest-state snapshot at
// the beginning of the single canvas render loop. This prevents metrics,
// visuals and spectrogram updates from being applied in bursts between frames.
export async function flushPendingPhoenixData(env, CONFIG) {
const metricsFrame = pendingPhoenixFrame;
const visualsBuffer = pendingVisualsBuffer;
const spectroBuffer = pendingSpectroBuffer;
pendingPhoenixFrame = null;
pendingVisualsBuffer = null;
pendingSpectroBuffer = null;
if (metricsFrame) {
function schedulePhoenixPacketPump(env, CONFIG) {
if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return;
phoenixPacketRaf = requestAnimationFrame(async () => {
phoenixPacketRaf = 0;
const frame = pendingPhoenixFrame;
pendingPhoenixFrame = null;
if (!frame) return;
phoenixPacketBusy = true;
try {
const packet = buildPhoenixMeterPacket(metricsFrame);
const packet = buildPhoenixMeterPacket(frame);
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now());
} catch (err) {
console.warn('Phoenix packet error:', err);
} finally {
phoenixPacketBusy = false;
if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG);
}
}
});
}
if (visualsBuffer) {
try {
applyVisualsBuffer(env, visualsBuffer);
} catch (err) {
console.warn('Phoenix visual packet error:', err);
}
}
if (spectroBuffer) {
try {
const decoded = decodePhoenixSpectroBuffer(spectroBuffer);
if (decoded && copyPhoenixSpectroBins(env.audio, decoded)) {
env.requestRender?.('spectro');
}
} catch (err) {
console.warn('Phoenix spectrogram packet error:', err);
}
}
function enqueuePhoenixFrame(env, CONFIG, frame) {
pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame);
schedulePhoenixPacketPump(env, CONFIG);
}
function bindLifecycleHandlers() {
@@ -900,7 +927,7 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0,
bpo: runtimeBpoMode,
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'),
freqRange: CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm',
weighting: CONFIG.RTA_WEIGHTING || 'z',
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.125,
@@ -1144,7 +1171,7 @@ async function initPhoenixAudio(env) {
socket.onmessage = (event) => {
try {
const frame = JSON.parse(event.data);
enqueuePhoenixFrame(frame);
enqueuePhoenixFrame(env, CONFIG, frame);
} catch (err) {
console.warn('Phoenix metrics parse error:', err);
}
-2
View File
@@ -821,7 +821,6 @@ function loadConfig(opts = {}) {
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5;
}
@@ -1168,7 +1167,6 @@ function applyPhoenixGlobalConfig(payload = {}) {
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
}
const spectroGamma = Number(payload.spectroGamma);
if (Number.isFinite(spectroGamma)) CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, spectroGamma));
+4 -1
View File
@@ -1,10 +1,11 @@
export const RTW_CENTER_MAP = {
'1_3': [
20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
5, 6.3, 8, 10, 12.5, 16, 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160,
200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
],
'1_6': [
5, 5.6, 6.3, 7.08, 8, 9, 10, 11.2, 12.5, 14.1, 16, 18,
20, 22.4, 25, 28, 31.5, 35.5, 40, 45, 50, 56,
63, 70.8, 80, 90, 100, 112, 125, 141, 160, 180,
200, 224, 250, 282, 315, 355, 400, 450, 500, 560,
@@ -13,6 +14,8 @@ export const RTW_CENTER_MAP = {
6300, 7080, 8000, 9000, 10000, 11200, 12500, 14100, 16000, 18000, 20000,
],
'1_12': [
5, 5.3, 5.6, 5.95, 6.3, 6.7, 7.1, 7.5, 8, 8.5, 9, 9.5,
10, 10.6, 11.2, 11.8, 12.5, 13.2, 14, 15, 16, 17, 18, 19,
20, 21.2, 22.4, 23.8, 25, 26.5, 28, 29.8, 31.5, 33.4,
35.5, 37.6, 40, 42.4, 45, 47.5, 50, 53, 56, 59.5,
63, 67, 71, 75, 80, 85, 90, 95, 100, 106,
+6 -28
View File
@@ -23,7 +23,7 @@ try {
import { CONFIG, applyRtaBpoSelection, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js';
import * as utils from './core/utils.js';
import { meterFacade, registerMeter } from './core/registry.js';
import { initAudio, audioLost, reloadAudio, flushPendingPhoenixData } from './core/audio.js';
import { initAudio, audioLost, reloadAudio } from './core/audio.js';
import { createScreensaver } from './core/screensaver.js';
// Views
@@ -2634,7 +2634,6 @@ let prevSaverActive = false;
let audioRecoverInFlight = false;
let lastAudioRecoverAt = 0;
let renderDirty = true;
let renderRequestGeneration = 0;
let lastRenderedAudioSeq = 0;
let processingProfileDirty = true;
let appliedProcessingProfileView = '';
@@ -2643,7 +2642,6 @@ const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']);
function requestRender(reason = 'ui') {
renderDirty = true;
renderRequestGeneration += 1;
if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true;
env.__lastRenderReason = reason;
}
@@ -2675,10 +2673,6 @@ async function maybeRecoverAudio(reason) {
async function loop(now){
const elapsed = now - lastFrameTime;
// Apply all newest transport states once, immediately before this display
// frame reads them. WebSocket callbacks only buffer; this is the sole
// presentation pump for metrics, visuals and spectrogram data.
await flushPendingPhoenixData(env, CONFIG);
const audioOk = env.audio.alive && !audioLost(env);
const renderStyle = getRenderableStyle();
if (processingProfileDirty || appliedProcessingProfileView !== renderStyle) {
@@ -2692,30 +2686,17 @@ async function loop(now){
}
const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
const target = targetMs;
const currentMetricsSeq = Number.isFinite(env.audio?.phoenixMetricsSeq)
? env.audio.phoenixMetricsSeq
: 0;
const currentVisualsSeq = Number.isFinite(env.audio?.phoenixVisualsSeq)
? env.audio.phoenixVisualsSeq
: 0;
const currentXySeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
const currentAudioSeq = Math.max(currentMetricsSeq, currentVisualsSeq, currentXySeq);
const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
const audioDirty = currentAudioSeq > lastRenderedAudioSeq;
const shouldRenderFrame =
audioOk ||
renderDirty ||
audioDirty ||
!audioOk ||
style === 'options-panel' ||
style === 'recorder' ||
renderStyle === 'clock';
// With live audio, requestAnimationFrame itself is the display clock. An
// additional exact 60 Hz comparison occasionally rejected a real 60 Hz
// frame because of fractional timestamp jitter, creating 33 ms pauses in
// otherwise smooth peak decay. Only throttle the disconnected state.
const frameDue = audioOk || elapsed >= target;
if (frameDue){
lastFrameTime = audioOk ? now : now - (elapsed % targetMs);
if (elapsed >= target){
lastFrameTime = now - (elapsed % targetMs);
try{
updateRect();
meterHitRects.length = 0;
@@ -2733,17 +2714,14 @@ async function loop(now){
}
prevSaverActive = saverActive;
if (!saverActive && shouldRenderFrame) {
const renderGeneration = renderRequestGeneration;
meterFacade.setFrameStamp?.(++frameCounter);
drawBG();
if (!currentView || currentViewId !== renderStyle) setView(renderStyle);
if (currentView.render) await currentView.render(env, viewState);
drawOptionsPanelBackdrop();
drawAudioLostOverlay();
// A packet or UI event can request another frame while the async view
// render is in progress. Do not erase that newer request here.
if (renderRequestGeneration === renderGeneration) renderDirty = false;
lastRenderedAudioSeq = Math.max(lastRenderedAudioSeq, currentAudioSeq);
renderDirty = false;
lastRenderedAudioSeq = currentAudioSeq;
}
} catch(e){
showErr('Render error: ' + (e?.message || String(e)));
+1 -3
View File
@@ -1002,7 +1002,7 @@ function wireHandlers(env) {
return val;
});
h('opt_rtaFreq', v => {
CONFIG.RTA_FREQ_RANGE = CONFIG.RTA_BAR_LAYOUT === 'rtw' ? 'norm' : ((v === 'lf') ? 'lf' : 'norm');
CONFIG.RTA_FREQ_RANGE = (v === 'lf') ? 'lf' : 'norm';
return CONFIG.RTA_FREQ_RANGE;
});
h('opt_rtaDisplayGainFft', v => { CONFIG.RTA_DISPLAY_GAIN_FFT_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainFft', v=>`${v} dB`);
@@ -1013,11 +1013,9 @@ function wireHandlers(env) {
if (val === 'rtw') {
CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const forced = {
opt_rtaEngine: 'iir',
opt_rtaOrder: '6',
opt_rtaFreq: 'norm',
};
for (const [id, value] of Object.entries(forced)) {
const control = document.getElementById(id);
+1 -1
View File
@@ -11,7 +11,7 @@ const METER_PAD_BOTTOM = 5;
const METER_SLOT_SHRINK = 24; // reduziert nutzbare Höhe leicht, damit Slot niedriger wirkt
const METER_EXTRA_BOTTOM_PAD = 5;
const FLOOR_DB = -120;
const RTA_X_TICKS = [20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const RTA_X_TICKS = [5, 10, 20, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
const DEFAULT_RTA_BAR_BASE_COLOR = MID_COLOR;
const RTA_PEAK_COLOR = WARN_COLOR;
const EMBEDDED_RTA_HIDE_20HZ_WIDTH = 420;