Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 161 additions & 2 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use griff_core::{
ChunkId, ChunkMeta, EnsembleGroup, EnsembleRef, PairRelation, QualityFlag,
ReviewerDecision, SourceFormat, SourceRef, StyleCohort, SwancoreTag,
},
event::{NoteMarks, NotePosition, TechniqueSource, Ticks},
gesture,
event::{NoteMarks, NotePosition, Pitch, TechniqueSource, Ticks},
generate, gesture,
import::{self, ImportError},
midi::{self, MidiError},
score::{AtomEvent, Score, Track, Voice},
Expand Down Expand Up @@ -83,6 +83,23 @@ enum Command {
path: PathBuf,
},

/// Generate a fresh riff (S6) seeded from a tab's scale, rhythm, meter, and
/// pitch range, and write it to a MIDI file.
Generate {
/// Source MIDI or Guitar Pro file whose material seeds the generator.
#[arg(value_name = "INPUT")]
input: PathBuf,
/// Output `.mid` file for the generated riff.
#[arg(value_name = "OUTPUT")]
output: PathBuf,
/// Deterministic seed — the same seed always yields the same riff.
#[arg(long, default_value_t = 0)]
seed: u64,
/// Number of bars to generate.
#[arg(long, default_value_t = 8)]
bars: usize,
},

/// Interactively curate a MIDI or Guitar Pro file into a corpus `ChunkMeta` JSON record.
Curate {
/// Path to the MIDI or Guitar Pro file to curate.
Expand Down Expand Up @@ -110,6 +127,12 @@ fn run() -> Result<(), CliError> {
Command::Classify { path } => cmd_classify(&path),
Command::Structure { path } => cmd_structure(&path),
Command::Phrases { path } => cmd_phrases(&path),
Command::Generate {
input,
output,
seed,
bars,
} => cmd_generate(&input, &output, seed, bars),
Command::Curate {
path,
output,
Expand Down Expand Up @@ -467,6 +490,134 @@ fn phrase_reasons(r: boundary::BoundaryReason) -> String {
}
}

/// Generates a fresh riff (S6) seeded from the source's musical material — its
/// pitch palette, the rhythm of its first sounding bar, and its meter, tempo,
/// and range — then writes the result to a MIDI file.
fn cmd_generate(input: &Path, output: &Path, seed: u64, bars: usize) -> Result<(), CliError> {
let data = fs::read(input)?;
let score = import::import_score_auto(&data)?;
let request = generation_request_from_score(&score, seed, bars)?;
let candidate = generate::generate(&request)?;
let out_bytes = midi::export_score(&candidate.score)?;
fs::write(output, &out_bytes)?;
println!(
"generated {bars} bars ({strategy:?}, seed {seed}) from a {tones}-tone scale \
({n} bytes) -> {out}",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
strategy = candidate.strategy,
tones = request.pitch_material.intervals.len(),
n = out_bytes.len(),
out = output.display(),
);
Ok(())
}

/// Builds a tab-seeded [`generate::RuleGenerationRequest`]: the scale is the
/// source's distinct pitch classes, the rhythm template its first sounding bar,
/// and meter / tempo / range its transport.
fn generation_request_from_score(
score: &Score,
seed: u64,
bars: usize,
) -> Result<generate::RuleGenerationRequest, CliError> {
if bars == 0 {
return Err(CliError::Generate(generate::GenerationError::BarCountZero));
}
let pitches = all_pitches(score);
let (lo, hi) = pitch_range(&pitches)?;
let first_bar = score.master_bars.first().ok_or(CliError::Generate(
generate::GenerationError::InvalidConstraints,
))?;
let constraints = generate::GenerationConstraints {
bar_count: bars,
time_signature: first_bar.time_signature,
tempo: first_bar.tempo,
ticks_per_quarter: Ticks(u32::from(score.ticks_per_quarter)),
pitch_lo: lo,
pitch_hi: hi,
};
Ok(generate::RuleGenerationRequest {
seed: generate::GenerationSeed(seed),
pitch_material: pitch_material_from(lo, &pitches),
constraints,
source_rhythms: vec![first_bar_rhythm(score)],
strategy: generate::GenerationStrategy::RhythmCopyPitchSubstitute,
})
}

/// Every note pitch across all tracks and voices, in track/voice order.
fn all_pitches(score: &Score) -> Vec<u8> {
score
.tracks
.iter()
.flat_map(|t| &t.voices)
.flat_map(|v| &v.event_groups)
.flat_map(|g| &g.atoms)
.filter_map(|a| match a {
AtomEvent::Note(n) => Some(n.pitch.0),
AtomEvent::Rest(_) => None,
})
.collect()
}

/// The lowest and highest pitch present; errors (no pitch material) when the
/// source is silent.
fn pitch_range(pitches: &[u8]) -> Result<(Pitch, Pitch), CliError> {
let lo = pitches.iter().min().copied().ok_or(CliError::Generate(
generate::GenerationError::EmptyPitchMaterial,
))?;
let hi = pitches.iter().max().copied().unwrap_or(lo);
Ok((Pitch(lo), Pitch(hi)))
}

/// A scale rooted at `lo` whose intervals are the distinct semitone classes the
/// source uses, so the generated riff stays in the tab's pitch palette.
fn pitch_material_from(lo: Pitch, pitches: &[u8]) -> generate::PitchMaterial {
let mut intervals: Vec<u8> = pitches
.iter()
.map(|&p| p.saturating_sub(lo.0).checked_rem(12).unwrap_or(0))
.collect();
intervals.sort_unstable();
intervals.dedup();
if intervals.is_empty() {
intervals.push(0);
}
generate::PitchMaterial {
root: lo,
intervals,
}
}

/// The note durations of the first *sounding* bar — the earliest master bar
/// holding any note across all tracks and voices — in onset order, as the
/// rhythm template the generator copies. Falls back to four quarter notes only
/// when the source is entirely silent.
fn first_bar_rhythm(score: &Score) -> Vec<Ticks> {
for bar in &score.master_bars {
let mut notes: Vec<(u32, Ticks)> = score
.tracks
.iter()
.flat_map(|t| &t.voices)
.flat_map(|v| &v.event_groups)
.flat_map(|g| &g.atoms)
.filter_map(|a| match a {
AtomEvent::Note(n)
if n.absolute_start.0 >= bar.tick_range.start.0
&& n.absolute_start.0 < bar.tick_range.end.0 =>
{
Some((n.absolute_start.0, n.duration))
}
_ => None,
})
.collect();
if !notes.is_empty() {
notes.sort_by_key(|&(onset, _)| onset);
return notes.into_iter().map(|(_, dur)| dur).collect();
}
}
let quarter = Ticks(u32::from(score.ticks_per_quarter));
vec![quarter; 4]
}

fn cmd_curate(path: &Path, output: Option<&Path>, ensemble: bool) -> Result<(), CliError> {
let data = fs::read(path)?;
let score = import::import_score_auto(&data)?;
Expand Down Expand Up @@ -814,6 +965,7 @@ enum CliError {
Midi(MidiError),
Json(serde_json::Error),
Ensemble(String),
Generate(generate::GenerationError),
}

impl fmt::Display for CliError {
Expand All @@ -824,6 +976,7 @@ impl fmt::Display for CliError {
Self::Midi(e) => write!(f, "MIDI error: {e}"),
Self::Json(e) => write!(f, "JSON error: {e}"),
Self::Ensemble(msg) => write!(f, "ensemble error: {msg}"),
Self::Generate(e) => write!(f, "generation error: {e:?}"),
}
}
}
Expand All @@ -846,6 +999,12 @@ impl From<ImportError> for CliError {
}
}

impl From<generate::GenerationError> for CliError {
fn from(e: generate::GenerationError) -> Self {
Self::Generate(e)
}
}

// ── tests ─────────────────────────────────────────────────────────────────────

/// Red → green for the Codex P2 finding on PR #36: ensemble part selection
Expand Down
30 changes: 26 additions & 4 deletions cli/tests/cli.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
//! S0 golden/characterization tests for the `griff` CLI.
//!
//! Every CLI subcommand (`import`, `inspect`, `export`, `classify`,
//! `structure`, `phrases`) is run against every committed fixture and its
//! stdout/stderr pinned to a golden snapshot. These tests describe what the CLI
//! *does* today; they must not be "fixed" by changing expectations without a
//! deliberate re-bless.
//! `structure`, `phrases`, `generate`) is run against every committed fixture
//! and its stdout/stderr pinned to a golden snapshot. These tests describe what
//! the CLI *does* today; they must not be "fixed" by changing expectations
//! without a deliberate re-bless.
//!
//! Regenerate fixtures: `cargo test -p griff-cli -- --ignored regenerate`
//! Re-bless snapshots: `GRIFF_BLESS=1 cargo test -p griff-cli`
Expand Down Expand Up @@ -125,6 +125,28 @@ fn export_golden() {
}
}

