Skip to main content

aether_core/
hotreload.rs

1//! Hot reload support for DSP nodes.
2//!
3//! Allows reloading node implementations without restarting the audio engine.
4//! Useful for rapid development and live coding.
5//!
6//! # Architecture
7//!
8//! Hot reload works by:
9//! 1. Watching for file changes in node source files
10//! 2. Recompiling the changed node
11//! 3. Loading the new implementation via dynamic library
12//! 4. Swapping the old node with the new one while preserving state
13//!
14//! # Limitations
15//!
16//! - Requires nodes to be compiled as dynamic libraries (.dll/.so/.dylib)
17//! - State preservation is best-effort (depends on state structure compatibility)
18//! - Not suitable for production use (development only)
19//! - Requires Rust toolchain to be installed
20
21use std::path::{Path, PathBuf};
22use std::time::SystemTime;
23
24/// Hot reload configuration.
25#[derive(Debug, Clone)]
26pub struct HotReloadConfig {
27    /// Directory to watch for changes.
28    pub watch_dir: PathBuf,
29
30    /// File extensions to watch (e.g., ".rs").
31    pub watch_extensions: Vec<String>,
32
33    /// Debounce time in milliseconds (wait this long after last change before reloading).
34    pub debounce_ms: u64,
35
36    /// Whether to preserve node state across reloads.
37    pub preserve_state: bool,
38}
39
40impl Default for HotReloadConfig {
41    fn default() -> Self {
42        Self {
43            watch_dir: PathBuf::from("crates/aether-nodes/src"),
44            watch_extensions: vec![".rs".to_string()],
45            debounce_ms: 500,
46            preserve_state: true,
47        }
48    }
49}
50
51/// Hot reload manager.
52///
53/// Watches for file changes and triggers recompilation/reload.
54pub struct HotReloadManager {
55    config: HotReloadConfig,
56    last_modified: std::collections::HashMap<PathBuf, SystemTime>,
57    pending_reload: Option<SystemTime>,
58}
59
60impl HotReloadManager {
61    /// Creates a new hot reload manager.
62    pub fn new(config: HotReloadConfig) -> Self {
63        Self {
64            config,
65            last_modified: std::collections::HashMap::new(),
66            pending_reload: None,
67        }
68    }
69
70    /// Checks for file changes and returns true if a reload is needed.
71    ///
72    /// Call this periodically (e.g., every 100ms) from a background thread.
73    ///
74    /// # Returns
75    ///
76    /// `true` if files have changed and debounce period has elapsed.
77    pub fn check_for_changes(&mut self) -> bool {
78        let mut changed = false;
79
80        // Scan watch directory for changes
81        if let Ok(entries) = std::fs::read_dir(&self.config.watch_dir) {
82            for entry in entries.flatten() {
83                if let Ok(metadata) = entry.metadata() {
84                    if metadata.is_file() {
85                        let path = entry.path();
86
87                        // Check if file extension matches
88                        if let Some(ext) = path.extension() {
89                            let ext_str = format!(".{}", ext.to_string_lossy());
90                            if !self.config.watch_extensions.contains(&ext_str) {
91                                continue;
92                            }
93                        } else {
94                            continue;
95                        }
96
97                        // Check if file was modified
98                        if let Ok(modified) = metadata.modified() {
99                            if let Some(&last_mod) = self.last_modified.get(&path) {
100                                if modified > last_mod {
101                                    changed = true;
102                                    self.last_modified.insert(path.clone(), modified);
103                                }
104                            } else {
105                                // First time seeing this file
106                                self.last_modified.insert(path, modified);
107                            }
108                        }
109                    }
110                }
111            }
112        }
113
114        // If changes detected, start debounce timer
115        if changed {
116            self.pending_reload = Some(SystemTime::now());
117            return false;
118        }
119
120        // Check if debounce period has elapsed
121        if let Some(pending_time) = self.pending_reload {
122            if let Ok(elapsed) = pending_time.elapsed() {
123                if elapsed.as_millis() >= self.config.debounce_ms as u128 {
124                    self.pending_reload = None;
125                    return true;
126                }
127            }
128        }
129
130        false
131    }
132
133    /// Triggers a reload of the specified node.
134    ///
135    /// This is a placeholder - actual implementation would:
136    /// 1. Run `cargo build --release -p aetherdsp-nodes`
137    /// 2. Load the new .dll/.so/.dylib
138    /// 3. Extract the node factory function
139    /// 4. Create new node instance
140    /// 5. Transfer state from old node to new node
141    /// 6. Swap nodes in the graph
142    ///
143    /// # Arguments
144    ///
145    /// * `node_name` - Name of the node to reload (e.g., "Oscillator")
146    ///
147    /// # Returns
148    ///
149    /// `Ok(())` if reload succeeded, `Err` otherwise.
150    pub fn reload_node(&self, node_name: &str) -> Result<(), String> {
151        // Placeholder implementation
152        println!("Hot reload: Recompiling {}...", node_name);
153
154        // In a real implementation, this would:
155        // 1. Run cargo build
156        // 2. Load dynamic library
157        // 3. Swap node implementation
158
159        Err("Hot reload not fully implemented (placeholder)".to_string())
160    }
161
162    /// Gets the watch directory.
163    pub fn watch_dir(&self) -> &Path {
164        &self.config.watch_dir
165    }
166
167    /// Gets the number of files being watched.
168    pub fn watched_file_count(&self) -> usize {
169        self.last_modified.len()
170    }
171}
172
173/// Node state snapshot for hot reload.
174///
175/// Captures the internal state of a node so it can be restored after reload.
176#[derive(Debug, Clone)]
177pub struct NodeStateSnapshot {
178    /// Node type name.
179    pub node_type: String,
180
181    /// Serialized state (JSON or binary).
182    pub state_data: Vec<u8>,
183
184    /// Parameter values.
185    pub param_values: Vec<f32>,
186}
187
188impl NodeStateSnapshot {
189    /// Creates a new state snapshot.
190    pub fn new(node_type: impl Into<String>) -> Self {
191        Self {
192            node_type: node_type.into(),
193            state_data: Vec::new(),
194            param_values: Vec::new(),
195        }
196    }
197
198    /// Adds a parameter value to the snapshot.
199    pub fn add_param(&mut self, value: f32) {
200        self.param_values.push(value);
201    }
202
203    /// Sets the state data.
204    pub fn set_state_data(&mut self, data: Vec<u8>) {
205        self.state_data = data;
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_hotreload_config_default() {
215        let config = HotReloadConfig::default();
216        assert_eq!(config.debounce_ms, 500);
217        assert!(config.preserve_state);
218        assert_eq!(config.watch_extensions.len(), 1);
219    }
220
221    #[test]
222    fn test_hotreload_manager_creation() {
223        let config = HotReloadConfig::default();
224        let manager = HotReloadManager::new(config);
225        assert_eq!(manager.watched_file_count(), 0);
226    }
227
228    #[test]
229    fn test_node_state_snapshot() {
230        let mut snapshot = NodeStateSnapshot::new("Oscillator");
231        snapshot.add_param(440.0);
232        snapshot.add_param(0.5);
233        snapshot.set_state_data(vec![1, 2, 3, 4]);
234
235        assert_eq!(snapshot.node_type, "Oscillator");
236        assert_eq!(snapshot.param_values.len(), 2);
237        assert_eq!(snapshot.state_data.len(), 4);
238    }
239
240    #[test]
241    fn test_hotreload_reload_node_placeholder() {
242        let config = HotReloadConfig::default();
243        let manager = HotReloadManager::new(config);
244
245        // Should return error (placeholder implementation)
246        let result = manager.reload_node("Oscillator");
247        assert!(result.is_err());
248    }
249}