Initial Phoenix analyzer
This commit is contained in:
+2104
File diff suppressed because it is too large
Load Diff
+154
@@ -0,0 +1,154 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::model::{InputSource, PhoenixGlobalConfig, PhoenixRtaConfig};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PhoenixConfig {
|
||||
pub listen_addr: String,
|
||||
pub alsa_card_index: u8,
|
||||
pub alsa_device_name: Option<String>,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub sample_rate: u32,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub period_size: u32,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub buffer_size: u32,
|
||||
pub global_config_path: PathBuf,
|
||||
pub frontend_presets_path: PathBuf,
|
||||
pub frontend_layouts_path: PathBuf,
|
||||
pub recordings_dir_analyzer: PathBuf,
|
||||
pub recordings_dir_volumio: PathBuf,
|
||||
pub max_recording_upload_bytes: u64,
|
||||
pub lr_fractional_delay_enabled: bool,
|
||||
pub lr_fractional_delay_samples: f32,
|
||||
}
|
||||
|
||||
impl PhoenixConfig {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
listen_addr: std::env::var("PHOENIX_LISTEN")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8789".to_string()),
|
||||
alsa_card_index: std::env::var("PHOENIX_ALSA_CARD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u8>().ok())
|
||||
.unwrap_or(2),
|
||||
alsa_device_name: std::env::var("PHOENIX_ALSA_DEVICE")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty()),
|
||||
sample_rate: std::env::var("PHOENIX_SAMPLE_RATE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(48_000),
|
||||
period_size: std::env::var("PHOENIX_PERIOD_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(128),
|
||||
buffer_size: std::env::var("PHOENIX_BUFFER_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(512),
|
||||
global_config_path: std::env::var("PHOENIX_GLOBAL_CONFIG_PATH")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_global_config_path),
|
||||
frontend_presets_path: std::env::var("PHOENIX_FRONTEND_PRESETS_PATH")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_frontend_presets_path),
|
||||
frontend_layouts_path: std::env::var("PHOENIX_FRONTEND_LAYOUTS_PATH")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_frontend_layouts_path),
|
||||
recordings_dir_analyzer: std::env::var("PHOENIX_RECORDINGS_DIR_ANALYZER")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_recordings_dir_analyzer),
|
||||
recordings_dir_volumio: std::env::var("PHOENIX_RECORDINGS_DIR_VOLUMIO")
|
||||
.ok()
|
||||
.map(|v| PathBuf::from(v.trim()))
|
||||
.filter(|v| !v.as_os_str().is_empty())
|
||||
.unwrap_or_else(default_recordings_dir_volumio),
|
||||
max_recording_upload_bytes: std::env::var("PHOENIX_MAX_RECORDING_UPLOAD_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(2 * 1024 * 1024 * 1024),
|
||||
lr_fractional_delay_enabled: std::env::var("PHOENIX_LR_FRAC_DELAY_ENABLED")
|
||||
.ok()
|
||||
.map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(true),
|
||||
lr_fractional_delay_samples: std::env::var("PHOENIX_LR_FRAC_DELAY_SAMPLES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<f32>().ok())
|
||||
.unwrap_or(0.996),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alsa_device(&self) -> String {
|
||||
if let Some(device) = &self.alsa_device_name {
|
||||
return device.clone();
|
||||
}
|
||||
format!("hw:{},0", self.alsa_card_index)
|
||||
}
|
||||
|
||||
pub fn default_rta_config(&self) -> PhoenixRtaConfig {
|
||||
let mut config = PhoenixRtaConfig::default();
|
||||
config.lr_fractional_delay_enabled = self.lr_fractional_delay_enabled;
|
||||
config.lr_fractional_delay_samples = self.lr_fractional_delay_samples;
|
||||
config
|
||||
}
|
||||
|
||||
pub fn default_global_config(&self) -> PhoenixGlobalConfig {
|
||||
let mut config = PhoenixGlobalConfig::default();
|
||||
config.input_source = InputSource::Line;
|
||||
config.lr_fractional_delay_enabled = self.lr_fractional_delay_enabled;
|
||||
config.lr_fractional_delay_samples = self.lr_fractional_delay_samples;
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
fn default_global_config_path() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".config");
|
||||
path.push("phoenix");
|
||||
path.push("global-config.json");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("phoenix-global-config.json")
|
||||
}
|
||||
|
||||
fn default_frontend_presets_path() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".config");
|
||||
path.push("phoenix");
|
||||
path.push("frontend-presets.json");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("phoenix-frontend-presets.json")
|
||||
}
|
||||
|
||||
fn default_frontend_layouts_path() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let mut path = PathBuf::from(home);
|
||||
path.push(".config");
|
||||
path.push("phoenix");
|
||||
path.push("frontend-layouts.json");
|
||||
return path;
|
||||
}
|
||||
PathBuf::from("phoenix-frontend-layouts.json")
|
||||
}
|
||||
|
||||
fn default_recordings_dir_analyzer() -> PathBuf {
|
||||
PathBuf::from("/home/analyzer/Aufnahmen")
|
||||
}
|
||||
|
||||
fn default_recordings_dir_volumio() -> PathBuf {
|
||||
PathBuf::from("/home/volumio/Aufnahmen")
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
mod audio;
|
||||
mod config;
|
||||
mod model;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{routing::{get, post}, Router};
|
||||
use config::PhoenixConfig;
|
||||
use state::AppState;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("PHOENIX_LOG")
|
||||
.unwrap_or_else(|_| "phoenix=info,tower_http=info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = PhoenixConfig::from_env();
|
||||
let state = AppState::new(config.clone());
|
||||
|
||||
state.spawn_audio_capture();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(routes::health))
|
||||
.route("/api/v1/status", get(routes::status))
|
||||
.route("/api/v1/global-config", get(routes::get_global_config).post(routes::set_global_config))
|
||||
.route("/api/v1/frontend-presets", get(routes::get_frontend_presets).post(routes::set_frontend_preset))
|
||||
.route("/api/v1/frontend-layouts", get(routes::get_frontend_layouts).post(routes::set_frontend_layout))
|
||||
.route("/api/v1/update/download", post(routes::download_online_update))
|
||||
.route("/api/v1/update/log", get(routes::get_online_update_log))
|
||||
.route("/api/v1/update/restart", post(routes::restart_after_online_update))
|
||||
.route("/api/v1/rta-config", get(routes::get_rta_config).post(routes::set_rta_config))
|
||||
.route("/api/v1/recordings/wav/start/:session_id", post(routes::start_wav_recording))
|
||||
.route("/api/v1/recordings/wav/stop/:session_id", post(routes::stop_wav_recording))
|
||||
.route("/api/v1/recordings/stop/:session_id/:format", post(routes::stop_recording_with_format))
|
||||
.route("/api/v1/recordings/save/:target/:filename", post(routes::save_recording_file))
|
||||
.route("/api/v1/metrics/ws", get(routes::metrics_ws))
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = config
|
||||
.listen_addr
|
||||
.parse()
|
||||
.with_context(|| format!("invalid listen address: {}", config.listen_addr))?;
|
||||
|
||||
info!("Phoenix listening on http://{}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum InputSource {
|
||||
Line,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ServiceStatus {
|
||||
pub ok: bool,
|
||||
pub input: InputSource,
|
||||
pub audio_engine: &'static str,
|
||||
pub metrics_mode: &'static str,
|
||||
pub alsa_device: String,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RtaFrame {
|
||||
pub engine: String,
|
||||
pub bands_avg: Vec<f32>,
|
||||
pub bands_peak: Vec<f32>,
|
||||
pub bands: Vec<f32>,
|
||||
pub centers: Vec<f32>,
|
||||
pub freq_min: f32,
|
||||
pub freq_max: f32,
|
||||
pub bpo: String,
|
||||
pub weighting: String,
|
||||
pub layout: String,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SpectroFrame {
|
||||
pub bins: Vec<f32>,
|
||||
pub sample_rate: u32,
|
||||
pub fft_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct WaveEnvFrame {
|
||||
pub data: Vec<f32>,
|
||||
pub columns: usize,
|
||||
pub channels: u8,
|
||||
pub column_samples: usize,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhoenixRtaConfig {
|
||||
pub engine: String,
|
||||
pub fft_size: u32,
|
||||
pub mono_input: bool,
|
||||
pub lr_fractional_delay_enabled: bool,
|
||||
pub lr_fractional_delay_samples: f32,
|
||||
pub bpo: String,
|
||||
pub freq_range: String,
|
||||
pub weighting: String,
|
||||
pub order: u8,
|
||||
pub tau_fast: f32,
|
||||
pub tau_slow: f32,
|
||||
pub integration: String,
|
||||
pub layout: String,
|
||||
pub input_offset_db_l: f32,
|
||||
pub input_offset_db_r: f32,
|
||||
pub ppm_din_attack_ms: f32,
|
||||
pub ppm_din_decay_db_per_s: f32,
|
||||
pub ppm_din_fast_attack: bool,
|
||||
pub ppm_ebu_attack_ms: f32,
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for PhoenixRtaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
engine: "iir".to_string(),
|
||||
fft_size: 8192,
|
||||
mono_input: false,
|
||||
lr_fractional_delay_enabled: true,
|
||||
lr_fractional_delay_samples: 0.05,
|
||||
bpo: "1_6".to_string(),
|
||||
freq_range: "norm".to_string(),
|
||||
weighting: "z".to_string(),
|
||||
order: 4,
|
||||
tau_fast: 0.12,
|
||||
tau_slow: 1.0,
|
||||
integration: "fast".to_string(),
|
||||
layout: "rtw".to_string(),
|
||||
input_offset_db_l: -5.0,
|
||||
input_offset_db_r: -5.0,
|
||||
ppm_din_attack_ms: 5.0,
|
||||
ppm_din_decay_db_per_s: 11.8,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 8.6,
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PhoenixGlobalConfig {
|
||||
pub fft_size: u32,
|
||||
pub input_source: InputSource,
|
||||
pub rta_bpo_mode: String,
|
||||
pub input_offset_db_l: f32,
|
||||
pub input_offset_db_r: f32,
|
||||
pub mono_input: bool,
|
||||
pub lr_fractional_delay_enabled: bool,
|
||||
pub lr_fractional_delay_samples: f32,
|
||||
pub al_markers_enabled: bool,
|
||||
pub meter_bar_thin: f32,
|
||||
pub panel_dividers_enabled: bool,
|
||||
pub ppm_din_attack_ms: f32,
|
||||
pub ppm_din_decay_db_per_s: f32,
|
||||
pub ppm_din_fast_attack: bool,
|
||||
pub ppm_ebu_attack_ms: f32,
|
||||
pub ppm_ebu_decay_db_per_s: f32,
|
||||
pub lufs_i_window_min: u32,
|
||||
pub lufs_i_norm_enabled: bool,
|
||||
pub ppm_din_loudness_boxes: bool,
|
||||
pub ppm_din_loudness_offset_db: f32,
|
||||
pub xy_points: u32,
|
||||
pub gonio_display_gain_db: f32,
|
||||
pub record_output_format: String,
|
||||
pub record_mp3_bitrate_kbps: u32,
|
||||
pub record_target: String,
|
||||
pub record_auto_split_gap_sec: f32,
|
||||
pub record_auto_threshold_dbfs: f32,
|
||||
pub clock_led_color: String,
|
||||
pub header_text_color: String,
|
||||
pub rta_bar_base_color: String,
|
||||
pub peak_history_scroll_mode: f32,
|
||||
pub peak_history_fill_enabled: bool,
|
||||
pub peak_history_fill_invert: bool,
|
||||
pub peak_history_slot: String,
|
||||
pub phase_display_gain_db: f32,
|
||||
pub phase_agc_enabled: bool,
|
||||
pub phase_trail_enabled: bool,
|
||||
pub phase_amplitude_mode: String,
|
||||
pub classic_needles_slot: String,
|
||||
pub waveform_color_left: String,
|
||||
pub waveform_color_right: String,
|
||||
pub waveform_color_diff: String,
|
||||
pub vu_color_normal: String,
|
||||
pub vu_color_warn: String,
|
||||
pub ppm_din_color_normal: String,
|
||||
pub ppm_din_color_warn: String,
|
||||
pub ppm_ebu_color_normal: String,
|
||||
pub ppm_ebu_color_warn: String,
|
||||
pub tp_color_normal: String,
|
||||
pub tp_color_warn: String,
|
||||
pub rms_color_normal: String,
|
||||
pub rms_color_warn: String,
|
||||
pub lufs_color_i: String,
|
||||
pub lufs_color_m: String,
|
||||
pub lufs_color_s: String,
|
||||
pub lufs_scale_label_color: String,
|
||||
pub screensaver_enabled: bool,
|
||||
pub screensaver_idle_min: f32,
|
||||
pub screensaver_activity_db: f32,
|
||||
pub screensaver_mode: String,
|
||||
pub screensaver_led_glow: bool,
|
||||
pub screensaver_led_color: String,
|
||||
}
|
||||
|
||||
impl Default for PhoenixGlobalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fft_size: 8192,
|
||||
input_source: InputSource::Line,
|
||||
rta_bpo_mode: "1_6".to_string(),
|
||||
input_offset_db_l: -5.0,
|
||||
input_offset_db_r: -5.0,
|
||||
mono_input: false,
|
||||
lr_fractional_delay_enabled: true,
|
||||
lr_fractional_delay_samples: 0.996,
|
||||
al_markers_enabled: false,
|
||||
meter_bar_thin: 0.55,
|
||||
panel_dividers_enabled: true,
|
||||
ppm_din_attack_ms: 5.0,
|
||||
ppm_din_decay_db_per_s: 11.8,
|
||||
ppm_din_fast_attack: false,
|
||||
ppm_ebu_attack_ms: 10.0,
|
||||
ppm_ebu_decay_db_per_s: 8.6,
|
||||
lufs_i_window_min: 4,
|
||||
lufs_i_norm_enabled: false,
|
||||
ppm_din_loudness_boxes: true,
|
||||
ppm_din_loudness_offset_db: 0.0,
|
||||
xy_points: 1024,
|
||||
gonio_display_gain_db: -5.0,
|
||||
record_output_format: "wav".to_string(),
|
||||
record_mp3_bitrate_kbps: 192,
|
||||
record_target: "analyzer".to_string(),
|
||||
record_auto_split_gap_sec: 1.5,
|
||||
record_auto_threshold_dbfs: -50.0,
|
||||
clock_led_color: "#ff0000".to_string(),
|
||||
header_text_color: "#ffe066".to_string(),
|
||||
rta_bar_base_color: "#ffe066".to_string(),
|
||||
peak_history_scroll_mode: 1.0,
|
||||
peak_history_fill_enabled: false,
|
||||
peak_history_fill_invert: false,
|
||||
peak_history_slot: "ppm-din".to_string(),
|
||||
phase_display_gain_db: 0.0,
|
||||
phase_agc_enabled: false,
|
||||
phase_trail_enabled: true,
|
||||
phase_amplitude_mode: "ppm-din".to_string(),
|
||||
classic_needles_slot: "vu".to_string(),
|
||||
waveform_color_left: "#00e7ff".to_string(),
|
||||
waveform_color_right: "#ff6b81".to_string(),
|
||||
waveform_color_diff: "#00e7ff".to_string(),
|
||||
vu_color_normal: "#ffe066".to_string(),
|
||||
vu_color_warn: "#ff3b3b".to_string(),
|
||||
ppm_din_color_normal: "#ffe066".to_string(),
|
||||
ppm_din_color_warn: "#ff3b3b".to_string(),
|
||||
ppm_ebu_color_normal: "#ffe066".to_string(),
|
||||
ppm_ebu_color_warn: "#ff3b3b".to_string(),
|
||||
tp_color_normal: "#ffe066".to_string(),
|
||||
tp_color_warn: "#ff3b3b".to_string(),
|
||||
rms_color_normal: "#34d399".to_string(),
|
||||
rms_color_warn: "#ff3b3b".to_string(),
|
||||
lufs_color_i: "#34d399".to_string(),
|
||||
lufs_color_m: "#ffe066".to_string(),
|
||||
lufs_color_s: "#ff3b3b".to_string(),
|
||||
lufs_scale_label_color: "#8fd3d4".to_string(),
|
||||
screensaver_enabled: true,
|
||||
screensaver_idle_min: 5.0,
|
||||
screensaver_activity_db: -50.0,
|
||||
screensaver_mode: "clock".to_string(),
|
||||
screensaver_led_glow: true,
|
||||
screensaver_led_color: "#ff0000".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhoenixGlobalConfigEnvelope {
|
||||
pub revision: u64,
|
||||
pub config: PhoenixGlobalConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MeterFrame {
|
||||
pub seq: u64,
|
||||
pub timestamp_ms: u128,
|
||||
pub rms_l: f32,
|
||||
pub rms_r: f32,
|
||||
pub vu_l: f32,
|
||||
pub vu_r: f32,
|
||||
pub tp_l: f32,
|
||||
pub tp_r: f32,
|
||||
pub ppm_din_l: f32,
|
||||
pub ppm_din_r: f32,
|
||||
pub ppm_ebu_l: f32,
|
||||
pub ppm_ebu_r: f32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_m: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_s: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_i: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lra: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_ml: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_mr: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_sl: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lufs_sr: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ppm_box_l: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ppm_box_r: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub wave_l: Vec<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub wave_r: Vec<f32>,
|
||||
pub wave_channels: u8,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub xy_l: Vec<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub xy_r: Vec<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rta: Option<RtaFrame>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spectro: Option<SpectroFrame>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wave_env: Option<WaveEnvFrame>,
|
||||
pub global_config_rev: u64,
|
||||
pub input: InputSource,
|
||||
pub source: &'static str,
|
||||
}
|
||||
+984
@@ -0,0 +1,984 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use http_body_util::BodyExt;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
process::Stdio,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::{fs, io::AsyncWriteExt, process::Command};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
model::{PhoenixGlobalConfig, PhoenixRtaConfig},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const ONLINE_UPDATE_ZIP_URL: &str = "https://webshare.casaderoll.de/share/Phoenix.zip";
|
||||
|
||||
fn stable_script_command(program: &str) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
command.env("LANG", "C").env("LC_ALL", "C");
|
||||
command
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct StopRecordingQuery {
|
||||
#[serde(default)]
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FrontendPresetSavePayload {
|
||||
pub preset_id: String,
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"service": "phoenix"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn status(State(state): State<AppState>) -> Json<crate::model::ServiceStatus> {
|
||||
Json(state.status().await)
|
||||
}
|
||||
|
||||
pub async fn get_rta_config(State(state): State<AppState>) -> Json<PhoenixRtaConfig> {
|
||||
Json(state.rta_config().await)
|
||||
}
|
||||
|
||||
pub async fn get_global_config(State(state): State<AppState>) -> Json<crate::model::PhoenixGlobalConfigEnvelope> {
|
||||
Json(state.global_config_envelope().await)
|
||||
}
|
||||
|
||||
pub async fn get_frontend_presets(State(state): State<AppState>) -> Response {
|
||||
match read_frontend_presets(&state.config).await {
|
||||
Ok(presets) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"presets": presets
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_frontend_preset(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<FrontendPresetSavePayload>,
|
||||
) -> Response {
|
||||
let preset_id = normalize_frontend_preset_id(&payload.preset_id);
|
||||
if preset_id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid preset id"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !payload.config.is_object() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "preset config must be a JSON object"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match persist_frontend_preset(&state.config, &preset_id, &payload.config).await {
|
||||
Ok(saved) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"presetId": preset_id,
|
||||
"config": saved
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_frontend_layouts(State(state): State<AppState>) -> Response {
|
||||
match read_frontend_layouts(&state.config).await {
|
||||
Ok(layouts) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"layouts": layouts
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_frontend_layout(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<FrontendPresetSavePayload>,
|
||||
) -> Response {
|
||||
let layout_id = normalize_frontend_layout_id(&payload.preset_id);
|
||||
if layout_id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid layout id"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !payload.config.is_object() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "layout config must be a JSON object"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match persist_frontend_layout(&state.config, &layout_id, &payload.config).await {
|
||||
Ok(saved) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"layoutId": layout_id,
|
||||
"config": saved
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_online_update() -> Response {
|
||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let log_path = build_online_update_log_path(&phoenix_root);
|
||||
let script = phoenix_root.join("scripts").join("update_phoenix_from_zip.sh");
|
||||
if !script.is_file() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": format!("update script missing: {}", script.display())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match stable_script_command("bash")
|
||||
.arg(&script)
|
||||
.arg(ONLINE_UPDATE_ZIP_URL)
|
||||
.arg(&phoenix_root)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"message": "download beendet"
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let log_tail = read_online_update_log_tail(&log_path, 80).await;
|
||||
let detail = summarize_update_failure(&stderr, &stdout, &log_tail);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": detail
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_online_update_log_tail(log_path: &PathBuf, max_lines: usize) -> String {
|
||||
let content = match tokio::fs::read_to_string(log_path).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
lines[start..].join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
fn summarize_update_failure(stderr: &str, stdout: &str, log_tail: &str) -> String {
|
||||
let detail = match (stderr.is_empty(), stdout.is_empty()) {
|
||||
(false, false) => format!("{stderr}\n{stdout}"),
|
||||
(false, true) => stderr.to_string(),
|
||||
(true, false) => stdout.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
if !detail.trim().is_empty() {
|
||||
return detail.trim().to_string();
|
||||
}
|
||||
if !log_tail.trim().is_empty() {
|
||||
return log_tail.trim().to_string();
|
||||
}
|
||||
"update failed".to_string()
|
||||
}
|
||||
|
||||
pub async fn restart_after_online_update() -> Response {
|
||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let script = phoenix_root.join("scripts").join("restart_phoenix_services.sh");
|
||||
if !script.is_file() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": format!("restart script missing: {}", script.display())
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(750)).await;
|
||||
let _ = stable_script_command("bash")
|
||||
.arg(script)
|
||||
.spawn();
|
||||
});
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn get_online_update_log() -> Response {
|
||||
let phoenix_root = match resolve_phoenix_root_dir() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let log_path = build_online_update_log_path(&phoenix_root);
|
||||
let content = match tokio::fs::read_to_string(&log_path).await {
|
||||
Ok(value) => value,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string(),
|
||||
"path": log_path.display().to_string(),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let max_lines = 400usize;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
let tail = lines[start..].join("\n");
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"path": log_path.display().to_string(),
|
||||
"content": tail,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn set_rta_config(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PhoenixRtaConfig>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let applied = state.set_rta_config(payload).await;
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"config": applied
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn set_global_config(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<PhoenixGlobalConfig>,
|
||||
) -> Response {
|
||||
match state.set_global_config(payload).await {
|
||||
Ok(applied) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"revision": applied.revision,
|
||||
"config": applied.config
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn metrics_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||
ws.on_upgrade(move |socket| metrics_ws_inner(socket, state))
|
||||
}
|
||||
|
||||
pub async fn start_wav_recording(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<u64>,
|
||||
) -> Response {
|
||||
match state.start_native_wav_recording(session_id) {
|
||||
Ok(()) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"sessionId": session_id
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_wav_recording(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<u64>,
|
||||
) -> Response {
|
||||
stop_recording_inner(state, session_id, "wav", None).await
|
||||
}
|
||||
|
||||
pub async fn stop_recording_with_format(
|
||||
State(state): State<AppState>,
|
||||
Path((session_id, format)): Path<(u64, String)>,
|
||||
Query(query): Query<StopRecordingQuery>,
|
||||
) -> Response {
|
||||
stop_recording_inner(state, session_id, &format, query.bitrate_kbps).await
|
||||
}
|
||||
|
||||
async fn stop_recording_inner(state: AppState, session_id: u64, format: &str, bitrate_kbps: Option<u32>) -> Response {
|
||||
let normalized_format = normalize_recording_format(format);
|
||||
if normalized_format.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid format; expected 'wav', 'mp3' or 'webm'"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match state.stop_native_wav_recording(session_id) {
|
||||
Ok(capture) => match build_capture_bytes(normalized_format, &capture, bitrate_kbps).await {
|
||||
Ok((mime_type, payload)) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", mime_type),
|
||||
("cache-control", "no-store"),
|
||||
],
|
||||
Body::from(payload),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
},
|
||||
Err(err) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": err.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_capture_bytes(
|
||||
format: &str,
|
||||
capture: &crate::state::NativeWavCapture,
|
||||
bitrate_kbps: Option<u32>,
|
||||
) -> anyhow::Result<(&'static str, Vec<u8>)> {
|
||||
match format {
|
||||
"mp3" => Ok((
|
||||
"audio/mpeg",
|
||||
encode_mp3_bytes(
|
||||
capture.sample_rate,
|
||||
capture.channels,
|
||||
&capture.pcm_bytes,
|
||||
normalize_mp3_bitrate_kbps(bitrate_kbps),
|
||||
).await?,
|
||||
)),
|
||||
"webm" => Ok((
|
||||
"audio/webm",
|
||||
encode_webm_bytes(capture.sample_rate, capture.channels, &capture.pcm_bytes).await?,
|
||||
)),
|
||||
_ => Ok((
|
||||
"audio/wav",
|
||||
build_wav_bytes(capture.sample_rate, capture.channels, &capture.pcm_bytes),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_mp3_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8], bitrate_kbps: u32) -> anyhow::Result<Vec<u8>> {
|
||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||
let token = format!(
|
||||
"{}-{}-{}",
|
||||
std::process::id(),
|
||||
sample_rate,
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let mut input_path = std::env::temp_dir();
|
||||
input_path.push(format!("phoenix-recording-{}.wav", token));
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("phoenix-recording-{}.mp3", token));
|
||||
fs::write(&input_path, wav).await?;
|
||||
let encode_result = try_encode_mp3_with_commands(&input_path, &output_path, bitrate_kbps).await;
|
||||
let payload = match encode_result {
|
||||
Ok(()) => fs::read(&output_path).await?,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn try_encode_mp3_with_commands(input_path: &PathBuf, output_path: &PathBuf, bitrate_kbps: u32) -> anyhow::Result<()> {
|
||||
let ffmpeg = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-hide_banner")
|
||||
.arg("-loglevel")
|
||||
.arg("error")
|
||||
.arg("-i")
|
||||
.arg(input_path)
|
||||
.arg("-codec:a")
|
||||
.arg("libmp3lame")
|
||||
.arg("-b:a")
|
||||
.arg(format!("{}k", bitrate_kbps))
|
||||
.arg(output_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
match ffmpeg {
|
||||
Ok(output) if output.status.success() => return Ok(()),
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
|
||||
let lame = Command::new("lame")
|
||||
.arg("--silent")
|
||||
.arg("-b")
|
||||
.arg(bitrate_kbps.to_string())
|
||||
.arg(input_path)
|
||||
.arg(output_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
match lame {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => Err(anyhow::anyhow!(
|
||||
"MP3 encoding failed; ffmpeg/libmp3lame or lame is required. lame stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)),
|
||||
Err(err) => Err(anyhow::anyhow!(
|
||||
"MP3 encoding unavailable; install ffmpeg with libmp3lame or lame ({})",
|
||||
err
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn encode_webm_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let wav = build_wav_bytes(sample_rate, channels, pcm_bytes);
|
||||
let token = format!(
|
||||
"{}-{}-{}",
|
||||
std::process::id(),
|
||||
sample_rate,
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let mut input_path = std::env::temp_dir();
|
||||
input_path.push(format!("phoenix-recording-{}.wav", token));
|
||||
let mut output_path = std::env::temp_dir();
|
||||
output_path.push(format!("phoenix-recording-{}.webm", token));
|
||||
fs::write(&input_path, wav).await?;
|
||||
let encode_result = Command::new("ffmpeg")
|
||||
.arg("-y")
|
||||
.arg("-hide_banner")
|
||||
.arg("-loglevel")
|
||||
.arg("error")
|
||||
.arg("-i")
|
||||
.arg(&input_path)
|
||||
.arg("-codec:a")
|
||||
.arg("libopus")
|
||||
.arg("-b:a")
|
||||
.arg("128k")
|
||||
.arg(&output_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
let payload = match encode_result {
|
||||
Ok(output) if output.status.success() => fs::read(&output_path).await?,
|
||||
Ok(output) => {
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"WebM/Opus encoding failed; ffmpeg with libopus is required. ffmpeg stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
return Err(anyhow::anyhow!(
|
||||
"WebM/Opus encoding unavailable; install ffmpeg with libopus ({})",
|
||||
err
|
||||
));
|
||||
}
|
||||
};
|
||||
let _ = fs::remove_file(&input_path).await;
|
||||
let _ = fs::remove_file(&output_path).await;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn normalize_recording_format(raw: &str) -> &'static str {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"mp3" | "mpeg" => "mp3",
|
||||
"webm" | "opus" => "webm",
|
||||
"wav" | "" => "wav",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_mp3_bitrate_kbps(raw: Option<u32>) -> u32 {
|
||||
match raw.unwrap_or(192) {
|
||||
64 => 64,
|
||||
128 => 128,
|
||||
192 => 192,
|
||||
256 => 256,
|
||||
320 => 320,
|
||||
_ => 192,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_recording_file(
|
||||
State(state): State<AppState>,
|
||||
Path((target, filename)): Path<(String, String)>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let safe_name = sanitize_recording_filename(&filename);
|
||||
if safe_name.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid filename"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let safe_target = sanitize_recording_target(&target);
|
||||
if safe_target.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": "invalid target"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
match write_recording_body(&state.config, &safe_target, &safe_name, request.into_body()).await {
|
||||
Ok(path) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"target": safe_target,
|
||||
"filename": safe_name,
|
||||
"path": path
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let status = if is_recording_upload_too_large(&message) {
|
||||
StatusCode::PAYLOAD_TOO_LARGE
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
(
|
||||
status,
|
||||
Json(serde_json::json!({
|
||||
"ok": false,
|
||||
"error": message
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_frontend_presets(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
let path = config.frontend_presets_path.clone();
|
||||
let raw = match fs::read_to_string(&path).await {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend presets {} ({})", path.display(), err)),
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend presets {} ({})", path.display(), err))?;
|
||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn read_frontend_layouts(config: &crate::config::PhoenixConfig) -> anyhow::Result<serde_json::Map<String, serde_json::Value>> {
|
||||
let path = config.frontend_layouts_path.clone();
|
||||
let raw = match fs::read_to_string(&path).await {
|
||||
Ok(raw) => raw,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read frontend layouts {} ({})", path.display(), err)),
|
||||
};
|
||||
let parsed = serde_json::from_str::<serde_json::Value>(&raw)
|
||||
.map_err(|err| anyhow::anyhow!("failed to parse frontend layouts {} ({})", path.display(), err))?;
|
||||
Ok(parsed.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn persist_frontend_presets(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
presets: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = config.frontend_presets_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create frontend preset dir {}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(presets.clone()))?;
|
||||
fs::write(&path, json)
|
||||
.await
|
||||
.with_context(|| format!("failed to write frontend presets {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_frontend_layouts(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
layouts: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let path = config.frontend_layouts_path.clone();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create frontend layout dir {}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(&serde_json::Value::Object(layouts.clone()))?;
|
||||
fs::write(&path, json)
|
||||
.await
|
||||
.with_context(|| format!("failed to write frontend layouts {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn persist_frontend_preset(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
preset_id: &str,
|
||||
preset_config: &serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut presets = read_frontend_presets(config).await?;
|
||||
presets.insert(preset_id.to_string(), preset_config.clone());
|
||||
persist_frontend_presets(config, &presets).await?;
|
||||
Ok(preset_config.clone())
|
||||
}
|
||||
|
||||
async fn persist_frontend_layout(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
layout_id: &str,
|
||||
layout_config: &serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut layouts = read_frontend_layouts(config).await?;
|
||||
layouts.insert(layout_id.to_string(), layout_config.clone());
|
||||
persist_frontend_layouts(config, &layouts).await?;
|
||||
Ok(layout_config.clone())
|
||||
}
|
||||
|
||||
fn normalize_frontend_preset_id(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"claus" => "claus".to_string(),
|
||||
"michael" => "michael".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_frontend_layout_id(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "layout1" | "slot1" | "a" => "1".to_string(),
|
||||
"2" | "layout2" | "slot2" | "b" => "2".to_string(),
|
||||
"3" | "layout3" | "slot3" | "c" => "3".to_string(),
|
||||
"4" | "layout4" | "slot4" | "d" => "4".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn metrics_ws_inner(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||
let mut rx = state.subscribe_metrics();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(frame) => {
|
||||
let payload = match serde_json::to_string(&frame) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
warn!("failed to encode metrics frame: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if socket
|
||||
.send(axum::extract::ws::Message::Text(payload))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
match err {
|
||||
tokio::sync::broadcast::error::RecvError::Lagged(skipped) => {
|
||||
warn!("metrics channel lagged by {}; dropping stale frames", skipped);
|
||||
continue;
|
||||
}
|
||||
tokio::sync::broadcast::error::RecvError::Closed => {
|
||||
warn!("metrics channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_phoenix_root_dir() -> anyhow::Result<PathBuf> {
|
||||
let exe = std::env::current_exe().context("failed to resolve current executable")?;
|
||||
for ancestor in exe.ancestors() {
|
||||
if ancestor.join("Cargo.toml").is_file()
|
||||
&& ancestor.join("www").is_dir()
|
||||
&& ancestor.join("src").is_dir()
|
||||
{
|
||||
return Ok(ancestor.to_path_buf());
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"failed to locate Phoenix root from executable path {}",
|
||||
exe.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn build_online_update_log_path(phoenix_root: &PathBuf) -> PathBuf {
|
||||
let phoenix_parent = phoenix_root
|
||||
.parent()
|
||||
.map(|path| path.to_path_buf())
|
||||
.unwrap_or_else(|| phoenix_root.clone());
|
||||
let phoenix_name = phoenix_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Phoenix");
|
||||
phoenix_parent.join(format!("{phoenix_name}.update.log"))
|
||||
}
|
||||
|
||||
fn build_wav_bytes(sample_rate: u32, channels: u16, pcm_bytes: &[u8]) -> Vec<u8> {
|
||||
let data_size = pcm_bytes.len() as u32;
|
||||
let bytes_per_sample = 2u16;
|
||||
let block_align = channels.saturating_mul(bytes_per_sample);
|
||||
let byte_rate = sample_rate.saturating_mul(block_align as u32);
|
||||
let mut out = Vec::with_capacity(44 + pcm_bytes.len());
|
||||
out.extend_from_slice(b"RIFF");
|
||||
out.extend_from_slice(&(36u32.saturating_add(data_size)).to_le_bytes());
|
||||
out.extend_from_slice(b"WAVE");
|
||||
out.extend_from_slice(b"fmt ");
|
||||
out.extend_from_slice(&16u32.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&channels.to_le_bytes());
|
||||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
out.extend_from_slice(&block_align.to_le_bytes());
|
||||
out.extend_from_slice(&16u16.to_le_bytes());
|
||||
out.extend_from_slice(b"data");
|
||||
out.extend_from_slice(&data_size.to_le_bytes());
|
||||
out.extend_from_slice(pcm_bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn sanitize_recording_filename(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn sanitize_recording_target(raw: &str) -> String {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"analyzer" => "analyzer".to_string(),
|
||||
"volumio" => "volumio".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_recording_upload_too_large(message: &str) -> bool {
|
||||
message.starts_with("recording upload exceeds limit")
|
||||
}
|
||||
|
||||
async fn write_recording_body(
|
||||
config: &crate::config::PhoenixConfig,
|
||||
target: &str,
|
||||
filename: &str,
|
||||
mut body: Body,
|
||||
) -> anyhow::Result<String> {
|
||||
let dir = match target {
|
||||
"analyzer" => config.recordings_dir_analyzer.clone(),
|
||||
"volumio" => config.recordings_dir_volumio.clone(),
|
||||
_ => anyhow::bail!("invalid target"),
|
||||
};
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join(filename);
|
||||
let tmp_path = dir.join(format!(".{}.part", filename));
|
||||
let mut file = tokio::fs::File::create(&tmp_path).await?;
|
||||
let mut written = 0u64;
|
||||
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame
|
||||
.map_err(|err| anyhow::anyhow!("failed to read recording upload body ({})", err))?;
|
||||
if let Some(chunk) = frame.data_ref() {
|
||||
written = written.saturating_add(chunk.len() as u64);
|
||||
if written > config.max_recording_upload_bytes {
|
||||
drop(file);
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
anyhow::bail!(
|
||||
"recording upload exceeds limit of {} bytes",
|
||||
config.max_recording_upload_bytes
|
||||
);
|
||||
}
|
||||
file.write_all(chunk).await?;
|
||||
}
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
tokio::fs::rename(&tmp_path, &path).await?;
|
||||
Ok(path.display().to_string())
|
||||
}
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user