diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..41321d2d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,15 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "griff-cli" +version = "0.1.0" + +[[package]] +name = "griff-core" +version = "0.1.0" + +[[package]] +name = "griff-plugin" +version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 53c391f1..90812195 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ rust-version = "1.74" license = "MIT" repository = "https://github.com/physshell/griff" homepage = "https://github.com/physshell/griff" +readme = "README.md" +keywords = ["guitar", "midi", "music", "composition"] +categories = ["multimedia::audio", "command-line-utilities"] [profile.release] lto = "thin" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index abe8d661..94fc388e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true [[bin]] name = "griff" diff --git a/core/Cargo.toml b/core/Cargo.toml index c3895137..e0fbaf0b 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true [lints] workspace = true diff --git a/core/src/event.rs b/core/src/event.rs index 897707b7..90f0bc83 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -1,21 +1,105 @@ //! Fundamental musical event types. +/// Maximum value accepted by MIDI 7-bit fields. +pub const MIDI_7_BIT_MAX: u8 = 127; + +/// Validation error for core musical values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationError { + /// MIDI pitch must be in the inclusive `0..=127` range. + PitchOutOfRange { value: u8 }, + /// MIDI velocity must be in the inclusive `0..=127` range. + VelocityOutOfRange { value: u8 }, + /// Tempo must be finite and greater than zero beats per minute. + InvalidTempo, + /// Time-signature numerator must be greater than zero. + InvalidTimeSignatureNumerator, + /// Time-signature denominator must be a non-zero power of two. + InvalidTimeSignatureDenominator { value: u8 }, + /// Summing event durations exceeded the `Ticks` storage capacity. + DurationOverflow, + /// Tick ranges must be ordered as `start <= end`. + InvalidTickRange, + /// Counting musical items exceeded the platform `usize` capacity. + CountOverflow, + /// Generation requires at least one pitch to repeat. + EmptyPitchSequence, + /// PPQN resolution must be greater than zero. + InvalidTicksPerQuarter, + /// Generated note step duration must be greater than zero. + InvalidStepDuration, +} + /// MIDI pitch number (0–127; 60 = middle C). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Pitch(pub u8); +impl Pitch { + /// Creates a pitch after checking the MIDI 7-bit range. + pub const fn new(value: u8) -> Result { + if value <= MIDI_7_BIT_MAX { + Ok(Self(value)) + } else { + Err(ValidationError::PitchOutOfRange { value }) + } + } +} + /// Duration in ticks (PPQN-relative; track resolution is carried externally). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Ticks(pub u32); +impl Ticks { + /// Zero ticks. + pub const ZERO: Self = Self(0); + + /// Adds two tick durations, returning an error on overflow. + pub fn checked_add(self, rhs: Self) -> Result { + self.0 + .checked_add(rhs.0) + .map(Self) + .ok_or(ValidationError::DurationOverflow) + } + + /// Subtracts two tick durations, returning an error on underflow. + pub fn checked_sub(self, rhs: Self) -> Result { + self.0 + .checked_sub(rhs.0) + .map(Self) + .ok_or(ValidationError::InvalidTickRange) + } +} + /// MIDI velocity (0–127). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Velocity(pub u8); +impl Velocity { + /// Creates a velocity after checking the MIDI 7-bit range. + pub const fn new(value: u8) -> Result { + if value <= MIDI_7_BIT_MAX { + Ok(Self(value)) + } else { + Err(ValidationError::VelocityOutOfRange { value }) + } + } +} + /// Tempo in beats per minute. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] pub struct Tempo(pub f64); +impl Tempo { + /// Creates a tempo after checking that it is finite and positive. + pub fn new(beats_per_minute: f64) -> Result { + if beats_per_minute.is_finite() && beats_per_minute > 0.0 { + Ok(Self(beats_per_minute)) + } else { + Err(ValidationError::InvalidTempo) + } + } +} + /// Time signature, e.g. 4/4 or 7/8. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct TimeSignature { @@ -25,6 +109,22 @@ pub struct TimeSignature { pub denominator: u8, } +impl TimeSignature { + /// Creates a time signature with a non-zero numerator and power-of-two denominator. + pub const fn new(numerator: u8, denominator: u8) -> Result { + if numerator == 0 { + Err(ValidationError::InvalidTimeSignatureNumerator) + } else if !denominator.is_power_of_two() { + Err(ValidationError::InvalidTimeSignatureDenominator { value: denominator }) + } else { + Ok(Self { + numerator, + denominator, + }) + } + } +} + /// Per-note guitar articulation carried as optional metadata. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Articulation { @@ -79,7 +179,7 @@ pub enum Event { impl Event { /// Duration of this event regardless of its kind. - pub fn duration(self) -> Ticks { + pub const fn duration(self) -> Ticks { match self { Self::Note(n) => n.duration, Self::Rest(r) => r.duration, @@ -98,6 +198,15 @@ pub struct Bar { pub events: Vec, } +impl Bar { + /// Total duration of all events in the bar. + pub fn duration(&self) -> Result { + self.events.iter().try_fold(Ticks::ZERO, |total, event| { + total.checked_add(event.duration()) + }) + } +} + /// An ordered sequence of bars forming a musical phrase. #[derive(Debug, Clone, PartialEq)] pub struct Phrase { @@ -105,13 +214,105 @@ pub struct Phrase { pub bars: Vec, } +impl Phrase { + /// Total duration of all bars in the phrase. + pub fn duration(&self) -> Result { + self.bars + .iter() + .try_fold(Ticks::ZERO, |total, bar| total.checked_add(bar.duration()?)) + } +} + #[cfg(test)] mod tests { use super::{ Articulation, Bar, Event, Note, Phrase, Pitch, Rest, Tempo, Ticks, TimeSignature, - Velocity, + ValidationError, Velocity, }; + #[test] + fn pitch_accepts_midi_range() { + assert_eq!(Pitch::new(0), Ok(Pitch(0))); + assert_eq!(Pitch::new(127), Ok(Pitch(127))); + } + + #[test] + fn pitch_rejects_values_outside_midi_range() { + assert_eq!( + Pitch::new(128), + Err(ValidationError::PitchOutOfRange { value: 128 }), + ); + } + + #[test] + fn velocity_accepts_midi_range() { + assert_eq!(Velocity::new(0), Ok(Velocity(0))); + assert_eq!(Velocity::new(127), Ok(Velocity(127))); + } + + #[test] + fn velocity_rejects_values_outside_midi_range() { + assert_eq!( + Velocity::new(128), + Err(ValidationError::VelocityOutOfRange { value: 128 }), + ); + } + + #[test] + fn tempo_accepts_positive_finite_values() { + assert_eq!(Tempo::new(120.0), Ok(Tempo(120.0))); + } + + #[test] + fn tempo_rejects_invalid_values() { + assert_eq!(Tempo::new(0.0), Err(ValidationError::InvalidTempo)); + assert_eq!( + Tempo::new(f64::INFINITY), + Err(ValidationError::InvalidTempo) + ); + } + + #[test] + fn time_signature_accepts_common_meter() { + assert_eq!( + TimeSignature::new(4, 4), + Ok(TimeSignature { + numerator: 4, + denominator: 4 + }), + ); + } + + #[test] + fn time_signature_rejects_invalid_components() { + assert_eq!( + TimeSignature::new(0, 4), + Err(ValidationError::InvalidTimeSignatureNumerator), + ); + assert_eq!( + TimeSignature::new(4, 3), + Err(ValidationError::InvalidTimeSignatureDenominator { value: 3 }), + ); + } + + #[test] + fn ticks_checked_add_reports_overflow() { + assert_eq!(Ticks(1).checked_add(Ticks(2)), Ok(Ticks(3))); + assert_eq!( + Ticks(u32::MAX).checked_add(Ticks(1)), + Err(ValidationError::DurationOverflow), + ); + } + + #[test] + fn ticks_checked_sub_reports_underflow() { + assert_eq!(Ticks(3).checked_sub(Ticks(1)), Ok(Ticks(2))); + assert_eq!( + Ticks(1).checked_sub(Ticks(3)), + Err(ValidationError::InvalidTickRange), + ); + } + #[test] fn note_event_duration_matches() { let note = Note { @@ -129,7 +330,9 @@ mod tests { #[test] fn rest_event_duration_matches() { - let rest = Rest { duration: Ticks(240) }; + let rest = Rest { + duration: Ticks(240), + }; assert_eq!( Event::Rest(rest).duration(), Ticks(240), @@ -146,7 +349,10 @@ mod tests { articulation: Some(Articulation::PalmMute), }; let bar = Bar { - time_signature: TimeSignature { numerator: 4, denominator: 4 }, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, tempo: Tempo(120.0), events: vec![Event::Note(note)], }; @@ -157,12 +363,55 @@ mod tests { ); } + #[test] + fn bar_duration_sums_events() { + let bar = Bar { + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + events: vec![ + Event::Rest(Rest { + duration: Ticks(120), + }), + Event::Rest(Rest { + duration: Ticks(360), + }), + ], + }; + assert_eq!(bar.duration(), Ok(Ticks(480))); + } + + #[test] + fn bar_duration_reports_overflow() { + let bar = Bar { + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + events: vec![ + Event::Rest(Rest { + duration: Ticks(u32::MAX), + }), + Event::Rest(Rest { duration: Ticks(1) }), + ], + }; + assert_eq!(bar.duration(), Err(ValidationError::DurationOverflow)); + } + #[test] fn phrase_collects_bars() { let bar = Bar { - time_signature: TimeSignature { numerator: 7, denominator: 8 }, + time_signature: TimeSignature { + numerator: 7, + denominator: 8, + }, tempo: Tempo(140.0), - events: vec![Event::Rest(Rest { duration: Ticks(1920) })], + events: vec![Event::Rest(Rest { + duration: Ticks(1920), + })], }; let phrase = Phrase { bars: vec![bar] }; assert_eq!( @@ -171,4 +420,33 @@ mod tests { "phrase must contain the one bar that was added", ); } + + #[test] + fn phrase_duration_sums_bars() { + let phrase = Phrase { + bars: vec![ + Bar { + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + events: vec![Event::Rest(Rest { + duration: Ticks(480), + })], + }, + Bar { + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + events: vec![Event::Rest(Rest { + duration: Ticks(960), + })], + }, + ], + }; + assert_eq!(phrase.duration(), Ok(Ticks(1440))); + } } diff --git a/core/src/feature.rs b/core/src/feature.rs new file mode 100644 index 00000000..f541742e --- /dev/null +++ b/core/src/feature.rs @@ -0,0 +1,246 @@ +//! Feature extraction over structured musical phrases. + +use crate::event::{Event, Phrase, Pitch, Ticks, ValidationError, Velocity}; + +/// Inclusive pitch span found in a phrase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PitchRange { + /// Lowest MIDI pitch in the phrase. + pub lowest: Pitch, + /// Highest MIDI pitch in the phrase. + pub highest: Pitch, +} + +impl PitchRange { + fn include(self, pitch: Pitch) -> Self { + Self { + lowest: self.lowest.min(pitch), + highest: self.highest.max(pitch), + } + } +} + +/// Inclusive velocity span found in a phrase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VelocityRange { + /// Lowest MIDI velocity in the phrase. + pub lowest: Velocity, + /// Highest MIDI velocity in the phrase. + pub highest: Velocity, +} + +impl VelocityRange { + fn include(self, velocity: Velocity) -> Self { + Self { + lowest: self.lowest.min(velocity), + highest: self.highest.max(velocity), + } + } +} + +/// Basic phrase-level features useful for scheduling, generation, and analysis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PhraseFeatures { + /// Number of bars in the phrase. + pub bar_count: usize, + /// Number of events across all bars. + pub event_count: usize, + /// Number of note events. + pub note_count: usize, + /// Number of rest events. + pub rest_count: usize, + /// Number of notes carrying articulation metadata. + pub articulated_note_count: usize, + /// Total phrase duration in ticks. + pub total_duration: Ticks, + /// Pitch span across notes, or `None` when the phrase has no notes. + pub pitch_range: Option, + /// Velocity span across notes, or `None` when the phrase has no notes. + pub velocity_range: Option, +} + +/// Extracts basic phrase-level counts, spans, and total duration. +pub fn phrase_features(phrase: &Phrase) -> Result { + let mut features = PhraseFeatures { + bar_count: phrase.bars.len(), + event_count: 0, + note_count: 0, + rest_count: 0, + articulated_note_count: 0, + total_duration: Ticks::ZERO, + pitch_range: None, + velocity_range: None, + }; + + for bar in &phrase.bars { + for event in &bar.events { + features.event_count = increment(features.event_count)?; + features.total_duration = features.total_duration.checked_add(event.duration())?; + + match event { + Event::Note(note) => { + features.note_count = increment(features.note_count)?; + if note.articulation.is_some() { + features.articulated_note_count = + increment(features.articulated_note_count)?; + } + features.pitch_range = Some(features.pitch_range.map_or( + PitchRange { + lowest: note.pitch, + highest: note.pitch, + }, + |range| range.include(note.pitch), + )); + features.velocity_range = Some(features.velocity_range.map_or( + VelocityRange { + lowest: note.velocity, + highest: note.velocity, + }, + |range| range.include(note.velocity), + )); + } + Event::Rest(_) => { + features.rest_count = increment(features.rest_count)?; + } + } + } + } + + Ok(features) +} + +fn increment(value: usize) -> Result { + value.checked_add(1).ok_or(ValidationError::CountOverflow) +} + +#[cfg(test)] +mod tests { + use super::{phrase_features, PhraseFeatures, PitchRange, VelocityRange}; + use crate::event::{ + Articulation, Bar, Event, Note, Phrase, Pitch, Rest, Tempo, Ticks, TimeSignature, + ValidationError, Velocity, + }; + + fn meter() -> TimeSignature { + TimeSignature { + numerator: 4, + denominator: 4, + } + } + + fn note(pitch: u8, duration: u32, velocity: u8, articulation: Option) -> Event { + Event::Note(Note { + pitch: Pitch(pitch), + duration: Ticks(duration), + velocity: Velocity(velocity), + articulation, + }) + } + + fn rest(duration: u32) -> Event { + Event::Rest(Rest { + duration: Ticks(duration), + }) + } + + #[test] + fn phrase_features_extracts_counts_spans_and_duration() { + let phrase = Phrase { + bars: vec![ + Bar { + time_signature: meter(), + tempo: Tempo(120.0), + events: vec![note(64, 120, 80, None), rest(60)], + }, + Bar { + time_signature: meter(), + tempo: Tempo(120.0), + events: vec![ + note(60, 240, 100, Some(Articulation::PalmMute)), + note(67, 120, 70, None), + ], + }, + ], + }; + + assert_eq!( + phrase_features(&phrase), + Ok(PhraseFeatures { + bar_count: 2, + event_count: 4, + note_count: 3, + rest_count: 1, + articulated_note_count: 1, + total_duration: Ticks(540), + pitch_range: Some(PitchRange { + lowest: Pitch(60), + highest: Pitch(67), + }), + velocity_range: Some(VelocityRange { + lowest: Velocity(70), + highest: Velocity(100), + }), + }), + ); + } + + #[test] + fn phrase_features_reports_empty_phrase() { + let phrase = Phrase { bars: Vec::new() }; + + assert_eq!( + phrase_features(&phrase), + Ok(PhraseFeatures { + bar_count: 0, + event_count: 0, + note_count: 0, + rest_count: 0, + articulated_note_count: 0, + total_duration: Ticks(0), + pitch_range: None, + velocity_range: None, + }), + ); + } + + #[test] + fn phrase_features_keeps_spans_empty_for_rest_only_phrase() { + let phrase = Phrase { + bars: vec![Bar { + time_signature: meter(), + tempo: Tempo(120.0), + events: vec![rest(120), rest(360)], + }], + }; + + assert_eq!( + phrase_features(&phrase), + Ok(PhraseFeatures { + bar_count: 1, + event_count: 2, + note_count: 0, + rest_count: 2, + articulated_note_count: 0, + total_duration: Ticks(480), + pitch_range: None, + velocity_range: None, + }), + ); + } + + #[test] + fn phrase_features_reports_duration_overflow() { + let phrase = Phrase { + bars: vec![Bar { + time_signature: meter(), + tempo: Tempo(120.0), + events: vec![rest(u32::MAX), rest(1)], + }], + }; + + assert_eq!( + phrase_features(&phrase), + Err(ValidationError::DurationOverflow), + ); + } +} diff --git a/core/src/generate.rs b/core/src/generate.rs new file mode 100644 index 00000000..95469668 --- /dev/null +++ b/core/src/generate.rs @@ -0,0 +1,295 @@ +//! Deterministic phrase generation primitives. + +use crate::event::{ + Articulation, Bar, Event, Note, Phrase, Pitch, Tempo, Ticks, TimeSignature, ValidationError, + Velocity, +}; + +/// Pattern repeated by the built-in phrase generator. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepeatingPattern { + /// Ordered pitches to emit as notes. + pub pitches: Vec, + /// Duration of each generated note. + pub step: Ticks, + /// Velocity used for every generated note. + pub velocity: Velocity, + /// Optional articulation copied to every generated note. + pub articulation: Option, +} + +/// Request for deterministic phrase generation. +#[derive(Debug, Clone, PartialEq)] +pub struct GeneratePhraseRequest { + /// Number of bars to generate. + pub bar_count: usize, + /// Time signature copied to each generated bar. + pub time_signature: TimeSignature, + /// Tempo copied to each generated bar. + pub tempo: Tempo, + /// PPQN resolution used to compute each bar duration. + pub ticks_per_quarter: Ticks, + /// Repeating note pattern. + pub pattern: RepeatingPattern, +} + +/// Computes the duration of one bar for a PPQN resolution and meter. +pub fn bar_duration_ticks( + time_signature: TimeSignature, + ticks_per_quarter: Ticks, +) -> Result { + TimeSignature::new(time_signature.numerator, time_signature.denominator)?; + + if ticks_per_quarter == Ticks::ZERO { + return Err(ValidationError::InvalidTicksPerQuarter); + } + + let numerator_ticks = ticks_per_quarter + .0 + .checked_mul(u32::from(time_signature.numerator)) + .ok_or(ValidationError::DurationOverflow)?; + let whole_note_scaled = numerator_ticks + .checked_mul(4) + .ok_or(ValidationError::DurationOverflow)?; + + Ok(Ticks( + whole_note_scaled + .checked_div(u32::from(time_signature.denominator)) + .ok_or(ValidationError::InvalidTimeSignatureDenominator { + value: time_signature.denominator, + })?, + )) +} + +/// Generates a phrase by repeating a pitch pattern into every requested bar. +pub fn generate_repeating_phrase( + request: &GeneratePhraseRequest, +) -> Result { + validate_request(request)?; + + let bar_duration = bar_duration_ticks(request.time_signature, request.ticks_per_quarter)?; + let mut bars = Vec::new(); + let mut pitch_index = 0_usize; + + for _ in 0..request.bar_count { + let mut events = Vec::new(); + let mut cursor = Ticks::ZERO; + + while cursor < bar_duration { + let remaining = bar_duration.checked_sub(cursor)?; + let duration = request.pattern.step.min(remaining); + let pitch = select_pitch(&request.pattern.pitches, pitch_index)?; + + events.push(Event::Note(Note { + pitch, + duration, + velocity: request.pattern.velocity, + articulation: request.pattern.articulation, + })); + cursor = cursor.checked_add(duration)?; + pitch_index = increment_wrapping(pitch_index, request.pattern.pitches.len())?; + } + + bars.push(Bar { + time_signature: request.time_signature, + tempo: request.tempo, + events, + }); + } + + Ok(Phrase { bars }) +} + +fn validate_request(request: &GeneratePhraseRequest) -> Result<(), ValidationError> { + if request.pattern.pitches.is_empty() { + return Err(ValidationError::EmptyPitchSequence); + } + + if request.pattern.step == Ticks::ZERO { + return Err(ValidationError::InvalidStepDuration); + } + + let _bar_duration = bar_duration_ticks(request.time_signature, request.ticks_per_quarter)?; + + Ok(()) +} + +fn select_pitch(pitches: &[Pitch], index: usize) -> Result { + pitches + .get(index) + .copied() + .ok_or(ValidationError::EmptyPitchSequence) +} + +fn increment_wrapping(value: usize, len: usize) -> Result { + let next = value.checked_add(1).ok_or(ValidationError::CountOverflow)?; + if next == len { + Ok(0) + } else { + Ok(next) + } +} + +#[cfg(test)] +mod tests { + use super::{ + bar_duration_ticks, generate_repeating_phrase, GeneratePhraseRequest, RepeatingPattern, + }; + use crate::event::{ + Articulation, Bar, Event, Note, Phrase, Pitch, Tempo, Ticks, TimeSignature, + ValidationError, Velocity, + }; + + fn request() -> GeneratePhraseRequest { + GeneratePhraseRequest { + bar_count: 2, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(480), + pattern: RepeatingPattern { + pitches: vec![Pitch(60), Pitch(64), Pitch(67)], + step: Ticks(480), + velocity: Velocity(96), + articulation: Some(Articulation::PalmMute), + }, + } + } + + fn meter() -> TimeSignature { + TimeSignature { + numerator: 4, + denominator: 4, + } + } + + fn note(pitch: u8, duration: u32) -> Event { + Event::Note(Note { + pitch: Pitch(pitch), + duration: Ticks(duration), + velocity: Velocity(96), + articulation: Some(Articulation::PalmMute), + }) + } + + fn generated_bar(events: Vec) -> Bar { + Bar { + time_signature: meter(), + tempo: Tempo(120.0), + events, + } + } + + #[test] + fn bar_duration_ticks_uses_meter_and_ppqn() { + assert_eq!( + bar_duration_ticks( + TimeSignature { + numerator: 4, + denominator: 4, + }, + Ticks(480), + ), + Ok(Ticks(1920)), + ); + assert_eq!( + bar_duration_ticks( + TimeSignature { + numerator: 7, + denominator: 8, + }, + Ticks(480), + ), + Ok(Ticks(1680)), + ); + } + + #[test] + fn bar_duration_ticks_rejects_invalid_inputs() { + assert_eq!( + bar_duration_ticks( + TimeSignature { + numerator: 4, + denominator: 4, + }, + Ticks(0), + ), + Err(ValidationError::InvalidTicksPerQuarter), + ); + assert_eq!( + bar_duration_ticks( + TimeSignature { + numerator: 4, + denominator: 3, + }, + Ticks(480), + ), + Err(ValidationError::InvalidTimeSignatureDenominator { value: 3 }), + ); + } + + #[test] + fn generate_repeating_phrase_fills_requested_bars() { + assert_eq!( + generate_repeating_phrase(&request()), + Ok(Phrase { + bars: vec![ + generated_bar(vec![ + note(60, 480), + note(64, 480), + note(67, 480), + note(60, 480), + ]), + generated_bar(vec![ + note(64, 480), + note(67, 480), + note(60, 480), + note(64, 480), + ]), + ], + }), + ); + } + + #[test] + fn generate_repeating_phrase_truncates_last_step_to_bar() { + let mut source = request(); + source.bar_count = 1; + source.pattern.step = Ticks(700); + + assert_eq!( + generate_repeating_phrase(&source), + Ok(Phrase { + bars: vec![generated_bar(vec![ + note(60, 700), + note(64, 700), + note(67, 520), + ])], + }), + ); + } + + #[test] + fn generate_repeating_phrase_rejects_empty_pitch_sequence() { + let mut source = request(); + source.pattern.pitches = Vec::new(); + + assert_eq!( + generate_repeating_phrase(&source), + Err(ValidationError::EmptyPitchSequence), + ); + } + + #[test] + fn generate_repeating_phrase_rejects_zero_step() { + let mut source = request(); + source.pattern.step = Ticks::ZERO; + + assert_eq!( + generate_repeating_phrase(&source), + Err(ValidationError::InvalidStepDuration), + ); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 04680590..6924744a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -5,3 +5,6 @@ //! the rest of the codebase works exclusively with these structured types. pub mod event; +pub mod feature; +pub mod generate; +pub mod slice; diff --git a/core/src/slice.rs b/core/src/slice.rs new file mode 100644 index 00000000..edd53252 --- /dev/null +++ b/core/src/slice.rs @@ -0,0 +1,308 @@ +//! Tick-range slicing helpers for phrases and bars. + +use crate::event::{Bar, Event, Phrase, Ticks, ValidationError}; + +/// Half-open tick range: `start <= tick < end`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TickRange { + /// Inclusive range start. + pub start: Ticks, + /// Exclusive range end. + pub end: Ticks, +} + +impl TickRange { + /// Creates a half-open range after checking `start <= end`. + pub const fn new(start: Ticks, end: Ticks) -> Result { + if start.0 <= end.0 { + Ok(Self { start, end }) + } else { + Err(ValidationError::InvalidTickRange) + } + } + + /// Range length in ticks. + pub fn len(self) -> Result { + self.end.checked_sub(self.start) + } + + /// Returns whether this range contains no ticks. + pub const fn is_empty(self) -> bool { + self.start.0 == self.end.0 + } + + /// Returns whether an event starting at `event_start` with `duration` intersects the range. + pub fn intersects_event( + self, + event_start: Ticks, + duration: Ticks, + ) -> Result { + let event_end = event_start.checked_add(duration)?; + Ok(event_start.0 < self.end.0 && event_end.0 > self.start.0) + } +} + +/// Event annotated with absolute phrase position and source indexes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimedEvent { + /// Zero-based bar index in the source phrase. + pub bar_index: usize, + /// Zero-based event index in the source bar. + pub event_index: usize, + /// Absolute event start in phrase ticks. + pub absolute_start: Ticks, + /// Source event payload. + pub event: Event, +} + +/// Returns all events in a bar annotated with absolute starts. +pub fn timed_bar_events( + bar: &Bar, + bar_index: usize, + absolute_bar_start: Ticks, +) -> Result, ValidationError> { + let mut cursor = absolute_bar_start; + let mut events = Vec::new(); + + for (event_index, event) in bar.events.iter().copied().enumerate() { + events.push(TimedEvent { + bar_index, + event_index, + absolute_start: cursor, + event, + }); + cursor = cursor.checked_add(event.duration())?; + } + + Ok(events) +} + +/// Returns all events in a phrase annotated with absolute starts. +pub fn timed_phrase_events(phrase: &Phrase) -> Result, ValidationError> { + let mut bar_start = Ticks::ZERO; + let mut events = Vec::new(); + + for (bar_index, bar) in phrase.bars.iter().enumerate() { + events.extend(timed_bar_events(bar, bar_index, bar_start)?); + bar_start = bar_start.checked_add(bar.duration()?)?; + } + + Ok(events) +} + +/// Returns all phrase events whose half-open spans intersect `range`. +pub fn slice_phrase_events( + phrase: &Phrase, + range: TickRange, +) -> Result, ValidationError> { + let mut events = Vec::new(); + + for timed_event in timed_phrase_events(phrase)? { + if range.intersects_event(timed_event.absolute_start, timed_event.event.duration())? { + events.push(timed_event); + } + } + + Ok(events) +} + +#[cfg(test)] +mod tests { + use super::{ + slice_phrase_events, timed_bar_events, timed_phrase_events, TickRange, TimedEvent, + }; + use crate::event::{Bar, Event, Phrase, Rest, Tempo, Ticks, TimeSignature, ValidationError}; + + fn rest(duration: u32) -> Event { + Event::Rest(Rest { + duration: Ticks(duration), + }) + } + + fn bar(durations: &[u32]) -> Bar { + Bar { + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + events: durations.iter().copied().map(rest).collect(), + } + } + + #[test] + fn tick_range_accepts_ordered_bounds() { + assert_eq!( + TickRange::new(Ticks(10), Ticks(20)), + Ok(TickRange { + start: Ticks(10), + end: Ticks(20), + }), + ); + } + + #[test] + fn tick_range_rejects_reversed_bounds() { + assert_eq!( + TickRange::new(Ticks(20), Ticks(10)), + Err(ValidationError::InvalidTickRange), + ); + } + + #[test] + fn tick_range_len_and_empty_follow_half_open_bounds() { + let range = TickRange { + start: Ticks(10), + end: Ticks(20), + }; + assert_eq!(range.len(), Ok(Ticks(10))); + assert!(!range.is_empty(), "non-empty range must report false"); + + let empty = TickRange { + start: Ticks(10), + end: Ticks(10), + }; + assert_eq!(empty.len(), Ok(Ticks(0))); + assert!(empty.is_empty(), "empty range must report true"); + } + + #[test] + fn tick_range_detects_event_intersection() { + let range = TickRange { + start: Ticks(100), + end: Ticks(200), + }; + + assert_eq!(range.intersects_event(Ticks(0), Ticks(100)), Ok(false)); + assert_eq!(range.intersects_event(Ticks(0), Ticks(101)), Ok(true)); + assert_eq!(range.intersects_event(Ticks(150), Ticks(10)), Ok(true)); + assert_eq!(range.intersects_event(Ticks(200), Ticks(10)), Ok(false)); + } + + #[test] + fn timed_bar_events_emit_absolute_starts_and_indexes() { + let source = bar(&[120, 240, 360]); + assert_eq!( + timed_bar_events(&source, 2, Ticks(480)), + Ok(vec![ + TimedEvent { + bar_index: 2, + event_index: 0, + absolute_start: Ticks(480), + event: rest(120), + }, + TimedEvent { + bar_index: 2, + event_index: 1, + absolute_start: Ticks(600), + event: rest(240), + }, + TimedEvent { + bar_index: 2, + event_index: 2, + absolute_start: Ticks(840), + event: rest(360), + }, + ]), + ); + } + + #[test] + fn timed_phrase_events_cross_bar_boundaries() { + let phrase = Phrase { + bars: vec![bar(&[120, 360]), bar(&[240])], + }; + + assert_eq!( + timed_phrase_events(&phrase), + Ok(vec![ + TimedEvent { + bar_index: 0, + event_index: 0, + absolute_start: Ticks(0), + event: rest(120), + }, + TimedEvent { + bar_index: 0, + event_index: 1, + absolute_start: Ticks(120), + event: rest(360), + }, + TimedEvent { + bar_index: 1, + event_index: 0, + absolute_start: Ticks(480), + event: rest(240), + }, + ]), + ); + } + + #[test] + fn slice_phrase_events_returns_intersecting_events() { + let phrase = Phrase { + bars: vec![bar(&[120, 360]), bar(&[240, 240])], + }; + let range = TickRange { + start: Ticks(100), + end: Ticks(500), + }; + + assert_eq!( + slice_phrase_events(&phrase, range), + Ok(vec![ + TimedEvent { + bar_index: 0, + event_index: 0, + absolute_start: Ticks(0), + event: rest(120), + }, + TimedEvent { + bar_index: 0, + event_index: 1, + absolute_start: Ticks(120), + event: rest(360), + }, + TimedEvent { + bar_index: 1, + event_index: 0, + absolute_start: Ticks(480), + event: rest(240), + }, + ]), + ); + } + + #[test] + fn slice_phrase_events_excludes_events_on_half_open_boundaries() { + let phrase = Phrase { + bars: vec![bar(&[100, 100, 100])], + }; + let range = TickRange { + start: Ticks(100), + end: Ticks(200), + }; + + assert_eq!( + slice_phrase_events(&phrase, range), + Ok(vec![TimedEvent { + bar_index: 0, + event_index: 1, + absolute_start: Ticks(100), + event: rest(100), + }]), + ); + } + + #[test] + fn timed_phrase_events_reports_duration_overflow() { + let phrase = Phrase { + bars: vec![bar(&[u32::MAX]), bar(&[1])], + }; + + assert_eq!( + timed_phrase_events(&phrase), + Err(ValidationError::DurationOverflow), + ); + } +} diff --git a/plugin/Cargo.toml b/plugin/Cargo.toml index e4ef8e0a..1e0b616e 100644 --- a/plugin/Cargo.toml +++ b/plugin/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true [lints] workspace = true