diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml index 4537fcb..74b0f64 100644 --- a/daw-backend/Cargo.toml +++ b/daw-backend/Cargo.toml @@ -39,6 +39,9 @@ serde_json = "1.0" # BeamDSP scripting engine beamdsp = { path = "../lightningbeam-ui/beamdsp" } +# Neural Amp Modeler FFI +nam-ffi = { path = "../nam-ffi" } + [dev-dependencies] [profile.release] diff --git a/daw-backend/src/audio/node_graph/nodes/amp_sim.rs b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs new file mode 100644 index 0000000..fddb720 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs @@ -0,0 +1,200 @@ +use crate::audio::midi::MidiEvent; +use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType}; +use nam_ffi::NamModel; +use std::path::Path; + +const PARAM_INPUT_GAIN: u32 = 0; +const PARAM_OUTPUT_GAIN: u32 = 1; +const PARAM_MIX: u32 = 2; + +/// Guitar amp simulator node using Neural Amp Modeler (.nam) models. +pub struct AmpSimNode { + name: String, + input_gain: f32, + output_gain: f32, + mix: f32, + + model: Option, + model_path: Option, + + // Mono scratch buffers for NAM processing (NAM is mono-only) + mono_in: Vec, + mono_out: Vec, + + inputs: Vec, + outputs: Vec, + parameters: Vec, +} + +impl AmpSimNode { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + + let inputs = vec![NodePort::new("Audio In", SignalType::Audio, 0)]; + let outputs = vec![NodePort::new("Audio Out", SignalType::Audio, 0)]; + + let parameters = vec![ + Parameter::new(PARAM_INPUT_GAIN, "Input Gain", 0.0, 4.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_OUTPUT_GAIN, "Output Gain", 0.0, 4.0, 1.0, ParameterUnit::Generic), + Parameter::new(PARAM_MIX, "Mix", 0.0, 1.0, 1.0, ParameterUnit::Generic), + ]; + + Self { + name, + input_gain: 1.0, + output_gain: 1.0, + mix: 1.0, + model: None, + model_path: None, + mono_in: Vec::new(), + mono_out: Vec::new(), + inputs, + outputs, + parameters, + } + } + + /// Load a .nam model file. Call from the audio thread via command dispatch. + pub fn load_model(&mut self, path: &str) -> Result<(), String> { + let model_path = Path::new(path); + let mut model = + NamModel::from_file(model_path).map_err(|e| format!("{}", e))?; + model.set_max_buffer_size(1024); + self.model = Some(model); + self.model_path = Some(path.to_string()); + Ok(()) + } + + /// Get the loaded model path (for preset serialization). + pub fn model_path(&self) -> Option<&str> { + self.model_path.as_deref() + } +} + +impl AudioNode for AmpSimNode { + fn category(&self) -> NodeCategory { + NodeCategory::Effect + } + + fn inputs(&self) -> &[NodePort] { + &self.inputs + } + + fn outputs(&self) -> &[NodePort] { + &self.outputs + } + + fn parameters(&self) -> &[Parameter] { + &self.parameters + } + + fn set_parameter(&mut self, id: u32, value: f32) { + match id { + PARAM_INPUT_GAIN => self.input_gain = value.clamp(0.0, 4.0), + PARAM_OUTPUT_GAIN => self.output_gain = value.clamp(0.0, 4.0), + PARAM_MIX => self.mix = value.clamp(0.0, 1.0), + _ => {} + } + } + + fn get_parameter(&self, id: u32) -> f32 { + match id { + PARAM_INPUT_GAIN => self.input_gain, + PARAM_OUTPUT_GAIN => self.output_gain, + PARAM_MIX => self.mix, + _ => 0.0, + } + } + + fn process( + &mut self, + inputs: &[&[f32]], + outputs: &mut [&mut [f32]], + _midi_inputs: &[&[MidiEvent]], + _midi_outputs: &mut [&mut Vec], + _sample_rate: u32, + ) { + if inputs.is_empty() || outputs.is_empty() { + return; + } + + let input = inputs[0]; + let output = &mut outputs[0]; + + let frames = input.len() / 2; + let output_frames = output.len() / 2; + let frames_to_process = frames.min(output_frames); + + if let Some(ref mut model) = self.model { + // Ensure scratch buffers are large enough + if self.mono_in.len() < frames_to_process { + self.mono_in.resize(frames_to_process, 0.0); + self.mono_out.resize(frames_to_process, 0.0); + } + + // Deinterleave stereo to mono (average L+R) and apply input gain + for frame in 0..frames_to_process { + let left = input[frame * 2]; + let right = input[frame * 2 + 1]; + self.mono_in[frame] = (left + right) * 0.5 * self.input_gain; + } + + // Process through NAM model + model.process( + &self.mono_in[..frames_to_process], + &mut self.mono_out[..frames_to_process], + ); + + // Apply output gain, mix wet/dry, copy mono back to stereo + for frame in 0..frames_to_process { + let dry = (input[frame * 2] + input[frame * 2 + 1]) * 0.5; + let wet = self.mono_out[frame] * self.output_gain; + let mixed = dry * (1.0 - self.mix) + wet * self.mix; + output[frame * 2] = mixed; + output[frame * 2 + 1] = mixed; + } + } else { + // No model loaded — pass through unchanged + let samples = frames_to_process * 2; + output[..samples].copy_from_slice(&input[..samples]); + } + } + + fn reset(&mut self) { + // No persistent filter state to reset + } + + fn node_type(&self) -> &str { + "AmpSim" + } + + fn name(&self) -> &str { + &self.name + } + + fn clone_node(&self) -> Box { + // Cannot clone the NAM model (C++ pointer), so clone without model. + // The model will need to be reloaded via command if needed. + Box::new(Self { + name: self.name.clone(), + input_gain: self.input_gain, + output_gain: self.output_gain, + mix: self.mix, + model: None, + model_path: self.model_path.clone(), + mono_in: Vec::new(), + mono_out: Vec::new(), + inputs: self.inputs.clone(), + outputs: self.outputs.clone(), + parameters: self.parameters.clone(), + }) + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/daw-backend/src/audio/node_graph/preset.rs b/daw-backend/src/audio/node_graph/preset.rs index 60d7811..7f19bfa 100644 --- a/daw-backend/src/audio/node_graph/preset.rs +++ b/daw-backend/src/audio/node_graph/preset.rs @@ -127,6 +127,10 @@ pub struct SerializedNode { /// For Script nodes: BeamDSP source code #[serde(skip_serializing_if = "Option::is_none")] pub script_source: Option, + + /// For AmpSim nodes: path to the .nam model file + #[serde(skip_serializing_if = "Option::is_none")] + pub nam_model_path: Option, } /// Serialized group definition (frontend-only visual grouping, stored opaquely by backend) @@ -222,6 +226,7 @@ impl SerializedNode { template_graph: None, sample_data: None, script_source: None, + nam_model_path: None, } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 84ea9b2..e2e2441 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -186,6 +186,9 @@ pub enum Command { /// Load audio sample data into a Script node's sample slot (track_id, node_id, slot_index, audio_data, sample_rate, name) GraphSetScriptSample(TrackId, u32, usize, Vec, u32, String), + /// Load a NAM model into an AmpSim node (track_id, node_id, model_path) + AmpSimLoadModel(TrackId, u32, String), + /// Load a sample into a SimpleSampler node (track_id, node_id, file_path) SamplerLoadSample(TrackId, u32, String), /// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index) diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 60d7ef4..ca38b2f 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -1142,6 +1142,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1678,6 +1687,7 @@ dependencies = [ "memmap2", "midir", "midly", + "nam-ffi", "pathdiff", "petgraph 0.6.5", "rand 0.8.5", @@ -3823,6 +3833,13 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nam-ffi" +version = "0.1.0" +dependencies = [ + "cmake", +] + [[package]] name = "ndk" version = "0.9.0" diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index de21ff0..7a66854 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -50,6 +50,9 @@ notify-rust = "4.11" [profile.dev.package.daw-backend] opt-level = 2 +[profile.dev.package.nam-ffi] +opt-level = 2 + [profile.dev.package.beamdsp] opt-level = 2