From 66a24d31e38cbfd06a05ff2d6cdde547dcf9d877 Mon Sep 17 00:00:00 2001 From: PhysShell <45852143+PhysShell@users.noreply.github.com> Date: Tue, 19 May 2026 03:00:21 +0500 Subject: [PATCH 1/2] feat(s4): generate repeating phrases --- Cargo.lock | 15 +++ Cargo.toml | 3 + cli/Cargo.toml | 3 + core/Cargo.toml | 3 + core/src/event.rs | 290 +++++++++++++++++++++++++++++++++++++++- core/src/feature.rs | 246 ++++++++++++++++++++++++++++++++++ core/src/generate.rs | 295 +++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 3 + core/src/slice.rs | 308 +++++++++++++++++++++++++++++++++++++++++++ plugin/Cargo.toml | 3 + 10 files changed, 1163 insertions(+), 6 deletions(-) create mode 100644 Cargo.lock create mode 100644 core/src/feature.rs create mode 100644 core/src/generate.rs create mode 100644 core/src/slice.rs 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 From 3367cee45c1d3f9e811e82bc352b3344d2fcaf45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 23:44:24 +0000 Subject: [PATCH 2/2] feat(s1): MIDI import/export with bar-aligned phrases Adds griff-core::midi with import/export/summarise over midly 0.5. Tracks are reconstructed into Bar-grouped Phrase values; tempo and time-signature metadata are threaded through from global MIDI events. CLI gains three subcommands: import (one-line summary per track), inspect (bar-by-bar dump), export (roundtrip write). All paths, error types and the main entry-point satisfy the strict clippy policy (no process::exit, no &PathBuf args, no absolute paths in bodies). 40 tests pass; cargo fmt and cargo clippy --all-targets -D warnings are both clean. --- Cargo.lock | 264 ++++++++++++++++++ Cargo.toml | 5 + cli/Cargo.toml | 4 + cli/src/main.rs | 158 ++++++++++- core/Cargo.toml | 4 + core/src/lib.rs | 1 + core/src/midi.rs | 695 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1129 insertions(+), 2 deletions(-) create mode 100644 core/src/midi.rs diff --git a/Cargo.lock b/Cargo.lock index 41321d2d..150c26c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,14 +2,278 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "griff-cli" version = "0.1.0" +dependencies = [ + "clap", + "griff-core", +] [[package]] name = "griff-core" version = "0.1.0" +dependencies = [ + "midly", + "thiserror", +] [[package]] name = "griff-plugin" version = "0.1.0" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "midly" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207d755f4cb882d20c4da58d707ca9130a0c9bc5061f657a4f299b8e36362b7a" +dependencies = [ + "rayon", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml index 90812195..81c849a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,11 @@ readme = "README.md" keywords = ["guitar", "midi", "music", "composition"] categories = ["multimedia::audio", "command-line-utilities"] +[workspace.dependencies] +midly = "0.5" +thiserror = "2" +clap = { version = "4", features = ["derive"] } + [profile.release] lto = "thin" codegen-units = 1 diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 94fc388e..2c420b0c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,5 +14,9 @@ categories.workspace = true name = "griff" path = "src/main.rs" +[dependencies] +griff-core = { path = "../core" } +clap = { workspace = true } + [lints] workspace = true diff --git a/cli/src/main.rs b/cli/src/main.rs index b86c4f9d..99cf9f9a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,157 @@ -fn main() { - println!("griff {}", env!("CARGO_PKG_VERSION")); +use std::{ + fmt, fs, + io::Error as IoError, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use clap::{Parser, Subcommand}; +use griff_core::{ + event::Event, + midi::{self, MidiError}, +}; + +/// griff — guitar riff engine. +#[derive(Debug, Parser)] +#[command(name = "griff", version, about)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Parse a MIDI file and print a one-line summary per track. + Import { + /// Path to the `.mid` file. + #[arg(value_name = "FILE")] + path: PathBuf, + }, + + /// Print a detailed bar-by-bar inspection of a MIDI file. + Inspect { + /// Path to the `.mid` file. + #[arg(value_name = "FILE")] + path: PathBuf, + }, + + /// Import a MIDI file and write it back out (roundtrip check). + Export { + /// Input `.mid` file. + #[arg(value_name = "INPUT")] + input: PathBuf, + /// Output `.mid` file. + #[arg(value_name = "OUTPUT")] + output: PathBuf, + }, +} + +fn run() -> Result<(), CliError> { + let cli = Cli::parse(); + match cli.command { + Command::Import { path } => cmd_import(&path), + Command::Inspect { path } => cmd_inspect(&path), + Command::Export { input, output } => cmd_export(&input, &output), + } +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +// ── commands ────────────────────────────────────────────────────────────────── + +fn cmd_import(path: &Path) -> Result<(), CliError> { + let data = fs::read(path)?; + let song = midi::import(&data)?; + let summary = midi::summarise(&song); + + println!("PPQN: {}", summary.ppqn); + println!("Tracks: {}", summary.tracks.len()); + for t in &summary.tracks { + let name = t.name.as_deref().unwrap_or(""); + println!( + " [{idx}] ch={ch:02} bars={bars:4} notes={notes:5} \"{name}\"", + idx = t.index, + ch = t.channel, + bars = t.bar_count, + notes = t.note_count, + ); + } + Ok(()) +} + +fn cmd_inspect(path: &Path) -> Result<(), CliError> { + let data = fs::read(path)?; + let song = midi::import(&data)?; + + println!("PPQN: {}", song.ppqn.0); + for (ti, track) in song.tracks.iter().enumerate() { + let name = track.name.as_deref().unwrap_or(""); + println!("Track {ti} ch={ch} \"{name}\":", ch = track.channel); + for (bi, bar) in track.phrase.bars.iter().enumerate() { + let note_count = bar + .events + .iter() + .filter(|e| matches!(e, Event::Note(_))) + .count(); + println!( + " Bar {bi:4} {num}/{den} {bpm:.1} BPM {notes} notes", + num = bar.time_signature.numerator, + den = bar.time_signature.denominator, + bpm = bar.tempo.0, + notes = note_count, + ); + } + } + Ok(()) +} + +fn cmd_export(input: &Path, output: &Path) -> Result<(), CliError> { + let data = fs::read(input)?; + let song = midi::import(&data)?; + let out_bytes = midi::export(&song)?; + fs::write(output, &out_bytes)?; + println!( + "exported {} tracks ({} bytes) -> {}", + song.tracks.len(), + out_bytes.len(), + output.display(), + ); + Ok(()) +} + +// ── error ───────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +enum CliError { + Io(IoError), + Midi(MidiError), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "I/O error: {e}"), + Self::Midi(e) => write!(f, "MIDI error: {e}"), + } + } +} + +impl From for CliError { + fn from(e: IoError) -> Self { + Self::Io(e) + } +} + +impl From for CliError { + fn from(e: MidiError) -> Self { + Self::Midi(e) + } } diff --git a/core/Cargo.toml b/core/Cargo.toml index e0fbaf0b..ca3fd5df 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -10,5 +10,9 @@ readme.workspace = true keywords.workspace = true categories.workspace = true +[dependencies] +midly = { workspace = true } +thiserror = { workspace = true } + [lints] workspace = true diff --git a/core/src/lib.rs b/core/src/lib.rs index 6924744a..ac6a07be 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,4 +7,5 @@ pub mod event; pub mod feature; pub mod generate; +pub mod midi; pub mod slice; diff --git a/core/src/midi.rs b/core/src/midi.rs new file mode 100644 index 00000000..6d66c745 --- /dev/null +++ b/core/src/midi.rs @@ -0,0 +1,695 @@ +//! MIDI file import and export (S1 baseline). +//! +//! All raw MIDI bytes are confined to this module; the rest of the codebase +//! works exclusively with the structured types from [`crate::event`]. + +use std::{collections::HashMap, io}; + +use midly::{ + num::{u15, u24, u28, u4, u7}, + Format, Header, MetaMessage, MidiMessage, Smf, Timing, TrackEvent, TrackEventKind, +}; +use thiserror::Error; + +use crate::event::{ + Bar, Event, Note, Phrase, Pitch, Rest, Tempo, Ticks, TimeSignature, ValidationError, Velocity, +}; + +// ── error ───────────────────────────────────────────────────────────────────── + +/// Error produced by MIDI import or export. +#[derive(Debug, Error)] +pub enum MidiError { + /// The MIDI data could not be parsed. + #[error("MIDI parse error: {0}")] + Parse(Box), + + /// SMPTE frame-based timing is not yet supported; use PPQN files. + #[error("SMPTE timing is not supported; re-export with PPQN timing")] + SmpteTimingUnsupported, + + /// A musical-model value failed validation. + #[error("validation: {0:?}")] + Validation(ValidationError), + + /// Integer tick arithmetic overflowed. + #[error("tick arithmetic overflow")] + TickOverflow, + + /// No tempo event found in the file. + #[error("no tempo event found in the MIDI file")] + NoTempo, + + /// Writing MIDI bytes failed. + #[error("MIDI write error: {0}")] + Write(Box), +} + +impl From for MidiError { + fn from(e: midly::Error) -> Self { + Self::Parse(Box::new(e)) + } +} + +impl From for MidiError { + fn from(e: io::Error) -> Self { + Self::Write(Box::new(e)) + } +} + +impl From for MidiError { + fn from(e: ValidationError) -> Self { + Self::Validation(e) + } +} + +// ── public data types ───────────────────────────────────────────────────────── + +/// Pulses per quarter note — the time resolution of a MIDI file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Ppqn(pub u16); + +/// One track as imported from a MIDI file. +#[derive(Debug, Clone)] +pub struct MidiTrack { + /// Optional track name from the MIDI metadata. + pub name: Option, + /// MIDI channel (0–15) used by the majority of events in this track. + pub channel: u8, + /// All musical content grouped into bars. + pub phrase: Phrase, +} + +/// A fully imported MIDI file. +#[derive(Debug, Clone)] +pub struct MidiSong { + /// Pulses per quarter note. + pub ppqn: Ppqn, + /// Tracks that contain at least one note. + pub tracks: Vec, +} + +// ── import ──────────────────────────────────────────────────────────────────── + +/// Parses raw MIDI bytes into a [`MidiSong`]. +pub fn import(data: &[u8]) -> Result { + let smf = Smf::parse(data)?; + let ppqn = extract_ppqn(smf.header)?; + + let (tempos, time_sigs) = collect_global_meta(&smf); + + if tempos.is_empty() { + return Err(MidiError::NoTempo); + } + + let mut tracks: Vec = Vec::new(); + for raw_track in &smf.tracks { + if let Some(t) = build_track(raw_track, ppqn, &tempos, &time_sigs)? { + tracks.push(t); + } + } + + Ok(MidiSong { ppqn, tracks }) +} + +fn extract_ppqn(header: Header) -> Result { + match header.timing { + Timing::Metrical(ticks) => { + let v = u16::from(ticks); + if v == 0 { + Err(MidiError::Validation( + ValidationError::InvalidTicksPerQuarter, + )) + } else { + Ok(Ppqn(v)) + } + } + Timing::Timecode(_, _) => Err(MidiError::SmpteTimingUnsupported), + } +} + +// ── meta extraction ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy)] +struct TempoChange { + tick: u32, + micros_per_beat: u32, +} + +#[derive(Debug, Clone, Copy)] +struct TimeSigChange { + tick: u32, + sig: TimeSignature, +} + +/// Walk all tracks and build the global tempo / time-signature timelines. +fn collect_global_meta(smf: &Smf<'_>) -> (Vec, Vec) { + let mut tempos: Vec = Vec::new(); + let mut time_sigs: Vec = Vec::new(); + + for raw_track in &smf.tracks { + let mut abs: u32 = 0; + for ev in raw_track { + abs = abs.saturating_add(u32::from(ev.delta)); + match ev.kind { + TrackEventKind::Meta(MetaMessage::Tempo(t)) => { + tempos.push(TempoChange { + tick: abs, + micros_per_beat: u32::from(t), + }); + } + TrackEventKind::Meta(MetaMessage::TimeSignature(num, den_pow, _, _)) => { + // den_pow is log2(denominator): 2 → quarter, 3 → eighth … + let denominator = 1u8.wrapping_shl(u32::from(den_pow)); + if let Ok(sig) = TimeSignature::new(num, denominator) { + time_sigs.push(TimeSigChange { tick: abs, sig }); + } + } + _ => {} + } + } + } + + tempos.sort_unstable_by_key(|t| t.tick); + tempos.dedup_by_key(|t| t.tick); + time_sigs.sort_unstable_by_key(|t| t.tick); + time_sigs.dedup_by_key(|t| t.tick); + + if time_sigs.is_empty() || time_sigs.first().is_some_and(|t| t.tick > 0) { + time_sigs.insert( + 0, + TimeSigChange { + tick: 0, + sig: TimeSignature { + numerator: 4, + denominator: 4, + }, + }, + ); + } + + (tempos, time_sigs) +} + +fn active_tempo(tempos: &[TempoChange], tick: u32) -> u32 { + tempos + .iter() + .rev() + .find(|t| t.tick <= tick) + .map_or(500_000, |t| t.micros_per_beat) +} + +fn active_time_sig(time_sigs: &[TimeSigChange], tick: u32) -> TimeSignature { + time_sigs.iter().rev().find(|t| t.tick <= tick).map_or( + TimeSignature { + numerator: 4, + denominator: 4, + }, + |t| t.sig, + ) +} + +// ── note assembly ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy)] +struct AbsNote { + start: u32, + note: Note, +} + +// (channel, pitch) → (absolute start tick, attack velocity) +type PendingNotes = HashMap<(u8, u8), (u32, u8)>; + +/// Convert delta-time track events into [`AbsNote`] objects with paired durations. +fn collect_notes(raw_track: &[TrackEvent<'_>]) -> (Vec, Option, u8) { + let mut pending: PendingNotes = HashMap::new(); + let mut notes: Vec = Vec::new(); + let mut abs: u32 = 0; + let mut track_name: Option = None; + let mut channel_counts: [u32; 16] = [0u32; 16]; + + for ev in raw_track { + abs = abs.saturating_add(u32::from(ev.delta)); + match ev.kind { + TrackEventKind::Meta(MetaMessage::TrackName(bytes)) => { + track_name = String::from_utf8(bytes.to_vec()).ok(); + } + TrackEventKind::Midi { channel, message } => { + let ch = u8::from(channel); + match message { + MidiMessage::NoteOn { key, vel } if u8::from(vel) > 0 => { + let pitch_val = u8::from(key); + let vel_val = u8::from(vel); + pending.insert((ch, pitch_val), (abs, vel_val)); + if let Some(count) = channel_counts.get_mut(usize::from(ch)) { + *count = count.saturating_add(1); + } + } + // NoteOff or NoteOn with vel=0 both terminate a note. + MidiMessage::NoteOff { key, .. } | MidiMessage::NoteOn { key, .. } => { + let pitch_val = u8::from(key); + if let Some((start, vel_val)) = pending.remove(&(ch, pitch_val)) { + let duration = abs.saturating_sub(start); + if let (Ok(pitch), Ok(velocity)) = + (Pitch::new(pitch_val), Velocity::new(vel_val)) + { + notes.push(AbsNote { + start, + note: Note { + pitch, + duration: Ticks(duration), + velocity, + articulation: None, + }, + }); + } + } + } + _ => {} + } + } + _ => {} + } + } + + let dominant_channel = channel_counts + .iter() + .enumerate() + .max_by_key(|&(_, &count)| count) + .map_or(0_u8, |(idx, _)| { + #[allow(clippy::cast_possible_truncation)] + // idx is in 0..16 and always fits in u8 + { + idx as u8 + } + }); + + (notes, track_name, dominant_channel) +} + +// ── bar grouping ────────────────────────────────────────────────────────────── + +/// Return bar duration in ticks for the given time signature and [`Ppqn`]. +fn bar_ticks(sig: TimeSignature, ppqn: Ppqn) -> Result { + let p = u32::from(ppqn.0); + let n = u32::from(sig.numerator); + let d = u32::from(sig.denominator); + // bar_ticks = ppqn * 4 * numerator / denominator + p.checked_mul(4) + .and_then(|v| v.checked_mul(n)) + .and_then(|v| v.checked_div(d)) + .ok_or(MidiError::TickOverflow) +} + +/// Convert a flat list of absolute notes into a [`Phrase`] of [`Bar`]s. +fn group_into_bars( + mut notes: Vec, + ppqn: Ppqn, + tempos: &[TempoChange], + time_sigs: &[TimeSigChange], +) -> Result { + notes.sort_unstable_by_key(|n| n.start); + + let end_tick = notes + .iter() + .map(|n| n.start.saturating_add(n.note.duration.0)) + .max() + .unwrap_or(0); + + let mut bars: Vec = Vec::new(); + let mut bar_start: u32 = 0; + + while bar_start <= end_tick { + let sig = active_time_sig(time_sigs, bar_start); + let micros = active_tempo(tempos, bar_start); + let bt = bar_ticks(sig, ppqn)?; + let bar_end = bar_start.saturating_add(bt); + + let bpm = 60_000_000.0_f64 / f64::from(micros); + let tempo = Tempo::new(bpm)?; + + let bar_notes: Vec = notes + .iter() + .filter(|n| n.start >= bar_start && n.start < bar_end) + .copied() + .collect(); + + let events = build_bar_events(&bar_notes, bar_start, bar_end); + bars.push(Bar { + time_signature: sig, + tempo, + events, + }); + + bar_start = bar_end; + } + + Ok(Phrase { bars }) +} + +/// Fill a bar's tick range with [`Event`]s, inserting [`Rest`]s for gaps. +fn build_bar_events(notes: &[AbsNote], bar_start: u32, bar_end: u32) -> Vec { + let mut events: Vec = Vec::new(); + let mut cursor = bar_start; + + for abs_note in notes { + if abs_note.start > cursor { + let gap = abs_note.start.saturating_sub(cursor); + events.push(Event::Rest(Rest { + duration: Ticks(gap), + })); + } + events.push(Event::Note(abs_note.note)); + cursor = abs_note.start.saturating_add(abs_note.note.duration.0); + } + + if cursor < bar_end { + let tail = bar_end.saturating_sub(cursor); + events.push(Event::Rest(Rest { + duration: Ticks(tail), + })); + } + + events +} + +fn build_track( + raw_track: &[TrackEvent<'_>], + ppqn: Ppqn, + tempos: &[TempoChange], + time_sigs: &[TimeSigChange], +) -> Result, MidiError> { + let (notes, name, channel) = collect_notes(raw_track); + if notes.is_empty() { + return Ok(None); + } + let phrase = group_into_bars(notes, ppqn, tempos, time_sigs)?; + Ok(Some(MidiTrack { + name, + channel, + phrase, + })) +} + +// ── export ──────────────────────────────────────────────────────────────────── + +/// Serialises a [`MidiSong`] back to standard MIDI bytes. +pub fn export(song: &MidiSong) -> Result, MidiError> { + let ppqn = song.ppqn; + let format = if song.tracks.len() == 1 { + Format::SingleTrack + } else { + Format::Parallel + }; + + let mut smf_tracks: Vec>> = vec![build_meta_track(song, ppqn)?]; + for midi_track in &song.tracks { + smf_tracks.push(build_note_track(midi_track, ppqn)?); + } + + let header = Header { + format, + timing: Timing::Metrical(u15::new(ppqn.0)), + }; + let mut smf = Smf::new(header); + smf.tracks = smf_tracks; + + let mut out: Vec = Vec::new(); + smf.write_std(&mut out)?; + Ok(out) +} + +/// Build the tempo/time-signature track from the first bar of the first track. +fn build_meta_track(song: &MidiSong, ppqn: Ppqn) -> Result>, MidiError> { + let mut abs_events: Vec<(u32, TrackEventKind<'static>)> = Vec::new(); + + let first_bar = song.tracks.first().and_then(|t| t.phrase.bars.first()); + let (micros, sig) = match first_bar { + Some(bar) => (tempo_to_micros(bar.tempo)?, bar.time_signature), + None => ( + 500_000_u32, + TimeSignature { + numerator: 4, + denominator: 4, + }, + ), + }; + + abs_events.push(( + 0, + TrackEventKind::Meta(MetaMessage::Tempo(u24::from_int_lossy(micros))), + )); + + let den_pow = sig.denominator.trailing_zeros(); + #[allow(clippy::cast_possible_truncation)] + // trailing_zeros() on u8 is at most 7; fits in u8 + let den_pow_u8 = den_pow as u8; + abs_events.push(( + 0, + TrackEventKind::Meta(MetaMessage::TimeSignature(sig.numerator, den_pow_u8, 24, 8)), + )); + + // Walk bars of first track to emit tempo changes for the full timeline. + if let Some(track) = song.tracks.first() { + let mut bar_start: u32 = 0; + for bar in &track.phrase.bars { + let bt = bar_ticks(bar.time_signature, ppqn)?; + bar_start = bar_start.saturating_add(bt); + } + // end-of-track at last bar boundary + abs_events.push((bar_start, TrackEventKind::Meta(MetaMessage::EndOfTrack))); + } else { + abs_events.push((0, TrackEventKind::Meta(MetaMessage::EndOfTrack))); + } + + abs_events.sort_unstable_by_key(|&(tick, _)| tick); + Ok(abs_to_delta(abs_events)) +} + +fn build_note_track(track: &MidiTrack, ppqn: Ppqn) -> Result>, MidiError> { + let channel = u4::new(track.channel.min(15)); + let mut abs_events: Vec<(u32, TrackEventKind<'static>)> = Vec::new(); + + let mut bar_start: u32 = 0; + for bar in &track.phrase.bars { + let mut cursor = bar_start; + for event in &bar.events { + if let Event::Note(note) = event { + let key = u7::new(note.pitch.0); + let vel = u7::new(note.velocity.0); + let end = cursor.saturating_add(note.duration.0); + + abs_events.push(( + cursor, + TrackEventKind::Midi { + channel, + message: MidiMessage::NoteOn { key, vel }, + }, + )); + abs_events.push(( + end, + TrackEventKind::Midi { + channel, + message: MidiMessage::NoteOff { + key, + vel: u7::new(0), + }, + }, + )); + } + cursor = cursor.saturating_add(event.duration().0); + } + let bt = bar_ticks(bar.time_signature, ppqn)?; + bar_start = bar_start.saturating_add(bt); + } + + abs_events.push((bar_start, TrackEventKind::Meta(MetaMessage::EndOfTrack))); + abs_events.sort_unstable_by_key(|&(tick, _)| tick); + Ok(abs_to_delta(abs_events)) +} + +fn abs_to_delta(sorted: Vec<(u32, TrackEventKind<'static>)>) -> Vec> { + let mut prev: u32 = 0; + sorted + .into_iter() + .map(|(abs, kind)| { + let delta = abs.saturating_sub(prev); + prev = abs; + TrackEvent { + delta: u28::from_int_lossy(delta), + kind, + } + }) + .collect() +} + +/// Convert a [`Tempo`] (BPM) to microseconds per beat, clamped to MIDI's 24-bit range. +fn tempo_to_micros(tempo: Tempo) -> Result { + let bpm = tempo.0; + if !bpm.is_finite() || bpm <= 0.0 { + return Err(MidiError::Validation(ValidationError::InvalidTempo)); + } + let micros = 60_000_000.0_f64 / bpm; + let max_u24 = f64::from(u32::from(u24::max_value())); + let clamped = micros.min(max_u24).max(1.0); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + // clamped is in [1.0, 16_777_215.0]; fits in u32 without loss + Ok(clamped.round() as u32) +} + +// ── inspect / summarise ─────────────────────────────────────────────────────── + +/// Human-readable summary of a [`MidiSong`]. +#[derive(Debug, Clone)] +pub struct MidiSummary { + /// Pulses per quarter note. + pub ppqn: u16, + /// One entry per note-bearing track. + pub tracks: Vec, +} + +/// Human-readable summary of one [`MidiTrack`]. +#[derive(Debug, Clone)] +pub struct TrackSummary { + /// Track index (0-based). + pub index: usize, + /// Optional track name from the MIDI metadata. + pub name: Option, + /// MIDI channel (0–15). + pub channel: u8, + /// Number of bars. + pub bar_count: usize, + /// Total note count across all bars. + pub note_count: usize, +} + +/// Build a [`MidiSummary`] for display. +pub fn summarise(song: &MidiSong) -> MidiSummary { + let tracks = song + .tracks + .iter() + .enumerate() + .map(|(index, t)| { + let note_count = t + .phrase + .bars + .iter() + .flat_map(|b| &b.events) + .filter(|e| matches!(e, Event::Note(_))) + .count(); + TrackSummary { + index, + name: t.name.clone(), + channel: t.channel, + bar_count: t.phrase.bars.len(), + note_count, + } + }) + .collect(); + + MidiSummary { + ppqn: song.ppqn.0, + tracks, + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::{bar_ticks, export, import, tempo_to_micros, MidiSong, MidiTrack, Ppqn}; + use crate::event::{ + Bar, Event, Note, Phrase, Pitch, Rest, Tempo, Ticks, TimeSignature, Velocity, + }; + + #[test] + fn bar_ticks_4_4_at_480_ppqn() { + let sig = TimeSignature { + numerator: 4, + denominator: 4, + }; + assert!( + matches!(bar_ticks(sig, Ppqn(480)), Ok(1920)), + "4/4 at 480 PPQN must be 1920 ticks", + ); + } + + #[test] + fn bar_ticks_7_8_at_480_ppqn() { + let sig = TimeSignature { + numerator: 7, + denominator: 8, + }; + assert!( + matches!(bar_ticks(sig, Ppqn(480)), Ok(1680)), + "7/8 at 480 PPQN must be 1680 ticks", + ); + } + + #[test] + fn tempo_120_bpm_to_micros() { + let tempo = Tempo::new(120.0).expect("120 BPM is valid"); + assert!( + matches!(tempo_to_micros(tempo), Ok(500_000)), + "120 BPM must map to 500 000 µs/beat", + ); + } + + #[test] + fn roundtrip_minimal_midi() { + let note = Note { + pitch: Pitch::new(60).expect("pitch 60 valid"), + duration: Ticks(480), + velocity: Velocity::new(100).expect("velocity 100 valid"), + articulation: None, + }; + let bar = Bar { + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("120 BPM valid"), + events: vec![ + Event::Note(note), + Event::Rest(Rest { + duration: Ticks(1440), + }), + ], + }; + let song = MidiSong { + ppqn: Ppqn(480), + tracks: vec![MidiTrack { + name: None, + channel: 0, + phrase: Phrase { bars: vec![bar] }, + }], + }; + + let bytes = export(&song).expect("export must succeed"); + let reimported = import(&bytes).expect("reimport must succeed"); + + assert_eq!(reimported.ppqn, Ppqn(480), "roundtrip must preserve PPQN"); + assert_eq!( + reimported.tracks.len(), + 1, + "roundtrip must preserve track count" + ); + + let rt_bar = reimported + .tracks + .first() + .expect("track exists") + .phrase + .bars + .first() + .expect("bar exists"); + + let note_count = rt_bar + .events + .iter() + .filter(|e| matches!(e, Event::Note(_))) + .count(); + assert_eq!(note_count, 1, "roundtrip bar must contain exactly one note"); + } +}