Skip to main content

mlua_pulse/
drums.rs

1//! Drum catalogs, step grids, layered drum aliases, and 808-style helpers.
2
3use crate::effects::PulseEffect;
4use crate::error::{PulseError, PulseResult};
5use tunes::composition::Composition;
6use tunes::instruments::drums::DrumType;
7
8#[derive(Clone, Copy)]
9struct DrumPreset {
10    name: &'static str,
11    drum_type: DrumType,
12}
13
14#[derive(Clone, Copy)]
15struct LayeredDrumPreset {
16    name: &'static str,
17    drum_types: &'static [DrumType],
18}
19
20macro_rules! drum_presets {
21    ($(($name:literal, $variant:ident)),+ $(,)?) => {
22        const DRUM_PRESETS: &[DrumPreset] = &[
23            $(DrumPreset {
24                name: $name,
25                drum_type: DrumType::$variant,
26            },)+
27        ];
28
29        const DRUM_NAMES: &[&str] = &[
30            $($name,)+
31        ];
32    };
33}
34
35drum_presets!(
36    ("kick", Kick),
37    ("kick_808", Kick808),
38    ("sub_kick", SubKick),
39    ("snare", Snare),
40    ("snare_808", Snare808),
41    ("hihat_closed", HiHatClosed),
42    ("hihat_open", HiHatOpen),
43    ("hihat_808_closed", HiHat808Closed),
44    ("hihat_808_open", HiHat808Open),
45    ("clap", Clap),
46    ("clap_808", Clap808),
47    ("tom", Tom),
48    ("tom_high", TomHigh),
49    ("tom_low", TomLow),
50    ("rimshot", Rimshot),
51    ("cowbell", Cowbell),
52    ("crash", Crash),
53    ("ride", Ride),
54    ("china", China),
55    ("splash", Splash),
56    ("tambourine", Tambourine),
57    ("shaker", Shaker),
58    ("bass_drop", BassDrop),
59    ("boom", Boom),
60    ("claves", Claves),
61    ("triangle", Triangle),
62    ("side_stick", SideStick),
63    ("wood_block", WoodBlock),
64    ("kick_909", Kick909),
65    ("snare_909", Snare909),
66    ("conga_high", CongaHigh),
67    ("conga_low", CongaLow),
68    ("bongo_high", BongoHigh),
69    ("bongo_low", BongoLow),
70    ("ride_bell", RideBell),
71    ("floor_tom_low", FloorTomLow),
72    ("floor_tom_high", FloorTomHigh),
73    ("hihat_pedal", HiHatPedal),
74    ("crash_2", Crash2),
75    ("vibraslap", Vibraslap),
76    ("timbale_high", TimbaleHigh),
77    ("timbale_low", TimbaleLow),
78    ("agogo_high", AgogoHigh),
79    ("agogo_low", AgogoLow),
80    ("cabasa", Cabasa),
81    ("guiro_short", GuiroShort),
82    ("guiro_long", GuiroLong),
83    ("wood_block_high", WoodBlockHigh),
84    ("timpani", Timpani),
85    ("gong", Gong),
86    ("chimes", Chimes),
87    ("djembe", Djembe),
88    ("tabla_bayan", TablaBayan),
89    ("tabla_dayan", TablaDayan),
90    ("cajon", Cajon),
91    ("fingersnap", Fingersnap),
92    ("maracas", Maracas),
93    ("castanet", Castanet),
94    ("sleigh_bells", SleighBells),
95    ("laser_zap", LaserZap),
96    ("reverse_cymbal", ReverseCymbal),
97    ("white_noise_hit", WhiteNoiseHit),
98    ("stick_click", StickClick),
99    ("kick_tight", KickTight),
100    ("kick_deep", KickDeep),
101    ("kick_acoustic", KickAcoustic),
102    ("kick_click", KickClick),
103    ("snare_rim", SnareRim),
104    ("snare_tight", SnareTight),
105    ("snare_loose", SnareLoose),
106    ("snare_piccolo", SnarePiccolo),
107    ("hihat_half_open", HiHatHalfOpen),
108    ("hihat_sizzle", HiHatSizzle),
109    ("clap_dry", ClapDry),
110    ("clap_room", ClapRoom),
111    ("clap_group", ClapGroup),
112    ("clap_snare", ClapSnare),
113    ("crash_short", CrashShort),
114    ("ride_tip", RideTip),
115    ("egg_shaker", EggShaker),
116    ("tube_shaker", TubeShaker),
117    ("tom_808_low", Tom808Low),
118    ("tom_808_mid", Tom808Mid),
119    ("tom_808_high", Tom808High),
120    ("cowbell_808", Cowbell808),
121    ("clave_808", Clave808),
122    ("hihat_909_closed", HiHat909Closed),
123    ("hihat_909_open", HiHat909Open),
124    ("clap_909", Clap909),
125    ("cowbell_909", Cowbell909),
126    ("rim_909", Rim909),
127    ("reverse_snare", ReverseSnare),
128    ("cymbal_swell", CymbalSwell),
129);
130
131const LAYERED_DRUM_PRESETS: &[LayeredDrumPreset] = &[
132    LayeredDrumPreset {
133        name: "kick_808_deep_layer",
134        drum_types: &[DrumType::Kick808, DrumType::SubKick],
135    },
136    LayeredDrumPreset {
137        name: "kick_909_click_layer",
138        drum_types: &[DrumType::Kick909, DrumType::KickClick],
139    },
140    LayeredDrumPreset {
141        name: "kick_acoustic_room_layer",
142        drum_types: &[DrumType::KickAcoustic, DrumType::Boom],
143    },
144    LayeredDrumPreset {
145        name: "snare_808_body_layer",
146        drum_types: &[DrumType::Snare808, DrumType::SnareLoose],
147    },
148    LayeredDrumPreset {
149        name: "snare_909_snap_layer",
150        drum_types: &[DrumType::Snare909, DrumType::SnareRim],
151    },
152    LayeredDrumPreset {
153        name: "snare_piccolo_clap_layer",
154        drum_types: &[DrumType::SnarePiccolo, DrumType::ClapDry],
155    },
156    LayeredDrumPreset {
157        name: "clap_808_stack_layer",
158        drum_types: &[DrumType::Clap808, DrumType::ClapGroup],
159    },
160    LayeredDrumPreset {
161        name: "clap_909_wide_layer",
162        drum_types: &[DrumType::Clap909, DrumType::ClapRoom],
163    },
164    LayeredDrumPreset {
165        name: "hihat_808_sizzle_layer",
166        drum_types: &[DrumType::HiHat808Closed, DrumType::HiHatSizzle],
167    },
168    LayeredDrumPreset {
169        name: "hihat_909_sizzle_layer",
170        drum_types: &[DrumType::HiHat909Closed, DrumType::HiHatSizzle],
171    },
172    LayeredDrumPreset {
173        name: "tom_808_stack_layer",
174        drum_types: &[DrumType::Tom808Low, DrumType::Tom808Mid],
175    },
176    LayeredDrumPreset {
177        name: "crash_swell_layer",
178        drum_types: &[DrumType::Crash, DrumType::CymbalSwell],
179    },
180];
181
182/// Returns stable Lua-facing drum aliases.
183pub fn drum_names() -> Vec<&'static str> {
184    DRUM_NAMES
185        .iter()
186        .copied()
187        .chain(LAYERED_DRUM_PRESETS.iter().map(|preset| preset.name))
188        .collect()
189}
190
191/// Resolves a Lua-facing drum alias to `tunes::DrumType`.
192pub fn drum_by_name(name: &str) -> PulseResult<DrumType> {
193    let normalized = normalize_name(name);
194    DRUM_PRESETS
195        .iter()
196        .find(|preset| normalize_name(preset.name) == normalized)
197        .map(|preset| preset.drum_type)
198        .ok_or_else(|| PulseError::InvalidDrumType {
199            value: name.to_string(),
200        })
201}
202
203/// Resolves a Lua-facing drum alias to one or more `tunes::DrumType` voices.
204pub fn drum_voices_by_name(name: &str) -> PulseResult<Vec<DrumType>> {
205    let normalized = normalize_name(name);
206    if let Some(preset) = DRUM_PRESETS
207        .iter()
208        .find(|preset| normalize_name(preset.name) == normalized)
209    {
210        return Ok(vec![preset.drum_type]);
211    }
212
213    LAYERED_DRUM_PRESETS
214        .iter()
215        .find(|preset| normalize_name(preset.name) == normalized)
216        .map(|preset| preset.drum_types.to_vec())
217        .ok_or_else(|| PulseError::InvalidDrumType {
218            value: name.to_string(),
219        })
220}
221
222fn normalize_name(value: &str) -> String {
223    value
224        .trim()
225        .to_ascii_lowercase()
226        .replace(['_', '-', ' '], "")
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230/// One named drum sound and its zero-based step pattern.
231pub struct PulseDrumHit {
232    drum: String,
233    pattern: Vec<usize>,
234}
235
236impl PulseDrumHit {
237    /// Creates a drum hit pattern without validating the drum alias.
238    pub fn new(drum: impl Into<String>, pattern: Vec<usize>) -> Self {
239        Self {
240            drum: drum.into(),
241            pattern,
242        }
243    }
244
245    /// Returns the Lua-facing drum alias.
246    pub fn drum(&self) -> &str {
247        &self.drum
248    }
249
250    /// Returns zero-based step indices that trigger this drum.
251    pub fn pattern(&self) -> &[usize] {
252        &self.pattern
253    }
254}
255
256/// A step-based drum arrangement rendered into a `tunes` composition track.
257#[derive(Debug, Clone, PartialEq)]
258pub struct PulseDrumGrid {
259    track_name: String,
260    steps: usize,
261    step_duration: f32,
262    hits: Vec<PulseDrumHit>,
263    accents: Option<Vec<usize>>,
264    velocities: Option<Vec<f32>>,
265    repeats: usize,
266    effects: Vec<PulseEffect>,
267    volume: f32,
268    pan: f32,
269}
270
271impl Default for PulseDrumGrid {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277impl PulseDrumGrid {
278    /// Creates a 16-step drum grid named `drums` with eighth-note steps.
279    pub fn new() -> Self {
280        Self {
281            track_name: "drums".to_string(),
282            steps: 16,
283            step_duration: 0.125,
284            hits: Vec::new(),
285            accents: None,
286            velocities: None,
287            repeats: 0,
288            effects: Vec::new(),
289            volume: 1.0,
290            pan: 0.0,
291        }
292    }
293
294    /// Sets the output track name.
295    #[must_use]
296    pub fn with_track(mut self, track_name: impl Into<String>) -> Self {
297        self.track_name = track_name.into();
298        self
299    }
300
301    /// Sets the number of steps in the grid.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error when `steps` is zero.
306    pub fn with_steps(mut self, steps: usize) -> PulseResult<Self> {
307        validate_drum_steps(steps)?;
308        self.steps = steps;
309        Ok(self)
310    }
311
312    /// Sets the duration of one grid step in beats.
313    ///
314    /// # Errors
315    ///
316    /// Returns an error when `duration` is zero, negative, or not finite.
317    pub fn with_step_duration(mut self, duration: f32) -> PulseResult<Self> {
318        if !duration.is_finite() || duration <= 0.0 {
319            return Err(PulseError::InvalidStepDuration { duration });
320        }
321        self.step_duration = duration;
322        Ok(self)
323    }
324
325    /// Adds a drum alias and zero-based step pattern.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error when `drum` is not in the drum catalog.
330    pub fn sound(mut self, drum: impl Into<String>, pattern: Vec<usize>) -> PulseResult<Self> {
331        let drum = drum.into();
332        drum_voices_by_name(&drum)?;
333        self.hits.push(PulseDrumHit::new(drum, pattern));
334        Ok(self)
335    }
336
337    /// Sets accent steps that raise matching drum hit velocities.
338    #[must_use]
339    pub fn accent(mut self, pattern: Vec<usize>) -> Self {
340        self.accents = Some(pattern);
341        self
342    }
343
344    /// Sets per-step velocity multipliers.
345    #[must_use]
346    pub fn velocity(mut self, velocities: Vec<f32>) -> Self {
347        self.velocities = Some(velocities);
348        self
349    }
350
351    /// Sets additional grid repetitions after the first pass.
352    #[must_use]
353    pub fn with_repeat(mut self, repeats: usize) -> Self {
354        self.repeats = repeats;
355        self
356    }
357
358    /// Sets how many total times the grid should play.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error when `repeat_times` is zero.
363    pub fn with_repeat_times(mut self, repeat_times: usize) -> PulseResult<Self> {
364        if repeat_times == 0 {
365            return Err(PulseError::InvalidRepeatTimes { repeat_times });
366        }
367        validate_drum_repeat_expansion(self.steps, repeat_times)?;
368        self.repeats = repeat_times - 1;
369        Ok(self)
370    }
371
372    /// Adds a track-level effect to the drum grid.
373    #[must_use]
374    pub fn with_effect(mut self, effect: PulseEffect) -> Self {
375        self.effects.push(effect);
376        self
377    }
378
379    /// Sets the exported drum track volume.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error when `volume` is outside `0..=2`.
384    pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
385        validate_drum_range("volume", volume, 0.0, 2.0)?;
386        self.volume = volume;
387        Ok(self)
388    }
389
390    /// Sets the exported drum track stereo pan.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error when `pan` is outside `-1..=1`.
395    pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
396        validate_drum_range("pan", pan, -1.0, 1.0)?;
397        self.pan = pan;
398        Ok(self)
399    }
400
401    /// Applies the grid to a composition starting at time zero.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error when stored grid options are invalid.
406    pub fn apply_to_composition(&self, composition: &mut Composition) -> PulseResult<()> {
407        self.apply_to_composition_at(composition, 0.0)
408    }
409
410    /// Applies the grid to a composition at an absolute start offset in beats.
411    ///
412    /// # Errors
413    ///
414    /// Returns an error when stored grid options are invalid.
415    pub fn apply_to_composition_at(
416        &self,
417        composition: &mut Composition,
418        start_at: f32,
419    ) -> PulseResult<()> {
420        validate_drum_steps(self.steps)?;
421        if !self.step_duration.is_finite() || self.step_duration <= 0.0 {
422            return Err(PulseError::InvalidStepDuration {
423                duration: self.step_duration,
424            });
425        }
426        validate_drum_range("volume", self.volume, 0.0, 2.0)?;
427        validate_drum_range("pan", self.pan, -1.0, 1.0)?;
428        validate_drum_repeat_expansion(self.steps, self.repeats.saturating_add(1))?;
429
430        let resolved_hits = self
431            .hits
432            .iter()
433            .map(|hit| drum_voices_by_name(hit.drum()).map(|drums| (drums, hit.pattern().to_vec())))
434            .collect::<PulseResult<Vec<_>>>()?;
435        let accents = self.accents.clone();
436        let velocities = self.velocities.clone();
437        let repeats = self.repeats;
438
439        let mut builder = composition
440            .track(&self.track_name)
441            .volume(self.volume)
442            .pan(self.pan)
443            .at(start_at)
444            .drum_grid(self.steps, self.step_duration, |grid| {
445                let mut grid = resolved_hits.iter().fold(grid, |grid, (drums, pattern)| {
446                    drums
447                        .iter()
448                        .fold(grid, |grid, drum| grid.sound(*drum, pattern))
449                });
450                if let Some(pattern) = &accents {
451                    grid = grid.accent(pattern);
452                }
453                if let Some(values) = &velocities {
454                    grid = grid.velocity(values);
455                }
456                if repeats > 0 {
457                    grid = grid.repeat(repeats);
458                }
459                grid
460            });
461
462        for effect in &self.effects {
463            builder = effect.apply_to_track_builder(builder);
464        }
465        let _ = builder;
466
467        Ok(())
468    }
469
470    /// Returns the output track name.
471    pub fn track_name(&self) -> &str {
472        &self.track_name
473    }
474
475    /// Returns the number of steps.
476    pub fn steps(&self) -> usize {
477        self.steps
478    }
479
480    /// Returns the duration of one grid step in beats.
481    pub fn step_duration(&self) -> f32 {
482        self.step_duration
483    }
484
485    /// Returns all drum hit patterns.
486    pub fn hits(&self) -> &[PulseDrumHit] {
487        &self.hits
488    }
489
490    /// Returns additional repeat count.
491    pub fn repeats(&self) -> usize {
492        self.repeats
493    }
494
495    /// Returns total grid duration in beats after repeats.
496    pub fn duration(&self) -> f32 {
497        self.steps as f32 * self.step_duration * self.repeats.saturating_add(1) as f32
498    }
499
500    /// Returns track-level effects in insertion order.
501    pub fn effects(&self) -> &[PulseEffect] {
502        &self.effects
503    }
504
505    /// Returns track volume.
506    pub fn volume(&self) -> f32 {
507        self.volume
508    }
509
510    /// Returns track stereo pan.
511    pub fn pan(&self) -> f32 {
512        self.pan
513    }
514}
515
516fn validate_drum_range(option: &str, value: f32, min: f32, max: f32) -> PulseResult<()> {
517    if value.is_finite() && value >= min && value <= max {
518        Ok(())
519    } else {
520        Err(PulseError::InvalidDrumOption {
521            option: option.to_string(),
522            value: value.to_string(),
523        })
524    }
525}
526
527fn validate_drum_steps(steps: usize) -> PulseResult<()> {
528    if steps == 0 || steps > isize::MAX as usize {
529        return Err(PulseError::InvalidDrumGridSteps { steps });
530    }
531    Ok(())
532}
533
534fn validate_drum_repeat_expansion(steps: usize, repeat_times: usize) -> PulseResult<()> {
535    let total_steps = steps
536        .checked_mul(repeat_times)
537        .ok_or(PulseError::InvalidRepeatTimes { repeat_times })?;
538    if total_steps > isize::MAX as usize {
539        return Err(PulseError::InvalidRepeatTimes { repeat_times });
540    }
541    Ok(())
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545/// Drum machine kit selector.
546pub enum DrumKit {
547    /// Roland TR-808 style mapping.
548    Tr808,
549}
550
551/// Convenience builder for common 808 drum-machine patterns.
552#[derive(Debug, Clone, PartialEq)]
553pub struct PulseDrumMachine {
554    kit: DrumKit,
555    grid: PulseDrumGrid,
556}
557
558impl PulseDrumMachine {
559    /// Creates a drum-machine builder for a kit name.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error for unsupported kit names.
564    pub fn new(kit: &str) -> PulseResult<Self> {
565        match normalize_name(kit).as_str() {
566            "808" | "tr808" => Ok(Self {
567                kit: DrumKit::Tr808,
568                grid: PulseDrumGrid::new(),
569            }),
570            _ => Err(PulseError::InvalidDrumType {
571                value: kit.to_string(),
572            }),
573        }
574    }
575
576    /// Sets the backing grid step count.
577    ///
578    /// # Errors
579    ///
580    /// Returns an error when `steps` is zero.
581    pub fn steps(mut self, steps: usize) -> PulseResult<Self> {
582        self.grid = self.grid.with_steps(steps)?;
583        Ok(self)
584    }
585
586    /// Sets the backing grid step duration in beats.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error when `duration` is invalid.
591    pub fn step_duration(mut self, duration: f32) -> PulseResult<Self> {
592        self.grid = self.grid.with_step_duration(duration)?;
593        Ok(self)
594    }
595
596    /// Adds 808 kick hits.
597    ///
598    /// # Errors
599    ///
600    /// Returns an error if the mapped drum alias cannot be resolved.
601    pub fn kick(mut self, pattern: Vec<usize>) -> PulseResult<Self> {
602        self.grid = self.grid.sound("kick_808", pattern)?;
603        Ok(self)
604    }
605
606    /// Adds 808 snare hits.
607    ///
608    /// # Errors
609    ///
610    /// Returns an error if the mapped drum alias cannot be resolved.
611    pub fn snare(mut self, pattern: Vec<usize>) -> PulseResult<Self> {
612        self.grid = self.grid.sound("snare_808", pattern)?;
613        Ok(self)
614    }
615
616    /// Adds closed 808 hi-hat hits.
617    ///
618    /// # Errors
619    ///
620    /// Returns an error if the mapped drum alias cannot be resolved.
621    pub fn hihat(mut self, pattern: Vec<usize>) -> PulseResult<Self> {
622        self.grid = self.grid.sound("hihat_808_closed", pattern)?;
623        Ok(self)
624    }
625
626    /// Adds open 808 hi-hat hits.
627    ///
628    /// # Errors
629    ///
630    /// Returns an error if the mapped drum alias cannot be resolved.
631    pub fn open_hihat(mut self, pattern: Vec<usize>) -> PulseResult<Self> {
632        self.grid = self.grid.sound("hihat_808_open", pattern)?;
633        Ok(self)
634    }
635
636    /// Adds 808 clap hits.
637    ///
638    /// # Errors
639    ///
640    /// Returns an error if the mapped drum alias cannot be resolved.
641    pub fn clap(mut self, pattern: Vec<usize>) -> PulseResult<Self> {
642        self.grid = self.grid.sound("clap_808", pattern)?;
643        Ok(self)
644    }
645
646    /// Finishes the builder and returns its drum grid.
647    #[must_use]
648    pub fn grid(self) -> PulseDrumGrid {
649        self.grid
650    }
651
652    /// Returns the selected kit.
653    #[must_use]
654    pub fn kit(&self) -> DrumKit {
655        self.kit
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    fn test_compressor_effect() -> crate::effects::PulseEffect {
664        crate::effects::effect_from_options("compressor", crate::effects::EffectOptions::Default)
665            .expect("default compressor should parse")
666    }
667
668    #[test]
669    fn exposes_all_current_tunes_drum_types() {
670        let names = drum_names();
671        assert!(names.len() >= 100);
672        assert!(names.contains(&"kick_808"));
673        assert!(names.contains(&"snare_808"));
674        assert!(names.contains(&"hihat_808_closed"));
675        assert!(names.contains(&"clap_808"));
676        assert!(names.contains(&"kick_808_deep_layer"));
677        assert!(names.contains(&"snare_808_body_layer"));
678        assert!(names.contains(&"clap_909_wide_layer"));
679
680        for name in names {
681            drum_voices_by_name(name).expect("catalog entry should resolve");
682        }
683    }
684
685    #[test]
686    fn layered_drum_aliases_resolve_to_multiple_tunes_drum_types() {
687        let voices =
688            drum_voices_by_name("kick_808_deep_layer").expect("layered kick should resolve");
689
690        assert_eq!(voices, vec![DrumType::Kick808, DrumType::SubKick]);
691        assert_eq!(
692            drum_by_name("kick_808_deep_layer")
693                .expect_err("layered aliases should require voice resolution")
694                .to_string(),
695            "invalid drum type: kick_808_deep_layer"
696        );
697    }
698
699    #[test]
700    fn rejects_unknown_drum_alias() {
701        let error = drum_by_name("kick_707").expect_err("unknown drum should fail");
702        assert_eq!(error.to_string(), "invalid drum type: kick_707");
703    }
704
705    #[test]
706    fn drum_grid_validates_and_builds_patterns() {
707        let grid = PulseDrumGrid::new()
708            .with_steps(16)
709            .unwrap()
710            .with_step_duration(0.125)
711            .unwrap()
712            .sound("kick_808", vec![0, 4, 8, 12])
713            .unwrap()
714            .accent(vec![0, 4, 8, 12])
715            .with_repeat(1);
716
717        assert_eq!(grid.steps(), 16);
718        assert_eq!(grid.hits().len(), 1);
719        assert_eq!(grid.repeats(), 1);
720    }
721
722    #[test]
723    fn drum_grid_repeat_times_uses_total_play_count() {
724        let single_pass = PulseDrumGrid::new()
725            .with_steps(4)
726            .unwrap()
727            .with_step_duration(0.25)
728            .unwrap()
729            .with_repeat_times(1)
730            .unwrap();
731
732        assert_eq!(single_pass.repeats(), 0);
733        assert_eq!(single_pass.duration(), 1.0);
734
735        let double_pass = single_pass.clone().with_repeat_times(2).unwrap();
736        assert_eq!(double_pass.repeats(), 1);
737        assert_eq!(double_pass.duration(), 2.0);
738
739        assert_eq!(
740            PulseDrumGrid::new()
741                .with_repeat_times(0)
742                .unwrap_err()
743                .to_string(),
744            "invalid repeat times: 0"
745        );
746    }
747
748    #[test]
749    fn drum_grid_rejects_unrenderable_repeat_expansion() {
750        let error = PulseDrumGrid::new()
751            .with_repeat_times(usize::MAX)
752            .expect_err("unrenderable drum repeat expansion should fail");
753
754        assert!(matches!(error, PulseError::InvalidRepeatTimes { .. }));
755    }
756
757    #[test]
758    fn drum_grid_rejects_unrenderable_step_counts() {
759        let error = PulseDrumGrid::new()
760            .with_steps(usize::MAX)
761            .expect_err("unrenderable drum step count should fail");
762
763        assert!(matches!(error, PulseError::InvalidDrumGridSteps { .. }));
764    }
765
766    #[test]
767    fn drum_machine_808_builds_grid() {
768        let grid = PulseDrumMachine::new("808")
769            .unwrap()
770            .steps(16)
771            .unwrap()
772            .kick(vec![0, 4, 8, 12])
773            .unwrap()
774            .snare(vec![4, 12])
775            .unwrap()
776            .hihat(vec![0, 2, 4, 6, 8, 10, 12, 14])
777            .unwrap()
778            .grid();
779
780        assert_eq!(grid.hits().len(), 3);
781    }
782
783    #[test]
784    fn drum_grid_stores_and_applies_track_effects() {
785        let grid = PulseDrumGrid::new()
786            .sound("kick_808", vec![0, 4, 8, 12])
787            .unwrap()
788            .with_effect(test_compressor_effect());
789
790        assert_eq!(grid.effects().len(), 1);
791
792        let song = crate::composition::PulseSong::new().add_drum_grid(grid);
793        let mixer = song.to_mixer().expect("drum grid should build");
794        let tracks = mixer.all_tracks();
795
796        assert_eq!(tracks.len(), 1);
797        assert!(tracks[0].effects.compressor.is_some());
798    }
799
800    #[test]
801    fn drum_grid_applies_volume_and_pan_to_exported_track() {
802        let grid = PulseDrumGrid::new()
803            .sound("kick_808", vec![0])
804            .unwrap()
805            .with_volume(0.8)
806            .unwrap()
807            .with_pan(0.2)
808            .unwrap();
809
810        let song = crate::composition::PulseSong::new().add_drum_grid(grid);
811        let mixer = song.to_mixer().expect("drum grid should build");
812        let tracks = mixer.all_tracks();
813
814        assert_eq!(tracks.len(), 1);
815        assert_eq!(tracks[0].volume, 0.8);
816        assert_eq!(tracks[0].pan, 0.2);
817    }
818
819    #[test]
820    fn drum_grid_expands_layered_aliases_into_exported_events() {
821        let grid = PulseDrumGrid::new()
822            .sound("kick_808_deep_layer", vec![0])
823            .unwrap();
824
825        let song = crate::composition::PulseSong::new().add_drum_grid(grid);
826        let mixer = song.to_mixer().expect("layered drum grid should build");
827        let tracks = mixer.all_tracks();
828        let drum_events = tracks[0]
829            .events
830            .iter()
831            .filter(|event| matches!(event, tunes::track::AudioEvent::Drum(_)))
832            .count();
833
834        assert_eq!(drum_events, 2);
835    }
836}