#[test]
fn generate_golden() {
for (name, _) in fixtures() {
let src = fixture_path(name);
let dst = env::temp_dir().join(format!("griff_s0_generate_{name}.mid"));
fs::remove_file(&dst).ok();

let out = griff(
&["generate", src.to_str().unwrap(), dst.to_str().unwrap()],
dst.to_str(),
);
let out = out.replace(src.to_str().unwrap(), "<SRC>");
assert_golden(&format!("generate__{name}"), &out);

assert!(
dst.exists(),
"generate must have written the output file for `{name}`"
);
fs::remove_file(&dst).ok();
}
}

/// A missing input file is observable CLI behavior worth pinning.
#[test]
fn missing_file_golden() {
Expand Down
5 changes: 5 additions & 0 deletions cli/tests/snapshots/generate__multi_track.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
$ griff generate <SRC> <OUT>
exit: 0
--- stdout ---
generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 6-tone scale (558 bytes) -> <OUT>
--- stderr ---
5 changes: 5 additions & 0 deletions cli/tests/snapshots/generate__seven_eight.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
$ griff generate <SRC> <OUT>
exit: 0
--- stdout ---
generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 4-tone scale (558 bytes) -> <OUT>
--- stderr ---
5 changes: 5 additions & 0 deletions cli/tests/snapshots/generate__simple_4_4.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
$ griff generate <SRC> <OUT>
exit: 0
--- stdout ---
generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 6-tone scale (342 bytes) -> <OUT>
--- stderr ---
5 changes: 5 additions & 0 deletions cli/tests/snapshots/generate__tempo_change.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
$ griff generate <SRC> <OUT>
exit: 0
--- stdout ---
generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 4-tone scale (342 bytes) -> <OUT>
--- stderr ---
5 changes: 5 additions & 0 deletions cli/tests/snapshots/generate__two_phrases.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
$ griff generate <SRC> <OUT>
exit: 0
--- stdout ---
generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 7-tone scale (342 bytes) -> <OUT>
--- stderr ---