aether_timbre/node.rs
1//! TimbreTransferNode — integrates timbre transfer into the AetherDSP graph.
2//!
3//! This node sits in the signal chain and reshapes the incoming audio's
4//! spectral envelope to match a target instrument's timbre profile.
5//!
6//! Signal flow:
7//! [Source Node] → [TimbreTransferNode] → [Output / Mixer]
8//!
9//! Params:
10//! 0: Amount (0.0 = dry, 1.0 = full transfer)
11
12use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
13use crate::{analysis::TimbreProfile, transfer::TimbreTransfer};
14use std::sync::{Arc, Mutex};
15
16/// A real-time timbre transfer node.
17pub struct TimbreTransferNode {
18 transfer: TimbreTransfer,
19 /// Shared profile — can be swapped from the control thread.
20 profile: Arc<Mutex<Option<TimbreProfile>>>,
21 /// Current MIDI note for pitch-dependent timbre selection.
22 current_note: u8,
23 /// Whether the profile has been loaded into the transfer engine.
24 profile_loaded: bool,
25}
26
27impl TimbreTransferNode {
28 pub fn new() -> Self {
29 Self {
30 transfer: TimbreTransfer::new(2048),
31 profile: Arc::new(Mutex::new(None)),
32 current_note: 60,
33 profile_loaded: false,
34 }
35 }
36
37 /// Get the profile slot for loading from the control thread.
38 pub fn profile_slot(&self) -> Arc<Mutex<Option<TimbreProfile>>> {
39 Arc::clone(&self.profile)
40 }
41
42 /// Set the current MIDI note (for pitch-dependent timbre).
43 pub fn set_note(&mut self, note: u8) {
44 if note != self.current_note {
45 self.current_note = note;
46 self.profile_loaded = false; // Reload envelope for new note
47 }
48 }
49
50 fn maybe_reload_profile(&mut self) {
51 if self.profile_loaded {
52 return;
53 }
54 if let Ok(guard) = self.profile.try_lock() {
55 if let Some(profile) = guard.as_ref() {
56 if let Some(envelope) = profile.envelope_for_note(self.current_note) {
57 self.transfer.set_target(envelope.clone());
58 self.profile_loaded = true;
59 }
60 }
61 }
62 }
63}
64
65impl Default for TimbreTransferNode {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl DspNode for TimbreTransferNode {
72 fn process(
73 &mut self,
74 inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
75 output: &mut [f32; BUFFER_SIZE],
76 params: &mut ParamBlock,
77 _sample_rate: f32,
78 ) {
79 // Param 0: transfer amount (0.0 = dry pass-through, 1.0 = full transfer).
80 // We read `params.params[0].current` — the smoothed running value — not
81 // `target`, so the amount ramps sample-accurately when automated.
82 // If no params are registered (params.count == 0), default to full transfer (1.0).
83 let amount = if params.count > 0 {
84 params.params[0].current.clamp(0.0, 1.0)
85 } else {
86 // No param block registered — default to full transfer.
87 1.0
88 };
89 self.transfer.amount = amount;
90
91 // Try to load profile if not yet loaded (non-blocking try_lock — RT safe).
92 self.maybe_reload_profile();
93
94 // No input connected → output silence.
95 // Invariant: if inputs[0] is None the node has no upstream source; fill
96 // the output buffer with zeros rather than leaving it uninitialised.
97 let input = match inputs[0] {
98 Some(buf) => buf,
99 None => {
100 // No input → silence.
101 output.fill(0.0);
102 return;
103 }
104 };
105
106 // amount ≈ 0 → dry pass-through.
107 // Skip the FFT pipeline entirely and copy input to output unchanged.
108 // Threshold of 0.001 avoids audible artefacts from near-zero processing.
109 if amount < 0.001 {
110 // Dry pass-through: copy input to output, no DSP applied.
111 output.copy_from_slice(input);
112 return;
113 }
114
115 // amount > 0 → full or partial timbre transfer.
116 // `TimbreTransfer::process_block` blends dry and wet internally using
117 // `self.transfer.amount`, so amount=1.0 gives full spectral replacement.
118 let processed = self.transfer.process_block(input);
119 for (i, s) in processed.iter().enumerate().take(BUFFER_SIZE) {
120 output[i] = *s;
121 }
122 }
123
124 fn type_name(&self) -> &'static str {
125 "TimbreTransferNode"
126 }
127
128 // `capture_state` and `restore_state` are no-ops for now — the DspNode
129 // trait provides default implementations that return StateBlob::EMPTY and
130 // do nothing on restore, respectively. They compile without any override.
131}