Add amp sim

This commit is contained in:
Skyler Lehmkuhl 2026-02-21 09:43:03 -05:00
parent 3eba231447
commit 7e3f18c95b
6 changed files with 231 additions and 0 deletions

View File

@ -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]

View File

@ -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<NamModel>,
model_path: Option<String>,
// Mono scratch buffers for NAM processing (NAM is mono-only)
mono_in: Vec<f32>,
mono_out: Vec<f32>,
inputs: Vec<NodePort>,
outputs: Vec<NodePort>,
parameters: Vec<Parameter>,
}
impl AmpSimNode {
pub fn new(name: impl Into<String>) -> 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<MidiEvent>],
_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<dyn AudioNode> {
// 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
}
}

View File

@ -127,6 +127,10 @@ pub struct SerializedNode {
/// For Script nodes: BeamDSP source code
#[serde(skip_serializing_if = "Option::is_none")]
pub script_source: Option<String>,
/// For AmpSim nodes: path to the .nam model file
#[serde(skip_serializing_if = "Option::is_none")]
pub nam_model_path: Option<String>,
}
/// 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,
}
}

View File

@ -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<f32>, 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)

View File

@ -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"

View File

@ -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