use crate::drums::PulseDrumGrid;
use crate::effects::PulseEffect;
use crate::error::{PulseError, PulseResult};
use crate::instruments::instrument_by_name;
use crate::synthesis::PulseSynth;
use crate::theory::transpose_notes;
use tunes::composition::{Composition, Tempo};
use tunes::synthesis::sample::Sample;
use tunes::track::{AudioEvent, Mixer, Track};
#[derive(Debug, Clone, PartialEq)]
pub struct PulseNoteEvent {
frequencies: Vec<f32>,
duration: f32,
}
impl PulseNoteEvent {
pub fn new(frequencies: Vec<f32>, duration: f32) -> PulseResult<Self> {
validate_event_frequencies(&frequencies)?;
validate_sequence_duration(duration)?;
Ok(Self {
frequencies,
duration,
})
}
pub fn frequencies(&self) -> &[f32] {
&self.frequencies
}
pub fn duration(&self) -> f32 {
self.duration
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PulseSequence {
notes: Vec<f32>,
chords: Vec<Vec<f32>>,
durations: Vec<f32>,
instrument: String,
synth: Option<PulseSynth>,
effects: Vec<PulseEffect>,
start_at: f32,
volume: f32,
pan: f32,
velocity: f32,
}
impl Default for PulseSequence {
fn default() -> Self {
Self::new()
}
}
impl PulseSequence {
pub fn new() -> Self {
Self {
notes: Vec::new(),
chords: Vec::new(),
durations: Vec::new(),
instrument: "electric_piano".to_string(),
synth: None,
effects: Vec::new(),
start_at: 0.0,
volume: 1.0,
pan: 0.0,
velocity: 0.8,
}
}
#[must_use]
pub fn with_notes(mut self, notes: Vec<f32>) -> Self {
self.notes = notes;
self.chords.clear();
self
}
#[must_use]
pub fn with_chords(mut self, chords: Vec<Vec<f32>>) -> Self {
self.chords = chords;
self.notes.clear();
self
}
#[must_use]
pub fn with_durations(mut self, durations: Vec<f32>) -> Self {
self.durations = durations;
self
}
#[must_use]
pub fn with_instrument(mut self, instrument: impl Into<String>) -> Self {
self.instrument = instrument.into();
self
}
#[must_use]
pub fn with_synth(mut self, synth: PulseSynth) -> Self {
self.synth = Some(synth);
self
}
pub fn with_start_at(mut self, start_at: f32) -> PulseResult<Self> {
validate_non_negative_sequence_option("at", start_at)?;
self.start_at = start_at;
Ok(self)
}
pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
validate_sequence_range("volume", volume, 0.0, 2.0)?;
self.volume = volume;
Ok(self)
}
pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
validate_sequence_range("pan", pan, -1.0, 1.0)?;
self.pan = pan;
Ok(self)
}
pub fn with_velocity(mut self, velocity: f32) -> PulseResult<Self> {
validate_sequence_range("velocity", velocity, 0.0, 1.0)?;
self.velocity = velocity;
Ok(self)
}
#[must_use]
pub fn transposed(&self, semitones: i32) -> Self {
let mut sequence = self.clone();
sequence.notes = transpose_notes(&sequence.notes, semitones);
sequence.chords = sequence
.chords
.iter()
.map(|chord| transpose_notes(chord, semitones))
.collect();
sequence
}
#[must_use]
pub fn with_effect(mut self, effect: PulseEffect) -> Self {
self.effects.push(effect);
self
}
pub fn validate(&self) -> PulseResult<()> {
let event_count = self.event_count();
if event_count != self.durations.len() {
return Err(PulseError::DurationCountMismatch {
notes: event_count,
durations: self.durations.len(),
});
}
for &duration in &self.durations {
validate_sequence_duration(duration)?;
}
if !self.chords.is_empty() {
for chord in &self.chords {
validate_event_frequencies(chord)?;
}
} else {
for &frequency in &self.notes {
validate_note_frequency(frequency)?;
}
}
if matches!(self.synth, Some(PulseSynth::KarplusStrong(_)))
&& self.chords.iter().any(|chord| chord.len() > 1)
{
return Err(PulseError::InvalidSynthOption {
synth: "karplus_strong".to_string(),
option: "chords".to_string(),
value: "polyphonic".to_string(),
});
}
validate_non_negative_sequence_option("at", self.start_at)?;
validate_sequence_range("volume", self.volume, 0.0, 2.0)?;
validate_sequence_range("pan", self.pan, -1.0, 1.0)?;
validate_sequence_range("velocity", self.velocity, 0.0, 1.0)?;
instrument_by_name(&self.instrument)?;
Ok(())
}
pub fn events(&self) -> PulseResult<Vec<PulseNoteEvent>> {
self.validate()?;
if !self.chords.is_empty() {
return self
.chords
.iter()
.cloned()
.zip(self.durations.iter().copied())
.map(|(frequencies, duration)| PulseNoteEvent::new(frequencies, duration))
.collect();
}
self.notes
.iter()
.copied()
.zip(self.durations.iter().copied())
.map(|(frequency, duration)| PulseNoteEvent::new(vec![frequency], duration))
.collect()
}
pub fn notes(&self) -> &[f32] {
&self.notes
}
pub fn chords(&self) -> &[Vec<f32>] {
&self.chords
}
pub fn durations(&self) -> &[f32] {
&self.durations
}
pub fn instrument(&self) -> &str {
&self.instrument
}
pub fn synth(&self) -> Option<&PulseSynth> {
self.synth.as_ref()
}
pub fn effects(&self) -> &[PulseEffect] {
&self.effects
}
pub fn start_at(&self) -> f32 {
self.start_at
}
pub fn volume(&self) -> f32 {
self.volume
}
pub fn pan(&self) -> f32 {
self.pan
}
pub fn velocity(&self) -> f32 {
self.velocity
}
pub fn duration(&self) -> f32 {
if let Some(PulseSynth::Granular(synth)) = self.synth() {
return self.start_at + synth.duration;
}
self.start_at + self.durations.iter().sum::<f32>()
}
fn event_count(&self) -> usize {
if self.chords.is_empty() {
self.notes.len()
} else {
self.chords.len()
}
}
}
fn validate_sequence_duration(duration: f32) -> PulseResult<()> {
if duration.is_finite() && duration > 0.0 {
Ok(())
} else {
Err(PulseError::InvalidDuration { duration })
}
}
fn validate_note_frequency(frequency: f32) -> PulseResult<()> {
if frequency.is_finite() && frequency > 0.0 {
Ok(())
} else {
Err(PulseError::InvalidFrequency { frequency })
}
}
fn validate_event_frequencies(frequencies: &[f32]) -> PulseResult<()> {
if frequencies.is_empty() || frequencies.len() > 8 {
return Err(PulseError::InvalidChordVoiceCount {
count: frequencies.len(),
});
}
for &frequency in frequencies {
validate_note_frequency(frequency)?;
}
Ok(())
}
fn validate_sequence_range(option: &str, value: f32, min: f32, max: f32) -> PulseResult<()> {
if value.is_finite() && value >= min && value <= max {
Ok(())
} else {
Err(invalid_sequence_option(option, value))
}
}
fn validate_non_negative_sequence_option(option: &str, value: f32) -> PulseResult<()> {
if value.is_finite() && value >= 0.0 {
Ok(())
} else {
Err(invalid_sequence_option(option, value))
}
}
fn invalid_sequence_option(option: &str, value: f32) -> PulseError {
PulseError::InvalidSequenceOption {
option: option.to_string(),
value: value.to_string(),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PulseSampleClip {
path: String,
playback_rate: f32,
gain: f32,
pitch_shift: f32,
time_stretch: f32,
start_at: f32,
track_name: Option<String>,
volume: f32,
pan: f32,
effects: Vec<PulseEffect>,
slice_range: Option<(f32, f32)>,
reverse: bool,
loop_for: Option<f32>,
normalize: bool,
fade_in: f32,
fade_out: f32,
}
impl PulseSampleClip {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
playback_rate: 1.0,
gain: 1.0,
pitch_shift: 0.0,
time_stretch: 1.0,
start_at: 0.0,
track_name: None,
volume: 1.0,
pan: 0.0,
effects: Vec::new(),
slice_range: None,
reverse: false,
loop_for: None,
normalize: false,
fade_in: 0.0,
fade_out: 0.0,
}
}
pub fn with_playback_rate(mut self, playback_rate: f32) -> PulseResult<Self> {
validate_positive_sample_option("rate", playback_rate)?;
self.playback_rate = playback_rate;
Ok(self)
}
pub fn with_gain(mut self, gain: f32) -> PulseResult<Self> {
if !gain.is_finite() || gain < 0.0 {
return Err(invalid_sample_option("gain", gain));
}
self.gain = gain;
Ok(self)
}
pub fn with_pitch_shift(mut self, semitones: f32) -> PulseResult<Self> {
if !semitones.is_finite() {
return Err(invalid_sample_option("pitch_shift", semitones));
}
self.pitch_shift = semitones;
Ok(self)
}
pub fn with_time_stretch(mut self, factor: f32) -> PulseResult<Self> {
validate_positive_sample_option("time_stretch", factor)?;
self.time_stretch = factor;
Ok(self)
}
pub fn with_start_at(mut self, start_at: f32) -> PulseResult<Self> {
if !start_at.is_finite() || start_at < 0.0 {
return Err(invalid_sample_option("at", start_at));
}
self.start_at = start_at;
Ok(self)
}
#[must_use]
pub fn with_track(mut self, track_name: impl Into<String>) -> Self {
self.track_name = Some(track_name.into());
self
}
pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
validate_sample_volume(volume)?;
self.volume = volume;
Ok(self)
}
pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
validate_sample_pan(pan)?;
self.pan = pan;
Ok(self)
}
#[must_use]
pub fn with_effect(mut self, effect: PulseEffect) -> Self {
self.effects.push(effect);
self
}
pub fn with_slice(mut self, start: f32, end: f32) -> PulseResult<Self> {
validate_sample_slice(start, end)?;
self.slice_range = Some((start, end));
Ok(self)
}
pub fn with_reverse(mut self) -> Self {
self.reverse = true;
self
}
pub fn with_loop_for(mut self, duration: f32) -> PulseResult<Self> {
validate_positive_sample_option("loop_for", duration)?;
self.loop_for = Some(duration);
Ok(self)
}
pub fn with_normalize(mut self) -> Self {
self.normalize = true;
self
}
pub fn with_fade_in(mut self, duration: f32) -> PulseResult<Self> {
validate_non_negative_sample_option("fade_in", duration)?;
self.fade_in = duration;
Ok(self)
}
pub fn with_fade_out(mut self, duration: f32) -> PulseResult<Self> {
validate_non_negative_sample_option("fade_out", duration)?;
self.fade_out = duration;
Ok(self)
}
pub fn path(&self) -> &str {
&self.path
}
pub fn playback_rate(&self) -> f32 {
self.playback_rate
}
pub fn start_at(&self) -> f32 {
self.start_at
}
pub fn track_name(&self) -> Option<&str> {
self.track_name.as_deref()
}
pub fn volume(&self) -> f32 {
self.volume
}
pub fn pan(&self) -> f32 {
self.pan
}
pub fn effects(&self) -> &[PulseEffect] {
&self.effects
}
pub fn slice_range(&self) -> Option<(f32, f32)> {
self.slice_range
}
pub fn reverse(&self) -> bool {
self.reverse
}
pub fn loop_for(&self) -> Option<f32> {
self.loop_for
}
pub fn normalize(&self) -> bool {
self.normalize
}
pub fn fade_in(&self) -> f32 {
self.fade_in
}
pub fn fade_out(&self) -> f32 {
self.fade_out
}
pub fn duration(&self) -> PulseResult<f32> {
if let Ok(duration) = self.metadata_duration() {
return Ok(duration);
}
let sample = self.transformed_sample()?;
Ok(self.start_at + sample.duration / self.playback_rate)
}
fn metadata_duration(&self) -> PulseResult<f32> {
validate_sample_timing_options(self)?;
let source_duration = wav_duration_seconds(&self.path)?;
let mut duration = if let Some((start, end)) = self.slice_range {
if end > source_duration {
return Err(PulseError::SampleLoadFailed {
message: format!("slice end {end} exceeds sample duration {source_duration}"),
});
}
end - start
} else {
source_duration
};
if let Some(loop_for) = self.loop_for {
validate_positive_sample_option("loop_for", loop_for)?;
duration = loop_for;
}
if (self.time_stretch - 1.0).abs() >= 0.01 {
duration *= self.time_stretch;
}
Ok(self.start_at + duration / self.playback_rate)
}
fn transformed_sample(&self) -> PulseResult<Sample> {
validate_sample_timing_options(self)?;
validate_sample_volume(self.volume)?;
validate_sample_pan(self.pan)?;
validate_non_negative_sample_option("fade_in", self.fade_in)?;
validate_non_negative_sample_option("fade_out", self.fade_out)?;
validate_non_negative_sample_option("gain", self.gain)?;
validate_finite_sample_option("pitch_shift", self.pitch_shift)?;
let mut sample =
Sample::from_file(&self.path).map_err(|error| PulseError::SampleLoadFailed {
message: error.to_string(),
})?;
if let Some((start, end)) = self.slice_range {
sample = sample
.slice(start, end)
.map_err(|error| PulseError::SampleLoadFailed {
message: error.to_string(),
})?;
}
if self.reverse {
sample = sample.reverse();
}
if let Some(duration) = self.loop_for {
validate_positive_sample_option("loop_for", duration)?;
sample = loop_sample_for_duration(&sample, duration)?;
}
if self.normalize {
sample = sample.normalize();
}
if self.fade_in > 0.0 {
sample = sample.with_fade_in(self.fade_in);
}
if self.fade_out > 0.0 {
sample = sample.with_fade_out(self.fade_out);
}
if self.pitch_shift.abs() >= 0.01 {
sample = sample.pitch_shift(self.pitch_shift);
}
if (self.time_stretch - 1.0).abs() >= 0.01 {
sample = sample.time_stretch(self.time_stretch);
}
if (self.gain - 1.0).abs() >= 0.001 {
sample = sample.with_gain(self.gain);
}
Ok(sample)
}
}
fn loop_sample_for_duration(sample: &Sample, duration: f32) -> PulseResult<Sample> {
validate_positive_sample_option("loop_for", duration)?;
if sample.duration <= 0.0 || !sample.duration.is_finite() {
return Err(PulseError::SampleLoadFailed {
message: "sample duration must be positive before loop_for".to_string(),
});
}
let sample_rate = sample.sample_rate();
let frame_count = sample_frame_count("loop_for", duration, sample_rate)?;
if frame_count == 0 {
return Err(invalid_sample_option("loop_for", duration));
}
let mut data = Vec::with_capacity(frame_count);
for frame in 0..frame_count {
let time = frame as f32 / sample_rate as f32;
let source_time = time % sample.duration;
let (left, right) = sample.sample_at_interpolated(source_time, 1.0);
data.push((left + right) * 0.5);
}
Ok(Sample::from_mono(data, sample_rate))
}
fn sample_frame_count(option: &str, duration: f32, sample_rate: u32) -> PulseResult<usize> {
let frames = f64::from(duration) * f64::from(sample_rate);
let max_frames = isize::MAX as usize / std::mem::size_of::<f32>();
if !frames.is_finite() || frames <= 0.0 || frames > max_frames as f64 {
return Err(invalid_sample_option(option, duration));
}
Ok(frames.round() as usize)
}
fn wav_duration_seconds(path: &str) -> PulseResult<f32> {
let reader = hound::WavReader::open(path).map_err(|error| PulseError::SampleLoadFailed {
message: error.to_string(),
})?;
let spec = reader.spec();
if spec.sample_rate == 0 {
return Err(PulseError::SampleLoadFailed {
message: "wav sample rate must be positive".to_string(),
});
}
Ok(reader.duration() as f32 / spec.sample_rate as f32)
}
fn validate_sample_timing_options(sample_clip: &PulseSampleClip) -> PulseResult<()> {
if sample_clip.path.trim().is_empty() {
return Err(PulseError::InvalidSampleOption {
option: "path".to_string(),
value: "empty".to_string(),
});
}
validate_positive_sample_option("rate", sample_clip.playback_rate)?;
validate_positive_sample_option("time_stretch", sample_clip.time_stretch)?;
if !sample_clip.start_at.is_finite() || sample_clip.start_at < 0.0 {
return Err(invalid_sample_option("at", sample_clip.start_at));
}
if let Some((start, end)) = sample_clip.slice_range {
validate_sample_slice(start, end)?;
}
Ok(())
}
fn validate_positive_sample_option(option: &str, value: f32) -> PulseResult<()> {
if value.is_finite() && value > 0.0 {
Ok(())
} else {
Err(invalid_sample_option(option, value))
}
}
fn validate_non_negative_sample_option(option: &str, value: f32) -> PulseResult<()> {
if value.is_finite() && value >= 0.0 {
Ok(())
} else {
Err(invalid_sample_option(option, value))
}
}
fn validate_finite_sample_option(option: &str, value: f32) -> PulseResult<()> {
if value.is_finite() {
Ok(())
} else {
Err(invalid_sample_option(option, value))
}
}
fn validate_sample_volume(value: f32) -> PulseResult<()> {
if value.is_finite() && (0.0..=2.0).contains(&value) {
Ok(())
} else {
Err(invalid_sample_option("volume", value))
}
}
fn validate_sample_pan(value: f32) -> PulseResult<()> {
if value.is_finite() && (-1.0..=1.0).contains(&value) {
Ok(())
} else {
Err(invalid_sample_option("pan", value))
}
}
fn invalid_sample_option(option: &str, value: f32) -> PulseError {
PulseError::InvalidSampleOption {
option: option.to_string(),
value: value.to_string(),
}
}
fn validate_sample_slice(start: f32, end: f32) -> PulseResult<()> {
if start.is_finite() && end.is_finite() && start >= 0.0 && end > start {
Ok(())
} else {
Err(PulseError::InvalidSampleOption {
option: "slice".to_string(),
value: format!("{start}..{end}"),
})
}
}
#[derive(Debug, Clone)]
pub struct PulseMidiClip {
mixer: Mixer,
start_at: f32,
track_name: Option<String>,
volume: f32,
pan: Option<f32>,
repeat_times: usize,
effects: Vec<PulseEffect>,
}
impl PartialEq for PulseMidiClip {
fn eq(&self, other: &Self) -> bool {
self.start_at == other.start_at
&& self.track_name == other.track_name
&& self.volume == other.volume
&& self.pan == other.pan
&& self.repeat_times == other.repeat_times
&& self.effects == other.effects
&& self.mixer.total_duration() == other.mixer.total_duration()
}
}
impl PulseMidiClip {
pub fn new(mixer: Mixer) -> Self {
Self {
mixer,
start_at: 0.0,
track_name: None,
volume: 1.0,
pan: None,
repeat_times: 1,
effects: Vec::new(),
}
}
pub fn with_start_at(mut self, start_at: f32) -> PulseResult<Self> {
validate_midi_clip_non_negative("at", start_at)?;
self.start_at = start_at;
Ok(self)
}
#[must_use]
pub fn with_track(mut self, track_name: impl Into<String>) -> Self {
self.track_name = Some(track_name.into());
self
}
pub fn with_volume(mut self, volume: f32) -> PulseResult<Self> {
validate_midi_clip_volume(volume)?;
self.volume = volume;
Ok(self)
}
pub fn with_pan(mut self, pan: f32) -> PulseResult<Self> {
validate_midi_clip_pan(pan)?;
self.pan = Some(pan);
Ok(self)
}
pub fn with_repeat_times(mut self, repeat_times: usize) -> PulseResult<Self> {
if repeat_times == 0 {
return Err(PulseError::InvalidRepeatTimes { repeat_times });
}
self.repeat_times = repeat_times;
Ok(self)
}
#[must_use]
pub fn with_effect(mut self, effect: PulseEffect) -> Self {
self.effects.push(effect);
self
}
pub fn start_at(&self) -> f32 {
self.start_at
}
pub fn track_name(&self) -> Option<&str> {
self.track_name.as_deref()
}
pub fn volume(&self) -> f32 {
self.volume
}
pub fn pan(&self) -> Option<f32> {
self.pan
}
pub fn repeat_times(&self) -> usize {
self.repeat_times
}
pub fn effects(&self) -> &[PulseEffect] {
&self.effects
}
pub fn duration(&self) -> PulseResult<f32> {
self.validate()?;
let repeated_duration =
repeated_timeline_duration(self.mixer.total_duration(), self.repeat_times)?;
timeline_offset_duration(self.start_at, repeated_duration, self.repeat_times)
}
fn validate(&self) -> PulseResult<()> {
validate_midi_clip_non_negative("at", self.start_at)?;
validate_midi_clip_volume(self.volume)?;
if let Some(pan) = self.pan {
validate_midi_clip_pan(pan)?;
}
if self.repeat_times == 0 {
return Err(PulseError::InvalidRepeatTimes {
repeat_times: self.repeat_times,
});
}
repeated_timeline_duration(self.mixer.total_duration(), self.repeat_times)?;
Ok(())
}
fn arranged_tracks(&self, start_at: f32, default_name: &str) -> PulseResult<Vec<Track>> {
self.validate()?;
let source_tracks = self.mixer.all_tracks();
let source_track_count = source_tracks.len();
let source_duration = self.mixer.total_duration();
let mut arranged = Vec::with_capacity(source_track_count);
for (track_index, source_track) in source_tracks.into_iter().enumerate() {
let event_capacity =
midi_repeat_event_capacity(source_track.events.len(), self.repeat_times)?;
let mut events = Vec::with_capacity(event_capacity);
let should_expand_events =
repeat_expansion_capacity(source_track.events.len(), self.repeat_times)? > 0;
if should_expand_events {
for repeat_index in 0..self.repeat_times {
let repeat_offset =
start_at + self.start_at + source_duration * repeat_index as f32;
for event in &source_track.events {
let mut event = event.clone();
offset_audio_event(&mut event, repeat_offset);
events.push(event);
}
}
}
events.sort_by(|left, right| {
left.start_time()
.partial_cmp(&right.start_time())
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut track = Track::new();
track.events = events;
track.name =
Some(self.arranged_track_name(default_name, track_index, source_track_count));
track.midi_program = source_track.midi_program;
track.volume = (source_track.volume * self.volume).clamp(0.0, 2.0);
track.pan = source_track.pan;
track.filter = source_track.filter;
track.effects = source_track.effects.clone();
track.modulation = source_track.modulation.clone();
if let Some(pan) = self.pan {
track.pan = pan;
}
for effect in &self.effects {
effect.apply_to_track(&mut track);
}
arranged.push(track);
}
Ok(arranged)
}
fn arranged_track_name(
&self,
default_name: &str,
track_index: usize,
source_track_count: usize,
) -> String {
let base_name = self
.track_name
.as_deref()
.unwrap_or(default_name)
.trim()
.to_string();
let base_name = if base_name.is_empty() {
default_name.to_string()
} else {
base_name
};
if source_track_count == 1 {
base_name
} else {
format!("{base_name}_{track_index}")
}
}
}
fn midi_repeat_event_capacity(event_count: usize, repeat_times: usize) -> PulseResult<usize> {
let capacity = repeat_expansion_capacity(event_count, repeat_times)?;
let max_events = isize::MAX as usize / std::mem::size_of::<AudioEvent>();
if capacity > max_events {
return Err(PulseError::InvalidRepeatTimes { repeat_times });
}
Ok(capacity)
}
fn repeat_expansion_capacity(item_count: usize, repeat_times: usize) -> PulseResult<usize> {
let capacity = item_count
.checked_mul(repeat_times)
.ok_or(PulseError::InvalidRepeatTimes { repeat_times })?;
if capacity > isize::MAX as usize {
return Err(PulseError::InvalidRepeatTimes { repeat_times });
}
Ok(capacity)
}
fn repeated_timeline_duration(duration: f32, repeat_times: usize) -> PulseResult<f32> {
if repeat_times == 0 {
return Err(PulseError::InvalidRepeatTimes { repeat_times });
}
let repeated = duration * repeat_times as f32;
if duration.is_finite() && repeated.is_finite() {
Ok(repeated)
} else {
Err(PulseError::InvalidRepeatTimes { repeat_times })
}
}
fn timeline_offset_duration(start_at: f32, duration: f32, repeat_times: usize) -> PulseResult<f32> {
let total = start_at + duration;
if start_at.is_finite() && duration.is_finite() && total.is_finite() {
Ok(total)
} else {
Err(PulseError::InvalidRepeatTimes { repeat_times })
}
}
fn offset_audio_event(event: &mut AudioEvent, offset: f32) {
match event {
AudioEvent::Note(note) => note.start_time += offset,
AudioEvent::Drum(drum) => drum.start_time += offset,
AudioEvent::Sample(sample) => sample.start_time += offset,
AudioEvent::TempoChange(tempo) => tempo.start_time += offset,
AudioEvent::TimeSignature(time_signature) => time_signature.start_time += offset,
AudioEvent::KeySignature(key_signature) => key_signature.start_time += offset,
}
}
fn validate_midi_clip_non_negative(option: &str, value: f32) -> PulseResult<()> {
if value.is_finite() && value >= 0.0 {
Ok(())
} else {
Err(invalid_midi_clip_option(option, value))
}
}
fn validate_midi_clip_volume(value: f32) -> PulseResult<()> {
if value.is_finite() && (0.0..=2.0).contains(&value) {
Ok(())
} else {
Err(invalid_midi_clip_option("volume", value))
}
}
fn validate_midi_clip_pan(value: f32) -> PulseResult<()> {
if value.is_finite() && (-1.0..=1.0).contains(&value) {
Ok(())
} else {
Err(invalid_midi_clip_option("pan", value))
}
}
fn invalid_midi_clip_option(option: &str, value: f32) -> PulseError {
PulseError::InvalidMidiClipOption {
option: option.to_string(),
value: value.to_string(),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PulsePhrase {
sequences: Vec<PulseSequence>,
drum_grids: Vec<PulseDrumGrid>,
sample_clips: Vec<PulseSampleClip>,
midi_clips: Vec<PulseMidiClip>,
repeat_times: usize,
}
impl Default for PulsePhrase {
fn default() -> Self {
Self::new()
}
}
impl PulsePhrase {
pub fn new() -> Self {
Self {
sequences: Vec::new(),
drum_grids: Vec::new(),
sample_clips: Vec::new(),
midi_clips: Vec::new(),
repeat_times: 1,
}
}
#[must_use]
pub fn add_sequence(mut self, sequence: PulseSequence) -> Self {
self.sequences.push(sequence);
self
}
#[must_use]
pub fn add_drum_grid(mut self, grid: PulseDrumGrid) -> Self {
self.drum_grids.push(grid);
self
}
#[must_use]
pub fn add_sample_clip(mut self, sample_clip: PulseSampleClip) -> Self {
self.sample_clips.push(sample_clip);
self
}
#[must_use]
pub fn add_midi_clip(mut self, midi_clip: PulseMidiClip) -> Self {
self.midi_clips.push(midi_clip);
self
}
pub fn with_repeat_times(mut self, repeat_times: usize) -> PulseResult<Self> {
if repeat_times == 0 {
return Err(PulseError::InvalidRepeatTimes { repeat_times });
}
self.repeat_times = repeat_times;
Ok(self)
}
pub fn duration(&self) -> PulseResult<f32> {
let sequence_duration = self
.sequences
.iter()
.map(PulseSequence::duration)
.fold(0.0, f32::max);
let drum_duration = self
.drum_grids
.iter()
.map(PulseDrumGrid::duration)
.fold(0.0, f32::max);
let mut sample_duration = 0.0_f32;
for sample_clip in &self.sample_clips {
sample_duration = sample_duration.max(sample_clip.duration()?);
}
let mut midi_duration = 0.0_f32;
for midi_clip in &self.midi_clips {
midi_duration = midi_duration.max(midi_clip.duration()?);
}
Ok(sequence_duration
.max(drum_duration)
.max(sample_duration)
.max(midi_duration))
}
pub fn total_duration(&self) -> PulseResult<f32> {
repeated_timeline_duration(self.duration()?, self.repeat_times)
}
pub fn sequences(&self) -> &[PulseSequence] {
&self.sequences
}
pub fn drum_grids(&self) -> &[PulseDrumGrid] {
&self.drum_grids
}
pub fn sample_clips(&self) -> &[PulseSampleClip] {
&self.sample_clips
}
pub fn midi_clips(&self) -> &[PulseMidiClip] {
&self.midi_clips
}
pub fn repeat_times(&self) -> usize {
self.repeat_times
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PulseSong {
tempo: f32,
sequences: Vec<PulseSequence>,
drum_grids: Vec<PulseDrumGrid>,
phrases: Vec<PulsePhrase>,
sample_clips: Vec<PulseSampleClip>,
midi_clips: Vec<PulseMidiClip>,
master_effects: Vec<PulseEffect>,
}
impl Default for PulseSong {
fn default() -> Self {
Self::new()
}
}
impl PulseSong {
pub fn new() -> Self {
Self {
tempo: 120.0,
sequences: Vec::new(),
drum_grids: Vec::new(),
phrases: Vec::new(),
sample_clips: Vec::new(),
midi_clips: Vec::new(),
master_effects: Vec::new(),
}
}
pub fn with_tempo(mut self, bpm: f32) -> PulseResult<Self> {
if !bpm.is_finite() || bpm <= 0.0 {
return Err(PulseError::InvalidTempo { bpm });
}
self.tempo = bpm;
Ok(self)
}
#[must_use]
pub fn add_sequence(mut self, sequence: PulseSequence) -> Self {
self.sequences.push(sequence);
self
}
#[must_use]
pub fn add_drum_grid(mut self, grid: PulseDrumGrid) -> Self {
self.drum_grids.push(grid);
self
}
#[must_use]
pub fn add_phrase(mut self, phrase: PulsePhrase) -> Self {
self.phrases.push(phrase);
self
}
#[must_use]
pub fn add_sample_clip(mut self, sample_clip: PulseSampleClip) -> Self {
self.sample_clips.push(sample_clip);
self
}
#[must_use]
pub fn add_midi_clip(mut self, midi_clip: PulseMidiClip) -> Self {
self.midi_clips.push(midi_clip);
self
}
#[must_use]
pub fn with_master_effect(mut self, effect: PulseEffect) -> Self {
self.master_effects.push(effect);
self
}
pub fn to_tunes_composition(&self) -> PulseResult<Composition> {
let mut composition = Composition::new(Tempo::new(self.tempo));
for (index, sequence) in self.sequences.iter().enumerate() {
let track_name = format!("sequence_{index}");
apply_sequence_to_composition(&mut composition, &track_name, sequence, 0.0)?;
}
for grid in &self.drum_grids {
grid.apply_to_composition(&mut composition)?;
}
for (index, sample_clip) in self.sample_clips.iter().enumerate() {
let track_name = format!("sample_{index}");
apply_sample_clip_to_composition(&mut composition, &track_name, sample_clip, 0.0)?;
}
let mut phrase_start = 0.0;
for (phrase_index, phrase) in self.phrases.iter().enumerate() {
let phrase_duration = phrase.duration()?;
let repeat_times = phrase.repeat_times();
let phrase_total_duration = repeated_timeline_duration(phrase_duration, repeat_times)?;
let arrangement_count =
phrase.sequences().len() + phrase.drum_grids().len() + phrase.sample_clips().len();
let should_expand_phrase =
repeat_expansion_capacity(arrangement_count, repeat_times)? > 0;
if should_expand_phrase {
for repeat_index in 0..repeat_times {
let repeat_start = phrase_start + phrase_duration * repeat_index as f32;
for (sequence_index, sequence) in phrase.sequences().iter().enumerate() {
let track_name = format!("phrase_{phrase_index}_sequence_{sequence_index}");
apply_sequence_to_composition(
&mut composition,
&track_name,
sequence,
repeat_start,
)?;
}
for grid in phrase.drum_grids() {
grid.apply_to_composition_at(&mut composition, repeat_start)?;
}
for (sample_index, sample_clip) in phrase.sample_clips().iter().enumerate() {
let track_name = format!("phrase_{phrase_index}_sample_{sample_index}");
apply_sample_clip_to_composition(
&mut composition,
&track_name,
sample_clip,
repeat_start,
)?;
}
}
}
phrase_start =
timeline_offset_duration(phrase_start, phrase_total_duration, repeat_times)?;
}
Ok(composition)
}
pub fn to_mixer(&self) -> PulseResult<Mixer> {
let mut mixer = self.to_arranged_mixer()?;
for effect in &self.master_effects {
effect.apply_to_master(&mut mixer)?;
}
Ok(mixer)
}
pub(crate) fn to_arranged_mixer(&self) -> PulseResult<Mixer> {
let mut mixer = self.to_tunes_composition()?.into_mixer();
self.apply_midi_clips_to_mixer(&mut mixer)?;
Ok(mixer)
}
fn apply_midi_clips_to_mixer(&self, mixer: &mut Mixer) -> PulseResult<()> {
let mut next_track_id = next_mixer_track_id(mixer);
for (index, midi_clip) in self.midi_clips.iter().enumerate() {
append_midi_clip_to_mixer(
mixer,
midi_clip,
0.0,
&format!("midi_{index}"),
&mut next_track_id,
)?;
}
let mut phrase_start = 0.0;
for (phrase_index, phrase) in self.phrases.iter().enumerate() {
let phrase_duration = phrase.duration()?;
let repeat_times = phrase.repeat_times();
let phrase_total_duration = repeated_timeline_duration(phrase_duration, repeat_times)?;
let should_expand_midi =
repeat_expansion_capacity(phrase.midi_clips().len(), repeat_times)? > 0;
if should_expand_midi {
for repeat_index in 0..repeat_times {
let repeat_start = phrase_start + phrase_duration * repeat_index as f32;
for (midi_index, midi_clip) in phrase.midi_clips().iter().enumerate() {
append_midi_clip_to_mixer(
mixer,
midi_clip,
repeat_start,
&format!("phrase_{phrase_index}_midi_{midi_index}"),
&mut next_track_id,
)?;
}
}
}
phrase_start =
timeline_offset_duration(phrase_start, phrase_total_duration, repeat_times)?;
}
Ok(())
}
pub fn tempo(&self) -> f32 {
self.tempo
}
pub fn sequences(&self) -> &[PulseSequence] {
&self.sequences
}
pub fn drum_grids(&self) -> &[PulseDrumGrid] {
&self.drum_grids
}
pub fn phrases(&self) -> &[PulsePhrase] {
&self.phrases
}
pub fn sample_clips(&self) -> &[PulseSampleClip] {
&self.sample_clips
}
pub fn midi_clips(&self) -> &[PulseMidiClip] {
&self.midi_clips
}
pub fn master_effects(&self) -> &[PulseEffect] {
&self.master_effects
}
}
fn apply_sequence_to_composition(
composition: &mut Composition,
track_name: &str,
sequence: &PulseSequence,
start_at: f32,
) -> PulseResult<()> {
sequence.validate()?;
let instrument = instrument_by_name(sequence.instrument())?;
let mut builder = composition
.instrument(track_name, &instrument)
.at(start_at + sequence.start_at())
.volume(sequence.volume())
.pan(sequence.pan())
.velocity(sequence.velocity());
let events = sequence.events()?;
match sequence.synth() {
Some(PulseSynth::KarplusStrong(synth)) => {
for event in &events {
let sample = synth.to_sample(event.frequencies()[0], event.duration(), 44_100)?;
builder = builder.play_sample(&sample, 1.0);
}
}
Some(PulseSynth::Granular(synth)) => {
tunes::synthesis::sample::Sample::from_file(&synth.source).map_err(|error| {
PulseError::InvalidSynthOption {
synth: "granular".to_string(),
option: "source".to_string(),
value: error.to_string(),
}
})?;
builder = builder.granular(&synth.source, synth.params.clone(), synth.duration);
}
synth => {
if let Some(synth) = synth {
builder = synth.apply_to_track_builder(builder);
}
for event in &events {
builder = builder.note(event.frequencies(), event.duration());
}
}
}
for effect in sequence.effects() {
builder = effect.apply_to_track_builder(builder);
}
let _ = builder;
Ok(())
}
fn append_midi_clip_to_mixer(
mixer: &mut Mixer,
midi_clip: &PulseMidiClip,
start_at: f32,
default_name: &str,
next_track_id: &mut u32,
) -> PulseResult<()> {
for mut track in midi_clip.arranged_tracks(start_at, default_name)? {
track.id = *next_track_id;
*next_track_id = (*next_track_id).saturating_add(1);
track.bus_id = 0;
mixer.add_track(track);
}
Ok(())
}
fn next_mixer_track_id(mixer: &Mixer) -> u32 {
mixer
.all_tracks()
.into_iter()
.map(|track| track.id)
.max()
.unwrap_or(0)
.saturating_add(1)
}
fn apply_sample_clip_to_composition(
composition: &mut Composition,
track_name: &str,
sample_clip: &PulseSampleClip,
start_at: f32,
) -> PulseResult<()> {
let sample = sample_clip.transformed_sample()?;
let effective_track_name = sample_clip.track_name().unwrap_or(track_name);
let mut builder = composition
.track(effective_track_name)
.at(start_at + sample_clip.start_at())
.volume(sample_clip.volume())
.pan(sample_clip.pan())
.play_sample(&sample, sample_clip.playback_rate());
for effect in sample_clip.effects() {
builder = effect.apply_to_track_builder(builder);
}
let _ = builder;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theory::parse_note_frequency;
use tunes::track::AudioEvent;
fn test_delay_effect() -> crate::effects::PulseEffect {
crate::effects::effect_from_options("delay", crate::effects::EffectOptions::Default)
.expect("default delay should parse")
}
fn test_eq_effect() -> crate::effects::PulseEffect {
crate::effects::effect_from_options("eq", crate::effects::EffectOptions::Default)
.expect("default eq should parse")
}
fn test_filter_effect() -> crate::effects::PulseEffect {
crate::effects::effect_from_options("filter", crate::effects::EffectOptions::Default)
.expect("default filter should parse")
}
#[test]
fn sequence_validates_duration_count_and_positive_values() {
let c4 = parse_note_frequency("C4").unwrap();
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.5])
.with_instrument("electric_piano");
assert!(sequence.validate().is_ok());
let mismatch = PulseSequence::new()
.with_notes(vec![c4, c4])
.with_durations(vec![0.5])
.with_instrument("electric_piano");
assert_eq!(
mismatch
.validate()
.expect_err("mismatch should fail")
.to_string(),
"duration count mismatch: 2 notes and 1 durations"
);
let invalid_duration = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.0])
.with_instrument("electric_piano");
assert_eq!(
invalid_duration
.validate()
.expect_err("zero duration should fail")
.to_string(),
"invalid duration: 0"
);
}
#[test]
fn sequence_transposes_without_mutating_original() {
let c4 = parse_note_frequency("C4").unwrap();
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.5])
.with_instrument("electric_piano");
let transposed = sequence.transposed(12);
assert!(transposed.notes()[0] > sequence.notes()[0] * 1.99);
assert!(sequence.notes()[0] < transposed.notes()[0]);
}
#[test]
fn sequence_can_render_polyphonic_chords_with_mix_controls() {
let c4 = parse_note_frequency("C4").unwrap();
let e4 = parse_note_frequency("E4").unwrap();
let g4 = parse_note_frequency("G4").unwrap();
let f4 = parse_note_frequency("F4").unwrap();
let a4 = parse_note_frequency("A4").unwrap();
let c5 = parse_note_frequency("C5").unwrap();
let sequence = PulseSequence::new()
.with_chords(vec![vec![c4, e4, g4], vec![f4, a4, c5]])
.with_durations(vec![0.5, 0.5])
.with_instrument("electric_piano")
.with_start_at(0.25)
.unwrap()
.with_volume(0.7)
.unwrap()
.with_pan(-0.25)
.unwrap()
.with_velocity(0.6)
.unwrap();
let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].volume, 0.7);
assert_eq!(tracks[0].pan, -0.25);
let AudioEvent::Note(note) = &tracks[0].events[0] else {
panic!("expected note event");
};
assert_eq!(note.start_time, 0.25);
assert_eq!(note.duration, 0.5);
assert_eq!(note.num_freqs, 3);
assert_eq!(note.frequencies[0], c4);
assert_eq!(note.frequencies[1], e4);
assert_eq!(note.frequencies[2], g4);
assert_eq!(note.velocity, 0.6);
}
#[test]
fn song_rejects_invalid_tempo() {
let error = PulseSong::new()
.with_tempo(0.0)
.expect_err("zero tempo should fail");
assert_eq!(error.to_string(), "invalid tempo: 0");
}
#[test]
fn song_converts_valid_sequence_to_tunes_composition() {
let c4 = parse_note_frequency("C4").unwrap();
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano");
let song = PulseSong::new()
.with_tempo(120.0)
.unwrap()
.add_sequence(sequence);
let composition = song.to_tunes_composition();
assert!(composition.is_ok());
}
#[test]
fn song_converts_drum_grid_to_tunes_composition() {
let drum_grid = crate::drums::PulseDrumGrid::new()
.with_steps(16)
.unwrap()
.with_step_duration(0.125)
.unwrap()
.sound("kick_808", vec![0, 4, 8, 12])
.unwrap();
let song = PulseSong::new().add_drum_grid(drum_grid);
assert!(song.to_tunes_composition().is_ok());
assert_eq!(song.drum_grids().len(), 1);
}
#[test]
fn phrase_repeats_sequences_and_drums_on_the_song_timeline() {
let c4 = parse_note_frequency("C4").unwrap();
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano");
let drum_grid = crate::drums::PulseDrumGrid::new()
.with_steps(4)
.unwrap()
.with_step_duration(0.25)
.unwrap()
.sound("kick_808", vec![0])
.unwrap();
let phrase = PulsePhrase::new()
.add_sequence(sequence)
.add_drum_grid(drum_grid)
.with_repeat_times(2)
.unwrap();
assert_eq!(phrase.repeat_times(), 2);
assert_eq!(phrase.duration().unwrap(), 1.0);
let mixer = PulseSong::new().add_phrase(phrase).to_mixer().unwrap();
let mut note_times = Vec::new();
let mut drum_times = Vec::new();
for track in mixer.all_tracks() {
for event in &track.events {
match event {
AudioEvent::Note(note) => note_times.push(note.start_time),
AudioEvent::Drum(drum) => drum_times.push(drum.start_time),
_ => {}
}
}
}
note_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
drum_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
assert_eq!(note_times, vec![0.0, 1.0]);
assert_eq!(drum_times, vec![0.0, 1.0]);
}
#[test]
fn sequence_stores_and_applies_track_effects() {
let c4 = parse_note_frequency("C4").unwrap();
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano")
.with_effect(test_delay_effect())
.with_effect(test_filter_effect());
assert_eq!(sequence.effects().len(), 2);
let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 1);
assert!(tracks[0].effects.delay.is_some());
assert!(matches!(
tracks[0].filter.filter_type,
tunes::synthesis::filter::FilterType::LowPass
));
}
#[test]
fn song_stores_and_applies_master_effects() {
let song = PulseSong::new().with_master_effect(test_eq_effect());
assert_eq!(song.master_effects().len(), 1);
let mixer = song.to_mixer().expect("song with master eq should build");
assert!(mixer.master.eq.is_some());
}
#[test]
fn song_rejects_track_only_filter_on_master() {
let error = PulseSong::new()
.with_master_effect(test_filter_effect())
.to_mixer()
.expect_err("filter is not a master effect");
assert_eq!(error.to_string(), "invalid effect scope filter: master");
}
#[test]
fn sequence_applies_fm_synth_to_exported_notes() {
let c4 = parse_note_frequency("C4").unwrap();
let synth = crate::synthesis::synth_from_options(
"fm",
crate::synthesis::SynthOptions::Preset("bell".to_string()),
)
.expect("fm bell preset should parse");
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano")
.with_synth(synth);
assert_eq!(
sequence.synth().map(crate::synthesis::PulseSynth::name),
Some("fm")
);
let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 1);
let AudioEvent::Note(note) = &tracks[0].events[0] else {
panic!("expected note event");
};
assert_eq!(
note.fm_params.mod_index,
tunes::synthesis::fm_synthesis::FMParams::bell().mod_index
);
}
#[test]
fn sequence_applies_additive_and_wavetable_synths_to_exported_notes() {
let c4 = parse_note_frequency("C4").unwrap();
let additive = crate::synthesis::synth_from_options(
"additive",
crate::synthesis::SynthOptions::Harmonics(vec![1.0, 0.5, 0.25]),
)
.expect("additive harmonics should parse");
let wavetable = crate::synthesis::synth_from_options(
"wavetable",
crate::synthesis::SynthOptions::Default,
)
.expect("wavetable should parse");
let additive_sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano")
.with_synth(additive);
let wavetable_sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano")
.with_synth(wavetable);
let mixer = PulseSong::new()
.add_sequence(additive_sequence)
.add_sequence(wavetable_sequence)
.to_mixer()
.unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 2);
for track in tracks {
let AudioEvent::Note(note) = &track.events[0] else {
panic!("expected note event");
};
assert!(note.custom_wavetable.is_some());
}
}
#[test]
fn sequence_renders_karplus_strong_synth_as_sample_events() {
let c4 = parse_note_frequency("C4").unwrap();
let synth = crate::synthesis::synth_from_options(
"karplus_strong",
crate::synthesis::SynthOptions::Default,
)
.expect("karplus-strong should parse");
let sequence = PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano")
.with_synth(synth);
assert_eq!(
sequence.synth().map(crate::synthesis::PulseSynth::name),
Some("karplus_strong")
);
let mixer = PulseSong::new().add_sequence(sequence).to_mixer().unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 1);
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected karplus-strong sample event");
};
assert_eq!(sample.start_time, 0.0);
assert_eq!(sample.sample.sample_rate, 44_100);
assert!((sample.sample.duration - 0.25).abs() < 0.001);
}
#[test]
fn midi_clip_rejects_unrenderable_repeat_expansion() {
let c4 = parse_note_frequency("C4").unwrap();
let source_mixer = PulseSong::new()
.add_sequence(
PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano"),
)
.to_mixer()
.unwrap();
let clip = PulseMidiClip::new(source_mixer)
.with_repeat_times(usize::MAX)
.unwrap();
let error = clip
.arranged_tracks(0.0, "midi")
.expect_err("unrenderable repeat expansion should return an error");
assert!(matches!(error, PulseError::InvalidRepeatTimes { .. }));
}
#[test]
fn granular_sequence_duration_uses_output_duration_for_phrase_timing() {
let synth = crate::synthesis::synth_from_options(
"granular",
crate::synthesis::SynthOptions::Params(std::collections::BTreeMap::from([
(
"source".to_string(),
crate::synthesis::SynthOption::Text("source.wav".to_string()),
),
(
"duration".to_string(),
crate::synthesis::SynthOption::Number(0.75),
),
])),
)
.expect("granular should parse");
let sequence = PulseSequence::new().with_synth(synth);
let phrase = PulsePhrase::new()
.add_sequence(sequence)
.with_repeat_times(2)
.unwrap();
assert_eq!(phrase.duration().unwrap(), 0.75);
assert_eq!(phrase.total_duration().unwrap(), 1.5);
}
#[test]
fn phrase_rejects_unrenderable_repeat_expansion_before_looping() {
let c4 = parse_note_frequency("C4").unwrap();
let phrase = PulsePhrase::new()
.add_sequence(
PulseSequence::new()
.with_notes(vec![c4])
.with_durations(vec![0.25])
.with_instrument("electric_piano"),
)
.with_repeat_times(usize::MAX)
.unwrap();
let error = match PulseSong::new().add_phrase(phrase).to_tunes_composition() {
Ok(_) => panic!("unrenderable phrase repeat expansion should return an error"),
Err(error) => error,
};
assert!(matches!(error, PulseError::InvalidRepeatTimes { .. }));
}
#[test]
fn song_converts_sample_clips_to_sample_events() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_pitch_shift(12.0)
.unwrap()
.with_time_stretch(1.5)
.unwrap()
.with_playback_rate(0.5)
.unwrap();
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 1);
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected sample event");
};
assert_eq!(sample.start_time, 0.0);
assert_eq!(sample.playback_rate, 0.5);
assert!(sample.sample.duration > 0.1);
}
#[test]
fn sample_clip_applies_track_name_volume_pan_and_effects() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("mix-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25, 0.5, 0.25, -0.25], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_track("loop_bus")
.with_volume(0.42)
.unwrap()
.with_pan(-0.25)
.unwrap()
.with_effect(test_delay_effect());
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].name.as_deref(), Some("loop_bus"));
assert!((tracks[0].volume - 0.42).abs() < f32::EPSILON);
assert!((tracks[0].pan + 0.25).abs() < f32::EPSILON);
assert!(tracks[0].effects.delay.is_some());
}
#[test]
fn sample_clip_rejects_invalid_track_volume_and_pan() {
assert!(PulseSampleClip::new("source.wav")
.with_volume(-0.01)
.is_err());
assert!(PulseSampleClip::new("source.wav")
.with_volume(2.01)
.is_err());
assert!(PulseSampleClip::new("source.wav").with_pan(-1.01).is_err());
assert!(PulseSampleClip::new("source.wav").with_pan(1.01).is_err());
}
#[test]
fn phrase_repeats_sample_clips_on_the_song_timeline() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("phrase-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref());
let phrase = PulsePhrase::new()
.add_sample_clip(clip)
.with_repeat_times(2)
.unwrap();
assert!((phrase.duration().unwrap() - 0.1).abs() < 0.001);
assert!((phrase.total_duration().unwrap() - 0.2).abs() < 0.001);
let mixer = PulseSong::new().add_phrase(phrase).to_mixer().unwrap();
let mut sample_times = Vec::new();
for track in mixer.all_tracks() {
for event in &track.events {
if let AudioEvent::Sample(sample) = event {
sample_times.push(sample.start_time);
}
}
}
sample_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
assert_eq!(sample_times, vec![0.0, 0.1]);
}
#[test]
fn sample_clip_offset_places_song_and_phrase_events_on_the_timeline() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("offset-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let song_clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_start_at(0.25)
.unwrap();
let phrase_clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_start_at(0.5)
.unwrap();
let phrase = PulsePhrase::new()
.add_sample_clip(phrase_clip)
.with_repeat_times(2)
.unwrap();
let mixer = PulseSong::new()
.add_sample_clip(song_clip)
.add_phrase(phrase)
.to_mixer()
.unwrap();
let mut sample_times = Vec::new();
for track in mixer.all_tracks() {
for event in &track.events {
if let AudioEvent::Sample(sample) = event {
sample_times.push(sample.start_time);
}
}
}
sample_times.sort_by(|left, right| left.partial_cmp(right).unwrap());
assert_eq!(sample_times, vec![0.25, 0.5, 1.1]);
}
#[test]
fn sample_clip_slice_uses_only_the_selected_source_range() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("slice-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 44_100], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_slice(0.25, 0.5)
.unwrap()
.with_start_at(0.125)
.unwrap();
assert!((clip.duration().unwrap() - 0.375).abs() < 0.01);
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected sliced sample event");
};
assert_eq!(sample.start_time, 0.125);
assert!((sample.sample.duration - 0.25).abs() < 0.01);
}
#[test]
fn sample_clip_fade_in_and_out_shape_exported_sample_edges() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("fade-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![1.0; 44_100], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_slice(0.0, 0.25)
.unwrap()
.with_fade_in(0.05)
.unwrap()
.with_fade_out(0.05)
.unwrap();
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected faded sample event");
};
let first = sample.sample.data[0];
let middle = sample.sample.data[sample.sample.data.len() / 2];
let last = *sample
.sample
.data
.last()
.expect("sample should not be empty");
assert!(first.abs() < 0.001);
assert!(middle > 0.9);
assert!(last.abs() < 0.01);
}
#[test]
fn sample_clip_reverse_flips_the_selected_source_range() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("reverse-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.1, 0.2, 0.8, 1.0], 4)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_slice(0.0, 0.75)
.unwrap()
.with_reverse();
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected reversed sample event");
};
assert_eq!(sample.sample.data.len(), 3);
assert!((sample.sample.data[0] - 0.8).abs() < 0.01);
assert!((sample.sample.data[1] - 0.2).abs() < 0.01);
assert!((sample.sample.data[2] - 0.1).abs() < 0.01);
}
#[test]
fn sample_clip_loop_for_expands_source_to_requested_duration() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("loop-for-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.1, 0.2], 4)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_loop_for(1.25)
.unwrap();
assert!((clip.duration().unwrap() - 1.25).abs() < 0.01);
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected looped sample event");
};
assert!((sample.sample.duration - 1.25).abs() < 0.01);
assert_eq!(sample.sample.data.len(), 5);
assert!((sample.sample.data[0] - 0.1).abs() < 0.01);
assert!((sample.sample.data[1] - 0.2).abs() < 0.01);
assert!((sample.sample.data[2] - 0.1).abs() < 0.01);
assert!((sample.sample.data[3] - 0.2).abs() < 0.01);
assert!((sample.sample.data[4] - 0.1).abs() < 0.01);
}
#[test]
fn sample_clip_loop_for_rejects_unrenderable_durations() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("huge-loop-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.1, 0.2], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_loop_for(f32::MAX)
.unwrap();
let error = PulseSong::new()
.add_sample_clip(clip)
.to_mixer()
.expect_err("unrenderable loop duration should return an error");
assert!(matches!(
error,
PulseError::InvalidSampleOption { ref option, .. } if option == "loop_for"
));
}
#[test]
fn sample_frame_count_rejects_vec_capacity_overflow() {
let sample_rate = 44_100;
let max_vec_frames = isize::MAX as usize / std::mem::size_of::<f32>();
let duration = (max_vec_frames as f32 / sample_rate as f32) * 2.0;
let error = sample_frame_count("loop_for", duration, sample_rate)
.expect_err("frame count should reject Vec capacity overflow");
assert!(matches!(
error,
PulseError::InvalidSampleOption { ref option, .. } if option == "loop_for"
));
}
#[test]
fn sample_clip_duration_accounts_for_loop_stretch_and_playback_rate() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("duration-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 44_100], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_start_at(0.25)
.unwrap()
.with_slice(0.1, 0.6)
.unwrap()
.with_loop_for(0.75)
.unwrap()
.with_time_stretch(2.0)
.unwrap()
.with_playback_rate(0.5)
.unwrap();
assert!((clip.duration().unwrap() - 3.25).abs() < 0.001);
}
#[test]
fn sample_clip_metadata_duration_matches_public_duration_for_wav_transforms() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("metadata-duration.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 22_050], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_start_at(0.125)
.unwrap()
.with_slice(0.1, 0.4)
.unwrap()
.with_time_stretch(1.5)
.unwrap()
.with_playback_rate(0.75)
.unwrap()
.with_pitch_shift(12.0)
.unwrap()
.with_reverse()
.with_normalize()
.with_fade_in(0.01)
.unwrap()
.with_fade_out(0.02)
.unwrap()
.with_gain(0.8)
.unwrap();
let metadata_duration = clip.metadata_duration().unwrap();
let public_duration = clip.duration().unwrap();
assert!((metadata_duration - public_duration).abs() < 0.01);
assert!((metadata_duration - 0.725).abs() < 0.01);
}
#[test]
fn sample_clip_duration_matches_rendered_sample_for_subtle_time_stretch() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("subtle-stretch.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 441_000], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_time_stretch(1.009)
.unwrap();
let rendered_duration = clip
.transformed_sample()
.expect("sample should transform")
.duration
/ clip.playback_rate();
let metadata_duration = clip.metadata_duration().unwrap();
assert!(
(rendered_duration - metadata_duration).abs() < 0.01,
"metadata duration {metadata_duration} must match rendered duration {rendered_duration} for subtle time_stretch"
);
}
#[test]
fn sample_clip_duration_rejects_slice_beyond_wav_duration() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("short-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.25; 4_410], 44_100)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_slice(0.0, 0.2)
.unwrap();
assert!(clip.duration().is_err());
}
#[test]
fn sample_clip_normalize_scales_source_peak_before_gain() {
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
let source = temp_dir.path().join("normalize-sample.wav");
tunes::synthesis::sample::Sample::from_mono(vec![0.1, -0.25, 0.5], 3)
.export_wav(&source)
.expect("test sample should be written");
let clip = PulseSampleClip::new(source.to_string_lossy().as_ref())
.with_normalize()
.with_gain(0.5)
.unwrap();
let mixer = PulseSong::new().add_sample_clip(clip).to_mixer().unwrap();
let tracks = mixer.all_tracks();
let AudioEvent::Sample(sample) = &tracks[0].events[0] else {
panic!("expected normalized sample event");
};
let peak = sample
.sample
.data
.iter()
.fold(0.0_f32, |current, value| current.max(value.abs()));
assert!((peak - 0.5).abs() < 0.01);
}
}