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}