Skip to main content

aether_nodes/
granular.rs

1//! Granular synthesis node.
2//!
3//! Takes any audio input and produces a cloud of grains.
4//! Feed a Krar recording → get textures no sample library can produce.
5//! Feed a Djembe hit → get rhythmic clouds.
6//!
7//! Param layout:
8//!   0 = grain_size   (ms, 10 – 500)
9//!   1 = density      (grains/sec, 1 – 50)
10//!   2 = pitch_scatter (semitones, 0 – 2)
11//!   3 = position     (0.0 – 1.0, position in input buffer)
12//!   4 = pos_scatter  (0.0 – 1.0)
13//!   5 = wet          (0.0 – 1.0)
14
15use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
16
17const MAX_GRAIN_SAMPLES: usize = 48_000 / 2; // 500ms at 48kHz
18const MAX_GRAINS: usize = 64;
19const INPUT_BUF_SIZE: usize = 48_000 * 4; // 4 seconds of input
20
21struct Grain {
22    active: bool,
23    pos: f64,       // current read position in input buffer
24    speed: f64,     // playback speed (pitch)
25    age: usize,     // samples since grain started
26    duration: usize, // grain duration in samples
27    amplitude: f32,
28}
29
30impl Grain {
31    fn new() -> Self {
32        Self { active: false, pos: 0.0, speed: 1.0, age: 0, duration: 1024, amplitude: 0.0 }
33    }
34
35    #[inline(always)]
36    fn envelope(&self) -> f32 {
37        // Hann window envelope
38        let t = self.age as f32 / self.duration as f32;
39        let hann = 0.5 * (1.0 - (std::f32::consts::TAU * t).cos());
40        hann * self.amplitude
41    }
42
43    #[inline(always)]
44    fn next_sample(&mut self, input_buf: &[f32]) -> f32 {
45        if !self.active { return 0.0; }
46        let env = self.envelope();
47        let idx = self.pos as usize % input_buf.len();
48        let frac = (self.pos - self.pos.floor()) as f32;
49        let s0 = input_buf[idx];
50        let s1 = input_buf[(idx + 1) % input_buf.len()];
51        let sample = s0 + (s1 - s0) * frac;
52        self.pos += self.speed;
53        self.age += 1;
54        if self.age >= self.duration { self.active = false; }
55        sample * env
56    }
57}
58
59pub struct Granular {
60    grains: [Grain; MAX_GRAINS],
61    input_buf: Box<[f32; INPUT_BUF_SIZE]>,
62    write_pos: usize,
63    samples_since_last_grain: usize,
64    rng: u32,
65}
66
67impl Granular {
68    pub fn new() -> Self {
69        Self {
70            grains: std::array::from_fn(|_| Grain::new()),
71            input_buf: Box::new([0.0; INPUT_BUF_SIZE]),
72            write_pos: 0,
73            samples_since_last_grain: 0,
74            rng: 0xDEAD_BEEF,
75        }
76    }
77
78    fn rand_f32(&mut self) -> f32 {
79        self.rng ^= self.rng << 13;
80        self.rng ^= self.rng >> 17;
81        self.rng ^= self.rng << 5;
82        self.rng as f32 / u32::MAX as f32
83    }
84
85    fn spawn_grain(&mut self, grain_size_ms: f32, pitch_scatter: f32, position: f32, pos_scatter: f32, sr: f32) {
86        let duration = ((grain_size_ms / 1000.0) * sr) as usize;
87        let duration = duration.clamp(64, MAX_GRAIN_SAMPLES);
88
89        // Find an inactive grain slot
90        let slot = self.grains.iter().position(|g| !g.active);
91        let slot = match slot { Some(s) => s, None => return };
92
93        // Position in input buffer
94        let pos_center = (position + (self.rand_f32() - 0.5) * pos_scatter).clamp(0.0, 1.0);
95        let buf_pos = (pos_center * INPUT_BUF_SIZE as f32) as usize;
96
97        // Pitch: scatter in semitones
98        let semitone_offset = (self.rand_f32() - 0.5) * 2.0 * pitch_scatter;
99        let speed = 2.0f64.powf(semitone_offset as f64 / 12.0);
100
101        self.grains[slot] = Grain {
102            active: true,
103            pos: buf_pos as f64,
104            speed,
105            age: 0,
106            duration,
107            amplitude: 0.7 + self.rand_f32() * 0.3,
108        };
109    }
110}
111
112impl Default for Granular {
113    fn default() -> Self { Self::new() }
114}
115
116impl DspNode for Granular {
117    fn process(
118        &mut self,
119        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
120        output: &mut [f32; BUFFER_SIZE],
121        params: &mut ParamBlock,
122        sample_rate: f32,
123    ) {
124        let silence = [0.0f32; BUFFER_SIZE];
125        let input = inputs[0].unwrap_or(&silence);
126
127        for (i, out) in output.iter_mut().enumerate() {
128            let grain_size  = params.get(0).current.clamp(10.0, 500.0);
129            let density     = params.get(1).current.clamp(1.0, 50.0);
130            let pitch_scat  = params.get(2).current.clamp(0.0, 2.0);
131            let position    = params.get(3).current.clamp(0.0, 1.0);
132            let pos_scat    = params.get(4).current.clamp(0.0, 1.0);
133            let wet         = params.get(5).current.clamp(0.0, 1.0);
134
135            // Write input into circular buffer
136            self.input_buf[self.write_pos] = input[i];
137            self.write_pos = (self.write_pos + 1) % INPUT_BUF_SIZE;
138
139            // Spawn grains at the requested density
140            let samples_per_grain = (sample_rate / density) as usize;
141            self.samples_since_last_grain += 1;
142            if self.samples_since_last_grain >= samples_per_grain {
143                self.samples_since_last_grain = 0;
144                self.spawn_grain(grain_size, pitch_scat, position, pos_scat, sample_rate);
145            }
146
147            // Sum all active grains
148            let mut wet_signal = 0.0f32;
149            for grain in self.grains.iter_mut() {
150                wet_signal += grain.next_sample(&*self.input_buf);
151            }
152
153            *out = input[i] * (1.0 - wet) + wet_signal * wet;
154            params.tick_all();
155        }
156    }
157
158    fn type_name(&self) -> &'static str { "Granular" }
159}