Spaces:
Runtime error
Runtime error
| # T-012: TOML Config System β Hot-Reload via `notify`, Typed Config Structs | |
| **Type:** Task | **Phase:** 0 | **Autonomy:** `agent:autonomous` | **Stack:** `stack:rust` | |
| **Version:** v0.1 | **Iteration:** iter-1 | **Effort:** S (half-day) | |
| > β οΈ **Scope:** Create `config.toml` schema, typed Rust structs, and a hot-reload watcher. Config is read once at startup and re-read when the file changes. No IPC command needed β config changes log a message and update shared state. Camera config (`camera-config.toml`) and controller map (`controller-map.toml`) have their own files managed by T-031 and T-023; this task owns the **application** config only. | |
| --- | |
| ## Prerequisites | |
| - [ ] T-001 merged β workspace exists | |
| - [ ] T-011 merged β `tracing` initialised (config loader uses `tracing::info!`) | |
| ## New Workspace Dependencies | |
| ```toml | |
| # Add to [workspace.dependencies] | |
| notify = { version = "*", features = ["macos_kqueue"] } | |
| toml = { version = "*" } | |
| # Add to kansas/Cargo.toml [dependencies] | |
| notify = { workspace = true } | |
| toml = { workspace = true } | |
| ``` | |
| ## Acceptance Criteria | |
| - [ ] `config.toml` created at repo root with all fields from the schema below and sensible defaults | |
| - [ ] `kansas/src/config.rs` β `AppConfig` struct, `load()`, `watch()` functions | |
| - [ ] On startup, `AppConfig::load()` reads `config.toml`; missing file uses defaults (does not crash) | |
| - [ ] `notify` watcher fires when `config.toml` is saved; updated config logged at INFO level | |
| - [ ] `AppConfig` stored as `tauri::State<Arc<Mutex<AppConfig>>>` β readable from any command handler | |
| - [ ] `cargo check --workspace` β zero errors; `cargo clippy -- -D warnings` β zero warnings | |
| ## `config.toml` Schema | |
| ```toml | |
| [audio] | |
| sample_rate = 48000 | |
| buffer_frames = 512 | |
| enable_input = true | |
| wasapi_exclusive = false # true = lower latency, no other apps share device | |
| [transport] | |
| default_bpm = 128.0 | |
| default_time_sig_num = 4 | |
| default_time_sig_den = 4 | |
| [models] | |
| models_dir = "models" # relative to workspace root | |
| directml_device_id = 0 # GPU index for DirectML | |
| [rerun] | |
| enabled = false # set true to enable telemetry sink (T-121) | |
| address = "127.0.0.1:9876" | |
| [ui] | |
| theme = "dark" | |
| window_width = 1280 | |
| window_height = 800 | |
| ``` | |
| ## `kansas/src/config.rs` | |
| ```rust | |
| use std::{ | |
| path::Path, | |
| sync::{Arc, Mutex}, | |
| time::Duration, | |
| }; | |
| use notify::{Event, RecursiveMode, Watcher}; | |
| use serde::Deserialize; | |
| const CONFIG_PATH: &str = "config.toml"; | |
| #[derive(Debug, Clone, Deserialize)] | |
| #[serde(default)] | |
| pub struct AppConfig { | |
| pub audio: AudioConfig, | |
| pub transport: TransportConfig, | |
| pub models: ModelsConfig, | |
| pub rerun: RerunConfig, | |
| pub ui: UiConfig, | |
| } | |
| #[derive(Debug, Clone, Deserialize)] | |
| #[serde(default)] | |
| pub struct AudioConfig { | |
| pub sample_rate: u32, | |
| pub buffer_frames: u32, | |
| pub enable_input: bool, | |
| pub wasapi_exclusive: bool, | |
| } | |
| #[derive(Debug, Clone, Deserialize)] | |
| #[serde(default)] | |
| pub struct TransportConfig { | |
| pub default_bpm: f32, | |
| pub default_time_sig_num: u8, | |
| pub default_time_sig_den: u8, | |
| } | |
| #[derive(Debug, Clone, Deserialize)] | |
| #[serde(default)] | |
| pub struct ModelsConfig { | |
| pub models_dir: String, | |
| pub directml_device_id: u32, | |
| } | |
| #[derive(Debug, Clone, Deserialize)] | |
| #[serde(default)] | |
| pub struct RerunConfig { | |
| pub enabled: bool, | |
| pub address: String, | |
| } | |
| #[derive(Debug, Clone, Deserialize)] | |
| #[serde(default)] | |
| pub struct UiConfig { | |
| pub theme: String, | |
| pub window_width: u32, | |
| pub window_height: u32, | |
| } | |
| impl Default for AppConfig { | |
| fn default() -> Self { | |
| toml::from_str("").unwrap_or_else(|_| Self { | |
| audio: AudioConfig::default(), | |
| transport: TransportConfig::default(), | |
| models: ModelsConfig::default(), | |
| rerun: RerunConfig::default(), | |
| ui: UiConfig::default(), | |
| }) | |
| } | |
| } | |
| impl Default for AudioConfig { | |
| fn default() -> Self { Self { sample_rate: 48_000, buffer_frames: 512, enable_input: true, wasapi_exclusive: false } } | |
| } | |
| impl Default for TransportConfig { | |
| fn default() -> Self { Self { default_bpm: 128.0, default_time_sig_num: 4, default_time_sig_den: 4 } } | |
| } | |
| impl Default for ModelsConfig { | |
| fn default() -> Self { Self { models_dir: "models".into(), directml_device_id: 0 } } | |
| } | |
| impl Default for RerunConfig { | |
| fn default() -> Self { Self { enabled: false, address: "127.0.0.1:9876".into() } } | |
| } | |
| impl Default for UiConfig { | |
| fn default() -> Self { Self { theme: "dark".into(), window_width: 1280, window_height: 800 } } | |
| } | |
| pub fn load() -> AppConfig { | |
| match std::fs::read_to_string(CONFIG_PATH) { | |
| Ok(text) => toml::from_str(&text).unwrap_or_else(|e| { | |
| tracing::warn!("[config] Parse error, using defaults: {e}"); | |
| AppConfig::default() | |
| }), | |
| Err(_) => { | |
| tracing::info!("[config] config.toml not found β using defaults"); | |
| AppConfig::default() | |
| } | |
| } | |
| } | |
| /// Start a background watcher that reloads config on file change. | |
| pub fn watch(config: Arc<Mutex<AppConfig>>) { | |
| std::thread::Builder::new() | |
| .name("synesthesia-config-watcher".into()) | |
| .spawn(move || { | |
| let (tx, rx) = std::sync::mpsc::channel(); | |
| let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| { | |
| if let Ok(event) = res { let _ = tx.send(event); } | |
| }).expect("config watcher init failed"); | |
| watcher.watch(Path::new(CONFIG_PATH), RecursiveMode::NonRecursive).ok(); | |
| tracing::info!("[config] Watching {CONFIG_PATH} for changes"); | |
| for _event in rx { | |
| let updated = load(); | |
| tracing::info!("[config] Reloaded β bpm={}", updated.transport.default_bpm); | |
| if let Ok(mut cfg) = config.lock() { *cfg = updated; } | |
| } | |
| }) | |
| .ok(); | |
| } | |
| ``` | |
| Register in `main.rs`: | |
| ```rust | |
| use std::sync::{Arc, Mutex}; | |
| mod config; | |
| fn main() { | |
| logging::setup(); | |
| let cfg = Arc::new(Mutex::new(config::load())); | |
| config::watch(Arc::clone(&cfg)); | |
| tauri::Builder::default() | |
| .manage(cfg) | |
| // ... | |
| ``` | |
| --- | |
| ## GitHub CLI | |
| ```bash | |
| gh issue create \ | |
| --title "T-012: TOML config system β hot-reload via notify, typed config structs" \ | |
| --label "type:task,stack:rust,agent:autonomous,priority:high,status:ready,day:1" \ | |
| --body-file T-012.md | |
| ``` | |
| **Parent:** GENESIS | **Blocks:** T-031 (camera config), T-023 (controller-map) | **Blocked By:** T-001, T-011 | |