Skip to main content

phonic/generator/sampler/
granular.rs

1//! Granular playback impl for Sampler.
2
3use std::sync::{Arc, LazyLock};
4
5use rand::{rngs::SmallRng, Rng, SeedableRng};
6
7use assume::assume;
8use strum::EnumCount;
9
10use crate::{utils::buffer::InterleavedBufferMut, Error};
11
12// -------------------------------------------------------------------------------------------------
13
14/// Playback direction for grains.
15#[derive(
16    Clone, Copy, Debug, PartialEq, Eq, strum::EnumString, strum::Display, strum::VariantNames,
17)]
18#[repr(u8)]
19pub enum GrainPlaybackDirection {
20    /// Play grains forward through the file.
21    Forward,
22    /// Play grains backward through the file.
23    Backward,
24    /// Play grains in a random direction.
25    Random,
26}
27
28// -------------------------------------------------------------------------------------------------
29
30/// Grain overlap mode for controlling how grains are scheduled.
31#[derive(
32    Clone, Copy, Debug, PartialEq, Eq, strum::EnumString, strum::Display, strum::VariantNames,
33)]
34#[repr(u8)]
35pub enum GrainOverlapMode {
36    /// Multiple grains can overlap freely (current behavior).
37    /// Grains trigger at density-based intervals.
38    /// Up to POOL_SIZE concurrent grains.
39    Cloud,
40    /// Queue-based playback with adaptive crossfading.
41    /// New grain triggers when current grain reaches its crossfade point.
42    /// Maximum 2 grains active during crossfade.
43    /// grain_density parameter is ignored.
44    Sequential,
45}
46
47/// Grain window mode selection (optimized for granular synthesis)
48/// Ordered by smoothness: smooth → balanced → sharp → rhythmic
49#[derive(
50    Clone,
51    Copy,
52    Debug,
53    PartialEq,
54    Eq,
55    strum::EnumString,
56    strum::Display,
57    strum::VariantNames,
58    strum::EnumCount,
59)]
60#[repr(u8)]
61pub enum GrainWindowMode {
62    Hann = 0,
63    Blackman = 1,
64    Triangle = 2,
65    Tukey = 3,
66    Trapezoid = 4,
67    Exponential = 5,
68    RampUp = 6,
69    RampDown = 7,
70}
71
72impl GrainWindowMode {
73    /// Get the crossfade trigger point (0.0-1.0) for sequential playback.
74    /// Returns the grain progress percentage at which the next grain should trigger.
75    ///
76    /// Smooth windows need early crossfade for gap-free playback, while
77    /// sharp/sustaining windows can wait longer for maximum grain separation.
78    pub fn sequential_crossfade_point(&self) -> f32 {
79        match self {
80            // Smooth windows need early crossfade for gap-free playback
81            GrainWindowMode::Hann
82            | GrainWindowMode::Blackman
83            | GrainWindowMode::Triangle
84            | GrainWindowMode::Tukey => 0.5,
85
86            // Trapezoid has sustain, can wait until near the end
87            GrainWindowMode::Trapezoid => 0.9,
88
89            // Pointed/asymmetric windows
90            GrainWindowMode::Exponential | GrainWindowMode::RampUp | GrainWindowMode::RampDown => {
91                0.8
92            }
93        }
94    }
95}
96
97/// Precomputed grain windows (optimized for granular synthesis)
98/// `N` must be a pow2 value.
99pub(crate) struct GrainWindow<const N: usize> {
100    luts: [[f32; N]; GrainWindowMode::COUNT],
101}
102
103impl<const N: usize> GrainWindow<N> {
104    /// Calculate bit mask from N
105    const _VERIFY_N: () = assert!(
106        N.is_power_of_two(),
107        "Grain window size must be a pow2 value"
108    );
109    const MASK: usize = N - 1;
110
111    /// Precompute all window LUTs
112    pub fn new() -> Self {
113        let mut luts = [[0.0; N]; GrainWindowMode::COUNT];
114
115        #[allow(clippy::needless_range_loop)]
116        for i in 0..N {
117            let phase = i as f32 / N as f32; // [0.0, 1.0)
118
119            // Hann: cosine-squared window (perfect overlap-add)
120            // Standard for granular synthesis
121            // Also known as Hanning window
122            luts[GrainWindowMode::Hann as usize][i] =
123                0.5 * (1.0 - (2.0 * std::f32::consts::PI * phase).cos());
124
125            // Blackman: classic DSP window with steep spectral rolloff
126            // a0=0.42, a1=0.5, a2=0.08 (standard coefficients)
127            // Smooth spectral transitions, wider main lobe
128            let pi_phase = std::f32::consts::PI * phase;
129            luts[GrainWindowMode::Blackman as usize][i] =
130                0.42 - 0.5 * (2.0 * pi_phase).cos() + 0.08 * (4.0 * pi_phase).cos();
131
132            // Triangle: linear rise to peak at 0.5, linear fall
133            luts[GrainWindowMode::Triangle as usize][i] = if phase < 0.5 {
134                2.0 * phase
135            } else {
136                2.0 * (1.0 - phase)
137            };
138
139            // Tukey: tapered cosine (truncation α = 0.5)
140            // Variable sustain for longer grains
141            // Morphs from rectangular to fully-cosine-tapered
142            let alpha = 0.5;
143            let width = alpha / 2.0;
144            luts[GrainWindowMode::Tukey as usize][i] = if phase < width {
145                let u = phase / width;
146                0.5 * (1.0 - (std::f32::consts::PI * u).cos())
147            } else if phase > 1.0 - width {
148                let u = (1.0 - phase) / width;
149                0.5 * (1.0 - (std::f32::consts::PI * u).cos())
150            } else {
151                1.0
152            };
153
154            // Trapezoid: linear ramps with flat sustain (~80% sustain)
155            // Percussive with clear transients, punchy
156            let ramp_width = 0.1;
157            luts[GrainWindowMode::Trapezoid as usize][i] = if phase < ramp_width {
158                phase / ramp_width
159            } else if phase > 1.0 - ramp_width {
160                (1.0 - phase) / ramp_width
161            } else {
162                1.0
163            };
164
165            // Exponential: non-linear decay from center (Poisson window)
166            // Creates "push" character with emphasis on center
167            // Rhythmic emphasis, pointed grains
168            let decay_rate = 6.0;
169            let center_dist = (phase - 0.5).abs();
170            luts[GrainWindowMode::Exponential as usize][i] = (-decay_rate * center_dist).exp();
171
172            // Ramp Up: 90% linear rise, 10% quick cosine fade
173            // Strong upward movement, rhythmic effects
174            luts[GrainWindowMode::RampUp as usize][i] = if phase < 0.9 {
175                // Linear rise over 90%
176                phase / 0.9
177            } else {
178                // Quick cosine fade over last 10%
179                let u = (phase - 0.9) / 0.1;
180                0.5 * (1.0 + (std::f32::consts::PI * u).cos())
181            };
182
183            // Ramp Down: 10% quick cosine rise, 90% linear fall
184            // Strong downward movement, rhythmic effects
185            luts[GrainWindowMode::RampDown as usize][i] = if phase < 0.1 {
186                // Quick cosine rise over first 10%
187                let u = phase / 0.1;
188                0.5 * (1.0 - (std::f32::consts::PI * u).cos())
189            } else {
190                // Linear fall over remaining 90%
191                1.0 - ((phase - 0.1) / 0.9)
192            };
193        }
194
195        Self { luts }
196    }
197
198    /// Evaluate a window at normalized phase [0.0, 1.0]
199    /// Uses linear interpolation for smooth lookup between LUT samples
200    #[inline]
201    pub fn sample(&self, mode: GrainWindowMode, phase: f64) -> f32 {
202        debug_assert!((0.0..=1.0).contains(&phase));
203
204        let index_float = phase * (N - 1) as f64;
205        let index = (index_float as usize) & Self::MASK;
206        let fraction = index_float.fract() as f32;
207        let next_index = (index + 1) & Self::MASK;
208
209        let lut = &self.luts[mode as usize];
210        if index < N - 1 {
211            lut[index] * (1.0 - fraction) + lut[next_index] * fraction
212        } else {
213            lut[N - 1]
214        }
215    }
216}
217
218// -------------------------------------------------------------------------------------------------
219
220/// Static, shared lookup table for the envelope window modes
221static GRAIN_WINDOW_LUT: LazyLock<GrainWindow<2048>> = LazyLock::new(GrainWindow::new);
222
223// -------------------------------------------------------------------------------------------------
224
225/// Modulation buffers for block-based parameter modulation.
226/// Contains pre-computed modulation values for a block of samples.
227pub(crate) struct GranularParameterModulation<'a> {
228    pub size: &'a [f32],
229    pub density: &'a [f32],
230    pub variation: &'a [f32],
231    pub spray: &'a [f32],
232    pub pan_spread: &'a [f32],
233    pub position: &'a [f32],
234    pub speed: &'a [f32],
235}
236
237// -------------------------------------------------------------------------------------------------
238
239/// Parameters controlling granular playback behavior.
240#[derive(Clone, Debug)]
241pub struct GranularParameters {
242    /// Grain overlap mode (Cloud or Sequential).
243    pub overlap_mode: GrainOverlapMode,
244    /// Grain window mode.
245    pub window: GrainWindowMode,
246    /// Size of each grain in milliseconds (1.0 - 1000.0).
247    pub size: f32,
248    /// Density of grain spawning in Hz (1.0 - 100.0).
249    /// Represents the number of new grains triggered per second.
250    pub density: f32,
251    /// Grain variation (0.0 = no variation, 1.0 = full variation of size and volume)
252    /// At 1.0, grain size varies 25%-200% and volume varies 0.0-1.0.
253    pub variation: f32,
254    /// Random variation in grain start position (0.0 - 1.0).
255    /// Each grain's start position is varied by ±1.0 seconds at maximum spray.
256    pub spray: f32,
257    /// Random stereo spread per grain (0.0 - 1.0).
258    /// Each grain's panning is offset by ±(pan_spread × 0.5) from the voice's base pan.
259    pub pan_spread: f32,
260    /// Direction for grain playback (forward, backward, or random).
261    pub playback_direction: GrainPlaybackDirection,
262    /// Position in the file (0.0 - 1.0) when step is 0.0.
263    pub position: f32,
264    /// Playback step multiplier (-4.0 = backwards, 0.0 = stay at position, 4.0 = forward).
265    pub step: f32,
266}
267
268impl Default for GranularParameters {
269    fn default() -> Self {
270        Self {
271            overlap_mode: GrainOverlapMode::Cloud,
272            window: GrainWindowMode::Triangle,
273            size: 100.0,
274            density: 10.0,
275            spray: 0.0,
276            variation: 0.0,
277            pan_spread: 0.0,
278            playback_direction: GrainPlaybackDirection::Forward,
279            position: 0.5,
280            step: 0.0,
281        }
282    }
283}
284
285impl GranularParameters {
286    pub fn new() -> Self {
287        Self::default()
288    }
289
290    /// Validate all parameters.
291    pub fn validate(&self) -> Result<(), Error> {
292        if self.size < 1.0 || self.size > 1000.0 {
293            return Err(Error::ParameterError(
294                "Grain size must be between 1 and 1000 ms".to_string(),
295            ));
296        }
297
298        if self.density < 1.0 || self.density > 100.0 {
299            return Err(Error::ParameterError(
300                "Grain density must be between 1.0 and 100.0 Hz".to_string(),
301            ));
302        }
303
304        if self.spray < 0.0 || self.spray > 1.0 {
305            return Err(Error::ParameterError(
306                "Grain spray must be between 0.0 and 1.0".to_string(),
307            ));
308        }
309
310        if self.variation < 0.0 || self.variation > 1.0 {
311            return Err(Error::ParameterError(
312                "Grain variation must be between 0.0 and 1.0".to_string(),
313            ));
314        }
315
316        if self.pan_spread < 0.0 || self.pan_spread > 1.0 {
317            return Err(Error::ParameterError(
318                "Grain pan spread must be between 0.0 and 1.0".to_string(),
319            ));
320        }
321
322        if self.position < 0.0 || self.position > 1.0 {
323            return Err(Error::ParameterError(
324                "Position must be between 0.0 and 1.0".to_string(),
325            ));
326        }
327
328        if self.step < -4.0 || self.step > 4.0 {
329            return Err(Error::ParameterError(
330                "Step must be between -4.0 and 4.0".to_string(),
331            ));
332        }
333
334        Ok(())
335    }
336}
337
338// -------------------------------------------------------------------------------------------------
339
340/// Fixed-size pool and playback manager of up to `POOL_SIZE` pre-allocated, reusable [Grain]
341/// instances.
342///
343/// Grains are triggered at a rate set by [GranularParameters]'s `density`, and spawn around a
344/// fixed `position` or from an advancing playhead (when `step` is non-zero).
345pub(crate) struct GrainPool<const POOL_SIZE: usize> {
346    /// Current overlap mode (Cloud or Sequential).
347    overlap_mode: GrainOverlapMode,
348    /// Pool of reusable grain instances.
349    grain_pool: [Grain; POOL_SIZE],
350    /// Indices of currently active grains.
351    active_grain_indices: Vec<usize>,
352    /// Index of primary grain in Sequential mode (for tracking crossfade point).
353    primary_grain_index: Option<usize>,
354    /// Grain source buffer (a resampled, decoded mono sample buffer)
355    sample_buffer: Arc<Box<[f32]>>,
356    /// Loop range for playback (normalized 0.0..1.0).
357    sample_loop_range: Option<(f32, f32)>,
358    /// Loop range playback status.
359    playing_loop_range: bool,
360    /// Whether new grains should be triggered (set to false when stopping).
361    trigger_new_grains: bool,
362    /// Current phase of the grain trigger oscillator (0.0..1.0).
363    /// Increments based on grain_density_hz to determine when to spawn new grains.
364    trigger_phase: f32,
365    /// Playback speed/pitch multiplier for all grains.
366    speed: f64,
367    /// Overall volume multiplier for all grains (0.0..1.0+).
368    volume: f32,
369    /// Base stereo panning position for grains (-1.0..1.0).
370    panning: f32,
371    /// Current playhead position, when playback step is != 0 (0.0..1.0).
372    playhead: f32,
373    /// Sample rate of the audio output.
374    sample_rate: u32,
375    /// Random number generator for spray and pan spread variations.
376    rng: SmallRng,
377}
378
379impl<const POOL_SIZE: usize> GrainPool<POOL_SIZE> {
380    /// Minimum envelope amplitude threshold below which grains are skipped.
381    const ENVELOPE_THRESHOLD: f32 = 0.001; // ~ -60dB
382
383    /// Create a new grain pool with the given sample rate, source sample buffer and optional loop points.
384    pub fn new(
385        sample_rate: u32,
386        sample_buffer: Arc<Box<[f32]>>,
387        sample_loop_range: Option<(f32, f32)>,
388    ) -> Self {
389        debug_assert!(
390            !sample_buffer.is_empty(),
391            "Need a valid, non empty sample buffer"
392        );
393        debug_assert!(
394            sample_loop_range
395                .is_none_or(|l| (0.0..=1.0).contains(&l.0) && (0.0..=1.0).contains(&l.1)),
396            "Invalid loop points (should be relative positions), but are: {:?}",
397            sample_loop_range
398        );
399        let overlap_mode = GrainOverlapMode::Cloud;
400        let grain_pool = [Grain::new(); POOL_SIZE];
401        let active_grain_indices = Vec::with_capacity(POOL_SIZE);
402        let primary_grain_index = None;
403        let playing_loop_range = false;
404        let trigger_phase = 0.0;
405        let trigger_new_grains = true;
406        let speed = 1.0;
407        let volume = 1.0;
408        let panning = 0.0;
409        let playhead = 0.0;
410        let rng = SmallRng::from_os_rng();
411
412        Self {
413            overlap_mode,
414            grain_pool,
415            active_grain_indices,
416            primary_grain_index,
417            sample_buffer,
418            sample_loop_range,
419            playing_loop_range,
420            trigger_new_grains,
421            trigger_phase,
422            speed,
423            volume,
424            panning,
425            playhead,
426            sample_rate,
427            rng,
428        }
429    }
430
431    /// Fold `position` into `[loop_start, loop_end)` using modular arithmetic.
432    #[inline]
433    fn fold_into_loop_range(position: f64, loop_start: f64, loop_end: f64) -> f64 {
434        let loop_len = loop_end - loop_start;
435        if loop_len > 0.0 {
436            loop_start + (position - loop_start).rem_euclid(loop_len)
437        } else {
438            loop_start
439        }
440    }
441
442    pub fn is_exhausted(&self) -> bool {
443        !self.trigger_new_grains && self.active_grain_indices.is_empty()
444    }
445
446    pub fn playback_position(&self, parameters: &GranularParameters, position_mod: f32) -> f32 {
447        // Determine base position based on step value
448        let mut base_position = if parameters.step == 0.0 {
449            parameters.position
450        } else {
451            self.playhead
452        };
453
454        // Apply modulation
455        if position_mod != 0.0 {
456            base_position += position_mod;
457        }
458
459        // When playing in loop, fold modulated position into loop range
460        if self.playing_loop_range {
461            if let Some((loop_start, loop_end)) = self.sample_loop_range {
462                base_position = Self::fold_into_loop_range(
463                    base_position as f64,
464                    loop_start as f64,
465                    loop_end as f64,
466                ) as f32;
467            }
468        }
469
470        // Return modulated position and ensure it's valid
471        base_position.rem_euclid(1.0)
472    }
473
474    pub fn start(
475        &mut self,
476        parameters: &GranularParameters,
477        speed: f64,
478        volume: f32,
479        panning: f32,
480    ) {
481        self.trigger_new_grains = true;
482        self.trigger_phase = 1.0;
483
484        self.speed = speed;
485        self.volume = volume;
486        self.panning = panning;
487        self.playhead = parameters.position;
488        self.playing_loop_range = false;
489    }
490
491    pub fn stop(&mut self) {
492        self.trigger_new_grains = false;
493    }
494
495    pub fn reset(&mut self) {
496        self.active_grain_indices.clear();
497        for grain in &mut self.grain_pool {
498            grain.deactivate();
499        }
500        self.trigger_new_grains = true;
501        self.primary_grain_index = None;
502    }
503
504    pub fn set_speed(&mut self, speed: f64) {
505        self.speed = speed;
506    }
507
508    pub fn set_volume(&mut self, volume: f32) {
509        self.volume = volume;
510    }
511
512    pub fn set_panning(&mut self, panning: f32) {
513        self.panning = panning;
514    }
515
516    pub fn set_loop_range(&mut self, loop_range: Option<(f32, f32)>) {
517        self.sample_loop_range = loop_range;
518    }
519
520    /// Try to trigger a new grain if the trigger phase indicates it's time.
521    /// Returns true if a grain was triggered.
522    #[inline]
523    #[allow(clippy::too_many_arguments)]
524    fn try_trigger_grain(
525        &mut self,
526        parameters: &GranularParameters,
527        size_mod: f32,
528        density_mod: f32,
529        variation_mod: f32,
530        spray_mod: f32,
531        pan_spread_mod: f32,
532        position_mod: f32,
533    ) -> bool {
534        // Detect mode changes
535        if self.overlap_mode != parameters.overlap_mode {
536            self.overlap_mode = parameters.overlap_mode;
537            self.primary_grain_index = None;
538        }
539
540        // Sequential mode: check if primary grain has reached crossfade point
541        if self.overlap_mode == GrainOverlapMode::Sequential {
542            if let Some(primary_index) = self.primary_grain_index {
543                let primary_grain = &self.grain_pool[primary_index];
544                if primary_grain.is_active() {
545                    // Calculate grain progress (window_phase ranges 0.0-1.0)
546                    let grain_progress = primary_grain.window_phase();
547                    let crossfade_point = parameters.window.sequential_crossfade_point();
548
549                    // Block new grain until primary reaches crossfade point
550                    if grain_progress < crossfade_point as f64 {
551                        return false;
552                    }
553                }
554            }
555        }
556
557        if !self.trigger_new_grains || !self.update_trigger_phase(parameters, density_mod) {
558            return false;
559        }
560
561        // Apply spray to randomize grain start position
562        let spray_variation = if !self.sample_buffer.is_empty() {
563            let file_duration = self.sample_buffer.len() as f64 / self.sample_rate as f64;
564            // Apply modulation to spray (additive, clamped)
565            let modulated_spray = (parameters.spray + spray_mod).clamp(0.0, 1.0);
566            // Spray range: +/- 1.0 seconds at 1.0
567            let spray_seconds = modulated_spray as f64 * 2.0 * (self.rng.random::<f64>() - 0.5);
568            spray_seconds / file_duration
569        } else {
570            0.0
571        };
572
573        // Calculate playback position and apply spray
574        let mut grain_position =
575            self.playback_position(parameters, position_mod) as f64 + spray_variation;
576        // When playing in loop, fold modulated position into loop range
577        if self.playing_loop_range {
578            if let Some((loop_start, loop_end)) = self.sample_loop_range {
579                grain_position =
580                    Self::fold_into_loop_range(grain_position, loop_start as f64, loop_end as f64);
581            }
582        }
583        // either way ensure it's always valid
584        grain_position = grain_position.rem_euclid(1.0);
585
586        // Start a new grain
587        let activated_index = self.activate_new_grain(
588            parameters,
589            size_mod,
590            variation_mod,
591            pan_spread_mod,
592            grain_position,
593        );
594
595        // In Sequential mode, track the primary grain for crossfade timing
596        if self.overlap_mode == GrainOverlapMode::Sequential {
597            if let Some(index) = activated_index {
598                self.primary_grain_index = Some(index);
599            }
600        }
601
602        activated_index.is_some()
603    }
604
605    /// Advance the playhead position when step > 0.
606    #[inline]
607    fn advance_playhead(&mut self, buffer_frame_count: usize, step: f32, speed_mod: f32) {
608        // Apply modulation to step (multiplicative)
609        let speed_mult = 1.0 + speed_mod;
610        let modulated_step = step * speed_mult;
611
612        // Advance position by one frame worth of time at the current step
613        let position_increment = modulated_step / buffer_frame_count as f32;
614        self.playhead += position_increment;
615
616        // Wrap around at file boundaries or loop points
617        if let Some((loop_start, loop_end)) = self.sample_loop_range {
618            if self.playing_loop_range {
619                self.playhead = Self::fold_into_loop_range(
620                    self.playhead as f64,
621                    loop_start as f64,
622                    loop_end as f64,
623                ) as f32;
624            } else if self.playhead >= loop_start && self.playhead < loop_end {
625                // Playhead entered the loop range from either direction; start looping
626                self.playing_loop_range = true;
627            } else {
628                // Not yet in the loop: wrap globally so we can reach the loop from either side
629                if self.playhead >= 1.0 {
630                    self.playhead -= 1.0;
631                } else if self.playhead < 0.0 {
632                    self.playhead += 1.0;
633                }
634            }
635        } else if self.playhead >= 1.0 {
636            self.playhead -= 1.0;
637        } else if self.playhead < 0.0 {
638            self.playhead += 1.0;
639        }
640    }
641
642    pub fn process(
643        &mut self,
644        mut output: &mut [f32],
645        channel_count: usize,
646        parameters: &GranularParameters,
647        modulation: &GranularParameterModulation,
648    ) -> usize {
649        let grain_window = &*GRAIN_WINDOW_LUT;
650
651        let sample_frame_count = self.sample_buffer.len();
652        let move_playhead = parameters.step != 0.0 && sample_frame_count > 0;
653
654        // Eliminate channel count match branch from hot path
655        match channel_count {
656            1 => {
657                // Mono processing
658                for (frame_index, frame) in output.as_frames_mut::<1>().iter_mut().enumerate() {
659                    // Trigger new grains with modulated parameters
660                    self.try_trigger_grain(
661                        parameters,
662                        modulation.size[frame_index],
663                        modulation.density[frame_index],
664                        modulation.variation[frame_index],
665                        modulation.spray[frame_index],
666                        modulation.pan_spread[frame_index],
667                        modulation.position[frame_index],
668                    );
669                    // Move Playhead
670                    if move_playhead {
671                        self.advance_playhead(
672                            sample_frame_count,
673                            parameters.step,
674                            modulation.speed[frame_index],
675                        );
676                    }
677                    // Process all active grains and mix to mono output
678                    for &grain_index in &self.active_grain_indices {
679                        let grain = &mut self.grain_pool[grain_index];
680                        if !grain.is_active() {
681                            continue;
682                        }
683                        let grain_output = grain.process(grain_window);
684                        if grain_output.envelope > Self::ENVELOPE_THRESHOLD {
685                            let sample = self.sample_at_position(grain_output.position);
686                            frame[0] += sample * grain_output.envelope;
687                        }
688                    }
689                }
690            }
691            2 => {
692                // Stereo processing
693                for (frame_index, frame) in output.as_frames_mut::<2>().iter_mut().enumerate() {
694                    // Trigger new grains with modulated parameters
695                    self.try_trigger_grain(
696                        parameters,
697                        modulation.size[frame_index],
698                        modulation.density[frame_index],
699                        modulation.variation[frame_index],
700                        modulation.spray[frame_index],
701                        modulation.pan_spread[frame_index],
702                        modulation.position[frame_index],
703                    );
704                    // Move Playhead
705                    if move_playhead {
706                        self.advance_playhead(
707                            sample_frame_count,
708                            parameters.step,
709                            modulation.speed[frame_index],
710                        );
711                    }
712                    // Process all active grains and mix to stereo output
713                    for &grain_index in &self.active_grain_indices {
714                        let grain = &mut self.grain_pool[grain_index];
715                        if grain.is_active() {
716                            let grain_output = grain.process(grain_window);
717                            if grain_output.envelope > Self::ENVELOPE_THRESHOLD {
718                                let sample = self.sample_at_position(grain_output.position);
719                                let windowed_sample = sample * grain_output.envelope;
720
721                                let left_gain = (1.0 - grain_output.panning) * 0.5;
722                                let right_gain = (1.0 + grain_output.panning) * 0.5;
723                                frame[0] += windowed_sample * left_gain;
724                                frame[1] += windowed_sample * right_gain;
725                            }
726                        }
727                    }
728                }
729            }
730            _ => {
731                // Multi-channel processing (only modify first two channels)
732                for (frame_index, frame) in output.frames_mut(channel_count).enumerate() {
733                    // Trigger new grains
734                    self.try_trigger_grain(
735                        parameters,
736                        modulation.size[frame_index],
737                        modulation.density[frame_index],
738                        modulation.variation[frame_index],
739                        modulation.spray[frame_index],
740                        modulation.pan_spread[frame_index],
741                        modulation.position[frame_index],
742                    );
743                    // Move Playhead
744                    if move_playhead {
745                        self.advance_playhead(
746                            sample_frame_count,
747                            parameters.step,
748                            modulation.speed[frame_index],
749                        );
750                    }
751                    // Process all active grains on a temp stereo output pair
752                    let mut stereo_out = [0.0; 2];
753                    for &grain_index in &self.active_grain_indices {
754                        let grain = &mut self.grain_pool[grain_index];
755                        if !grain.is_active() {
756                            continue;
757                        }
758                        let grain_output = grain.process(grain_window);
759                        if grain_output.envelope > Self::ENVELOPE_THRESHOLD {
760                            let sample = self.sample_at_position(grain_output.position);
761                            let windowed_sample = sample * grain_output.envelope;
762
763                            let left_gain = (1.0 - grain_output.panning) * 0.5;
764                            let right_gain = (1.0 + grain_output.panning) * 0.5;
765                            stereo_out[0] += windowed_sample * left_gain;
766                            stereo_out[1] += windowed_sample * right_gain;
767                        }
768                    }
769                    // Copy stereo output pair
770                    for (channel, sample) in frame.enumerate() {
771                        if channel < 2 {
772                            *sample += stereo_out[channel];
773                        }
774                    }
775                }
776            }
777        }
778
779        // Cleanup grains from the list which finished playback
780        self.active_grain_indices
781            .retain(|&index| self.grain_pool[index].is_active());
782
783        output.len()
784    }
785
786    /// Get the current grain trigger phase for density-based grain spawning.
787    /// Returns true if a grain should be triggered in this sample.
788    fn update_trigger_phase(
789        &mut self,
790        granular_params: &GranularParameters,
791        density_mod: f32,
792    ) -> bool {
793        // Sequential mode triggers new grains as soon as the old one finished
794        if self.overlap_mode == GrainOverlapMode::Sequential {
795            return true;
796        }
797        // Density: bipolar modulation, multiplies current density
798        let density_mult = 1.0 + density_mod;
799        let density = (granular_params.density * density_mult).clamp(1.0, 100.0);
800
801        let trigger_increment = density / self.sample_rate as f32;
802        self.trigger_phase += trigger_increment;
803
804        if self.trigger_phase >= 1.0 {
805            self.trigger_phase -= 1.0;
806            return true;
807        }
808        false
809    }
810
811    /// Activate a new grain at the given position with the voice's current pitch.
812    /// Returns Some(index) if a grain was successfully activated, None if no free grains available.
813    fn activate_new_grain(
814        &mut self,
815        parameters: &GranularParameters,
816        size_mod: f32,
817        variation_mod: f32,
818        pan_spread_mod: f32,
819        position: f64,
820    ) -> Option<usize> {
821        if let Some(index) = self.grain_pool.iter().position(|g| !g.is_active()) {
822            let grain = &mut self.grain_pool[index];
823            let window_mode = parameters.window;
824            let speed = self.speed;
825
826            // Apply modulation to variation (additive, clamped)
827            let variation = (parameters.variation + variation_mod).clamp(0.0, 1.0);
828
829            // Volume variation: 1.0 -> 0..1, 0.0 -> 1.0
830            let volume_scale = 1.0 - (variation * self.rng.random::<f32>());
831            let volume = self.volume * volume_scale;
832
833            // Pitch variation: 1.0 -> ±0.5 semitone
834            let random_semitones = variation as f64 * (self.rng.random::<f64>() - 0.5);
835            let speed = if random_semitones != 0.0 {
836                speed * 2.0_f64.powf(random_semitones / 12.0)
837            } else {
838                speed
839            };
840
841            // Grain size variation: 1.0 -> 25%..400%
842            let min_scale = 1.0 - (0.75 * variation);
843            let max_scale = 1.0 + (2.0 * variation);
844            let size_scale = min_scale + (max_scale - min_scale) * self.rng.random::<f32>();
845
846            // Size: bipolar modulation, multiplies current size
847            // Convert mod value to multiplier: -1 -> 0.5×, 0 -> 1.0×, +1 -> 2.0×
848            let size_mult = 1.0 + size_mod;
849            let grain_size_ms = (parameters.size * size_mult).clamp(1.0, 1000.0);
850
851            let grain_size =
852                ((grain_size_ms * size_scale * self.sample_rate as f32 / 1000.0) as usize).max(2);
853
854            // Apply modulation to pan_spread (additive, clamped)
855            let modulated_pan_spread = (parameters.pan_spread + pan_spread_mod).clamp(0.0, 1.0);
856            let panning_spread = modulated_pan_spread * (self.rng.random::<f32>() * 2.0 - 1.0);
857            let panning = (self.panning + panning_spread).clamp(-1.0, 1.0);
858
859            // Pitch variation: 1.0 -> ±0.5 semitones, 0.0 -> no variation
860            let pitch_variation_semitones =
861                variation * (self.rng.random::<f32>() * 2.0 - 1.0) * 0.5;
862            let pitch_variation_mult = 2.0_f64.powf(pitch_variation_semitones as f64 / 12.0);
863            let varied_speed = speed * pitch_variation_mult;
864
865            let file_length_frames = self.sample_buffer.len();
866            let reverse = match parameters.playback_direction {
867                GrainPlaybackDirection::Forward => false,
868                GrainPlaybackDirection::Backward => true,
869                GrainPlaybackDirection::Random => self.rng.random::<bool>(),
870            };
871            let loop_range = if self.playing_loop_range {
872                self.sample_loop_range
873                    .map(|(start, end)| (start as f64, end as f64))
874            } else {
875                None
876            };
877            grain.activate(
878                window_mode,
879                position,
880                varied_speed,
881                volume,
882                panning,
883                grain_size,
884                file_length_frames,
885                reverse,
886                loop_range,
887            );
888            if let Some(position) = self.active_grain_indices.iter().position(|&v| v == index) {
889                // don't recycle a grain when it got stopped in the current process cycle
890                self.active_grain_indices.remove(position);
891            }
892            self.active_grain_indices.push(index);
893            Some(index)
894        } else {
895            None
896        }
897    }
898
899    /// Sample from the file at a normalized position (0.0-1.0) using cubic interpolation.
900    #[inline]
901    fn sample_at_position(&self, normalized_pos: f32) -> f32 {
902        let len = self.sample_buffer.len();
903
904        assume!(unsafe: len > 0, "Buffer len is asserted in constructor");
905        let max_index = len - 1;
906        let float_index = normalized_pos * max_index as f32;
907
908        let index = (float_index as usize).min(max_index);
909        let fraction = float_index - (index as f32);
910
911        // Calculate indices for 4-point cubic interpolation
912        let i1 = index;
913        let i2 = if i1 < max_index { i1 + 1 } else { 0 };
914        let i0 = if i1 > 0 { i1 - 1 } else { max_index };
915        let i3 = if i2 < max_index { i2 + 1 } else { 0 };
916
917        assume!(unsafe: i0 < len);
918        let y0 = self.sample_buffer[i0];
919        assume!(unsafe: i1 < len);
920        let y1 = self.sample_buffer[i1];
921        assume!(unsafe: i2 < len);
922        let y2 = self.sample_buffer[i2];
923        assume!(unsafe: i3 < len);
924        let y3 = self.sample_buffer[i3];
925
926        // Cubic interpolation (Catmull-Rom)
927        let a = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3;
928        let b = y0 - 2.5 * y1 + 2.0 * y2 - 0.5 * y3;
929        let c = -0.5 * y0 + 0.5 * y2;
930        let d = y1;
931
932        a * fraction * fraction * fraction + b * fraction * fraction + c * fraction + d
933    }
934}
935
936// -------------------------------------------------------------------------------------------------
937
938/// Single sample processing result of a [Grain].
939///
940/// Contains the envelope amplitude, stereo panning, and normalized file position
941/// for a grain at a specific moment in time. This is the output of [Grain::process]
942/// and is used by [GrainPool] to read and mix samples from the audio buffer.
943#[derive(Debug, Copy, Clone)]
944struct GrainOutput {
945    /// Amplitude envelope value (0.0..1.0), combining grain volume and sine window.
946    envelope: f32,
947    /// Stereo panning position (-1.0 = full left, 0.0 = center, 1.0 = full right).
948    panning: f32,
949    /// Normalized position in the audio file (0.0 = start, 1.0 = end).
950    position: f32,
951}
952
953// -------------------------------------------------------------------------------------------------
954
955/// Represents a single grain of audio.
956///
957/// A grain is a short burst of audio with a configurable amplitude envelope.
958/// Grains are spawned at regular intervals (density) and processed in parallel,
959/// allowing for polyphonic granular synthesis effects.
960#[derive(Debug, Clone, Copy)]
961struct Grain {
962    /// Is this grain currently active?
963    active: bool,
964    /// Grain's overall volume. May be randomized when there's a volume spread.
965    volume: f32,
966    /// Grain's panning position. May be randomized when there's a pan spread.
967    panning: f32,
968    /// Current playback position in the file (0.0 = start, 1.0 = end).
969    /// This is independent of the voice's playback position.
970    position: f64,
971    /// Increment to apply to position each sample.
972    /// Determined by grain pitch and playback direction.
973    increment: f64,
974    /// Number of samples remaining in this grain.
975    /// When this reaches 0, the grain deactivates.
976    samples_remaining: usize,
977    /// Current position of the window envelope (0.0 to 1.0).
978    window_phase: f64,
979    /// Amount to increment envelope_phase each sample.
980    window_increment: f64,
981    /// Grain window type that should be applied.
982    window_mode: GrainWindowMode,
983    /// Loop range (normalized 0.0..1.0) to wrap position within, if any.
984    loop_range: Option<(f64, f64)>,
985}
986
987impl Default for Grain {
988    fn default() -> Self {
989        Self::new()
990    }
991}
992
993impl Grain {
994    /// Create a new inactive grain.
995    pub const fn new() -> Self {
996        Self {
997            active: false,
998            position: 0.0,
999            volume: 1.0,
1000            panning: 0.0,
1001            increment: 0.0,
1002            samples_remaining: 0,
1003            window_phase: 0.0,
1004            window_increment: 0.0,
1005            window_mode: GrainWindowMode::Triangle,
1006            loop_range: None,
1007        }
1008    }
1009
1010    /// Check if this grain is currently active.
1011    #[inline]
1012    pub fn is_active(&self) -> bool {
1013        self.active
1014    }
1015
1016    /// Get current window phase (0.0-1.0) indicating grain progress.
1017    /// Used for sequential mode crossfade triggering.
1018    #[inline]
1019    pub fn window_phase(&self) -> f64 {
1020        self.window_phase
1021    }
1022
1023    /// Activate this grain with the given parameters.
1024    #[allow(clippy::too_many_arguments)]
1025    pub fn activate(
1026        &mut self,
1027        window_mode: GrainWindowMode,
1028        position: f64,
1029        speed: f64,
1030        volume: f32,
1031        panning: f32,
1032        grain_size_samples: usize,
1033        file_length_frames: usize,
1034        reverse: bool,
1035        loop_range: Option<(f64, f64)>,
1036    ) {
1037        self.active = true;
1038        self.window_mode = window_mode;
1039        self.position = position.clamp(0.0, 1.0);
1040        self.volume = volume.clamp(0.0, 100.0);
1041        self.panning = panning.clamp(-1.0, 1.0);
1042        self.samples_remaining = grain_size_samples;
1043        self.loop_range = loop_range;
1044
1045        // Calculate the increment per sample
1046        // For a normalized position (0.0 to 1.0) spanning file_length_frames:
1047        // - At speed = 1.0: traverse the entire file (1.0) in file_length_frames samples
1048        // - increment = 1.0 / file_length_frames per sample
1049        // - With speed: increment = speed / file_length_frames
1050        let base_increment = if file_length_frames > 0 {
1051            speed / file_length_frames as f64
1052        } else {
1053            0.0
1054        };
1055
1056        self.increment = base_increment * if reverse { -1.0 } else { 1.0 };
1057
1058        // Initialize sine window envelope
1059        // The envelope will go from 0 to π during the grain's lifetime
1060        self.window_phase = 0.0;
1061        if grain_size_samples > 0 {
1062            // Increment to traverse the whole envelope in grain_size_samples steps
1063            self.window_increment = 1.0 / grain_size_samples as f64;
1064        } else {
1065            self.window_increment = 0.0;
1066        }
1067    }
1068
1069    /// Deactivate this grain immediately.
1070    #[allow(dead_code)]
1071    pub fn deactivate(&mut self) {
1072        self.active = false;
1073        self.samples_remaining = 0;
1074    }
1075
1076    /// Process this grain for one sample.
1077    ///
1078    /// Returns (envelope_value, position) for this sample.
1079    /// The caller should use this to read from the audio file at `position`
1080    /// and multiply the sample by `envelope_value`.
1081    pub fn process(&mut self, grain_window: &GrainWindow<2048>) -> GrainOutput {
1082        #[cfg(not(test))]
1083        debug_assert!(self.active, "Should only process active grains");
1084
1085        let envelope_value = grain_window.sample(self.window_mode, self.window_phase);
1086
1087        // Store current position for the caller to read the sample
1088        let position = self.position as f32;
1089
1090        // Advance to next sample
1091        self.position += self.increment;
1092        self.window_phase += self.window_increment;
1093        self.samples_remaining = self.samples_remaining.saturating_sub(1);
1094
1095        // Wrap position within loop range, or globally to [0.0, 1.0]
1096        if let Some((loop_start, loop_end)) = self.loop_range {
1097            let loop_len = loop_end - loop_start;
1098            if loop_len > 0.0 {
1099                self.position = loop_start + (self.position - loop_start).rem_euclid(loop_len);
1100            }
1101        } else if self.position < 0.0 {
1102            self.position += 1.0;
1103        } else if self.position > 1.0 {
1104            self.position -= 1.0;
1105        }
1106
1107        // Deactivate grain when we played through the whole grain
1108        if self.samples_remaining == 0 {
1109            self.active = false;
1110        }
1111
1112        let envelope = envelope_value * self.volume;
1113        let panning = self.panning;
1114
1115        GrainOutput {
1116            envelope,
1117            panning,
1118            position,
1119        }
1120    }
1121}