574 lines
26 KiB
Rust
574 lines
26 KiB
Rust
use std::{
|
|
collections::HashMap,
|
|
sync::{
|
|
atomic::{AtomicU64, Ordering},
|
|
Arc,
|
|
Mutex,
|
|
},
|
|
};
|
|
|
|
use anyhow::{anyhow, Context};
|
|
use tokio::{
|
|
fs,
|
|
sync::{broadcast, RwLock},
|
|
};
|
|
|
|
use crate::{
|
|
audio::{spawn_audio_capture_worker, AudioWorkerDeps},
|
|
config::PhoenixConfig,
|
|
model::{InputSource, MeterFrame, PhoenixGlobalConfig, PhoenixGlobalConfigEnvelope, PhoenixRtaConfig, ServiceStatus},
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub config: PhoenixConfig,
|
|
current_input: Arc<RwLock<InputSource>>,
|
|
seq: Arc<AtomicU64>,
|
|
metrics_tx: broadcast::Sender<MeterFrame>,
|
|
restart_token: Arc<AtomicU64>,
|
|
rta_config: Arc<RwLock<PhoenixRtaConfig>>,
|
|
global_config: Arc<RwLock<PhoenixGlobalConfig>>,
|
|
global_config_rev: Arc<AtomicU64>,
|
|
native_wav_recorders: Arc<Mutex<HashMap<u64, NativeWavRecorder>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct NativeWavCapture {
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
pub pcm_bytes: Vec<u8>,
|
|
}
|
|
|
|
pub(crate) struct NativeWavRecorder {
|
|
pub sample_rate: u32,
|
|
pub channels: u16,
|
|
pub pcm_bytes: Vec<u8>,
|
|
pub total_frames: usize,
|
|
pub last_l: i16,
|
|
pub last_r: i16,
|
|
pub pending_discontinuity: bool,
|
|
}
|
|
|
|
|
|
const NATIVE_RECORDING_EDGE_FADE_FRAMES: usize = 128;
|
|
|
|
fn apply_native_recording_edge_fades(pcm_bytes: &mut [u8], channels: u16) {
|
|
let ch = usize::from(channels.max(1));
|
|
let frame_size = ch.saturating_mul(2);
|
|
if frame_size == 0 || pcm_bytes.len() < frame_size * 2 {
|
|
return;
|
|
}
|
|
let total_frames = pcm_bytes.len() / frame_size;
|
|
if total_frames < 2 {
|
|
return;
|
|
}
|
|
let fade_frames = NATIVE_RECORDING_EDGE_FADE_FRAMES.min(total_frames / 2).max(1);
|
|
|
|
for frame_idx in 0..fade_frames {
|
|
let in_gain = (frame_idx as f32 + 1.0) / fade_frames as f32;
|
|
let out_gain = (fade_frames as f32 - frame_idx as f32) / fade_frames as f32;
|
|
let out_frame_idx = total_frames - fade_frames + frame_idx;
|
|
for ch_idx in 0..ch {
|
|
let in_off = frame_idx * frame_size + ch_idx * 2;
|
|
let in_sample = i16::from_le_bytes([pcm_bytes[in_off], pcm_bytes[in_off + 1]]);
|
|
let in_scaled = (in_sample as f32 * in_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
|
pcm_bytes[in_off..in_off + 2].copy_from_slice(&in_scaled.to_le_bytes());
|
|
|
|
let out_off = out_frame_idx * frame_size + ch_idx * 2;
|
|
let out_sample = i16::from_le_bytes([pcm_bytes[out_off], pcm_bytes[out_off + 1]]);
|
|
let out_scaled = (out_sample as f32 * out_gain).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
|
pcm_bytes[out_off..out_off + 2].copy_from_slice(&out_scaled.to_le_bytes());
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppState {
|
|
pub fn new(config: PhoenixConfig) -> Self {
|
|
let (metrics_tx, _) = broadcast::channel(512);
|
|
let initial_global_config = load_global_config(&config);
|
|
let initial_rta_config = apply_global_to_rta(config.default_rta_config(), &initial_global_config);
|
|
Self {
|
|
config,
|
|
current_input: Arc::new(RwLock::new(InputSource::Line)),
|
|
seq: Arc::new(AtomicU64::new(0)),
|
|
metrics_tx,
|
|
restart_token: Arc::new(AtomicU64::new(0)),
|
|
rta_config: Arc::new(RwLock::new(initial_rta_config)),
|
|
global_config: Arc::new(RwLock::new(initial_global_config)),
|
|
global_config_rev: Arc::new(AtomicU64::new(1)),
|
|
native_wav_recorders: Arc::new(Mutex::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub fn subscribe_metrics(&self) -> broadcast::Receiver<MeterFrame> {
|
|
self.metrics_tx.subscribe()
|
|
}
|
|
|
|
pub async fn input(&self) -> InputSource {
|
|
*self.current_input.read().await
|
|
}
|
|
|
|
pub async fn status(&self) -> ServiceStatus {
|
|
ServiceStatus {
|
|
ok: true,
|
|
input: self.input().await,
|
|
audio_engine: "alsa-direct",
|
|
metrics_mode: "live",
|
|
alsa_device: self.config.alsa_device(),
|
|
sample_rate: self.config.sample_rate,
|
|
}
|
|
}
|
|
|
|
pub async fn rta_config(&self) -> PhoenixRtaConfig {
|
|
self.rta_config.read().await.clone()
|
|
}
|
|
|
|
pub async fn set_rta_config(&self, config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|
let normalized = normalize_rta_config(config);
|
|
*self.rta_config.write().await = normalized.clone();
|
|
let current = self.global_config.read().await.clone();
|
|
let updated_global = PhoenixGlobalConfig {
|
|
fft_size: normalized.fft_size,
|
|
input_source: current.input_source,
|
|
rta_bpo_mode: current.rta_bpo_mode.clone(),
|
|
input_offset_db_l: normalized.input_offset_db_l,
|
|
input_offset_db_r: normalized.input_offset_db_r,
|
|
mono_input: normalized.mono_input,
|
|
lr_fractional_delay_enabled: normalized.lr_fractional_delay_enabled,
|
|
lr_fractional_delay_samples: normalized.lr_fractional_delay_samples,
|
|
al_markers_enabled: current.al_markers_enabled,
|
|
meter_bar_thin: current.meter_bar_thin,
|
|
panel_dividers_enabled: current.panel_dividers_enabled,
|
|
ppm_din_attack_ms: normalized.ppm_din_attack_ms,
|
|
ppm_din_decay_db_per_s: normalized.ppm_din_decay_db_per_s,
|
|
ppm_din_fast_attack: normalized.ppm_din_fast_attack,
|
|
ppm_ebu_attack_ms: normalized.ppm_ebu_attack_ms,
|
|
ppm_ebu_decay_db_per_s: normalized.ppm_ebu_decay_db_per_s,
|
|
lufs_i_window_min: normalized.lufs_i_window_min,
|
|
lufs_i_norm_enabled: normalized.lufs_i_norm_enabled,
|
|
ppm_din_loudness_boxes: current.ppm_din_loudness_boxes,
|
|
ppm_din_loudness_offset_db: current.ppm_din_loudness_offset_db,
|
|
xy_points: current.xy_points,
|
|
gonio_display_gain_db: current.gonio_display_gain_db,
|
|
record_output_format: current.record_output_format.clone(),
|
|
record_mp3_bitrate_kbps: current.record_mp3_bitrate_kbps,
|
|
record_target: current.record_target.clone(),
|
|
record_auto_split_gap_sec: current.record_auto_split_gap_sec,
|
|
record_auto_threshold_dbfs: current.record_auto_threshold_dbfs,
|
|
clock_led_color: current.clock_led_color.clone(),
|
|
header_text_color: current.header_text_color.clone(),
|
|
rta_bar_base_color: current.rta_bar_base_color.clone(),
|
|
peak_history_scroll_mode: current.peak_history_scroll_mode,
|
|
peak_history_fill_enabled: current.peak_history_fill_enabled,
|
|
peak_history_fill_invert: current.peak_history_fill_invert,
|
|
peak_history_slot: current.peak_history_slot.clone(),
|
|
phase_display_gain_db: current.phase_display_gain_db,
|
|
phase_agc_enabled: current.phase_agc_enabled,
|
|
phase_trail_enabled: current.phase_trail_enabled,
|
|
phase_amplitude_mode: current.phase_amplitude_mode.clone(),
|
|
classic_needles_slot: current.classic_needles_slot.clone(),
|
|
waveform_color_left: current.waveform_color_left.clone(),
|
|
waveform_color_right: current.waveform_color_right.clone(),
|
|
waveform_color_diff: current.waveform_color_diff.clone(),
|
|
vu_color_normal: current.vu_color_normal.clone(),
|
|
vu_color_warn: current.vu_color_warn.clone(),
|
|
ppm_din_color_normal: current.ppm_din_color_normal.clone(),
|
|
ppm_din_color_warn: current.ppm_din_color_warn.clone(),
|
|
ppm_ebu_color_normal: current.ppm_ebu_color_normal.clone(),
|
|
ppm_ebu_color_warn: current.ppm_ebu_color_warn.clone(),
|
|
tp_color_normal: current.tp_color_normal.clone(),
|
|
tp_color_warn: current.tp_color_warn.clone(),
|
|
rms_color_normal: current.rms_color_normal.clone(),
|
|
rms_color_warn: current.rms_color_warn.clone(),
|
|
lufs_color_i: current.lufs_color_i.clone(),
|
|
lufs_color_m: current.lufs_color_m.clone(),
|
|
lufs_color_s: current.lufs_color_s.clone(),
|
|
lufs_scale_label_color: current.lufs_scale_label_color.clone(),
|
|
screensaver_enabled: current.screensaver_enabled,
|
|
screensaver_idle_min: current.screensaver_idle_min,
|
|
screensaver_activity_db: current.screensaver_activity_db,
|
|
screensaver_mode: current.screensaver_mode.clone(),
|
|
screensaver_led_glow: current.screensaver_led_glow,
|
|
screensaver_led_color: current.screensaver_led_color.clone(),
|
|
};
|
|
if current != updated_global {
|
|
*self.global_config.write().await = updated_global.clone();
|
|
let _ = persist_global_config(&self.config, &updated_global).await;
|
|
self.global_config_rev.fetch_add(1, Ordering::SeqCst);
|
|
}
|
|
normalized
|
|
}
|
|
|
|
pub async fn global_config(&self) -> PhoenixGlobalConfig {
|
|
self.global_config.read().await.clone()
|
|
}
|
|
|
|
pub fn global_config_revision(&self) -> u64 {
|
|
self.global_config_rev.load(Ordering::SeqCst)
|
|
}
|
|
|
|
pub async fn global_config_envelope(&self) -> PhoenixGlobalConfigEnvelope {
|
|
PhoenixGlobalConfigEnvelope {
|
|
revision: self.global_config_revision(),
|
|
config: self.global_config().await,
|
|
}
|
|
}
|
|
|
|
pub async fn set_global_config(
|
|
&self,
|
|
payload: PhoenixGlobalConfig,
|
|
) -> anyhow::Result<PhoenixGlobalConfigEnvelope> {
|
|
let normalized = normalize_global_config(payload, &self.config);
|
|
let current_global = self.global_config.read().await.clone();
|
|
if current_global == normalized {
|
|
return Ok(PhoenixGlobalConfigEnvelope {
|
|
revision: self.global_config_revision(),
|
|
config: current_global,
|
|
});
|
|
}
|
|
let merged_rta = {
|
|
let current = self.rta_config.read().await.clone();
|
|
apply_global_to_rta(current, &normalized)
|
|
};
|
|
*self.global_config.write().await = normalized.clone();
|
|
*self.rta_config.write().await = merged_rta;
|
|
persist_global_config(&self.config, &normalized).await?;
|
|
let revision = self.global_config_rev.fetch_add(1, Ordering::SeqCst) + 1;
|
|
Ok(PhoenixGlobalConfigEnvelope {
|
|
revision,
|
|
config: normalized,
|
|
})
|
|
}
|
|
|
|
pub fn spawn_audio_capture(&self) {
|
|
spawn_audio_capture_worker(AudioWorkerDeps {
|
|
config: self.config.clone(),
|
|
input: self.current_input.clone(),
|
|
rta_config: self.rta_config.clone(),
|
|
global_config_rev: self.global_config_rev.clone(),
|
|
native_wav_recorders: self.native_wav_recorders.clone(),
|
|
seq: self.seq.clone(),
|
|
metrics_tx: self.metrics_tx.clone(),
|
|
restart_token: self.restart_token.clone(),
|
|
});
|
|
}
|
|
|
|
pub fn start_native_wav_recording(&self, session_id: u64) -> anyhow::Result<()> {
|
|
if session_id == 0 {
|
|
return Err(anyhow!("invalid recording session"));
|
|
}
|
|
let mut guard = self
|
|
.native_wav_recorders
|
|
.lock()
|
|
.map_err(|_| anyhow!("native recorder lock poisoned"))?;
|
|
guard.insert(
|
|
session_id,
|
|
NativeWavRecorder {
|
|
sample_rate: self.config.sample_rate,
|
|
channels: 2,
|
|
pcm_bytes: Vec::new(),
|
|
total_frames: 0,
|
|
last_l: 0,
|
|
last_r: 0,
|
|
pending_discontinuity: false,
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop_native_wav_recording(&self, session_id: u64) -> anyhow::Result<NativeWavCapture> {
|
|
let mut guard = self
|
|
.native_wav_recorders
|
|
.lock()
|
|
.map_err(|_| anyhow!("native recorder lock poisoned"))?;
|
|
let Some(capture) = guard.remove(&session_id) else {
|
|
return Err(anyhow!("recording session not found"));
|
|
};
|
|
let mut pcm_bytes = capture.pcm_bytes;
|
|
apply_native_recording_edge_fades(&mut pcm_bytes, capture.channels);
|
|
Ok(NativeWavCapture {
|
|
sample_rate: capture.sample_rate,
|
|
channels: capture.channels,
|
|
pcm_bytes,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn normalize_rta_config(mut config: PhoenixRtaConfig) -> PhoenixRtaConfig {
|
|
config.engine = match config.engine.trim().to_ascii_lowercase().as_str() {
|
|
"fft" => "fft".to_string(),
|
|
_ => "iir".to_string(),
|
|
};
|
|
config.fft_size = match config.fft_size {
|
|
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
|
n if n < 3072 => 2048,
|
|
n if n < 6144 => 4096,
|
|
n if n < 12288 => 8192,
|
|
_ => 16384,
|
|
};
|
|
config.mono_input = !!config.mono_input;
|
|
config.lr_fractional_delay_enabled = !!config.lr_fractional_delay_enabled;
|
|
let lr_delay = if config.lr_fractional_delay_samples.is_finite() {
|
|
config.lr_fractional_delay_samples
|
|
} else {
|
|
0.05
|
|
};
|
|
config.lr_fractional_delay_samples = lr_delay.clamp(-1.5, 1.5);
|
|
config.bpo = match config.bpo.trim().replace('/', "_").as_str() {
|
|
"1_3" => "1_3".to_string(),
|
|
"1_12" => "1_12".to_string(),
|
|
_ => "1_6".to_string(),
|
|
};
|
|
config.freq_range = match config.freq_range.trim().to_ascii_lowercase().as_str() {
|
|
"lf" => "lf".to_string(),
|
|
_ => "norm".to_string(),
|
|
};
|
|
config.weighting = match config.weighting.trim().to_ascii_lowercase().as_str() {
|
|
"a" => "a".to_string(),
|
|
"c" => "c".to_string(),
|
|
_ => "z".to_string(),
|
|
};
|
|
config.layout = match config.layout.trim().to_ascii_lowercase().as_str() {
|
|
"iec" => "iec".to_string(),
|
|
_ => "rtw".to_string(),
|
|
};
|
|
config.integration = match config.integration.trim().to_ascii_lowercase().as_str() {
|
|
"impulse" => "impulse".to_string(),
|
|
"slow" => "slow".to_string(),
|
|
"peak" => "peak".to_string(),
|
|
_ => "fast".to_string(),
|
|
};
|
|
config.order = config.order.clamp(2, 8);
|
|
|
|
let tau_fast = if config.tau_fast.is_finite() { config.tau_fast } else { 0.12 };
|
|
let tau_slow = if config.tau_slow.is_finite() { config.tau_slow } else { 1.0 };
|
|
config.tau_fast = tau_fast.clamp(0.01, 3.0);
|
|
config.tau_slow = tau_slow.clamp(0.05, 10.0);
|
|
if config.tau_slow < config.tau_fast {
|
|
config.tau_slow = config.tau_fast;
|
|
}
|
|
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
|
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
|
config.input_offset_db_l = offset_l.clamp(-60.0, 20.0);
|
|
config.input_offset_db_r = offset_r.clamp(-60.0, 20.0);
|
|
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
|
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
|
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
|
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
|
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
|
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
|
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
|
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
|
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
|
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
|
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
|
config
|
|
}
|
|
|
|
fn normalize_ui_color(input: String, fallback: &str) -> String {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
fallback.to_string()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
fn normalize_meter_slot(input: String, fallback: &str) -> String {
|
|
match input.trim().to_ascii_lowercase().as_str() {
|
|
"vu" => "vu".to_string(),
|
|
"ppm-ebu" | "ppm_ebu" | "ppm" => "ppm-ebu".to_string(),
|
|
"ppm-din" | "ppm_din" => "ppm-din".to_string(),
|
|
"tp" => "tp".to_string(),
|
|
"hifi-peak" | "hifi_peak" => "hifi-peak".to_string(),
|
|
"rms" => "rms".to_string(),
|
|
"lufs" => "lufs".to_string(),
|
|
"stopwatch" => "stopwatch".to_string(),
|
|
_ => fallback.to_string(),
|
|
}
|
|
}
|
|
|
|
fn normalize_global_config(mut config: PhoenixGlobalConfig, runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
|
config.fft_size = match config.fft_size {
|
|
2048 | 4096 | 8192 | 16384 => config.fft_size,
|
|
n if n < 3072 => 2048,
|
|
n if n < 6144 => 4096,
|
|
n if n < 12288 => 8192,
|
|
_ => 16384,
|
|
};
|
|
config.input_source = InputSource::Line;
|
|
config.rta_bpo_mode = match config.rta_bpo_mode.trim().replace('/', "_").as_str() {
|
|
"1_3" => "1_3".to_string(),
|
|
"1_12" => "1_12".to_string(),
|
|
_ => "1_6".to_string(),
|
|
};
|
|
let offset_l = if config.input_offset_db_l.is_finite() { config.input_offset_db_l } else { -5.0 };
|
|
let offset_r = if config.input_offset_db_r.is_finite() { config.input_offset_db_r } else { -5.0 };
|
|
config.input_offset_db_l = offset_l.clamp(-10.0, 10.0);
|
|
config.input_offset_db_r = offset_r.clamp(-10.0, 10.0);
|
|
config.mono_input = !!config.mono_input;
|
|
config.lr_fractional_delay_enabled = !!config.lr_fractional_delay_enabled;
|
|
let delay = if config.lr_fractional_delay_samples.is_finite() {
|
|
config.lr_fractional_delay_samples
|
|
} else {
|
|
runtime.lr_fractional_delay_samples
|
|
};
|
|
config.lr_fractional_delay_samples = delay.clamp(-1.5, 1.5);
|
|
config.al_markers_enabled = !!config.al_markers_enabled;
|
|
let thin = if config.meter_bar_thin.is_finite() { config.meter_bar_thin } else { 0.55 };
|
|
config.meter_bar_thin = thin.clamp(0.35, 0.9);
|
|
config.panel_dividers_enabled = !!config.panel_dividers_enabled;
|
|
let din_attack_ms = if config.ppm_din_attack_ms.is_finite() { config.ppm_din_attack_ms } else { 5.0 };
|
|
let din_decay = if config.ppm_din_decay_db_per_s.is_finite() { config.ppm_din_decay_db_per_s } else { 20.0 / 1.7 };
|
|
let ebu_attack_ms = if config.ppm_ebu_attack_ms.is_finite() { config.ppm_ebu_attack_ms } else { 10.0 };
|
|
let ebu_decay = if config.ppm_ebu_decay_db_per_s.is_finite() { config.ppm_ebu_decay_db_per_s } else { 24.0 / 2.8 };
|
|
config.ppm_din_attack_ms = din_attack_ms.clamp(0.1, 100.0);
|
|
config.ppm_din_decay_db_per_s = din_decay.clamp(0.1, 120.0);
|
|
config.ppm_din_fast_attack = !!config.ppm_din_fast_attack;
|
|
config.ppm_ebu_attack_ms = ebu_attack_ms.clamp(0.1, 100.0);
|
|
config.ppm_ebu_decay_db_per_s = ebu_decay.clamp(0.1, 120.0);
|
|
config.lufs_i_window_min = config.lufs_i_window_min.clamp(1, 10);
|
|
config.lufs_i_norm_enabled = !!config.lufs_i_norm_enabled;
|
|
config.ppm_din_loudness_boxes = !!config.ppm_din_loudness_boxes;
|
|
let loudness_offset = if config.ppm_din_loudness_offset_db.is_finite() { config.ppm_din_loudness_offset_db } else { 0.0 };
|
|
config.ppm_din_loudness_offset_db = loudness_offset.clamp(-7.0, 7.0);
|
|
config.xy_points = match config.xy_points {
|
|
128 | 256 | 512 | 1024 | 2048 => config.xy_points,
|
|
n if n < 192 => 128,
|
|
n if n < 384 => 256,
|
|
n if n < 768 => 512,
|
|
n if n < 1536 => 1024,
|
|
_ => 2048,
|
|
};
|
|
let gonio_gain = if config.gonio_display_gain_db.is_finite() { config.gonio_display_gain_db } else { -5.0 };
|
|
config.gonio_display_gain_db = gonio_gain.clamp(-35.0, 35.0);
|
|
config.record_output_format = match config.record_output_format.trim().to_ascii_lowercase().as_str() {
|
|
"mp3" => "mp3".to_string(),
|
|
"webm" => "webm".to_string(),
|
|
_ => "wav".to_string(),
|
|
};
|
|
config.record_mp3_bitrate_kbps = match config.record_mp3_bitrate_kbps {
|
|
64 | 128 | 192 | 256 | 320 => config.record_mp3_bitrate_kbps,
|
|
n if n < 96 => 64,
|
|
n if n < 160 => 128,
|
|
n if n < 224 => 192,
|
|
n if n < 288 => 256,
|
|
_ => 320,
|
|
};
|
|
config.record_target = match config.record_target.trim().to_ascii_lowercase().as_str() {
|
|
"volumio" => "volumio".to_string(),
|
|
_ => "analyzer".to_string(),
|
|
};
|
|
let auto_gap = if config.record_auto_split_gap_sec.is_finite() { config.record_auto_split_gap_sec } else { 1.5 };
|
|
config.record_auto_split_gap_sec = (auto_gap.clamp(0.5, 3.0) * 2.0).round() / 2.0;
|
|
let auto_threshold = if config.record_auto_threshold_dbfs.is_finite() { config.record_auto_threshold_dbfs } else { -50.0 };
|
|
config.record_auto_threshold_dbfs = auto_threshold.clamp(-120.0, 20.0);
|
|
config.clock_led_color = normalize_ui_color(config.clock_led_color, "#ff0000");
|
|
config.header_text_color = normalize_ui_color(config.header_text_color, "#ffe066");
|
|
config.rta_bar_base_color = normalize_ui_color(config.rta_bar_base_color, "#ffe066");
|
|
config.peak_history_scroll_mode = match config.peak_history_scroll_mode {
|
|
x if !x.is_finite() => 1.0,
|
|
x if (x - 0.5).abs() < f32::EPSILON => 0.5,
|
|
x if (x - 1.0).abs() < f32::EPSILON => 1.0,
|
|
x if (x - 2.0).abs() < f32::EPSILON => 2.0,
|
|
x if (x - 4.0).abs() < f32::EPSILON => 4.0,
|
|
x if (x - 6.0).abs() < f32::EPSILON => 6.0,
|
|
x if x < 0.75 => 0.5,
|
|
x if x < 1.5 => 1.0,
|
|
x if x < 3.0 => 2.0,
|
|
x if x < 5.0 => 4.0,
|
|
_ => 6.0,
|
|
};
|
|
config.peak_history_fill_enabled = !!config.peak_history_fill_enabled;
|
|
config.peak_history_fill_invert = !!config.peak_history_fill_invert;
|
|
config.peak_history_slot = normalize_meter_slot(config.peak_history_slot, "ppm-din");
|
|
let phase_gain = if config.phase_display_gain_db.is_finite() { config.phase_display_gain_db } else { 0.0 };
|
|
config.phase_display_gain_db = (phase_gain.clamp(-35.0, 35.0) / 5.0).round() * 5.0;
|
|
config.phase_amplitude_mode = match config.phase_amplitude_mode.trim().to_ascii_lowercase().as_str() {
|
|
"ppm-din" => "ppm-din".to_string(),
|
|
_ => "bandpass".to_string(),
|
|
};
|
|
config.phase_agc_enabled = if config.phase_amplitude_mode == "ppm-din" {
|
|
false
|
|
} else {
|
|
!!config.phase_agc_enabled
|
|
};
|
|
config.phase_trail_enabled = !!config.phase_trail_enabled;
|
|
config.classic_needles_slot = normalize_meter_slot(config.classic_needles_slot, "vu");
|
|
config.waveform_color_left = normalize_ui_color(config.waveform_color_left, "#00e7ff");
|
|
config.waveform_color_right = normalize_ui_color(config.waveform_color_right, "#ff6b81");
|
|
config.waveform_color_diff = normalize_ui_color(config.waveform_color_diff, "#00e7ff");
|
|
config.vu_color_normal = normalize_ui_color(config.vu_color_normal, "#ffe066");
|
|
config.vu_color_warn = normalize_ui_color(config.vu_color_warn, "#ff3b3b");
|
|
config.ppm_din_color_normal = normalize_ui_color(config.ppm_din_color_normal, "#ffe066");
|
|
config.ppm_din_color_warn = normalize_ui_color(config.ppm_din_color_warn, "#ff3b3b");
|
|
config.ppm_ebu_color_normal = normalize_ui_color(config.ppm_ebu_color_normal, "#ffe066");
|
|
config.ppm_ebu_color_warn = normalize_ui_color(config.ppm_ebu_color_warn, "#ff3b3b");
|
|
config.tp_color_normal = normalize_ui_color(config.tp_color_normal, "#ffe066");
|
|
config.tp_color_warn = normalize_ui_color(config.tp_color_warn, "#ff3b3b");
|
|
config.rms_color_normal = normalize_ui_color(config.rms_color_normal, "#34d399");
|
|
config.rms_color_warn = normalize_ui_color(config.rms_color_warn, "#ff3b3b");
|
|
config.lufs_color_i = normalize_ui_color(config.lufs_color_i, "#34d399");
|
|
config.lufs_color_m = normalize_ui_color(config.lufs_color_m, "#ffe066");
|
|
config.lufs_color_s = normalize_ui_color(config.lufs_color_s, "#ff3b3b");
|
|
config.lufs_scale_label_color = normalize_ui_color(config.lufs_scale_label_color, "#8fd3d4");
|
|
config.screensaver_enabled = !!config.screensaver_enabled;
|
|
let idle = if config.screensaver_idle_min.is_finite() { config.screensaver_idle_min } else { 5.0 };
|
|
config.screensaver_idle_min = idle.clamp(0.0, 120.0);
|
|
let activity = if config.screensaver_activity_db.is_finite() { config.screensaver_activity_db } else { -50.0 };
|
|
config.screensaver_activity_db = activity.clamp(-120.0, 0.0);
|
|
config.screensaver_mode = match config.screensaver_mode.trim().to_ascii_lowercase().as_str() {
|
|
"starfield" => "starfield".to_string(),
|
|
"black" => "black".to_string(),
|
|
"dvd" => "dvd".to_string(),
|
|
_ => "clock".to_string(),
|
|
};
|
|
config.screensaver_led_glow = !!config.screensaver_led_glow;
|
|
let color = config.screensaver_led_color.trim();
|
|
config.screensaver_led_color = if color.is_empty() { "#ff0000".to_string() } else { color.to_string() };
|
|
config
|
|
}
|
|
|
|
fn apply_global_to_rta(mut config: PhoenixRtaConfig, global: &PhoenixGlobalConfig) -> PhoenixRtaConfig {
|
|
config.fft_size = global.fft_size;
|
|
config.bpo = global.rta_bpo_mode.clone();
|
|
config.input_offset_db_l = global.input_offset_db_l;
|
|
config.input_offset_db_r = global.input_offset_db_r;
|
|
config.mono_input = global.mono_input;
|
|
config.lr_fractional_delay_enabled = global.lr_fractional_delay_enabled;
|
|
config.lr_fractional_delay_samples = global.lr_fractional_delay_samples;
|
|
config.ppm_din_attack_ms = global.ppm_din_attack_ms;
|
|
config.ppm_din_decay_db_per_s = global.ppm_din_decay_db_per_s;
|
|
config.ppm_din_fast_attack = global.ppm_din_fast_attack;
|
|
config.ppm_ebu_attack_ms = global.ppm_ebu_attack_ms;
|
|
config.ppm_ebu_decay_db_per_s = global.ppm_ebu_decay_db_per_s;
|
|
config.lufs_i_window_min = global.lufs_i_window_min;
|
|
config.lufs_i_norm_enabled = global.lufs_i_norm_enabled;
|
|
normalize_rta_config(config)
|
|
}
|
|
|
|
fn load_global_config(runtime: &PhoenixConfig) -> PhoenixGlobalConfig {
|
|
let fallback = normalize_global_config(runtime.default_global_config(), runtime);
|
|
let Ok(raw) = std::fs::read_to_string(&runtime.global_config_path) else {
|
|
return fallback;
|
|
};
|
|
let Ok(parsed) = serde_json::from_str::<PhoenixGlobalConfig>(&raw) else {
|
|
return fallback;
|
|
};
|
|
normalize_global_config(parsed, runtime)
|
|
}
|
|
|
|
async fn persist_global_config(runtime: &PhoenixConfig, config: &PhoenixGlobalConfig) -> anyhow::Result<()> {
|
|
let path = runtime.global_config_path.clone();
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)
|
|
.await
|
|
.with_context(|| format!("failed to create Phoenix config dir {}", parent.display()))?;
|
|
}
|
|
let json = serde_json::to_vec_pretty(config)?;
|
|
fs::write(&path, json)
|
|
.await
|
|
.with_context(|| format!("failed to write Phoenix config {}", path.display()))?;
|
|
Ok(())
|
|
}
|