fix NAM model loading

This commit is contained in:
Skyler Lehmkuhl 2026-03-02 11:58:13 -05:00
parent 6b3a286caf
commit b4c7a45990
9 changed files with 231 additions and 78 deletions

View File

@ -1710,6 +1710,7 @@ impl Engine {
Command::AmpSimLoadModel(track_id, node_id, model_path) => { Command::AmpSimLoadModel(track_id, node_id, model_path) => {
use crate::audio::node_graph::nodes::AmpSimNode; use crate::audio::node_graph::nodes::AmpSimNode;
eprintln!("[AmpSim] Loading model: {:?} for track {:?} node {}", model_path, track_id, node_id);
let graph = match self.project.get_track_mut(track_id) { let graph = match self.project.get_track_mut(track_id) {
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
@ -1719,8 +1720,16 @@ impl Engine {
let node_idx = NodeIndex::new(node_id as usize); let node_idx = NodeIndex::new(node_id as usize);
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() { if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() {
if let Err(e) = amp_sim.load_model(&model_path) { let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") {
eprintln!("Failed to load NAM model: {}", e); eprintln!("[AmpSim] Loading bundled model: {}", bundled_name);
amp_sim.load_bundled_model(bundled_name)
} else {
eprintln!("[AmpSim] Loading model from file: {}", model_path);
amp_sim.load_model(&model_path)
};
match &result {
Ok(()) => eprintln!("[AmpSim] Model loaded successfully"),
Err(e) => eprintln!("[AmpSim] Failed to load NAM model: {}", e),
} }
} }
} }

View File

@ -1140,11 +1140,20 @@ impl AudioGraph {
if let Some(ref model_path) = serialized_node.nam_model_path { if let Some(ref model_path) = serialized_node.nam_model_path {
if serialized_node.node_type == "AmpSim" { if serialized_node.node_type == "AmpSim" {
use crate::audio::node_graph::nodes::AmpSimNode; use crate::audio::node_graph::nodes::AmpSimNode;
let resolved_path = resolve_sample_path(model_path); eprintln!("[AmpSim] Preset restore: nam_model_path={:?}", model_path);
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() { if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() {
if let Err(e) = amp_sim.load_model(&resolved_path) { let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") {
eprintln!("Warning: failed to load NAM model {}: {}", resolved_path, e); eprintln!("[AmpSim] Preset: loading bundled model {:?}", bundled_name);
amp_sim.load_bundled_model(bundled_name)
} else {
let resolved_path = resolve_sample_path(model_path);
eprintln!("[AmpSim] Preset: loading from file {:?}", resolved_path);
amp_sim.load_model(&resolved_path)
};
match &result {
Ok(()) => eprintln!("[AmpSim] Preset: model loaded successfully"),
Err(e) => eprintln!("[AmpSim] Preset: failed to load NAM model: {}", e),
} }
} }
} }

View File

@ -5,6 +5,7 @@ const PARAM_ATTACK: u32 = 0;
const PARAM_DECAY: u32 = 1; const PARAM_DECAY: u32 = 1;
const PARAM_SUSTAIN: u32 = 2; const PARAM_SUSTAIN: u32 = 2;
const PARAM_RELEASE: u32 = 3; const PARAM_RELEASE: u32 = 3;
const PARAM_CURVE: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
enum EnvelopeStage { enum EnvelopeStage {
@ -15,6 +16,19 @@ enum EnvelopeStage {
Release, Release,
} }
/// Curve shape for envelope segments
#[derive(Debug, Clone, Copy, PartialEq)]
enum CurveType {
Linear,
Exponential,
}
impl CurveType {
fn from_f32(v: f32) -> Self {
if v >= 0.5 { CurveType::Exponential } else { CurveType::Linear }
}
}
/// ADSR Envelope Generator /// ADSR Envelope Generator
/// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters /// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters
pub struct ADSRNode { pub struct ADSRNode {
@ -23,8 +37,15 @@ pub struct ADSRNode {
decay: f32, // seconds decay: f32, // seconds
sustain: f32, // level (0.0-1.0) sustain: f32, // level (0.0-1.0)
release: f32, // seconds release: f32, // seconds
curve: CurveType,
stage: EnvelopeStage, stage: EnvelopeStage,
level: f32, // current envelope level level: f32, // current envelope level
/// For exponential curves: the coefficient per sample (computed on stage entry)
exp_coeff: f32,
/// For exponential curves: the base level when the stage started
exp_base: f32,
/// For exponential curves: the target level
exp_target: f32,
gate_was_high: bool, gate_was_high: bool,
inputs: Vec<NodePort>, inputs: Vec<NodePort>,
outputs: Vec<NodePort>, outputs: Vec<NodePort>,
@ -48,6 +69,7 @@ impl ADSRNode {
Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time), Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time),
Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic), Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic),
Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time), Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time),
Parameter::new(PARAM_CURVE, "Curve", 0.0, 1.0, 0.0, ParameterUnit::Generic),
]; ];
Self { Self {
@ -56,8 +78,12 @@ impl ADSRNode {
decay: 0.1, decay: 0.1,
sustain: 0.7, sustain: 0.7,
release: 0.2, release: 0.2,
curve: CurveType::Linear,
stage: EnvelopeStage::Idle, stage: EnvelopeStage::Idle,
level: 0.0, level: 0.0,
exp_coeff: 0.0,
exp_base: 0.0,
exp_target: 0.0,
gate_was_high: false, gate_was_high: false,
inputs, inputs,
outputs, outputs,
@ -89,6 +115,7 @@ impl AudioNode for ADSRNode {
PARAM_DECAY => self.decay = value.clamp(0.001, 5.0), PARAM_DECAY => self.decay = value.clamp(0.001, 5.0),
PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0), PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0),
PARAM_RELEASE => self.release = value.clamp(0.001, 5.0), PARAM_RELEASE => self.release = value.clamp(0.001, 5.0),
PARAM_CURVE => self.curve = CurveType::from_f32(value),
_ => {} _ => {}
} }
} }
@ -99,6 +126,7 @@ impl AudioNode for ADSRNode {
PARAM_DECAY => self.decay, PARAM_DECAY => self.decay,
PARAM_SUSTAIN => self.sustain, PARAM_SUSTAIN => self.sustain,
PARAM_RELEASE => self.release, PARAM_RELEASE => self.release,
PARAM_CURVE => match self.curve { CurveType::Linear => 0.0, CurveType::Exponential => 1.0 },
_ => 0.0, _ => 0.0,
} }
} }
@ -130,9 +158,23 @@ impl AudioNode for ADSRNode {
if gate_high && !self.gate_was_high { if gate_high && !self.gate_was_high {
// Note on: Start attack // Note on: Start attack
self.stage = EnvelopeStage::Attack; self.stage = EnvelopeStage::Attack;
if self.curve == CurveType::Exponential {
// For exponential attack, compute coefficient for ~5 time constants
// We overshoot the target slightly so the curve reaches 1.0 naturally
let samples = self.attack * sample_rate_f32;
self.exp_coeff = (-5.0 / samples).exp();
self.exp_base = self.level;
self.exp_target = 1.0;
}
} else if !gate_high && self.gate_was_high { } else if !gate_high && self.gate_was_high {
// Note off: Start release // Note off: Start release
self.stage = EnvelopeStage::Release; self.stage = EnvelopeStage::Release;
if self.curve == CurveType::Exponential {
let samples = self.release * sample_rate_f32;
self.exp_coeff = (-5.0 / samples).exp();
self.exp_base = self.level;
self.exp_target = 0.0;
}
} }
self.gate_was_high = gate_high; self.gate_was_high = gate_high;
@ -142,7 +184,8 @@ impl AudioNode for ADSRNode {
self.level = 0.0; self.level = 0.0;
} }
EnvelopeStage::Attack => { EnvelopeStage::Attack => {
// Rise from current level to 1.0 match self.curve {
CurveType::Linear => {
let increment = 1.0 / (self.attack * sample_rate_f32); let increment = 1.0 / (self.attack * sample_rate_f32);
self.level += increment; self.level += increment;
if self.level >= 1.0 { if self.level >= 1.0 {
@ -150,9 +193,27 @@ impl AudioNode for ADSRNode {
self.stage = EnvelopeStage::Decay; self.stage = EnvelopeStage::Decay;
} }
} }
CurveType::Exponential => {
// Asymptotic approach: level moves toward overshoot target
// Using target of 1.0 + small overshoot so we actually reach 1.0
let overshoot_target = 1.0 + (1.0 - self.exp_base) * 0.01;
self.level = overshoot_target - (overshoot_target - self.level) * self.exp_coeff;
if self.level >= 1.0 {
self.level = 1.0;
self.stage = EnvelopeStage::Decay;
// Set up decay exponential
let samples = self.decay * sample_rate_f32;
self.exp_coeff = (-5.0 / samples).exp();
self.exp_base = 1.0;
self.exp_target = self.sustain;
}
}
}
}
EnvelopeStage::Decay => { EnvelopeStage::Decay => {
// Fall from 1.0 to sustain level
let target = self.sustain; let target = self.sustain;
match self.curve {
CurveType::Linear => {
let decrement = (1.0 - target) / (self.decay * sample_rate_f32); let decrement = (1.0 - target) / (self.decay * sample_rate_f32);
self.level -= decrement; self.level -= decrement;
if self.level <= target { if self.level <= target {
@ -160,12 +221,23 @@ impl AudioNode for ADSRNode {
self.stage = EnvelopeStage::Sustain; self.stage = EnvelopeStage::Sustain;
} }
} }
CurveType::Exponential => {
// Exponential decay toward sustain level
self.level = target + (self.level - target) * self.exp_coeff;
if (self.level - target).abs() < 0.001 {
self.level = target;
self.stage = EnvelopeStage::Sustain;
}
}
}
}
EnvelopeStage::Sustain => { EnvelopeStage::Sustain => {
// Hold at sustain level // Hold at sustain level
self.level = self.sustain; self.level = self.sustain;
} }
EnvelopeStage::Release => { EnvelopeStage::Release => {
// Fall from current level to 0.0 match self.curve {
CurveType::Linear => {
let decrement = self.level / (self.release * sample_rate_f32); let decrement = self.level / (self.release * sample_rate_f32);
self.level -= decrement; self.level -= decrement;
if self.level <= 0.001 { if self.level <= 0.001 {
@ -173,6 +245,16 @@ impl AudioNode for ADSRNode {
self.stage = EnvelopeStage::Idle; self.stage = EnvelopeStage::Idle;
} }
} }
CurveType::Exponential => {
// Exponential decay toward 0
self.level *= self.exp_coeff;
if self.level <= 0.001 {
self.level = 0.0;
self.stage = EnvelopeStage::Idle;
}
}
}
}
} }
// Write envelope value (CV is mono) // Write envelope value (CV is mono)
@ -183,6 +265,9 @@ impl AudioNode for ADSRNode {
fn reset(&mut self) { fn reset(&mut self) {
self.stage = EnvelopeStage::Idle; self.stage = EnvelopeStage::Idle;
self.level = 0.0; self.level = 0.0;
self.exp_coeff = 0.0;
self.exp_base = 0.0;
self.exp_target = 0.0;
self.gate_was_high = false; self.gate_was_high = false;
} }
@ -201,9 +286,13 @@ impl AudioNode for ADSRNode {
decay: self.decay, decay: self.decay,
sustain: self.sustain, sustain: self.sustain,
release: self.release, release: self.release,
stage: EnvelopeStage::Idle, // Reset state curve: self.curve,
level: 0.0, // Reset level stage: EnvelopeStage::Idle,
gate_was_high: false, // Reset gate level: 0.0,
exp_coeff: 0.0,
exp_base: 0.0,
exp_target: 0.0,
gate_was_high: false,
inputs: self.inputs.clone(), inputs: self.inputs.clone(),
outputs: self.outputs.clone(), outputs: self.outputs.clone(),
parameters: self.parameters.clone(), parameters: self.parameters.clone(),

View File

@ -65,6 +65,16 @@ impl AmpSimNode {
Ok(()) Ok(())
} }
/// Load a bundled NAM model by name (e.g. "BossSD1").
pub fn load_bundled_model(&mut self, name: &str) -> Result<(), String> {
let mut model = super::bundled_models::load_bundled_model(name)
.ok_or_else(|| format!("Unknown bundled model: {}", name))??;
model.set_max_buffer_size(1024);
self.model = Some(model);
self.model_path = Some(format!("bundled:{}", name));
Ok(())
}
/// Get the loaded model path (for preset serialization). /// Get the loaded model path (for preset serialization).
pub fn model_path(&self) -> Option<&str> { pub fn model_path(&self) -> Option<&str> {
self.model_path.as_deref() self.model_path.as_deref()

View File

@ -0,0 +1,50 @@
use nam_ffi::NamModel;
struct BundledModel {
name: &'static str,
filename: &'static str,
data: &'static [u8],
}
const BUNDLED_MODELS: &[BundledModel] = &[
BundledModel {
name: "BossSD1",
filename: "BossSD1-WaveNet.nam",
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/BossSD1-WaveNet.nam"),
},
BundledModel {
name: "DeluxeReverb",
filename: "DeluxeReverb.nam",
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/DeluxeReverb.nam"),
},
BundledModel {
name: "DingwallBass",
filename: "DingwallBass.nam",
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/DingwallBass.nam"),
},
BundledModel {
name: "Rhythm",
filename: "Rhythm.nam",
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/Rhythm.nam"),
},
];
/// Return display names of all bundled NAM models.
pub fn bundled_model_names() -> Vec<&'static str> {
BUNDLED_MODELS.iter().map(|m| m.name).collect()
}
/// Load a bundled NAM model by display name.
/// Returns `None` if the name isn't found, `Some(Err(...))` on load failure.
pub fn load_bundled_model(name: &str) -> Option<Result<NamModel, String>> {
eprintln!("[NAM] load_bundled_model: looking up {:?}", name);
let model = BUNDLED_MODELS.iter().find(|m| m.name == name)?;
eprintln!("[NAM] Found bundled model: name={}, filename={}, data_len={}", model.name, model.filename, model.data.len());
Some(
NamModel::from_bytes(model.filename, model.data)
.map_err(|e| {
eprintln!("[NAM] from_bytes failed for {}: {}", model.filename, e);
e.to_string()
}),
)
}

View File

@ -1,4 +1,5 @@
mod amp_sim; mod amp_sim;
pub mod bundled_models;
mod adsr; mod adsr;
mod arpeggiator; mod arpeggiator;
mod audio_input; mod audio_input;

View File

@ -515,6 +515,8 @@ impl NodeTemplateTrait for NodeTemplate {
ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Release".into(), DataType::CV, graph.add_input_param(node_id, "Release".into(), DataType::CV,
ValueType::float_param(0.2, 0.001, 5.0, " s", 3, None), InputParamKind::ConstantOnly, true); ValueType::float_param(0.2, 0.001, 5.0, " s", 3, None), InputParamKind::ConstantOnly, true);
graph.add_input_param(node_id, "Curve".into(), DataType::CV,
ValueType::float_param(0.0, 0.0, 1.0, "", 4, Some(&["Linear", "Exponential"])), InputParamKind::ConstantOnly, true);
graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV); graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV);
} }
NodeTemplate::Lfo => { NodeTemplate::Lfo => {

View File

@ -2499,43 +2499,15 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
.collect(); .collect();
self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
// Bundled NAM models — discover once and cache // Bundled NAM models — populate from embedded registry
if self.user_state.available_nam_models.is_empty() { if self.user_state.available_nam_models.is_empty() {
let bundled_dirs = [ for name in daw_backend::audio::node_graph::nodes::bundled_models::bundled_model_names() {
std::env::current_exe().ok()
.and_then(|p| p.parent().map(|d| d.join("models")))
.unwrap_or_default(),
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../vendor/NeuralAudio/Utils/Models"),
];
for dir in &bundled_dirs {
if let Ok(canon) = dir.canonicalize() {
if canon.is_dir() {
for entry in std::fs::read_dir(&canon).into_iter().flatten().flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "nam") {
let stem = path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
// Skip LSTM variants (performance alternates, not separate amps)
if stem.ends_with("-LSTM") {
continue;
}
// Clean up display name: remove "-WaveNet" suffix
let name = stem.strip_suffix("-WaveNet")
.unwrap_or(&stem)
.to_string();
self.user_state.available_nam_models.push(NamModelInfo { self.user_state.available_nam_models.push(NamModelInfo {
name, name: name.to_string(),
path: path.to_string_lossy().to_string(), path: format!("bundled:{}", name),
is_bundled: true, is_bundled: true,
}); });
} }
}
break; // use first directory found
}
}
}
self.user_state.available_nam_models.sort_by(|a, b| a.name.cmp(&b.name)); self.user_state.available_nam_models.sort_by(|a, b| a.name.cmp(&b.name));
} }

