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 299 additions and 225 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 1. Dienste deaktivieren
sudo systemctl disable --now lightdm.service sudo systemctl disable --now lightdm.service
sudo systemctl disable --now getty@tty1.service sudo systemctl mask --now getty@tty1.service
sudo systemctl reset-failed 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 2. systemd neu laden
sudo systemctl daemon-reload 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 sudo bash /home/volumio/Phoenix/scripts/install_volumio_services.sh
``` ```
Der Installer verändert keine Debian-/Analyzer-Units und startet laufende Der Installer überschreibt keine Debian-/Analyzer-Unitdateien und startet
Dienste nicht automatisch neu. 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: Wenn `toggle-ext` genutzt werden soll, sollte es also unter diesem Pfad liegen:
@@ -1,5 +1,7 @@
[Unit] [Unit]
Description=Analyzer Kiosk Volumio (Raspberry Pi OS) Description=Analyzer Kiosk Volumio (Raspberry Pi OS)
Conflicts=getty@tty1.service
After=systemd-user-sessions.service getty-pre.target
After=network-online.target After=network-online.target
After=analyzer-web-volumio.service After=analyzer-web-volumio.service
Wants=analyzer-web-volumio.service Wants=analyzer-web-volumio.service
@@ -1,5 +1,7 @@
[Unit] [Unit]
Description=Analyzer Kiosk (Raspberry Pi OS) Description=Analyzer Kiosk (Raspberry Pi OS)
Conflicts=getty@tty1.service
After=systemd-user-sessions.service getty-pre.target
After=network-online.target After=network-online.target
After=analyzer-web.service After=analyzer-web.service
Wants=analyzer-web.service Wants=analyzer-web.service
+5
View File
@@ -51,8 +51,13 @@ install -o root -g root -m 0440 \
"$SUDOERS_SOURCE" \ "$SUDOERS_SOURCE" \
/etc/sudoers.d/phoenix-update-volumio /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 daemon-reload
systemctl enable analyzer-web-volumio.service analyzer-kiosk-volumio.service systemctl enable analyzer-web-volumio.service analyzer-kiosk-volumio.service
echo "Volumio integration installed" 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" echo "Existing services were not restarted"
+58 -7
View File
@@ -209,6 +209,46 @@ build_volumio_candidate() {
echo "staged Volumio build completed" 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() { start_all_services() {
if is_volumio_install; then if is_volumio_install; then
restart_if_present analyzer-web-volumio.service restart_if_present analyzer-web-volumio.service
@@ -248,6 +288,12 @@ rollback_to_backup() {
return 1 return 1
fi fi
mv "$BACKUP_DIR" "$PHOENIX_ROOT" 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 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 echo "backup rollback restored files but Phoenix health did not recover" >&2
return 1 return 1
@@ -289,6 +335,7 @@ if [ ! -f "$STAGE_DIR/Cargo.toml" ] || [ ! -d "$STAGE_DIR/src" ] || [ ! -d "$STA
fi fi
if is_volumio_install; then if is_volumio_install; then
validate_volumio_integration_candidate "$STAGE_DIR"
build_volumio_candidate "$STAGE_DIR" build_volumio_candidate "$STAGE_DIR"
fi fi
@@ -303,6 +350,17 @@ mv "$STAGE_DIR" "$PHOENIX_ROOT"
health_attempts=45 health_attempts=45
health_delay=1 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 ! wait_for_candidate_health "$PHOENIX_ROOT" "$health_attempts" "$health_delay"; then
if ! rollback_to_backup; then if ! rollback_to_backup; then
echo "new Phoenix version failed health check and rollback failed" >&2 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 exit 1
fi 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 ===" 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 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 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 realtime = fs.readFileSync(new URL('../www/views/realtime.js', import.meta.url), 'utf8');
const styleSelect = html.match(/<select\b[^>]*\bid="styleSel"[^>]*>/)?.[0] || ''; 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(/adjustStyleDropdownWidth\(\)/.test(main), 'dynamic style selector sizing is missing');
assert(/styleSel\.style\.width\s*=/.test(main), 'dynamic style selector width assignment 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), assert(!/syncNativePeakHold/.test(realtime),
'native RTA peak anchors must not be copied directly into the visible hold line'); '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, assert((realtime.match(/applyPeakHold\(state, integrated, CONFIG, range\)/g) || []).length >= 3,
'every RTA engine must advance peak hold on the display clock'); 'every RTA engine must advance peak hold on the display clock');
assert(/useIirEngine[^]*?applyRealtimeBarBallistics\(state, displayBase, CONFIG, range\)/.test(realtime), assert(/useIirEngine[^]*?applyRealtimeBarBallistics\(state, displayBase, CONFIG, range\)/.test(realtime),
'native IIR bars must advance their visible release on the display clock'); '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'); console.log('GUI contract regression tests passed');
+7 -5
View File
@@ -31,9 +31,9 @@ const context = vm.createContext({
Number, Number,
normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds, normalizeCorrelationResponseSeconds: correlationContext.normalizeCorrelationResponseSeconds,
getRtwCenters(mode) { getRtwCenters(mode) {
if (mode === '1_12') return new Array(121).fill(0); if (mode === '1_12') return new Array(145).fill(0);
if (mode === '1_6') return new Array(61).fill(0); if (mode === '1_6') return new Array(73).fill(0);
return new Array(31).fill(0); return new Array(37).fill(0);
}, },
}); });
vm.runInContext(functionSource.replace('export function', 'function'), context); vm.runInContext(functionSource.replace('export function', 'function'), context);
@@ -56,7 +56,7 @@ const forced = context.buildRtaRuntimeConfig({
}); });
assert.equal(forced.engine, 'iir'); assert.equal(forced.engine, 'iir');
assert.equal(forced.bpo, '1_12'); assert.equal(forced.bpo, '1_12');
assert.equal(forced.freqRange, 'norm'); assert.equal(forced.freqRange, 'lf');
assert.equal(forced.order, 6); assert.equal(forced.order, 6);
assert.equal(forced.detector, 'peak'); assert.equal(forced.detector, 'peak');
assert.equal(forced.rtaPeakHoldMode, 'fall'); assert.equal(forced.rtaPeakHoldMode, 'fall');
@@ -64,7 +64,7 @@ assert.equal(forced.rtaPeakHoldSeconds, 4);
assert.equal(forced.rtaPeakDecayDbPerSecond, 20); assert.equal(forced.rtaPeakDecayDbPerSecond, 20);
assert.equal(forced.rtaPeakResetToken, 9); assert.equal(forced.rtaPeakResetToken, 9);
assert.equal(forced.tauFast, 0.125); 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.correlationResponseS, 2.5);
assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50); assert.equal(forced.correlationSilenceThresholdRmsDbfs, -50);
assert.equal(forced.correlationResetToken, 7); 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'); assert.equal(selectionContext.CONFIG.RTA_BAR_LAYOUT, 'rtw');
const twelfthCenters = getRtwCenters('1_12'); const twelfthCenters = getRtwCenters('1_12');
assert.equal(twelfthCenters[0], 5);
assert.equal(twelfthCenters.at(-1), 20000);
const staleTwelfthPacket = { const staleTwelfthPacket = {
engine: 'iir', engine: 'iir',
bpo: '1_12', bpo: '1_12',
+1 -1
View File
@@ -37,5 +37,5 @@ metrics.addEventListener('message', (event) => {
await new Promise((resolve) => setTimeout(resolve, 1100)); await new Promise((resolve) => setTimeout(resolve, 1100));
sockets.forEach((socket) => socket.close()); sockets.forEach((socket) => socket.close());
assert.equal(invalidPayload, false, 'metrics must be ordered and contain no large visual payloads'); 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)`); 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")] #[cfg(target_os = "linux")]
use crate::correlation::CorrelationMeter; use crate::correlation::CorrelationMeter;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use crate::goniometer::selected_sample_indices; use crate::goniometer::{selected_sample_indices, GoniometerClock};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame}; use crate::model::{RtaFrame, SpectroFrame, WaveEnvFrame};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -40,8 +40,8 @@ use crate::{
model::{InputSource, MeterFrame, PhoenixRtaConfig}, model::{InputSource, MeterFrame, PhoenixRtaConfig},
}; };
#[cfg(not(target_os = "linux"))] #[cfg(target_os = "linux")]
const PLACEHOLDER_UPDATES_PER_SECOND: u32 = 120; const METRICS_TARGET_UPDATES_PER_SECOND: u32 = 60;
#[derive(Clone, Copy, Debug, Default)] #[derive(Clone, Copy, Debug, Default)]
struct TransportPeaks { struct TransportPeaks {
@@ -90,24 +90,26 @@ const BOX_MAX_DB: f32 = 9.0;
const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256; const NATIVE_RECORDER_DISCONTINUITY_BLEND_FRAMES: usize = 256;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
const RTW_CENTERS_1_6: &[f32] = &[ 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, 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,
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, 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,
500.0, 560.0, 630.0, 708.0, 800.0, 900.0, 1000.0, 1120.0, 1250.0, 1410.0, 1600.0, 1800.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,
2000.0, 2240.0, 2500.0, 2820.0, 3150.0, 3550.0, 4000.0, 4500.0, 5000.0, 5600.0, 6300.0, 7080.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,
8000.0, 9000.0, 10000.0, 11200.0, 12500.0, 14100.0, 16000.0, 18000.0, 20000.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")] #[cfg(target_os = "linux")]
const RTW_CENTERS_1_12: &[f32] = &[ 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, 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,
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, 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,
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, 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,
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, 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,
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, 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,
1120.0, 1180.0, 1250.0, 1320.0, 1400.0, 1500.0, 1600.0, 1700.0, 1800.0, 1900.0, 2000.0, 2120.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,
2240.0, 2380.0, 2500.0, 2650.0, 2800.0, 2980.0, 3150.0, 3340.0, 3550.0, 3760.0, 4000.0, 4240.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,
4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, 6300.0, 6700.0, 7100.0, 7500.0, 8000.0, 8500.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,
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, 3340.0, 3550.0, 3760.0, 4000.0, 4240.0, 4500.0, 4750.0, 5000.0, 5300.0, 5600.0, 5950.0, 6300.0,
16_000.0, 17_000.0, 18_000.0, 19_000.0, 20_000.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)] #[derive(Clone)]
@@ -415,8 +417,7 @@ pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) { pub fn spawn_audio_capture_worker(deps: AudioWorkerDeps) {
tokio::spawn(async move { tokio::spawn(async move {
warn!("Phoenix ALSA capture is only available on Linux; emitting placeholder frames on this host"); 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_millis(16));
let mut ticker = tokio::time::interval(Duration::from_nanos(tick_ns));
deps.actual_sample_rate deps.actual_sample_rate
.store(deps.config.sample_rate as u64, Ordering::SeqCst); .store(deps.config.sample_rate as u64, Ordering::SeqCst);
loop { loop {
@@ -594,6 +595,7 @@ struct PpmState {
lr_delay_y1_r: f32, lr_delay_y1_r: f32,
true_peak_l: TruePeakDetector, true_peak_l: TruePeakDetector,
true_peak_r: TruePeakDetector, true_peak_r: TruePeakDetector,
transport_clock: GoniometerClock,
transport_peaks: TransportPeaks, transport_peaks: TransportPeaks,
correlation: CorrelationMeter, correlation: CorrelationMeter,
phase_wheel: PhaseWheelAnalyzer, phase_wheel: PhaseWheelAnalyzer,
@@ -626,6 +628,7 @@ impl Default for PpmState {
lr_delay_y1_r: 0.0, lr_delay_y1_r: 0.0,
true_peak_l: TruePeakDetector::default(), true_peak_l: TruePeakDetector::default(),
true_peak_r: TruePeakDetector::default(), true_peak_r: TruePeakDetector::default(),
transport_clock: GoniometerClock::default(),
transport_peaks: TransportPeaks::default(), transport_peaks: TransportPeaks::default(),
correlation: CorrelationMeter::new(48_000, 1.0, -75.0, 0), correlation: CorrelationMeter::new(48_000, 1.0, -75.0, 0),
phase_wheel: PhaseWheelAnalyzer::new(48_000), phase_wheel: PhaseWheelAnalyzer::new(48_000),
@@ -1251,6 +1254,13 @@ fn process_audio_block(
None 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 { 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::Iir(bank) => Some(build_iir_rta_frame(bank, sample_rate, rta_config)),
RtaEngineState::Fft(fft) if fft.ring_fill >= fft.fft_size => { RtaEngineState::Fft(fft) if fft.ring_fill >= fft.fft_size => {
+67 -8
View File
@@ -17,6 +17,7 @@ pub struct CorrelationMeter {
cross_power: f64, cross_power: f64,
gate_power: f64, gate_power: f64,
value: f32, value: f32,
display: f64,
negative_peak: f32, negative_peak: f32,
reset_token: u64, reset_token: u64,
} }
@@ -40,6 +41,7 @@ impl CorrelationMeter {
cross_power: 0.0, cross_power: 0.0,
gate_power: 0.0, gate_power: 0.0,
value: 0.0, value: 0.0,
display: 0.0,
negative_peak: 0.0, negative_peak: 0.0,
reset_token, reset_token,
}; };
@@ -93,18 +95,24 @@ impl CorrelationMeter {
* (0.5 * (l * l + r * r) - self.gate_power); * (0.5 * (l * l + r * r) - self.gate_power);
// Below the configured mono RMS threshold the correlation indication // 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; const MIN_POWER: f64 = 1.0e-10;
let denominator = (self.power_l * self.power_r).sqrt(); 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_l > MIN_POWER
&& self.power_r > MIN_POWER && self.power_r > MIN_POWER
&& denominator > 0.0 && denominator > 0.0;
{ if active {
(self.cross_power / denominator).clamp(-1.0, 1.0) as f32 // 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 { } 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 { if self.value < self.negative_peak {
self.negative_peak = self.value; self.negative_peak = self.value;
} }
@@ -255,7 +263,26 @@ mod tests {
}; };
(sample, sample) (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] #[test]
@@ -278,4 +305,36 @@ mod tests {
}); });
assert_eq!(meter.value(), 0.0); 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. //! Timing and sample-selection helpers for the realtime goniometer stream.
#[cfg(test)]
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct GoniometerClock { pub struct GoniometerClock {
phase: u64, phase: u64,
} }
#[cfg(test)]
impl GoniometerClock { impl GoniometerClock {
/// Returns true at the first capture boundary after the next display tick. /// Returns true at the first capture boundary after the next display tick.
/// The fractional phase is retained, so the average rate is independent /// The fractional phase is retained, so the average rate is independent
+10 -76
View File
@@ -14,7 +14,7 @@ use std::{
process::Stdio, process::Stdio,
sync::atomic::{AtomicU64, Ordering}, sync::atomic::{AtomicU64, Ordering},
sync::Arc, sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
use tokio::{fs, io::AsyncWriteExt, process::Command}; use tokio::{fs, io::AsyncWriteExt, process::Command};
use tracing::warn; use tracing::warn;
@@ -27,8 +27,6 @@ use crate::{
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip"; const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
const DEVICE_BACKUP_KIND: &str = "phoenix-device-backup"; const DEVICE_BACKUP_KIND: &str = "phoenix-device-backup";
const DEVICE_BACKUP_SCHEMA_VERSION: u32 = 1; 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); static JSON_WRITE_TOKEN: AtomicU64 = AtomicU64::new(1);
fn stable_script_command(program: &str) -> Command { 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) { async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics(); 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 { loop {
ticker.tick().await;
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else { let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
break; 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) { async fn visuals_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
let mut rx = state.subscribe_metrics(); 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 { loop {
ticker.tick().await;
let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else { let Some(mut latest) = recv_latest_meter_frame(&mut rx).await else {
return; return;
}; };
@@ -1426,14 +1416,19 @@ fn drain_visual_meter_frames(
latest: &mut Arc<MeterFrame>, latest: &mut Arc<MeterFrame>,
) -> (Option<WaveEnvFrame>, Option<(Vec<f32>, Vec<f32>)>) { ) -> (Option<WaveEnvFrame>, Option<(Vec<f32>, Vec<f32>)>) {
let mut combined_wave_env = latest.wave_env.clone(); let mut combined_wave_env = latest.wave_env.clone();
let mut combined_xy = None; let mut latest_xy = if latest.xy_l.is_empty() || latest.xy_r.is_empty() {
append_xy_samples(&mut combined_xy, &latest.xy_l, &latest.xy_r); None
} else {
Some((latest.xy_l.clone(), latest.xy_r.clone()))
};
loop { loop {
match rx.try_recv() { match rx.try_recv() {
Ok(newer) => { Ok(newer) => {
merge_wave_env(&mut combined_wave_env, newer.wave_env.clone()); 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; *latest = newer;
} }
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break, Err(tokio::sync::broadcast::error::TryRecvError::Empty) => break,
@@ -1444,31 +1439,7 @@ fn drain_visual_meter_frames(
} }
} }
(combined_wave_env, combined_xy) (combined_wave_env, latest_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);
}
} }
fn merge_wave_env(target: &mut Option<WaveEnvFrame>, incoming: Option<WaveEnvFrame>) { fn merge_wave_env(target: &mut Option<WaveEnvFrame>, incoming: Option<WaveEnvFrame>) {
@@ -1718,43 +1689,6 @@ mod tests {
assert_eq!(tp_r, -3.0); 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] #[test]
fn wave_envelopes_merge_without_losing_columns() { fn wave_envelopes_merge_without_losing_columns() {
let mut target = Some(WaveEnvFrame { let mut target = Some(WaveEnvFrame {
+4 -4
View File
@@ -6,9 +6,9 @@
use std::collections::VecDeque; use std::collections::VecDeque;
pub const RTW_THIRD_OCTAVE_CENTERS: &[f32] = &[ 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, 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,
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, 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,
6_300.0, 8_000.0, 10_000.0, 12_500.0, 16_000.0, 20_000.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. /// Maps a rounded preferred label to its exact IEC base-ten center frequency.
@@ -448,7 +448,7 @@ mod tests {
#[test] #[test]
fn third_octave_butterworth_has_correct_center_and_edges() { 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 { for &nominal_center in RTW_THIRD_OCTAVE_CENTERS {
let center = exact_fractional_octave_center(nominal_center, 3); let center = exact_fractional_octave_center(nominal_center, 3);
let (lower, upper) = fractional_octave_edges(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" { if config.layout == "rtw" {
config.engine = "iir".to_string(); config.engine = "iir".to_string();
config.order = 6; config.order = 6;
config.freq_range = "norm".to_string();
} }
// Fast and Slow are named standard responses, not free tuning controls. // 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.filterbank, "legacy");
assert_eq!(config.bpo, "1_12"); assert_eq!(config.bpo, "1_12");
assert_eq!(config.order, 6); assert_eq!(config.order, 6);
assert_eq!(config.freq_range, "norm"); assert_eq!(config.freq_range, "lf");
} }
#[test] #[test]
+69 -42
View File
@@ -16,14 +16,18 @@ let phoenixSpectroSocket = null;
let phoenixSpectroRetryTimer = null; let phoenixSpectroRetryTimer = null;
let phoenixSpectroDemanded = false; let phoenixSpectroDemanded = false;
let pendingSpectroBuffer = null; let pendingSpectroBuffer = null;
let phoenixSpectroRaf = 0;
let phoenixVisualsSocket = null; let phoenixVisualsSocket = null;
let phoenixVisualsRetryTimer = null; let phoenixVisualsRetryTimer = null;
let pendingVisualsBuffer = null; let pendingVisualsBuffer = null;
let phoenixVisualsRaf = 0;
let envRef = null; let envRef = null;
let lifecycleHandlersBound = false; let lifecycleHandlersBound = false;
let recoverTimer = null; let recoverTimer = null;
let lastHardRecoverAt = 0; let lastHardRecoverAt = 0;
let pendingPhoenixFrame = null; 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 RMS_RING = { L: new Float32Array(512), R: new Float32Array(512), i: 0, n: 0 };
const WAVEFORM_RING_SECONDS = 20; const WAVEFORM_RING_SECONDS = 20;
@@ -103,6 +107,8 @@ function closePhoenixSocket() {
phoenixSpectroSocket = null; phoenixSpectroSocket = null;
phoenixSpectroDemanded = false; phoenixSpectroDemanded = false;
pendingSpectroBuffer = null; pendingSpectroBuffer = null;
if (phoenixSpectroRaf) cancelAnimationFrame(phoenixSpectroRaf);
phoenixSpectroRaf = 0;
if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer); if (phoenixVisualsRetryTimer) clearTimeout(phoenixVisualsRetryTimer);
phoenixVisualsRetryTimer = null; phoenixVisualsRetryTimer = null;
if (phoenixVisualsSocket) { if (phoenixVisualsSocket) {
@@ -116,7 +122,12 @@ function closePhoenixSocket() {
} }
phoenixVisualsSocket = null; phoenixVisualsSocket = null;
pendingVisualsBuffer = null; pendingVisualsBuffer = null;
if (phoenixVisualsRaf) cancelAnimationFrame(phoenixVisualsRaf);
phoenixVisualsRaf = 0;
pendingPhoenixFrame = null; pendingPhoenixFrame = null;
if (phoenixPacketRaf) cancelAnimationFrame(phoenixPacketRaf);
phoenixPacketRaf = 0;
phoenixPacketBusy = false;
} }
async function requestPhoenixRtaConfig(baseUrl, config) { async function requestPhoenixRtaConfig(baseUrl, config) {
@@ -261,6 +272,7 @@ function openPhoenixSpectroSocket(baseUrl, env) {
socket.onmessage = (event) => { socket.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer)) return; if (!(event.data instanceof ArrayBuffer)) return;
pendingSpectroBuffer = event.data; pendingSpectroBuffer = event.data;
scheduleSpectroBufferPump(env);
}; };
const retry = () => { const retry = () => {
@@ -297,6 +309,27 @@ function setPhoenixSpectroDemand(baseUrl, env, demanded) {
} catch (_) {} } catch (_) {}
} }
pendingSpectroBuffer = null; 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) { function openPhoenixVisualsSocket(baseUrl, env) {
@@ -310,6 +343,7 @@ function openPhoenixVisualsSocket(baseUrl, env) {
socket.onmessage = (event) => { socket.onmessage = (event) => {
if (!(event.data instanceof ArrayBuffer)) return; if (!(event.data instanceof ArrayBuffer)) return;
pendingVisualsBuffer = event.data; pendingVisualsBuffer = event.data;
scheduleVisualsBufferPump(env);
}; };
const retry = () => { const retry = () => {
if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null; if (phoenixVisualsSocket === socket) phoenixVisualsSocket = null;
@@ -324,6 +358,22 @@ function openPhoenixVisualsSocket(baseUrl, env) {
socket.onclose = retry; 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) { function applyVisualsBuffer(env, buffer) {
const decoded = decodePhoenixVisualsBuffer(buffer); const decoded = decodePhoenixVisualsBuffer(buffer);
if (!decoded) return; if (!decoded) return;
@@ -382,6 +432,7 @@ async function applyIncomingAudioPacket(env, packet, CONFIG, sampleTs = performa
const previousSeq = Number(env.audio.phoenixMetricsSeq || 0); const previousSeq = Number(env.audio.phoenixMetricsSeq || 0);
if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return; if (Number.isFinite(seq) && seq > 0 && previousSeq > 0 && seq <= previousSeq) return;
if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq; if (Number.isFinite(seq) && seq > 0) env.audio.phoenixMetricsSeq = seq;
env.requestRender?.('audio');
env.audio.lastSampleTs = sampleTs; env.audio.lastSampleTs = sampleTs;
env.audio.alive = true; env.audio.alive = true;
if (Number.isFinite(Number(d.sampleRate)) && Number(d.sampleRate) > 0) { 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); 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) { function buildPhoenixMeterPacket(frame) {
@@ -579,49 +626,29 @@ function mergePendingPhoenixFrame(previous, next) {
return merged; return merged;
} }
function enqueuePhoenixFrame(frame) { function schedulePhoenixPacketPump(env, CONFIG) {
pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame); if (phoenixPacketRaf || phoenixPacketBusy || !pendingPhoenixFrame) return;
} phoenixPacketRaf = requestAnimationFrame(async () => {
phoenixPacketRaf = 0;
// WebSocket delivery and the display's VSync are independent clocks. Keep the const frame = pendingPhoenixFrame;
// 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; pendingPhoenixFrame = null;
pendingVisualsBuffer = null; if (!frame) return;
pendingSpectroBuffer = null; phoenixPacketBusy = true;
if (metricsFrame) {
try { try {
const packet = buildPhoenixMeterPacket(metricsFrame); const packet = buildPhoenixMeterPacket(frame);
await applyIncomingAudioPacket(env, packet, CONFIG, performance.now()); await applyIncomingAudioPacket(env, packet, CONFIG, performance.now());
} catch (err) { } catch (err) {
console.warn('Phoenix packet error:', err); console.warn('Phoenix packet error:', err);
} finally {
phoenixPacketBusy = false;
if (pendingPhoenixFrame) schedulePhoenixPacketPump(env, CONFIG);
} }
} });
}
if (visualsBuffer) { function enqueuePhoenixFrame(env, CONFIG, frame) {
try { pendingPhoenixFrame = mergePendingPhoenixFrame(pendingPhoenixFrame, frame);
applyVisualsBuffer(env, visualsBuffer); schedulePhoenixPacketPump(env, CONFIG);
} 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 bindLifecycleHandlers() { function bindLifecycleHandlers() {
@@ -900,7 +927,7 @@ export function buildRtaRuntimeConfig(CONFIG = {}) {
lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED, lrFractionalDelayEnabled: !!CONFIG.LR_FRACTIONAL_DELAY_ENABLED,
lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0, lrFractionalDelaySamples: Number.isFinite(CONFIG.LR_FRACTIONAL_DELAY_SAMPLES) ? CONFIG.LR_FRACTIONAL_DELAY_SAMPLES : 0,
bpo: runtimeBpoMode, bpo: runtimeBpoMode,
freqRange: rtwProfile ? 'norm' : (CONFIG.RTA_FREQ_RANGE || 'norm'), freqRange: CONFIG.RTA_FREQ_RANGE === 'lf' ? 'lf' : 'norm',
weighting: CONFIG.RTA_WEIGHTING || 'z', weighting: CONFIG.RTA_WEIGHTING || 'z',
order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6), order: rtwProfile ? 6 : (CONFIG.RTA_IIR_ORDER || 6),
tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.125, tauFast: CONFIG.RTA_IIR_TAU_FAST || 0.125,
@@ -1144,7 +1171,7 @@ async function initPhoenixAudio(env) {
socket.onmessage = (event) => { socket.onmessage = (event) => {
try { try {
const frame = JSON.parse(event.data); const frame = JSON.parse(event.data);
enqueuePhoenixFrame(frame); enqueuePhoenixFrame(env, CONFIG, frame);
} catch (err) { } catch (err) {
console.warn('Phoenix metrics parse error:', err); console.warn('Phoenix metrics parse error:', err);
} }
-2
View File
@@ -821,7 +821,6 @@ function loadConfig(opts = {}) {
if (CONFIG.RTA_BAR_LAYOUT === 'rtw') { if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir'; CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_IIR_ORDER = 6; CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC); const hold = Number(CONFIG.RTA_PEAK_HOLD_SEC);
CONFIG.RTA_PEAK_HOLD_SEC = hold >= 3.25 ? 4.0 : 2.5; 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') { if (CONFIG.RTA_BAR_LAYOUT === 'rtw') {
CONFIG.RTA_ENGINE = 'iir'; CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_IIR_ORDER = 6; CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
} }
const spectroGamma = Number(payload.spectroGamma); const spectroGamma = Number(payload.spectroGamma);
if (Number.isFinite(spectroGamma)) CONFIG.SPECTRO_GAMMA = Math.max(0.3, Math.min(1.2, 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 = { export const RTW_CENTER_MAP = {
'1_3': [ '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, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600,
2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500, 16000, 20000,
], ],
'1_6': [ '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, 20, 22.4, 25, 28, 31.5, 35.5, 40, 45, 50, 56,
63, 70.8, 80, 90, 100, 112, 125, 141, 160, 180, 63, 70.8, 80, 90, 100, 112, 125, 141, 160, 180,
200, 224, 250, 282, 315, 355, 400, 450, 500, 560, 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, 6300, 7080, 8000, 9000, 10000, 11200, 12500, 14100, 16000, 18000, 20000,
], ],
'1_12': [ '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, 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, 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, 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 { CONFIG, applyRtaBpoSelection, loadConfig, saveConfig, loadLayoutPreset } from './core/config.js';
import * as utils from './core/utils.js'; import * as utils from './core/utils.js';
import { meterFacade, registerMeter } from './core/registry.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'; import { createScreensaver } from './core/screensaver.js';
// Views // Views
@@ -2634,7 +2634,6 @@ let prevSaverActive = false;
let audioRecoverInFlight = false; let audioRecoverInFlight = false;
let lastAudioRecoverAt = 0; let lastAudioRecoverAt = 0;
let renderDirty = true; let renderDirty = true;
let renderRequestGeneration = 0;
let lastRenderedAudioSeq = 0; let lastRenderedAudioSeq = 0;
let processingProfileDirty = true; let processingProfileDirty = true;
let appliedProcessingProfileView = ''; let appliedProcessingProfileView = '';
@@ -2643,7 +2642,6 @@ const DATA_ONLY_RENDER_REASONS = new Set(['audio', 'spectro', 'visuals']);
function requestRender(reason = 'ui') { function requestRender(reason = 'ui') {
renderDirty = true; renderDirty = true;
renderRequestGeneration += 1;
if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true; if (!DATA_ONLY_RENDER_REASONS.has(reason)) processingProfileDirty = true;
env.__lastRenderReason = reason; env.__lastRenderReason = reason;
} }
@@ -2675,10 +2673,6 @@ async function maybeRecoverAudio(reason) {
async function loop(now){ async function loop(now){
const elapsed = now - lastFrameTime; 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 audioOk = env.audio.alive && !audioLost(env);
const renderStyle = getRenderableStyle(); const renderStyle = getRenderableStyle();
if (processingProfileDirty || appliedProcessingProfileView !== renderStyle) { if (processingProfileDirty || appliedProcessingProfileView !== renderStyle) {
@@ -2692,30 +2686,17 @@ async function loop(now){
} }
const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20); const targetMs = audioOk ? (1000 / desiredFpsForStyle(renderStyle)) : (1000 / 20);
const target = targetMs; const target = targetMs;
const currentMetricsSeq = Number.isFinite(env.audio?.phoenixMetricsSeq) const currentAudioSeq = Number.isFinite(env.audio?.xySeq) ? env.audio.xySeq : 0;
? 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 audioDirty = currentAudioSeq > lastRenderedAudioSeq; const audioDirty = currentAudioSeq > lastRenderedAudioSeq;
const shouldRenderFrame = const shouldRenderFrame =
audioOk ||
renderDirty || renderDirty ||
audioDirty || audioDirty ||
!audioOk || !audioOk ||
style === 'options-panel' || style === 'options-panel' ||
style === 'recorder' || style === 'recorder' ||
renderStyle === 'clock'; renderStyle === 'clock';
// With live audio, requestAnimationFrame itself is the display clock. An if (elapsed >= target){
// additional exact 60 Hz comparison occasionally rejected a real 60 Hz lastFrameTime = now - (elapsed % targetMs);
// 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);
try{ try{
updateRect(); updateRect();
meterHitRects.length = 0; meterHitRects.length = 0;
@@ -2733,17 +2714,14 @@ async function loop(now){
} }
prevSaverActive = saverActive; prevSaverActive = saverActive;
if (!saverActive && shouldRenderFrame) { if (!saverActive && shouldRenderFrame) {
const renderGeneration = renderRequestGeneration;
meterFacade.setFrameStamp?.(++frameCounter); meterFacade.setFrameStamp?.(++frameCounter);
drawBG(); drawBG();
if (!currentView || currentViewId !== renderStyle) setView(renderStyle); if (!currentView || currentViewId !== renderStyle) setView(renderStyle);
if (currentView.render) await currentView.render(env, viewState); if (currentView.render) await currentView.render(env, viewState);
drawOptionsPanelBackdrop(); drawOptionsPanelBackdrop();
drawAudioLostOverlay(); drawAudioLostOverlay();
// A packet or UI event can request another frame while the async view renderDirty = false;
// render is in progress. Do not erase that newer request here. lastRenderedAudioSeq = currentAudioSeq;
if (renderRequestGeneration === renderGeneration) renderDirty = false;
lastRenderedAudioSeq = Math.max(lastRenderedAudioSeq, currentAudioSeq);
} }
} catch(e){ } catch(e){
showErr('Render error: ' + (e?.message || String(e))); showErr('Render error: ' + (e?.message || String(e)));
+1 -3
View File
@@ -1002,7 +1002,7 @@ function wireHandlers(env) {
return val; return val;
}); });
h('opt_rtaFreq', v => { 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; return CONFIG.RTA_FREQ_RANGE;
}); });
h('opt_rtaDisplayGainFft', v => { CONFIG.RTA_DISPLAY_GAIN_FFT_DB = clamp(+v, -25, 25); }, 'val_rtaDisplayGainFft', v=>`${v} dB`); 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') { if (val === 'rtw') {
CONFIG.RTA_ENGINE = 'iir'; CONFIG.RTA_ENGINE = 'iir';
CONFIG.RTA_IIR_ORDER = 6; CONFIG.RTA_IIR_ORDER = 6;
CONFIG.RTA_FREQ_RANGE = 'norm';
const forced = { const forced = {
opt_rtaEngine: 'iir', opt_rtaEngine: 'iir',
opt_rtaOrder: '6', opt_rtaOrder: '6',
opt_rtaFreq: 'norm',
}; };
for (const [id, value] of Object.entries(forced)) { for (const [id, value] of Object.entries(forced)) {
const control = document.getElementById(id); 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_SLOT_SHRINK = 24; // reduziert nutzbare Höhe leicht, damit Slot niedriger wirkt
const METER_EXTRA_BOTTOM_PAD = 5; const METER_EXTRA_BOTTOM_PAD = 5;
const FLOOR_DB = -120; 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 DEFAULT_RTA_BAR_BASE_COLOR = MID_COLOR;
const RTA_PEAK_COLOR = WARN_COLOR; const RTA_PEAK_COLOR = WARN_COLOR;
const EMBEDDED_RTA_HIDE_20HZ_WIDTH = 420; const EMBEDDED_RTA_HIDE_20HZ_WIDTH = 420;