Skip to main content

mlua_pulse/
composition.rs

1//! Song, phrase, sequence, and sample-clip builders used by the Lua DSL.
2
3use crate::drums::PulseDrumGrid;
4use crate::effects::PulseEffect;
5use crate::error::{PulseError, PulseResult};
6use crate::instruments::instrument_by_name;
7use crate::synthesis::PulseSynth;
8use crate::theory::transpose_notes;
9use tunes::composition::{Composition, Tempo};
10use tunes::synthesis::sample::Sample;
11use tunes::track::{AudioEvent, Mixer, Track};
12
13/// One monophonic or polyphonic sequence event.
14#[derive(Debug, Clone, PartialEq)]
15pub struct PulseNoteEvent {
16    frequencies: Vec<f32>,
17    duration: f32,
18}
19
20impl PulseNoteEvent {
21    /// Creates one note or chord event.
22    pub fn new(frequencies: Vec<f32>, duration: f32) -> PulseResult<Self> {
23        validate_event_frequencies(&frequencies)?;
24        validate_sequence_duration(duration)?;
25        Ok(Self {
26            frequencies,
27            duration,
28        })
29    }
30
31    /// Returns event frequencies.
32    pub fn frequencies(&self) -> &[f32] {
33        &self.frequencies
34    }
35
36    /// Returns event duration.
37    pub fn duration(&self) -> f32 {
38        self.duration
39    }
40}
41
42/// A Lua-friendly melodic sequence with one duration per event.
43#[derive(Debug, Clone, PartialEq)]
44pub struct PulseSequence {
45    notes: Vec<f32>,
46    chords: Vec<Vec<f32>>,
47    durations: Vec<f32>,
48    instrument: String,
49    synth: Option<PulseSynth>,
50    effects: Vec<PulseEffect>,
51    start_at: f32,
52    volume: f32,
53    pan: f32,
54    velocity: f32,
55}
56
57impl Default for PulseSequence {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl PulseSequence {
64    /// Creates an empty sequence using `electric_piano`.
65    pub fn new() -> Self {
66        Self {
67            notes: Vec::new(),
68            chords: Vec::new(),
69            durations: Vec::new(),
70            instrument: "electric_piano".to_string(),
71            synth: None,
72            effects: Vec::new(),
73            start_at: 0.0,
74            volume: 1.0,
75            pan: 0.0,
76            velocity: 0.8,
77        }
78    }
79
80    /// Replaces the sequence note frequencies.
81    #[must_use]
82    pub fn with_notes(mut self, notes: Vec<f32>) -> Self {
83        self.notes = notes;
84        self.chords.clear();
85        self
86    }
87
88    /// Replaces the sequence with polyphonic note events.
89    #[must_use]
90    pub fn with_chords(mut self, chords: Vec<Vec<f32>>) -> Self {
91        self.chords = chords;
92        self.notes.clear();
93        self
94    }
95
96    /// Replaces the sequence durations.
97    #[must_use]
98    pub fn with_durations(mut self, durations: Vec<f32>) -> Self {
99        self.durations = durations;
100        self
101    }
102
103    /// Sets the instrument alias for this sequence.
104    #[must_use]
105    pub fn with_instrument(mut self, instrument: impl Into<String>) -> Self {
106        self.instrument = instrument.into();
107        self
108    }
109
110    /// Sets an explicit synthesis algorithm for this sequence.
111    #[must_use]
112    pub fn with_synth(mut self, synth: PulseSynth) -> Self {
113        self.synth = Some(synth);
114        self
115    }
116
117    /// Sets a sequence start offset relative to the song or phrase placement.
118    pub fn with_start_at(mut self, start_at: f32) -> PulseResult<Self> {
119        validate_non_negative_sequence_option("at", start_at)?;
120        self.start_at = start_at;
121        Ok(self)
122    }
123
124    /// Sets track volume for this sequence.
125    pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
126        validate_sequence_range("volume", volume, 0.0, 2.0)?;
127        self.volume = volume;
128        Ok(self)
129    }
130
131    /// Sets stereo pan for this sequence.
132    pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
133        validate_sequence_range("pan", pan, -1.0, 1.0)?;
134        self.pan = pan;
135        Ok(self)
136    }
137
138    /// Sets velocity for notes exported from this sequence.
139    pub fn with_velocity(mut self, velocity: f32) -> PulseResult<Self> {
140        validate_sequence_range("velocity", velocity, 0.0, 1.0)?;
141        self.velocity = velocity;
142        Ok(self)
143    }
144
145    /// Returns a transposed copy without mutating the original sequence.
146    #[must_use]
147    pub fn transposed(&self, semitones: i32) -> Self {
148        let mut sequence = self.clone();
149        sequence.notes = transpose_notes(&sequence.notes, semitones);
150        sequence.chords = sequence
151            .chords
152            .iter()
153            .map(|chord| transpose_notes(chord, semitones))
154            .collect();
155        sequence
156    }
157
158    /// Appends a track-level effect to the sequence.
159    #[must_use]
160    pub fn with_effect(mut self, effect: PulseEffect) -> Self {
161        self.effects.push(effect);
162        self
163    }
164
165    /// Validates sequence shape and instrument name.
166    pub fn validate(&self) -> PulseResult<()> {
167        let event_count = self.event_count();
168        if event_count != self.durations.len() {
169            return Err(PulseError::DurationCountMismatch {
170                notes: event_count,
171                durations: self.durations.len(),
172            });
173        }
174
175        for &duration in &self.durations {
176            validate_sequence_duration(duration)?;
177        }
178
179        if !self.chords.is_empty() {
180            for chord in &self.chords {
181                validate_event_frequencies(chord)?;
182            }
183        } else {
184            for &frequency in &self.notes {
185                validate_note_frequency(frequency)?;
186            }
187        }
188
189        if matches!(self.synth, Some(PulseSynth::KarplusStrong(_)))
190            && self.chords.iter().any(|chord| chord.len() > 1)
191        {
192            return Err(PulseError::InvalidSynthOption {
193                synth: "karplus_strong".to_string(),
194                option: "chords".to_string(),
195                value: "polyphonic".to_string(),
196            });
197        }
198
199        validate_non_negative_sequence_option("at", self.start_at)?;
200        validate_sequence_range("volume", self.volume, 0.0, 2.0)?;
201        validate_sequence_range("pan", self.pan, -1.0, 1.0)?;
202        validate_sequence_range("velocity", self.velocity, 0.0, 1.0)?;
203        instrument_by_name(&self.instrument)?;
204        Ok(())
205    }
206
207    /// Returns note/chord events with the current durations applied.
208    pub fn events(&self) -> PulseResult<Vec<PulseNoteEvent>> {
209        self.validate()?;
210        if !self.chords.is_empty() {
211            return self
212                .chords
213                .iter()
214                .cloned()
215                .zip(self.durations.iter().copied())
216                .map(|(frequencies, duration)| PulseNoteEvent::new(frequencies, duration))
217                .collect();
218        }
219
220        self.notes
221            .iter()
222            .copied()
223            .zip(self.durations.iter().copied())
224            .map(|(frequency, duration)| PulseNoteEvent::new(vec![frequency], duration))
225            .collect()
226    }
227
228    /// Returns note frequencies.
229    pub fn notes(&self) -> &[f32] {
230        &self.notes
231    }
232
233    /// Returns polyphonic event frequencies.
234    pub fn chords(&self) -> &[Vec<f32>] {
235        &self.chords
236    }
237
238    /// Returns durations in beats.
239    pub fn durations(&self) -> &[f32] {
240        &self.durations
241    }
242
243    /// Returns the instrument alias.
244    pub fn instrument(&self) -> &str {
245        &self.instrument
246    }
247
248    /// Returns the explicit synthesis algorithm, if one was selected.
249    pub fn synth(&self) -> Option<&PulseSynth> {
250        self.synth.as_ref()
251    }
252
253    /// Returns track-level effects in insertion order.
254    pub fn effects(&self) -> &[PulseEffect] {
255        &self.effects
256    }
257
258    /// Returns the sequence start offset.
259    pub fn start_at(&self) -> f32 {
260        self.start_at
261    }
262
263    /// Returns sequence track volume.
264    pub fn volume(&self) -> f32 {
265        self.volume
266    }
267
268    /// Returns sequence track pan.
269    pub fn pan(&self) -> f32 {
270        self.pan
271    }
272
273    /// Returns sequence note velocity.
274    pub fn velocity(&self) -> f32 {
275        self.velocity
276    }
277
278    /// Returns the sequence duration in the same timeline units used by `tunes`.
279    pub fn duration(&self) -> f32 {
280        if let Some(PulseSynth::Granular(synth)) = self.synth() {
281            return self.start_at + synth.duration;
282        }
283        self.start_at + self.durations.iter().sum::<f32>()
284    }
285
286    fn event_count(&self) -> usize {
287        if self.chords.is_empty() {
288            self.notes.len()
289        } else {
290            self.chords.len()
291        }
292    }
293}
294
295fn validate_sequence_duration(duration: f32) -> PulseResult<()> {
296    if duration.is_finite() && duration > 0.0 {
297        Ok(())
298    } else {
299        Err(PulseError::InvalidDuration { duration })
300    }
301}
302
303fn validate_note_frequency(frequency: f32) -> PulseResult<()> {
304    if frequency.is_finite() && frequency > 0.0 {
305        Ok(())
306    } else {
307        Err(PulseError::InvalidFrequency { frequency })
308    }
309}
310
311fn validate_event_frequencies(frequencies: &[f32]) -> PulseResult<()> {
312    if frequencies.is_empty() || frequencies.len() > 8 {
313        return Err(PulseError::InvalidChordVoiceCount {
314            count: frequencies.len(),
315        });
316    }
317
318    for &frequency in frequencies {
319        validate_note_frequency(frequency)?;
320    }
321
322    Ok(())
323}
324
325fn validate_sequence_range(option: &str, value: f32, min: f32, max: f32) -> PulseResult<()> {
326    if value.is_finite() && value >= min && value <= max {
327        Ok(())
328    } else {
329        Err(invalid_sequence_option(option, value))
330    }
331}
332
333fn validate_non_negative_sequence_option(option: &str, value: f32) -> PulseResult<()> {
334    if value.is_finite() && value >= 0.0 {
335        Ok(())
336    } else {
337        Err(invalid_sequence_option(option, value))
338    }
339}
340
341fn invalid_sequence_option(option: &str, value: f32) -> PulseError {
342    PulseError::InvalidSequenceOption {
343        option: option.to_string(),
344        value: value.to_string(),
345    }
346}
347
348/// A Lua-friendly sample clip that renders into offline audio exports.
349#[derive(Debug, Clone, PartialEq)]
350pub struct PulseSampleClip {
351    path: String,
352    playback_rate: f32,
353    gain: f32,
354    pitch_shift: f32,
355    time_stretch: f32,
356    start_at: f32,
357    track_name: Option<String>,
358    volume: f32,
359    pan: f32,
360    effects: Vec<PulseEffect>,
361    slice_range: Option<(f32, f32)>,
362    reverse: bool,
363    loop_for: Option<f32>,
364    normalize: bool,
365    fade_in: f32,
366    fade_out: f32,
367}
368
369impl PulseSampleClip {
370    /// Creates a sample clip from an audio file path.
371    pub fn new(path: impl Into<String>) -> Self {
372        Self {
373            path: path.into(),
374            playback_rate: 1.0,
375            gain: 1.0,
376            pitch_shift: 0.0,
377            time_stretch: 1.0,
378            start_at: 0.0,
379            track_name: None,
380            volume: 1.0,
381            pan: 0.0,
382            effects: Vec::new(),
383            slice_range: None,
384            reverse: false,
385            loop_for: None,
386            normalize: false,
387            fade_in: 0.0,
388            fade_out: 0.0,
389        }
390    }
391
392    /// Sets sample playback rate. Values above 1 play faster and higher.
393    pub fn with_playback_rate(mut self, playback_rate: f32) -> PulseResult<Self> {
394        validate_positive_sample_option("rate", playback_rate)?;
395        self.playback_rate = playback_rate;
396        Ok(self)
397    }
398
399    /// Sets sample gain before the clip is inserted into the composition.
400    pub fn with_gain(mut self, gain: f32) -> PulseResult<Self> {
401        if !gain.is_finite() || gain < 0.0 {
402            return Err(invalid_sample_option("gain", gain));
403        }
404        self.gain = gain;
405        Ok(self)
406    }
407
408    /// Shifts pitch in semitones without intentionally changing duration.
409    pub fn with_pitch_shift(mut self, semitones: f32) -> PulseResult<Self> {
410        if !semitones.is_finite() {
411            return Err(invalid_sample_option("pitch_shift", semitones));
412        }
413        self.pitch_shift = semitones;
414        Ok(self)
415    }
416
417    /// Stretches time without intentionally changing pitch.
418    pub fn with_time_stretch(mut self, factor: f32) -> PulseResult<Self> {
419        validate_positive_sample_option("time_stretch", factor)?;
420        self.time_stretch = factor;
421        Ok(self)
422    }
423
424    /// Sets clip start offset relative to the song or phrase placement.
425    pub fn with_start_at(mut self, start_at: f32) -> PulseResult<Self> {
426        if !start_at.is_finite() || start_at < 0.0 {
427            return Err(invalid_sample_option("at", start_at));
428        }
429        self.start_at = start_at;
430        Ok(self)
431    }
432
433    /// Sets the track name used when this sample clip is inserted into a song or phrase.
434    #[must_use]
435    pub fn with_track(mut self, track_name: impl Into<String>) -> Self {
436        self.track_name = Some(track_name.into());
437        self
438    }
439
440    /// Sets track-level volume for the sample clip.
441    pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
442        validate_sample_volume(volume)?;
443        self.volume = volume;
444        Ok(self)
445    }
446
447    /// Sets track-level stereo pan for the sample clip.
448    pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
449        validate_sample_pan(pan)?;
450        self.pan = pan;
451        Ok(self)
452    }
453
454    /// Appends one track-level effect to this sample clip.
455    #[must_use]
456    pub fn with_effect(mut self, effect: PulseEffect) -> Self {
457        self.effects.push(effect);
458        self
459    }
460
461    /// Selects a source audio time range before pitch, stretch, gain, and rate transforms.
462    pub fn with_slice(mut self, start: f32, end: f32) -> PulseResult<Self> {
463        validate_sample_slice(start, end)?;
464        self.slice_range = Some((start, end));
465        Ok(self)
466    }
467
468    /// Reverses the selected source sample range before fades, pitch, stretch, gain, and rate transforms.
469    pub fn with_reverse(mut self) -> Self {
470        self.reverse = true;
471        self
472    }
473
474    /// Repeats the selected source sample range to a fixed offline render duration.
475    pub fn with_loop_for(mut self, duration: f32) -> PulseResult<Self> {
476        validate_positive_sample_option("loop_for", duration)?;
477        self.loop_for = Some(duration);
478        Ok(self)
479    }
480
481    /// Normalizes the selected source sample peak before fades and final gain.
482    pub fn with_normalize(mut self) -> Self {
483        self.normalize = true;
484        self
485    }
486
487    /// Applies a fade-in to the rendered source sample before pitch, stretch, gain, and rate transforms.
488    pub fn with_fade_in(mut self, duration: f32) -> PulseResult<Self> {
489        validate_non_negative_sample_option("fade_in", duration)?;
490        self.fade_in = duration;
491        Ok(self)
492    }
493
494    /// Applies a fade-out to the rendered source sample before pitch, stretch, gain, and rate transforms.
495    pub fn with_fade_out(mut self, duration: f32) -> PulseResult<Self> {
496        validate_non_negative_sample_option("fade_out", duration)?;
497        self.fade_out = duration;
498        Ok(self)
499    }
500
501    /// Returns the source audio file path.
502    pub fn path(&self) -> &str {
503        &self.path
504    }
505
506    /// Returns the sample playback rate.
507    pub fn playback_rate(&self) -> f32 {
508        self.playback_rate
509    }
510
511    /// Returns clip start offset relative to the song or phrase placement.
512    pub fn start_at(&self) -> f32 {
513        self.start_at
514    }
515
516    /// Returns the explicit track name, if one was set.
517    pub fn track_name(&self) -> Option<&str> {
518        self.track_name.as_deref()
519    }
520
521    /// Returns track-level volume.
522    pub fn volume(&self) -> f32 {
523        self.volume
524    }
525
526    /// Returns track-level pan.
527    pub fn pan(&self) -> f32 {
528        self.pan
529    }
530
531    /// Returns track-level effects in insertion order.
532    pub fn effects(&self) -> &[PulseEffect] {
533        &self.effects
534    }
535
536    /// Returns selected source slice range, if any.
537    pub fn slice_range(&self) -> Option<(f32, f32)> {
538        self.slice_range
539    }
540
541    /// Returns whether the selected source sample range is reversed before rendering.
542    pub fn reverse(&self) -> bool {
543        self.reverse
544    }
545
546    /// Returns the requested offline loop duration, if any.
547    pub fn loop_for(&self) -> Option<f32> {
548        self.loop_for
549    }
550
551    /// Returns whether the rendered source sample is normalized before final gain.
552    pub fn normalize(&self) -> bool {
553        self.normalize
554    }
555
556    /// Returns fade-in duration in seconds.
557    pub fn fade_in(&self) -> f32 {
558        self.fade_in
559    }
560
561    /// Returns fade-out duration in seconds.
562    pub fn fade_out(&self) -> f32 {
563        self.fade_out
564    }
565
566    /// Returns rendered clip duration after sample transforms and playback rate.
567    pub fn duration(&self) -> PulseResult<f32> {
568        if let Ok(duration) = self.metadata_duration() {
569            return Ok(duration);
570        }
571
572        let sample = self.transformed_sample()?;
573        Ok(self.start_at + sample.duration / self.playback_rate)
574    }
575
576    fn metadata_duration(&self) -> PulseResult<f32> {
577        validate_sample_timing_options(self)?;
578
579        let source_duration = wav_duration_seconds(&self.path)?;
580        let mut duration = if let Some((start, end)) = self.slice_range {
581            if end > source_duration {
582                return Err(PulseError::SampleLoadFailed {
583                    message: format!("slice end {end} exceeds sample duration {source_duration}"),
584                });
585            }
586            end - start
587        } else {
588            source_duration
589        };
590
591        if let Some(loop_for) = self.loop_for {
592            validate_positive_sample_option("loop_for", loop_for)?;
593            duration = loop_for;
594        }
595
596        if (self.time_stretch - 1.0).abs() >= 0.01 {
597            duration *= self.time_stretch;
598        }
599
600        Ok(self.start_at + duration / self.playback_rate)
601    }
602
603    fn transformed_sample(&self) -> PulseResult<Sample> {
604        validate_sample_timing_options(self)?;
605        validate_sample_volume(self.volume)?;
606        validate_sample_pan(self.pan)?;
607        validate_non_negative_sample_option("fade_in", self.fade_in)?;
608        validate_non_negative_sample_option("fade_out", self.fade_out)?;
609        validate_non_negative_sample_option("gain", self.gain)?;
610        validate_finite_sample_option("pitch_shift", self.pitch_shift)?;
611
612        let mut sample =
613            Sample::from_file(&self.path).map_err(|error| PulseError::SampleLoadFailed {
614                message: error.to_string(),
615            })?;
616
617        if let Some((start, end)) = self.slice_range {
618            sample = sample
619                .slice(start, end)
620                .map_err(|error| PulseError::SampleLoadFailed {
621                    message: error.to_string(),
622                })?;
623        }
624        if self.reverse {
625            sample = sample.reverse();
626        }
627        if let Some(duration) = self.loop_for {
628            validate_positive_sample_option("loop_for", duration)?;
629            sample = loop_sample_for_duration(&sample, duration)?;
630        }
631        if self.normalize {
632            sample = sample.normalize();
633        }
634        if self.fade_in > 0.0 {
635            sample = sample.with_fade_in(self.fade_in);
636        }
637        if self.fade_out > 0.0 {
638            sample = sample.with_fade_out(self.fade_out);
639        }
640        if self.pitch_shift.abs() >= 0.01 {
641            sample = sample.pitch_shift(self.pitch_shift);
642        }
643        // Match the 0.01 threshold used by metadata_duration and tunes' own
644        // time_stretch, so duration predictions stay in lock-step with rendering.
645        if (self.time_stretch - 1.0).abs() >= 0.01 {
646            sample = sample.time_stretch(self.time_stretch);
647        }
648        if (self.gain - 1.0).abs() >= 0.001 {
649            sample = sample.with_gain(self.gain);
650        }
651
652        Ok(sample)
653    }
654}
655
656fn loop_sample_for_duration(sample: &Sample, duration: f32) -> PulseResult<Sample> {
657    validate_positive_sample_option("loop_for", duration)?;
658    if sample.duration <= 0.0 || !sample.duration.is_finite() {
659        return Err(PulseError::SampleLoadFailed {
660            message: "sample duration must be positive before loop_for".to_string(),
661        });
662    }
663
664    let sample_rate = sample.sample_rate();
665    let frame_count = sample_frame_count("loop_for", duration, sample_rate)?;
666    if frame_count == 0 {
667        return Err(invalid_sample_option("loop_for", duration));
668    }
669
670    let mut data = Vec::with_capacity(frame_count);
671    for frame in 0..frame_count {
672        let time = frame as f32 / sample_rate as f32;
673        let source_time = time % sample.duration;
674        let (left, right) = sample.sample_at_interpolated(source_time, 1.0);
675        data.push((left + right) * 0.5);
676    }
677
678    Ok(Sample::from_mono(data, sample_rate))
679}
680
681fn sample_frame_count(option: &str, duration: f32, sample_rate: u32) -> PulseResult<usize> {
682    let frames = f64::from(duration) * f64::from(sample_rate);
683    let max_frames = isize::MAX as usize / std::mem::size_of::<f32>();
684    if !frames.is_finite() || frames <= 0.0 || frames > max_frames as f64 {
685        return Err(invalid_sample_option(option, duration));
686    }
687
688    Ok(frames.round() as usize)
689}
690
691fn wav_duration_seconds(path: &str) -> PulseResult<f32> {
692    let reader = hound::WavReader::open(path).map_err(|error| PulseError::SampleLoadFailed {
693        message: error.to_string(),
694    })?;
695    let spec = reader.spec();
696    if spec.sample_rate == 0 {
697        return Err(PulseError::SampleLoadFailed {
698            message: "wav sample rate must be positive".to_string(),
699        });
700    }
701
702    Ok(reader.duration() as f32 / spec.sample_rate as f32)
703}
704
705fn validate_sample_timing_options(sample_clip: &PulseSampleClip) -> PulseResult<()> {
706    if sample_clip.path.trim().is_empty() {
707        return Err(PulseError::InvalidSampleOption {
708            option: "path".to_string(),
709            value: "empty".to_string(),
710        });
711    }
712    validate_positive_sample_option("rate", sample_clip.playback_rate)?;
713    validate_positive_sample_option("time_stretch", sample_clip.time_stretch)?;
714    if !sample_clip.start_at.is_finite() || sample_clip.start_at < 0.0 {
715        return Err(invalid_sample_option("at", sample_clip.start_at));
716    }
717    if let Some((start, end)) = sample_clip.slice_range {
718        validate_sample_slice(start, end)?;
719    }
720    Ok(())
721}
722
723fn validate_positive_sample_option(option: &str, value: f32) -> PulseResult<()> {
724    if value.is_finite() && value > 0.0 {
725        Ok(())
726    } else {
727        Err(invalid_sample_option(option, value))
728    }
729}
730
731fn validate_non_negative_sample_option(option: &str, value: f32) -> PulseResult<()> {
732    if value.is_finite() && value >= 0.0 {
733        Ok(())
734    } else {
735        Err(invalid_sample_option(option, value))
736    }
737}
738
739fn validate_finite_sample_option(option: &str, value: f32) -> PulseResult<()> {
740    if value.is_finite() {
741        Ok(())
742    } else {
743        Err(invalid_sample_option(option, value))
744    }
745}
746
747fn validate_sample_volume(value: f32) -> PulseResult<()> {
748    if value.is_finite() && (0.0..=2.0).contains(&value) {
749        Ok(())
750    } else {
751        Err(invalid_sample_option("volume", value))
752    }
753}
754
755fn validate_sample_pan(value: f32) -> PulseResult<()> {
756    if value.is_finite() && (-1.0..=1.0).contains(&value) {
757        Ok(())
758    } else {
759        Err(invalid_sample_option("pan", value))
760    }
761}
762
763fn invalid_sample_option(option: &str, value: f32) -> PulseError {
764    PulseError::InvalidSampleOption {
765        option: option.to_string(),
766        value: value.to_string(),
767    }
768}
769
770fn validate_sample_slice(start: f32, end: f32) -> PulseResult<()> {
771    if start.is_finite() && end.is_finite() && start >= 0.0 && end > start {
772        Ok(())
773    } else {
774        Err(PulseError::InvalidSampleOption {
775            option: "slice".to_string(),
776            value: format!("{start}..{end}"),
777        })
778    }
779}
780
781/// A MIDI file arranged as a reusable song or phrase clip.
782#[derive(Debug, Clone)]
783pub struct PulseMidiClip {
784    mixer: Mixer,
785    start_at: f32,
786    track_name: Option<String>,
787    volume: f32,
788    pan: Option<f32>,
789    repeat_times: usize,
790    effects: Vec<PulseEffect>,
791}
792
793impl PartialEq for PulseMidiClip {
794    fn eq(&self, other: &Self) -> bool {
795        self.start_at == other.start_at
796            && self.track_name == other.track_name
797            && self.volume == other.volume
798            && self.pan == other.pan
799            && self.repeat_times == other.repeat_times
800            && self.effects == other.effects
801            && self.mixer.total_duration() == other.mixer.total_duration()
802    }
803}
804
805impl PulseMidiClip {
806    /// Creates a MIDI clip from a `tunes` mixer.
807    pub fn new(mixer: Mixer) -> Self {
808        Self {
809            mixer,
810            start_at: 0.0,
811            track_name: None,
812            volume: 1.0,
813            pan: None,
814            repeat_times: 1,
815            effects: Vec::new(),
816        }
817    }
818
819    /// Sets the clip start offset relative to its song or phrase placement.
820    pub fn with_start_at(mut self, start_at: f32) -> PulseResult<Self> {
821        validate_midi_clip_non_negative("at", start_at)?;
822        self.start_at = start_at;
823        Ok(self)
824    }
825
826    /// Sets the track-name prefix used when imported MIDI tracks are merged.
827    #[must_use]
828    pub fn with_track(mut self, track_name: impl Into<String>) -> Self {
829        self.track_name = Some(track_name.into());
830        self
831    }
832
833    /// Sets a clip-level volume multiplier for all imported MIDI tracks.
834    pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
835        validate_midi_clip_volume(volume)?;
836        self.volume = volume;
837        Ok(self)
838    }
839
840    /// Overrides the stereo pan for all imported MIDI tracks.
841    pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
842        validate_midi_clip_pan(pan)?;
843        self.pan = Some(pan);
844        Ok(self)
845    }
846
847    /// Sets how many total times the imported MIDI material should play inside this clip.
848    pub fn with_repeat_times(mut self, repeat_times: usize) -> PulseResult<Self> {
849        if repeat_times == 0 {
850            return Err(PulseError::InvalidRepeatTimes { repeat_times });
851        }
852        self.repeat_times = repeat_times;
853        Ok(self)
854    }
855
856    /// Appends one track-level effect to every imported MIDI track.
857    #[must_use]
858    pub fn with_effect(mut self, effect: PulseEffect) -> Self {
859        self.effects.push(effect);
860        self
861    }
862
863    /// Returns the clip start offset.
864    pub fn start_at(&self) -> f32 {
865        self.start_at
866    }
867
868    /// Returns the explicit track-name prefix, if one was set.
869    pub fn track_name(&self) -> Option<&str> {
870        self.track_name.as_deref()
871    }
872
873    /// Returns the clip-level volume multiplier.
874    pub fn volume(&self) -> f32 {
875        self.volume
876    }
877
878    /// Returns the pan override, if one was set.
879    pub fn pan(&self) -> Option<f32> {
880        self.pan
881    }
882
883    /// Returns how many total times the imported MIDI material plays inside this clip.
884    pub fn repeat_times(&self) -> usize {
885        self.repeat_times
886    }
887
888    /// Returns track-level effects in insertion order.
889    pub fn effects(&self) -> &[PulseEffect] {
890        &self.effects
891    }
892
893    /// Returns clip duration including start offset and internal repeats.
894    pub fn duration(&self) -> PulseResult<f32> {
895        self.validate()?;
896        let repeated_duration =
897            repeated_timeline_duration(self.mixer.total_duration(), self.repeat_times)?;
898        timeline_offset_duration(self.start_at, repeated_duration, self.repeat_times)
899    }
900
901    fn validate(&self) -> PulseResult<()> {
902        validate_midi_clip_non_negative("at", self.start_at)?;
903        validate_midi_clip_volume(self.volume)?;
904        if let Some(pan) = self.pan {
905            validate_midi_clip_pan(pan)?;
906        }
907        if self.repeat_times == 0 {
908            return Err(PulseError::InvalidRepeatTimes {
909                repeat_times: self.repeat_times,
910            });
911        }
912        repeated_timeline_duration(self.mixer.total_duration(), self.repeat_times)?;
913        Ok(())
914    }
915
916    fn arranged_tracks(&self, start_at: f32, default_name: &str) -> PulseResult<Vec<Track>> {
917        self.validate()?;
918
919        let source_tracks = self.mixer.all_tracks();
920        let source_track_count = source_tracks.len();
921        let source_duration = self.mixer.total_duration();
922        let mut arranged = Vec::with_capacity(source_track_count);
923
924        for (track_index, source_track) in source_tracks.into_iter().enumerate() {
925            let event_capacity =
926                midi_repeat_event_capacity(source_track.events.len(), self.repeat_times)?;
927            let mut events = Vec::with_capacity(event_capacity);
928            let should_expand_events =
929                repeat_expansion_capacity(source_track.events.len(), self.repeat_times)? > 0;
930
931            if should_expand_events {
932                for repeat_index in 0..self.repeat_times {
933                    let repeat_offset =
934                        start_at + self.start_at + source_duration * repeat_index as f32;
935                    for event in &source_track.events {
936                        let mut event = event.clone();
937                        offset_audio_event(&mut event, repeat_offset);
938                        events.push(event);
939                    }
940                }
941            }
942            events.sort_by(|left, right| {
943                left.start_time()
944                    .partial_cmp(&right.start_time())
945                    .unwrap_or(std::cmp::Ordering::Equal)
946            });
947
948            let mut track = Track::new();
949            track.events = events;
950            track.name =
951                Some(self.arranged_track_name(default_name, track_index, source_track_count));
952            track.midi_program = source_track.midi_program;
953            track.volume = (source_track.volume * self.volume).clamp(0.0, 2.0);
954            track.pan = source_track.pan;
955            track.filter = source_track.filter;
956            track.effects = source_track.effects.clone();
957            track.modulation = source_track.modulation.clone();
958            if let Some(pan) = self.pan {
959                track.pan = pan;
960            }
961            for effect in &self.effects {
962                effect.apply_to_track(&mut track);
963            }
964            arranged.push(track);
965        }
966
967        Ok(arranged)
968    }
969
970    fn arranged_track_name(
971        &self,
972        default_name: &str,
973        track_index: usize,
974        source_track_count: usize,
975    ) -> String {
976        let base_name = self
977            .track_name
978            .as_deref()
979            .unwrap_or(default_name)
980            .trim()
981            .to_string();
982        let base_name = if base_name.is_empty() {
983            default_name.to_string()
984        } else {
985            base_name
986        };
987
988        if source_track_count == 1 {
989            base_name
990        } else {
991            format!("{base_name}_{track_index}")
992        }
993    }
994}
995
996fn midi_repeat_event_capacity(event_count: usize, repeat_times: usize) -> PulseResult<usize> {
997    let capacity = repeat_expansion_capacity(event_count, repeat_times)?;
998    let max_events = isize::MAX as usize / std::mem::size_of::<AudioEvent>();
999    if capacity > max_events {
1000        return Err(PulseError::InvalidRepeatTimes { repeat_times });
1001    }
1002    Ok(capacity)
1003}
1004
1005fn repeat_expansion_capacity(item_count: usize, repeat_times: usize) -> PulseResult<usize> {
1006    let capacity = item_count
1007        .checked_mul(repeat_times)
1008        .ok_or(PulseError::InvalidRepeatTimes { repeat_times })?;
1009    if capacity > isize::MAX as usize {
1010        return Err(PulseError::InvalidRepeatTimes { repeat_times });
1011    }
1012    Ok(capacity)
1013}
1014
1015fn repeated_timeline_duration(duration: f32, repeat_times: usize) -> PulseResult<f32> {
1016    if repeat_times == 0 {
1017        return Err(PulseError::InvalidRepeatTimes { repeat_times });
1018    }
1019
1020    let repeated = duration * repeat_times as f32;
1021    if duration.is_finite() && repeated.is_finite() {
1022        Ok(repeated)
1023    } else {
1024        Err(PulseError::InvalidRepeatTimes { repeat_times })
1025    }
1026}
1027
1028fn timeline_offset_duration(start_at: f32, duration: f32, repeat_times: usize) -> PulseResult<f32> {
1029    let total = start_at + duration;
1030    if start_at.is_finite() && duration.is_finite() && total.is_finite() {
1031        Ok(total)
1032    } else {
1033        Err(PulseError::InvalidRepeatTimes { repeat_times })
1034    }
1035}
1036
1037fn offset_audio_event(event: &mut AudioEvent, offset: f32) {
1038    match event {
1039        AudioEvent::Note(note) => note.start_time += offset,
1040        AudioEvent::Drum(drum) => drum.start_time += offset,
1041        AudioEvent::Sample(sample) => sample.start_time += offset,
1042        AudioEvent::TempoChange(tempo) => tempo.start_time += offset,
1043        AudioEvent::TimeSignature(time_signature) => time_signature.start_time += offset,
1044        AudioEvent::KeySignature(key_signature) => key_signature.start_time += offset,
1045    }
1046}
1047
1048fn validate_midi_clip_non_negative(option: &str, value: f32) -> PulseResult<()> {
1049    if value.is_finite() && value >= 0.0 {
1050        Ok(())
1051    } else {
1052        Err(invalid_midi_clip_option(option, value))
1053    }
1054}
1055
1056fn validate_midi_clip_volume(value: f32) -> PulseResult<()> {
1057    if value.is_finite() && (0.0..=2.0).contains(&value) {
1058        Ok(())
1059    } else {
1060        Err(invalid_midi_clip_option("volume", value))
1061    }
1062}
1063
1064fn validate_midi_clip_pan(value: f32) -> PulseResult<()> {
1065    if value.is_finite() && (-1.0..=1.0).contains(&value) {
1066        Ok(())
1067    } else {
1068        Err(invalid_midi_clip_option("pan", value))
1069    }
1070}
1071
1072fn invalid_midi_clip_option(option: &str, value: f32) -> PulseError {
1073    PulseError::InvalidMidiClipOption {
1074        option: option.to_string(),
1075        value: value.to_string(),
1076    }
1077}
1078
1079/// A reusable phrase containing layered sequences and drum grids.
1080#[derive(Debug, Clone, PartialEq)]
1081pub struct PulsePhrase {
1082    sequences: Vec<PulseSequence>,
1083    drum_grids: Vec<PulseDrumGrid>,
1084    sample_clips: Vec<PulseSampleClip>,
1085    midi_clips: Vec<PulseMidiClip>,
1086    repeat_times: usize,
1087}
1088
1089impl Default for PulsePhrase {
1090    fn default() -> Self {
1091        Self::new()
1092    }
1093}
1094
1095impl PulsePhrase {
1096    /// Creates an empty phrase that plays once.
1097    pub fn new() -> Self {
1098        Self {
1099            sequences: Vec::new(),
1100            drum_grids: Vec::new(),
1101            sample_clips: Vec::new(),
1102            midi_clips: Vec::new(),
1103            repeat_times: 1,
1104        }
1105    }
1106
1107    /// Appends one melodic sequence to this phrase.
1108    #[must_use]
1109    pub fn add_sequence(mut self, sequence: PulseSequence) -> Self {
1110        self.sequences.push(sequence);
1111        self
1112    }
1113
1114    /// Appends one drum grid to this phrase.
1115    #[must_use]
1116    pub fn add_drum_grid(mut self, grid: PulseDrumGrid) -> Self {
1117        self.drum_grids.push(grid);
1118        self
1119    }
1120
1121    /// Appends one sample clip to this phrase.
1122    #[must_use]
1123    pub fn add_sample_clip(mut self, sample_clip: PulseSampleClip) -> Self {
1124        self.sample_clips.push(sample_clip);
1125        self
1126    }
1127
1128    /// Appends one imported MIDI clip to this phrase.
1129    #[must_use]
1130    pub fn add_midi_clip(mut self, midi_clip: PulseMidiClip) -> Self {
1131        self.midi_clips.push(midi_clip);
1132        self
1133    }
1134
1135    /// Sets how many total times the phrase should play.
1136    pub fn with_repeat_times(mut self, repeat_times: usize) -> PulseResult<Self> {
1137        if repeat_times == 0 {
1138            return Err(PulseError::InvalidRepeatTimes { repeat_times });
1139        }
1140        self.repeat_times = repeat_times;
1141        Ok(self)
1142    }
1143
1144    /// Returns phrase duration for one play-through.
1145    pub fn duration(&self) -> PulseResult<f32> {
1146        let sequence_duration = self
1147            .sequences
1148            .iter()
1149            .map(PulseSequence::duration)
1150            .fold(0.0, f32::max);
1151        let drum_duration = self
1152            .drum_grids
1153            .iter()
1154            .map(PulseDrumGrid::duration)
1155            .fold(0.0, f32::max);
1156        let mut sample_duration = 0.0_f32;
1157        for sample_clip in &self.sample_clips {
1158            sample_duration = sample_duration.max(sample_clip.duration()?);
1159        }
1160        let mut midi_duration = 0.0_f32;
1161        for midi_clip in &self.midi_clips {
1162            midi_duration = midi_duration.max(midi_clip.duration()?);
1163        }
1164        Ok(sequence_duration
1165            .max(drum_duration)
1166            .max(sample_duration)
1167            .max(midi_duration))
1168    }
1169
1170    /// Returns phrase duration including repeats.
1171    pub fn total_duration(&self) -> PulseResult<f32> {
1172        repeated_timeline_duration(self.duration()?, self.repeat_times)
1173    }
1174
1175    /// Returns all melodic sequences.
1176    pub fn sequences(&self) -> &[PulseSequence] {
1177        &self.sequences
1178    }
1179
1180    /// Returns all drum grids.
1181    pub fn drum_grids(&self) -> &[PulseDrumGrid] {
1182        &self.drum_grids
1183    }
1184
1185    /// Returns all sample clips.
1186    pub fn sample_clips(&self) -> &[PulseSampleClip] {
1187        &self.sample_clips
1188    }
1189
1190    /// Returns all imported MIDI clips.
1191    pub fn midi_clips(&self) -> &[PulseMidiClip] {
1192        &self.midi_clips
1193    }
1194
1195    /// Returns how many total times this phrase plays.
1196    pub fn repeat_times(&self) -> usize {
1197        self.repeat_times
1198    }
1199}
1200
1201/// A Lua-friendly song container that can be converted to `tunes`.
1202#[derive(Debug, Clone, PartialEq)]
1203pub struct PulseSong {
1204    tempo: f32,
1205    sequences: Vec<PulseSequence>,
1206    drum_grids: Vec<PulseDrumGrid>,
1207    phrases: Vec<PulsePhrase>,
1208    sample_clips: Vec<PulseSampleClip>,
1209    midi_clips: Vec<PulseMidiClip>,
1210    master_effects: Vec<PulseEffect>,
1211}
1212
1213impl Default for PulseSong {
1214    fn default() -> Self {
1215        Self::new()
1216    }
1217}
1218
1219impl PulseSong {
1220    /// Creates an empty song at 120 BPM.
1221    pub fn new() -> Self {
1222        Self {
1223            tempo: 120.0,
1224            sequences: Vec::new(),
1225            drum_grids: Vec::new(),
1226            phrases: Vec::new(),
1227            sample_clips: Vec::new(),
1228            midi_clips: Vec::new(),
1229            master_effects: Vec::new(),
1230        }
1231    }
1232
1233    /// Sets the song tempo in beats per minute.
1234    pub fn with_tempo(mut self, bpm: f32) -> PulseResult<Self> {
1235        if !bpm.is_finite() || bpm <= 0.0 {
1236            return Err(PulseError::InvalidTempo { bpm });
1237        }
1238        self.tempo = bpm;
1239        Ok(self)
1240    }
1241
1242    /// Appends one sequence.
1243    #[must_use]
1244    pub fn add_sequence(mut self, sequence: PulseSequence) -> Self {
1245        self.sequences.push(sequence);
1246        self
1247    }
1248
1249    /// Appends one drum grid.
1250    #[must_use]
1251    pub fn add_drum_grid(mut self, grid: PulseDrumGrid) -> Self {
1252        self.drum_grids.push(grid);
1253        self
1254    }
1255
1256    /// Appends one reusable phrase to the song arrangement.
1257    #[must_use]
1258    pub fn add_phrase(mut self, phrase: PulsePhrase) -> Self {
1259        self.phrases.push(phrase);
1260        self
1261    }
1262
1263    /// Appends one sample clip to the song.
1264    #[must_use]
1265    pub fn add_sample_clip(mut self, sample_clip: PulseSampleClip) -> Self {
1266        self.sample_clips.push(sample_clip);
1267        self
1268    }
1269
1270    /// Appends one imported MIDI clip to the song.
1271    #[must_use]
1272    pub fn add_midi_clip(mut self, midi_clip: PulseMidiClip) -> Self {
1273        self.midi_clips.push(midi_clip);
1274        self
1275    }
1276
1277    /// Appends a master effect to the song.
1278    #[must_use]
1279    pub fn with_master_effect(mut self, effect: PulseEffect) -> Self {
1280        self.master_effects.push(effect);
1281        self
1282    }
1283
1284    /// Converts the wrapper DSL into a `tunes` composition.
1285    pub fn to_tunes_composition(&self) -> PulseResult<Composition> {
1286        let mut composition = Composition::new(Tempo::new(self.tempo));
1287
1288        for (index, sequence) in self.sequences.iter().enumerate() {
1289            let track_name = format!("sequence_{index}");
1290            apply_sequence_to_composition(&mut composition, &track_name, sequence, 0.0)?;
1291        }
1292
1293        for grid in &self.drum_grids {
1294            grid.apply_to_composition(&mut composition)?;
1295        }
1296
1297        for (index, sample_clip) in self.sample_clips.iter().enumerate() {
1298            let track_name = format!("sample_{index}");
1299            apply_sample_clip_to_composition(&mut composition, &track_name, sample_clip, 0.0)?;
1300        }
1301
1302        let mut phrase_start = 0.0;
1303        for (phrase_index, phrase) in self.phrases.iter().enumerate() {
1304            let phrase_duration = phrase.duration()?;
1305            let repeat_times = phrase.repeat_times();
1306            let phrase_total_duration = repeated_timeline_duration(phrase_duration, repeat_times)?;
1307            let arrangement_count =
1308                phrase.sequences().len() + phrase.drum_grids().len() + phrase.sample_clips().len();
1309            let should_expand_phrase =
1310                repeat_expansion_capacity(arrangement_count, repeat_times)? > 0;
1311
1312            if should_expand_phrase {
1313                for repeat_index in 0..repeat_times {
1314                    let repeat_start = phrase_start + phrase_duration * repeat_index as f32;
1315
1316                    for (sequence_index, sequence) in phrase.sequences().iter().enumerate() {
1317                        let track_name = format!("phrase_{phrase_index}_sequence_{sequence_index}");
1318                        apply_sequence_to_composition(
1319                            &mut composition,
1320                            &track_name,
1321                            sequence,
1322                            repeat_start,
1323                        )?;
1324                    }
1325
1326                    for grid in phrase.drum_grids() {
1327                        grid.apply_to_composition_at(&mut composition, repeat_start)?;
1328                    }
1329
1330                    for (sample_index, sample_clip) in phrase.sample_clips().iter().enumerate() {
1331                        let track_name = format!("phrase_{phrase_index}_sample_{sample_index}");
1332                        apply_sample_clip_to_composition(
1333                            &mut composition,
1334                            &track_name,
1335                            sample_clip,
1336                            repeat_start,
1337                        )?;
1338                    }
1339                }
1340            }
1341            phrase_start =
1342                timeline_offset_duration(phrase_start, phrase_total_duration, repeat_times)?;
1343        }
1344
1345        Ok(composition)
1346    }
1347
1348    /// Converts the wrapper DSL into a `tunes` mixer and applies master effects.
1349    pub fn to_mixer(&self) -> PulseResult<Mixer> {
1350        let mut mixer = self.to_arranged_mixer()?;
1351        for effect in &self.master_effects {
1352            effect.apply_to_master(&mut mixer)?;
1353        }
1354        Ok(mixer)
1355    }
1356
1357    /// Converts the wrapper DSL into a `tunes` mixer without applying master effects.
1358    pub(crate) fn to_arranged_mixer(&self) -> PulseResult<Mixer> {
1359        let mut mixer = self.to_tunes_composition()?.into_mixer();
1360        self.apply_midi_clips_to_mixer(&mut mixer)?;
1361        Ok(mixer)
1362    }
1363
1364    fn apply_midi_clips_to_mixer(&self, mixer: &mut Mixer) -> PulseResult<()> {
1365        let mut next_track_id = next_mixer_track_id(mixer);
1366
1367        for (index, midi_clip) in self.midi_clips.iter().enumerate() {
1368            append_midi_clip_to_mixer(
1369                mixer,
1370                midi_clip,
1371                0.0,
1372                &format!("midi_{index}"),
1373                &mut next_track_id,
1374            )?;
1375        }
1376
1377        let mut phrase_start = 0.0;
1378        for (phrase_index, phrase) in self.phrases.iter().enumerate() {
1379            let phrase_duration = phrase.duration()?;
1380            let repeat_times = phrase.repeat_times();
1381            let phrase_total_duration = repeated_timeline_duration(phrase_duration, repeat_times)?;
1382            let should_expand_midi =
1383                repeat_expansion_capacity(phrase.midi_clips().len(), repeat_times)? > 0;
1384
1385            if should_expand_midi {
1386                for repeat_index in 0..repeat_times {
1387                    let repeat_start = phrase_start + phrase_duration * repeat_index as f32;
1388                    for (midi_index, midi_clip) in phrase.midi_clips().iter().enumerate() {
1389                        append_midi_clip_to_mixer(
1390                            mixer,
1391                            midi_clip,
1392                            repeat_start,
1393                            &format!("phrase_{phrase_index}_midi_{midi_index}"),
1394                            &mut next_track_id,
1395                        )?;
1396                    }
1397                }
1398            }
1399            phrase_start =
1400                timeline_offset_duration(phrase_start, phrase_total_duration, repeat_times)?;
1401        }
1402
1403        Ok(())
1404    }
1405
1406    /// Returns the song tempo in beats per minute.
1407    pub fn tempo(&self) -> f32 {
1408        self.tempo
1409    }
1410
1411    /// Returns all sequences.
1412    pub fn sequences(&self) -> &[PulseSequence] {
1413        &self.sequences
1414    }
1415
1416    /// Returns all drum grids.
1417    pub fn drum_grids(&self) -> &[PulseDrumGrid] {
1418        &self.drum_grids
1419    }
1420
1421    /// Returns all phrases.
1422    pub fn phrases(&self) -> &[PulsePhrase] {
1423        &self.phrases
1424    }
1425
1426    /// Returns all sample clips.
1427    pub fn sample_clips(&self) -> &[PulseSampleClip] {
1428        &self.sample_clips
1429    }
1430
1431    /// Returns all imported MIDI clips.
1432    pub fn midi_clips(&self) -> &[PulseMidiClip] {
1433        &self.midi_clips
1434    }
1435
1436    /// Returns master effects in insertion order.
1437    pub fn master_effects(&self) -> &[PulseEffect] {
1438        &self.master_effects
1439    }
1440}
1441
1442fn apply_sequence_to_composition(
1443    composition: &mut Composition,
1444    track_name: &str,
1445    sequence: &PulseSequence,
1446    start_at: f32,
1447) -> PulseResult<()> {
1448    sequence.validate()?;
1449    let instrument = instrument_by_name(sequence.instrument())?;
1450    let mut builder = composition
1451        .instrument(track_name, &instrument)
1452        .at(start_at + sequence.start_at())
1453        .volume(sequence.volume())
1454        .pan(sequence.pan())
1455        .velocity(sequence.velocity());
1456    let events = sequence.events()?;
1457
1458    match sequence.synth() {
1459        Some(PulseSynth::KarplusStrong(synth)) => {
1460            for event in &events {
1461                let sample = synth.to_sample(event.frequencies()[0], event.duration(), 44_100)?;
1462                builder = builder.play_sample(&sample, 1.0);
1463            }
1464        }
1465        Some(PulseSynth::Granular(synth)) => {
1466            tunes::synthesis::sample::Sample::from_file(&synth.source).map_err(|error| {
1467                PulseError::InvalidSynthOption {
1468                    synth: "granular".to_string(),
1469                    option: "source".to_string(),
1470                    value: error.to_string(),
1471                }
1472            })?;
1473            builder = builder.granular(&synth.source, synth.params.clone(), synth.duration);
1474        }
1475        synth => {
1476            if let Some(synth) = synth {
1477                builder = synth.apply_to_track_builder(builder);
1478            }
1479
1480            for event in &events {
1481                builder = builder.note(event.frequencies(), event.duration());
1482            }
1483        }
1484    }
1485
1486    for effect in sequence.effects() {
1487        builder = effect.apply_to_track_builder(builder);
1488    }
1489    let _ = builder;
1490
1491    Ok(())
1492}
1493
1494fn append_midi_clip_to_mixer(
1495    mixer: &mut Mixer,
1496    midi_clip: &PulseMidiClip,
1497    start_at: f32,
1498    default_name: &str,
1499    next_track_id: &mut u32,
1500) -> PulseResult<()> {
1501    for mut track in midi_clip.arranged_tracks(start_at, default_name)? {
1502        track.id = *next_track_id;
1503        *next_track_id = (*next_track_id).saturating_add(1);
1504        track.bus_id = 0;
1505        mixer.add_track(track);
1506    }
1507    Ok(())
1508}
1509
1510fn next_mixer_track_id(mixer: &Mixer) -> u32 {
1511    mixer
1512        .all_tracks()
1513        .into_iter()
1514        .map(|track| track.id)
1515        .max()
1516        .unwrap_or(0)
1517        .saturating_add(1)
1518}
1519
1520fn apply_sample_clip_to_composition(
1521    composition: &mut Composition,
1522    track_name: &str,
1523    sample_clip: &PulseSampleClip,
1524    start_at: f32,
1525) -> PulseResult<()> {
1526    let sample = sample_clip.transformed_sample()?;
1527    let effective_track_name = sample_clip.track_name().unwrap_or(track_name);
1528    let mut builder = composition
1529        .track(effective_track_name)
1530        .at(start_at + sample_clip.start_at())
1531        .volume(sample_clip.volume())
1532        .pan(sample_clip.pan())
1533        .play_sample(&sample, sample_clip.playback_rate());
1534    for effect in sample_clip.effects() {
1535        builder = effect.apply_to_track_builder(builder);
1536    }
1537    let _ = builder;
1538    Ok(())
1539}
1540
1541#[cfg(test)]
1542mod tests {
1543    use super::*;
1544    use crate::theory::parse_note_frequency;
1545    use tunes::track::AudioEvent;
1546
1547    fn test_delay_effect() -> crate::effects::PulseEffect {
1548        crate::effects::effect_from_options("delay", crate::effects::EffectOptions::Default)
1549            .expect("default delay should parse")
1550    }
1551
1552    fn test_eq_effect() -> crate::effects::PulseEffect {
1553        crate::effects::effect_from_options("eq", crate::effects::EffectOptions::Default)
1554            .expect("default eq should parse")
1555    }
1556
1557    fn test_filter_effect() -> crate::effects::PulseEffect {
1558        crate::effects::effect_from_options("filter", crate::effects::EffectOptions::Default)
1559            .expect("default filter should parse")
1560    }
1561
1562    #[test]
1563    fn sequence_validates_duration_count_and_positive_values() {
1564        let c4 = parse_note_frequency("C4").unwrap();
1565        let sequence = PulseSequence::new()
1566            .with_notes(vec![c4])
1567            .with_durations(vec![0.5])
1568            .with_instrument("electric_piano");
1569
1570        assert!(sequence.validate().is_ok());
1571
1572        let mismatch = PulseSequence::new()
1573            .with_notes(vec![c4, c4])
1574            .with_durations(vec![0.5])
1575            .with_instrument("electric_piano");
1576        assert_eq!(
1577            mismatch
1578                .validate()
1579                .expect_err("mismatch should fail")
1580                .to_string(),
1581            "duration count mismatch: 2 notes and 1 durations"
1582        );
1583
1584        let invalid_duration = PulseSequence::new()
1585            .with_notes(vec![c4])
1586            .with_durations(vec![0.0])
1587            .with_instrument("electric_piano");
1588        assert_eq!(
1589            invalid_duration
1590                .validate()
1591                .expect_err("zero duration should fail")
1592                .to_string(),
1593            "invalid duration: 0"
1594        );
1595    }
1596
1597    #[test]
1598    fn sequence_transposes_without_mutating_original() {
1599        let c4 = parse_note_frequency("C4").unwrap();
1600        let sequence = PulseSequence::new()
1601            .with_notes(vec![c4])
1602            .with_durations(vec![0.5])
1603            .with_instrument("electric_piano");
1604
1605        let transposed = sequence.transposed(12);
1606
1607        assert!(transposed.notes()[0] > sequence.notes()[0] * 1.99);
1608        assert!(sequence.notes()[0] < transposed.notes()[0]);
1609    }
1610
1611    #[test]
1612    fn sequence_can_render_polyphonic_chords_with_mix_controls() {
1613        let c4 = parse_note_frequency("C4").unwrap();
1614        let e4 = parse_note_frequency("E4").unwrap();
1615        let g4 = parse_note_frequency("G4").unwrap();
1616        let f4 = parse_note_frequency("F4").unwrap();
1617        let a4 = parse_note_frequency("A4").unwrap();
1618        let c5 = parse_note_frequency("C5").unwrap();
1619
1620        let sequence = PulseSequence::new()
1621            .with_chords(vec![vec![c4, e4, g4], vec![f4, a4, c5]])
1622            .with_durations(vec![0.5, 0.5])
1623            .with_instrument("electric_piano")
1624            .with_start_at(0.25)
1625            .unwrap()
1626            .with_volume(0.7)
1627            .unwrap()
1628            .with_pan(-0.25)
1629            .unwrap()
1630            .with_velocity(0.6)
1631            .unwrap();
1632
1633        let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
1634        let tracks = mixer.all_tracks();
1635        assert_eq!(tracks.len(), 1);
1636        assert_eq!(tracks[0].volume, 0.7);
1637        assert_eq!(tracks[0].pan, -0.25);
1638
1639        let AudioEvent::Note(note) = &tracks[0].events[0] else {
1640            panic!("expected note event");
1641        };
1642
1643        assert_eq!(note.start_time, 0.25);
1644        assert_eq!(note.duration, 0.5);
1645        assert_eq!(note.num_freqs, 3);
1646        assert_eq!(note.frequencies[0], c4);
1647        assert_eq!(note.frequencies[1], e4);
1648        assert_eq!(note.frequencies[2], g4);
1649        assert_eq!(note.velocity, 0.6);
1650    }
1651
1652    #[test]
1653    fn song_rejects_invalid_tempo() {
1654        let error = PulseSong::new()
1655            .with_tempo(0.0)
1656            .expect_err("zero tempo should fail");
1657
1658        assert_eq!(error.to_string(), "invalid tempo: 0");
1659    }
1660
1661    #[test]
1662    fn song_converts_valid_sequence_to_tunes_composition() {
1663        let c4 = parse_note_frequency("C4").unwrap();
1664        let sequence = PulseSequence::new()
1665            .with_notes(vec![c4])
1666            .with_durations(vec![0.25])
1667            .with_instrument("electric_piano");
1668
1669        let song = PulseSong::new()
1670            .with_tempo(120.0)
1671            .unwrap()
1672            .add_sequence(sequence);
1673
1674        let composition = song.to_tunes_composition();
1675        assert!(composition.is_ok());
1676    }
1677
1678    #[test]
1679    fn song_converts_drum_grid_to_tunes_composition() {
1680        let drum_grid = crate::drums::PulseDrumGrid::new()
1681            .with_steps(16)
1682            .unwrap()
1683            .with_step_duration(0.125)
1684            .unwrap()
1685            .sound("kick_808", vec![0, 4, 8, 12])
1686            .unwrap();
1687
1688        let song = PulseSong::new().add_drum_grid(drum_grid);
1689
1690        assert!(song.to_tunes_composition().is_ok());
1691        assert_eq!(song.drum_grids().len(), 1);
1692    }
1693
1694    #[test]
1695    fn phrase_repeats_sequences_and_drums_on_the_song_timeline() {
1696        let c4 = parse_note_frequency("C4").unwrap();
1697        let sequence = PulseSequence::new()
1698            .with_notes(vec![c4])
1699            .with_durations(vec![0.25])
1700            .with_instrument("electric_piano");
1701        let drum_grid = crate::drums::PulseDrumGrid::new()
1702            .with_steps(4)
1703            .unwrap()
1704            .with_step_duration(0.25)
1705            .unwrap()
1706            .sound("kick_808", vec![0])
1707            .unwrap();
1708
1709        let phrase = PulsePhrase::new()
1710            .add_sequence(sequence)
1711            .add_drum_grid(drum_grid)
1712            .with_repeat_times(2)
1713            .unwrap();
1714
1715        assert_eq!(phrase.repeat_times(), 2);
1716        assert_eq!(phrase.duration().unwrap(), 1.0);
1717
1718        let mixer = PulseSong::new().add_phrase(phrase).to_mixer().unwrap();
1719        let mut note_times = Vec::new();
1720        let mut drum_times = Vec::new();
1721
1722        for track in mixer.all_tracks() {
1723            for event in &track.events {
1724                match event {
1725                    AudioEvent::Note(note) => note_times.push(note.start_time),
1726                    AudioEvent::Drum(drum) => drum_times.push(drum.start_time),
1727                    _ => {}
1728                }
1729            }
1730        }
1731
1732        note_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
1733        drum_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
1734
1735        assert_eq!(note_times, vec![0.0, 1.0]);
1736        assert_eq!(drum_times, vec![0.0, 1.0]);
1737    }
1738
1739    #[test]
1740    fn sequence_stores_and_applies_track_effects() {
1741        let c4 = parse_note_frequency("C4").unwrap();
1742        let sequence = PulseSequence::new()
1743            .with_notes(vec![c4])
1744            .with_durations(vec![0.25])
1745            .with_instrument("electric_piano")
1746            .with_effect(test_delay_effect())
1747            .with_effect(test_filter_effect());
1748
1749        assert_eq!(sequence.effects().len(), 2);
1750
1751        let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
1752        let tracks = mixer.all_tracks();
1753        assert_eq!(tracks.len(), 1);
1754        assert!(tracks[0].effects.delay.is_some());
1755        assert!(matches!(
1756            tracks[0].filter.filter_type,
1757            tunes::synthesis::filter::FilterType::LowPass
1758        ));
1759    }
1760
1761    #[test]
1762    fn song_stores_and_applies_master_effects() {
1763        let song = PulseSong::new().with_master_effect(test_eq_effect());
1764
1765        assert_eq!(song.master_effects().len(), 1);
1766
1767        let mixer = song.to_mixer().expect("song with master eq should build");
1768        assert!(mixer.master.eq.is_some());
1769    }
1770
1771    #[test]
1772    fn song_rejects_track_only_filter_on_master() {
1773        let error = PulseSong::new()
1774            .with_master_effect(test_filter_effect())
1775            .to_mixer()
1776            .expect_err("filter is not a master effect");
1777
1778        assert_eq!(error.to_string(), "invalid effect scope filter: master");
1779    }
1780
1781    #[test]
1782    fn sequence_applies_fm_synth_to_exported_notes() {
1783        let c4 = parse_note_frequency("C4").unwrap();
1784        let synth = crate::synthesis::synth_from_options(
1785            "fm",
1786            crate::synthesis::SynthOptions::Preset("bell".to_string()),
1787        )
1788        .expect("fm bell preset should parse");
1789        let sequence = PulseSequence::new()
1790            .with_notes(vec![c4])
1791            .with_durations(vec![0.25])
1792            .with_instrument("electric_piano")
1793            .with_synth(synth);
1794
1795        assert_eq!(
1796            sequence.synth().map(crate::synthesis::PulseSynth::name),
1797            Some("fm")
1798        );
1799
1800        let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
1801        let tracks = mixer.all_tracks();
1802        assert_eq!(tracks.len(), 1);
1803
1804        let AudioEvent::Note(note) = &tracks[0].events[0] else {
1805            panic!("expected note event");
1806        };
1807        assert_eq!(
1808            note.fm_params.mod_index,
1809            tunes::synthesis::fm_synthesis::FMParams::bell().mod_index
1810        );
1811    }
1812
1813    #[test]
1814    fn sequence_applies_additive_and_wavetable_synths_to_exported_notes() {
1815        let c4 = parse_note_frequency("C4").unwrap();
1816        let additive = crate::synthesis::synth_from_options(
1817            "additive",
1818            crate::synthesis::SynthOptions::Harmonics(vec![1.0, 0.5, 0.25]),
1819        )
1820        .expect("additive harmonics should parse");
1821        let wavetable = crate::synthesis::synth_from_options(
1822            "wavetable",
1823            crate::synthesis::SynthOptions::Default,
1824        )
1825        .expect("wavetable should parse");
1826
1827        let additive_sequence = PulseSequence::new()
1828            .with_notes(vec![c4])
1829            .with_durations(vec![0.25])
1830            .with_instrument("electric_piano")
1831            .with_synth(additive);
1832        let wavetable_sequence = PulseSequence::new()
1833            .with_notes(vec![c4])
1834            .with_durations(vec![0.25])
1835            .with_instrument("electric_piano")
1836            .with_synth(wavetable);
1837
1838        let mixer = PulseSong::new()
1839            .add_sequence(additive_sequence)
1840            .add_sequence(wavetable_sequence)
1841            .to_mixer()
1842            .unwrap();
1843        let tracks = mixer.all_tracks();
1844        assert_eq!(tracks.len(), 2);
1845
1846        for track in tracks {
1847            let AudioEvent::Note(note) = &track.events[0] else {
1848                panic!("expected note event");
1849            };
1850            assert!(note.custom_wavetable.is_some());
1851        }
1852    }
1853
1854    #[test]
1855    fn sequence_renders_karplus_strong_synth_as_sample_events() {
1856        let c4 = parse_note_frequency("C4").unwrap();
1857        let synth = crate::synthesis::synth_from_options(
1858            "karplus_strong",
1859            crate::synthesis::SynthOptions::Default,
1860        )
1861        .expect("karplus-strong should parse");
1862        let sequence = PulseSequence::new()
1863            .with_notes(vec![c4])
1864            .with_durations(vec![0.25])
1865            .with_instrument("electric_piano")
1866            .with_synth(synth);
1867
1868        assert_eq!(
1869            sequence.synth().map(crate::synthesis::PulseSynth::name),
1870            Some("karplus_strong")
1871        );
1872
1873        let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
1874        let tracks = mixer.all_tracks();
1875        assert_eq!(tracks.len(), 1);
1876
1877        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
1878            panic!("expected karplus-strong sample event");
1879        };
1880        assert_eq!(sample.start_time, 0.0);
1881        assert_eq!(sample.sample.sample_rate, 44_100);
1882        assert!((sample.sample.duration - 0.25).abs() < 0.001);
1883    }
1884
1885    #[test]
1886    fn midi_clip_rejects_unrenderable_repeat_expansion() {
1887        let c4 = parse_note_frequency("C4").unwrap();
1888        let source_mixer = PulseSong::new()
1889            .add_sequence(
1890                PulseSequence::new()
1891                    .with_notes(vec![c4])
1892                    .with_durations(vec![0.25])
1893                    .with_instrument("electric_piano"),
1894            )
1895            .to_mixer()
1896            .unwrap();
1897        let clip = PulseMidiClip::new(source_mixer)
1898            .with_repeat_times(usize::MAX)
1899            .unwrap();
1900
1901        let error = clip
1902            .arranged_tracks(0.0, "midi")
1903            .expect_err("unrenderable repeat expansion should return an error");
1904
1905        assert!(matches!(error, PulseError::InvalidRepeatTimes { .. }));
1906    }
1907
1908    #[test]
1909    fn granular_sequence_duration_uses_output_duration_for_phrase_timing() {
1910        let synth = crate::synthesis::synth_from_options(
1911            "granular",
1912            crate::synthesis::SynthOptions::Params(std::collections::BTreeMap::from([
1913                (
1914                    "source".to_string(),
1915                    crate::synthesis::SynthOption::Text("source.wav".to_string()),
1916                ),
1917                (
1918                    "duration".to_string(),
1919                    crate::synthesis::SynthOption::Number(0.75),
1920                ),
1921            ])),
1922        )
1923        .expect("granular should parse");
1924        let sequence = PulseSequence::new().with_synth(synth);
1925        let phrase = PulsePhrase::new()
1926            .add_sequence(sequence)
1927            .with_repeat_times(2)
1928            .unwrap();
1929
1930        assert_eq!(phrase.duration().unwrap(), 0.75);
1931        assert_eq!(phrase.total_duration().unwrap(), 1.5);
1932    }
1933
1934    #[test]
1935    fn phrase_rejects_unrenderable_repeat_expansion_before_looping() {
1936        let c4 = parse_note_frequency("C4").unwrap();
1937        let phrase = PulsePhrase::new()
1938            .add_sequence(
1939                PulseSequence::new()
1940                    .with_notes(vec![c4])
1941                    .with_durations(vec![0.25])
1942                    .with_instrument("electric_piano"),
1943            )
1944            .with_repeat_times(usize::MAX)
1945            .unwrap();
1946
1947        let error = match PulseSong::new().add_phrase(phrase).to_tunes_composition() {
1948            Ok(_) => panic!("unrenderable phrase repeat expansion should return an error"),
1949            Err(error) => error,
1950        };
1951
1952        assert!(matches!(error, PulseError::InvalidRepeatTimes { .. }));
1953    }
1954
1955    #[test]
1956    fn song_converts_sample_clips_to_sample_events() {
1957        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
1958        let source = temp_dir.path().join("sample.wav");
1959        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
1960            .export_wav(&source)
1961            .expect("test sample should be written");
1962
1963        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
1964            .with_pitch_shift(12.0)
1965            .unwrap()
1966            .with_time_stretch(1.5)
1967            .unwrap()
1968            .with_playback_rate(0.5)
1969            .unwrap();
1970
1971        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
1972        let tracks = mixer.all_tracks();
1973        assert_eq!(tracks.len(), 1);
1974
1975        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
1976            panic!("expected sample event");
1977        };
1978        assert_eq!(sample.start_time, 0.0);
1979        assert_eq!(sample.playback_rate, 0.5);
1980        assert!(sample.sample.duration > 0.1);
1981    }
1982
1983    #[test]
1984    fn sample_clip_applies_track_name_volume_pan_and_effects() {
1985        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
1986        let source = temp_dir.path().join("mix-sample.wav");
1987        tunes::synthesis::sample::Sample::from_mono(vec![0.25, 0.5, 0.25, -0.25], 44_100)
1988            .export_wav(&source)
1989            .expect("test sample should be written");
1990
1991        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
1992            .with_track("loop_bus")
1993            .with_volume(0.42)
1994            .unwrap()
1995            .with_pan(-0.25)
1996            .unwrap()
1997            .with_effect(test_delay_effect());
1998
1999        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
2000        let tracks = mixer.all_tracks();
2001        assert_eq!(tracks.len(), 1);
2002        assert_eq!(tracks[0].name.as_deref(), Some("loop_bus"));
2003        assert!((tracks[0].volume - 0.42).abs() < f32::EPSILON);
2004        assert!((tracks[0].pan + 0.25).abs() < f32::EPSILON);
2005        assert!(tracks[0].effects.delay.is_some());
2006    }
2007
2008    #[test]
2009    fn sample_clip_rejects_invalid_track_volume_and_pan() {
2010        assert!(PulseSampleClip::new("source.wav")
2011            .with_volume(-0.01)
2012            .is_err());
2013        assert!(PulseSampleClip::new("source.wav")
2014            .with_volume(2.01)
2015            .is_err());
2016        assert!(PulseSampleClip::new("source.wav").with_pan(-1.01).is_err());
2017        assert!(PulseSampleClip::new("source.wav").with_pan(1.01).is_err());
2018    }
2019
2020    #[test]
2021    fn phrase_repeats_sample_clips_on_the_song_timeline() {
2022        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2023        let source = temp_dir.path().join("phrase-sample.wav");
2024        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
2025            .export_wav(&source)
2026            .expect("test sample should be written");
2027
2028        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref());
2029        let phrase = PulsePhrase::new()
2030            .add_sample_clip(clip)
2031            .with_repeat_times(2)
2032            .unwrap();
2033
2034        assert!((phrase.duration().unwrap() - 0.1).abs() < 0.001);
2035        assert!((phrase.total_duration().unwrap() - 0.2).abs() < 0.001);
2036
2037        let mixer = PulseSong::new().add_phrase(phrase).to_mixer().unwrap();
2038        let mut sample_times = Vec::new();
2039
2040        for track in mixer.all_tracks() {
2041            for event in &track.events {
2042                if let AudioEvent::Sample(sample) = event {
2043                    sample_times.push(sample.start_time);
2044                }
2045            }
2046        }
2047
2048        sample_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
2049        assert_eq!(sample_times, vec![0.0, 0.1]);
2050    }
2051
2052    #[test]
2053    fn sample_clip_offset_places_song_and_phrase_events_on_the_timeline() {
2054        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2055        let source = temp_dir.path().join("offset-sample.wav");
2056        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
2057            .export_wav(&source)
2058            .expect("test sample should be written");
2059
2060        let song_clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2061            .with_start_at(0.25)
2062            .unwrap();
2063        let phrase_clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2064            .with_start_at(0.5)
2065            .unwrap();
2066        let phrase = PulsePhrase::new()
2067            .add_sample_clip(phrase_clip)
2068            .with_repeat_times(2)
2069            .unwrap();
2070
2071        let mixer = PulseSong::new()
2072            .add_sample_clip(song_clip)
2073            .add_phrase(phrase)
2074            .to_mixer()
2075            .unwrap();
2076        let mut sample_times = Vec::new();
2077
2078        for track in mixer.all_tracks() {
2079            for event in &track.events {
2080                if let AudioEvent::Sample(sample) = event {
2081                    sample_times.push(sample.start_time);
2082                }
2083            }
2084        }
2085
2086        sample_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
2087        assert_eq!(sample_times, vec![0.25, 0.5, 1.1]);
2088    }
2089
2090    #[test]
2091    fn sample_clip_slice_uses_only_the_selected_source_range() {
2092        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2093        let source = temp_dir.path().join("slice-sample.wav");
2094        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 44_100], 44_100)
2095            .export_wav(&source)
2096            .expect("test sample should be written");
2097
2098        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2099            .with_slice(0.25, 0.5)
2100            .unwrap()
2101            .with_start_at(0.125)
2102            .unwrap();
2103
2104        assert!((clip.duration().unwrap() - 0.375).abs() < 0.01);
2105
2106        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
2107        let tracks = mixer.all_tracks();
2108        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
2109            panic!("expected sliced sample event");
2110        };
2111
2112        assert_eq!(sample.start_time, 0.125);
2113        assert!((sample.sample.duration - 0.25).abs() < 0.01);
2114    }
2115
2116    #[test]
2117    fn sample_clip_fade_in_and_out_shape_exported_sample_edges() {
2118        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2119        let source = temp_dir.path().join("fade-sample.wav");
2120        tunes::synthesis::sample::Sample::from_mono(vec![1.0; 44_100], 44_100)
2121            .export_wav(&source)
2122            .expect("test sample should be written");
2123
2124        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2125            .with_slice(0.0, 0.25)
2126            .unwrap()
2127            .with_fade_in(0.05)
2128            .unwrap()
2129            .with_fade_out(0.05)
2130            .unwrap();
2131
2132        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
2133        let tracks = mixer.all_tracks();
2134        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
2135            panic!("expected faded sample event");
2136        };
2137
2138        let first = sample.sample.data[0];
2139        let middle = sample.sample.data[sample.sample.data.len() / 2];
2140        let last = *sample
2141            .sample
2142            .data
2143            .last()
2144            .expect("sample should not be empty");
2145
2146        assert!(first.abs() < 0.001);
2147        assert!(middle > 0.9);
2148        assert!(last.abs() < 0.01);
2149    }
2150
2151    #[test]
2152    fn sample_clip_reverse_flips_the_selected_source_range() {
2153        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2154        let source = temp_dir.path().join("reverse-sample.wav");
2155        tunes::synthesis::sample::Sample::from_mono(vec![0.1, 0.2, 0.8, 1.0], 4)
2156            .export_wav(&source)
2157            .expect("test sample should be written");
2158
2159        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2160            .with_slice(0.0, 0.75)
2161            .unwrap()
2162            .with_reverse();
2163
2164        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
2165        let tracks = mixer.all_tracks();
2166        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
2167            panic!("expected reversed sample event");
2168        };
2169
2170        assert_eq!(sample.sample.data.len(), 3);
2171        assert!((sample.sample.data[0] - 0.8).abs() < 0.01);
2172        assert!((sample.sample.data[1] - 0.2).abs() < 0.01);
2173        assert!((sample.sample.data[2] - 0.1).abs() < 0.01);
2174    }
2175
2176    #[test]
2177    fn sample_clip_loop_for_expands_source_to_requested_duration() {
2178        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2179        let source = temp_dir.path().join("loop-for-sample.wav");
2180        tunes::synthesis::sample::Sample::from_mono(vec![0.1, 0.2], 4)
2181            .export_wav(&source)
2182            .expect("test sample should be written");
2183
2184        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2185            .with_loop_for(1.25)
2186            .unwrap();
2187
2188        assert!((clip.duration().unwrap() - 1.25).abs() < 0.01);
2189
2190        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
2191        let tracks = mixer.all_tracks();
2192        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
2193            panic!("expected looped sample event");
2194        };
2195
2196        assert!((sample.sample.duration - 1.25).abs() < 0.01);
2197        assert_eq!(sample.sample.data.len(), 5);
2198        assert!((sample.sample.data[0] - 0.1).abs() < 0.01);
2199        assert!((sample.sample.data[1] - 0.2).abs() < 0.01);
2200        assert!((sample.sample.data[2] - 0.1).abs() < 0.01);
2201        assert!((sample.sample.data[3] - 0.2).abs() < 0.01);
2202        assert!((sample.sample.data[4] - 0.1).abs() < 0.01);
2203    }
2204
2205    #[test]
2206    fn sample_clip_loop_for_rejects_unrenderable_durations() {
2207        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2208        let source = temp_dir.path().join("huge-loop-sample.wav");
2209        tunes::synthesis::sample::Sample::from_mono(vec![0.1, 0.2], 44_100)
2210            .export_wav(&source)
2211            .expect("test sample should be written");
2212
2213        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2214            .with_loop_for(f32::MAX)
2215            .unwrap();
2216
2217        let error = PulseSong::new()
2218            .add_sample_clip(clip)
2219            .to_mixer()
2220            .expect_err("unrenderable loop duration should return an error");
2221
2222        assert!(matches!(
2223            error,
2224            PulseError::InvalidSampleOption { ref option, .. } if option == "loop_for"
2225        ));
2226    }
2227
2228    #[test]
2229    fn sample_frame_count_rejects_vec_capacity_overflow() {
2230        let sample_rate = 44_100;
2231        let max_vec_frames = isize::MAX as usize / std::mem::size_of::<f32>();
2232        let duration = (max_vec_frames as f32 / sample_rate as f32) * 2.0;
2233
2234        let error = sample_frame_count("loop_for", duration, sample_rate)
2235            .expect_err("frame count should reject Vec capacity overflow");
2236
2237        assert!(matches!(
2238            error,
2239            PulseError::InvalidSampleOption { ref option, .. } if option == "loop_for"
2240        ));
2241    }
2242
2243    #[test]
2244    fn sample_clip_duration_accounts_for_loop_stretch_and_playback_rate() {
2245        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2246        let source = temp_dir.path().join("duration-sample.wav");
2247        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 44_100], 44_100)
2248            .export_wav(&source)
2249            .expect("test sample should be written");
2250
2251        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2252            .with_start_at(0.25)
2253            .unwrap()
2254            .with_slice(0.1, 0.6)
2255            .unwrap()
2256            .with_loop_for(0.75)
2257            .unwrap()
2258            .with_time_stretch(2.0)
2259            .unwrap()
2260            .with_playback_rate(0.5)
2261            .unwrap();
2262
2263        assert!((clip.duration().unwrap() - 3.25).abs() < 0.001);
2264    }
2265
2266    #[test]
2267    fn sample_clip_metadata_duration_matches_public_duration_for_wav_transforms() {
2268        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2269        let source = temp_dir.path().join("metadata-duration.wav");
2270        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 22_050], 44_100)
2271            .export_wav(&source)
2272            .expect("test sample should be written");
2273
2274        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2275            .with_start_at(0.125)
2276            .unwrap()
2277            .with_slice(0.1, 0.4)
2278            .unwrap()
2279            .with_time_stretch(1.5)
2280            .unwrap()
2281            .with_playback_rate(0.75)
2282            .unwrap()
2283            .with_pitch_shift(12.0)
2284            .unwrap()
2285            .with_reverse()
2286            .with_normalize()
2287            .with_fade_in(0.01)
2288            .unwrap()
2289            .with_fade_out(0.02)
2290            .unwrap()
2291            .with_gain(0.8)
2292            .unwrap();
2293
2294        let metadata_duration = clip.metadata_duration().unwrap();
2295        let public_duration = clip.duration().unwrap();
2296
2297        assert!((metadata_duration - public_duration).abs() < 0.01);
2298        assert!((metadata_duration - 0.725).abs() < 0.01);
2299    }
2300
2301    #[test]
2302    fn sample_clip_duration_matches_rendered_sample_for_subtle_time_stretch() {
2303        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2304        let source = temp_dir.path().join("subtle-stretch.wav");
2305        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 441_000], 44_100)
2306            .export_wav(&source)
2307            .expect("test sample should be written");
2308
2309        // time_stretch in the [0.001, 0.01) band: metadata_duration used a
2310        // different threshold than transformed_sample, so they disagreed.
2311        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2312            .with_time_stretch(1.009)
2313            .unwrap();
2314
2315        let rendered_duration = clip
2316            .transformed_sample()
2317            .expect("sample should transform")
2318            .duration
2319            / clip.playback_rate();
2320        let metadata_duration = clip.metadata_duration().unwrap();
2321
2322        assert!(
2323            (rendered_duration - metadata_duration).abs() < 0.01,
2324            "metadata duration {metadata_duration} must match rendered duration {rendered_duration} for subtle time_stretch"
2325        );
2326    }
2327
2328    #[test]
2329    fn sample_clip_duration_rejects_slice_beyond_wav_duration() {
2330        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2331        let source = temp_dir.path().join("short-sample.wav");
2332        tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
2333            .export_wav(&source)
2334            .expect("test sample should be written");
2335
2336        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2337            .with_slice(0.0, 0.2)
2338            .unwrap();
2339
2340        assert!(clip.duration().is_err());
2341    }
2342
2343    #[test]
2344    fn sample_clip_normalize_scales_source_peak_before_gain() {
2345        let temp_dir = tempfile::tempdir().expect("temp dir should be created");
2346        let source = temp_dir.path().join("normalize-sample.wav");
2347        tunes::synthesis::sample::Sample::from_mono(vec![0.1, -0.25, 0.5], 3)
2348            .export_wav(&source)
2349            .expect("test sample should be written");
2350
2351        let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
2352            .with_normalize()
2353            .with_gain(0.5)
2354            .unwrap();
2355
2356        let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
2357        let tracks = mixer.all_tracks();
2358        let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
2359            panic!("expected normalized sample event");
2360        };
2361
2362        let peak = sample
2363            .sample
2364            .data
2365            .iter()
2366            .fold(0.0_f32, |current, value| current.max(value.abs()));
2367        assert!((peak - 0.5).abs() < 0.01);
2368    }
2369}