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 {
33            active: false,
34            pos: 0.0,
35            speed: 1.0,
36            age: 0,
37            duration: 1024,
38            amplitude: 0.0,
39        }
40    }
41
42    #[inline(always)]
43    fn envelope(&self) -> f32 {
44        // Hann window envelope
45        let t = self.age as f32 / self.duration as f32;
46        let hann = 0.5 * (1.0 - (std::f32::consts::TAU * t).cos());
47        hann * self.amplitude
48    }
49
50    #[inline(always)]
51    fn next_sample(&mut self, input_buf: &[f32]) -> f32 {
52        if !self.active {
53            return 0.0;
54        }
55        let env = self.envelope();
56        let idx = self.pos as usize % input_buf.len();
57        let frac = (self.pos - self.pos.floor()) as f32;
58        let s0 = input_buf[idx];
59        let s1 = input_buf[(idx + 1) % input_buf.len()];
60        let sample = s0 + (s1 - s0) * frac;
61        self.pos += self.speed;
62        self.age += 1;
63        if self.age >= self.duration {
64            self.active = false;
65        }
66        sample * env
67    }
68}
69
70pub struct Granular {
71    grains: [Grain; MAX_GRAINS],
72    input_buf: Box<[f32; INPUT_BUF_SIZE]>,
73    write_pos: usize,
74    samples_since_last_grain: usize,
75    rng: u32,
76}
77
78impl Granular {
79    pub fn new() -> Self {
80        Self {
81            grains: std::array::from_fn(|_| Grain::new()),
82            input_buf: Box::new([0.0; INPUT_BUF_SIZE]),
83            write_pos: 0,
84            samples_since_last_grain: 0,
85            rng: 0xDEAD_BEEF,
86        }
87    }
88
89    fn rand_f32(&mut self) -> f32 {
90        self.rng ^= self.rng << 13;
91        self.rng ^= self.rng >> 17;
92        self.rng ^= self.rng << 5;
93        self.rng as f32 / u32::MAX as f32
94    }
95
96    fn spawn_grain(
97        &mut self,
98        grain_size_ms: f32,
99        pitch_scatter: f32,
100        position: f32,
101        pos_scatter: f32,
102        sr: f32,
103    ) {
104        let duration = ((grain_size_ms / 1000.0) * sr) as usize;
105        let duration = duration.clamp(64, MAX_GRAIN_SAMPLES);
106
107        // Find an inactive grain slot
108        let slot = self.grains.iter().position(|g| !g.active);
109        let slot = match slot {
110            Some(s) => s,
111            None => return,
112        };
113
114        // Position in input buffer
115        let pos_center = (position + (self.rand_f32() - 0.5) * pos_scatter).clamp(0.0, 1.0);
116        let buf_pos = (pos_center * INPUT_BUF_SIZE as f32) as usize;
117
118        // Pitch: scatter in semitones
119        let semitone_offset = (self.rand_f32() - 0.5) * 2.0 * pitch_scatter;
120        let speed = 2.0f64.powf(semitone_offset as f64 / 12.0);
121
122        self.grains[slot] = Grain {
123            active: true,
124            pos: buf_pos as f64,
125            speed,
126            age: 0,
127            duration,
128            amplitude: 0.7 + self.rand_f32() * 0.3,
129        };
130    }
131}
132
133impl Default for Granular {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl DspNode for Granular {
140    fn process(
141        &mut self,
142        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
143        output: &mut [f32; BUFFER_SIZE],
144        params: &mut ParamBlock,
145        sample_rate: f32,
146    ) {
147        let silence = [0.0f32; BUFFER_SIZE];
148        let input = inputs[0].unwrap_or(&silence);
149
150        for (i, out) in output.iter_mut().enumerate() {
151            let grain_size = params.get(0).current.clamp(10.0, 500.0);
152            let density = params.get(1).current.clamp(1.0, 50.0);
153            let pitch_scat = params.get(2).current.clamp(0.0, 2.0);
154            let position = params.get(3).current.clamp(0.0, 1.0);
155            let pos_scat = params.get(4).current.clamp(0.0, 1.0);
156            let wet = params.get(5).current.clamp(0.0, 1.0);
157
158            // Write input into circular buffer
159            self.input_buf[self.write_pos] = input[i];
160            self.write_pos = (self.write_pos + 1) % INPUT_BUF_SIZE;
161
162            // Spawn grains at the requested density
163            let samples_per_grain = (sample_rate / density) as usize;
164            self.samples_since_last_grain += 1;
165            if self.samples_since_last_grain >= samples_per_grain {
166                self.samples_since_last_grain = 0;
167                self.spawn_grain(grain_size, pitch_scat, position, pos_scat, sample_rate);
168            }
169
170            // Sum all active grains
171            let mut wet_signal = 0.0f32;
172            for grain in self.grains.iter_mut() {
173                wet_signal += grain.next_sample(&*self.input_buf);
174            }
175
176            *out = input[i] * (1.0 - wet) + wet_signal * wet;
177            params.tick_all();
178        }
179    }
180
181    fn type_name(&self) -> &'static str {
182        "Granular"
183    }
184}