Skip to main content

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}