View File

@ -1,14 +1,13 @@
use std::path::Path; use std::path::Path;
#[cfg(windows)]
type WChar = u16;
#[cfg(not(windows))]
type WChar = u32;
#[allow(dead_code)] #[allow(dead_code)]
mod ffi { mod ffi {
use super::WChar; use std::os::raw::{c_char, c_float, c_int};
use std::os::raw::{c_float, c_int};
#[cfg(windows)]
type PathChar = u16; // wchar_t on Windows
#[cfg(not(windows))]
type PathChar = c_char; // char on Linux/macOS
#[repr(C)] #[repr(C)]
pub struct NeuralModel { pub struct NeuralModel {
@ -16,7 +15,7 @@ mod ffi {
} }
unsafe extern "C" { unsafe extern "C" {
pub fn CreateModelFromFile(model_path: *const WChar) -> *mut NeuralModel; pub fn CreateModelFromFile(model_path: *const PathChar) -> *mut NeuralModel;
pub fn DeleteModel(model: *mut NeuralModel); pub fn DeleteModel(model: *mut NeuralModel);
pub fn SetLSTMLoadMode(load_mode: c_int); pub fn SetLSTMLoadMode(load_mode: c_int);
@ -58,24 +57,36 @@ pub struct NamModel {
} }
impl NamModel { impl NamModel {
/// Load a model from in-memory bytes by writing to a temp file first.
/// The NAM C API only supports file-based loading.
pub fn from_bytes(name: &str, data: &[u8]) -> Result<Self, NamError> {
let dir = std::env::temp_dir().join("lightningbeam-nam");
eprintln!("[NAM] from_bytes: name={}, data_len={}, temp_dir={}", name, data.len(), dir.display());
std::fs::create_dir_all(&dir)
.map_err(|e| NamError::ModelLoadFailed(format!("create_dir_all failed: {}", e)))?;
let file_path = dir.join(name);
std::fs::write(&file_path, data)
.map_err(|e| NamError::ModelLoadFailed(format!("write failed: {}", e)))?;
eprintln!("[NAM] Wrote {} bytes to {}", data.len(), file_path.display());
Self::from_file(&file_path)
}
pub fn from_file(path: &Path) -> Result<Self, NamError> { pub fn from_file(path: &Path) -> Result<Self, NamError> {
let wide: Vec<WChar> = { let ptr = unsafe {
#[cfg(windows)] #[cfg(windows)]
{ {
use std::os::windows::ffi::OsStrExt; use std::os::windows::ffi::OsStrExt;
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
ffi::CreateModelFromFile(wide.as_ptr())
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
path.to_string_lossy() use std::ffi::CString;
.chars() let c_path = CString::new(path.to_string_lossy().as_bytes())
.map(|c| c as WChar) .map_err(|_| NamError::ModelLoadFailed(path.display().to_string()))?;
.chain(std::iter::once(0)) ffi::CreateModelFromFile(c_path.as_ptr())
.collect()
} }
}; };
let ptr = unsafe { ffi::CreateModelFromFile(wide.as_ptr()) };
if ptr.is_null() { if ptr.is_null() {
return Err(NamError::ModelLoadFailed(path.display().to_string())); return Err(NamError::ModelLoadFailed(path.display().to_string()));
} }