Compare commits
8
Commits
66110be76d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3b6c3059 | ||
|
|
684c5fe3d3 | ||
|
|
07563c3b11 | ||
|
|
48578c5919 | ||
|
|
cbbeac5b0f | ||
|
|
ab19798a2d | ||
|
|
96d323c32d | ||
|
|
dba83a2d98 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 ==="
|
||||||
@@ -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');
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||||
|
|||||||
+68
-41
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user