Skip to content
Merged
14 changes: 10 additions & 4 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use griff_core::{
midi::{self, MidiError},
score::{AtomEvent, Score, Track, Voice},
slice::{self, TickRange},
split, structure, technique, unfold,
split, structure, syncopation, technique, unfold,
};

/// griff — guitar riff engine.
Expand Down Expand Up @@ -1058,6 +1058,11 @@ fn build_chunk_meta(
let derived_harmony = track_index
.map(|idx| harmony::derive_harmony(score, idx))
.unwrap_or_default();
// Auto-derive the syncopated rhythm tag from onset placement (#75); merged
// additively as well.
let derived_syncopated = track_index
.map(|idx| syncopation::derive_syncopated(score, idx))
.unwrap_or_default();

let now = "2026-05-20T00:00:00Z".to_owned();
ChunkMeta {
Expand All @@ -1073,10 +1078,11 @@ fn build_chunk_meta(
time_signature,
tuning: inputs.tuning.clone(),
tags: {
// Additive: curator choices first, then derived technique tags, then
// derived chord-quality tags (#75) — none overrides the others.
// Additive: curator choices first, then derived technique, chord-
// quality (#75), and syncopation (#75) tags — none overrides another.
let with_techniques = technique::merge_tags(&inputs.tags, &derived.tags);
technique::merge_tags(&with_techniques, &derived_harmony)
let with_harmony = technique::merge_tags(&with_techniques, &derived_harmony);
technique::merge_tags(&with_harmony, &derived_syncopated)
},
boundaries,
techniques: derived.names,
Expand Down
6 changes: 3 additions & 3 deletions core/src/complement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,9 @@ fn rotated_correlation(histogram: &[f64; 12], profile: &[f64; 12], tonic: u8) ->
for (x, y) in histogram.iter().zip(rotated.iter()) {
let dx = x - mean_x;
let dy = y - mean_y;
numerator += dx * dy;
var_x += dx * dx;
var_y += dy * dy;
numerator = dx.mul_add(dy, numerator);
var_x = dx.mul_add(dx, var_x);
var_y = dy.mul_add(dy, var_y);
}
let denominator = (var_x * var_y).sqrt();
if denominator > 0.0 {
Expand Down
26 changes: 26 additions & 0 deletions core/src/gp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ fn detect_gp_version(data: &[u8]) -> Option<u8> {

// ── Song → Score conversion ───────────────────────────────────────────────────

/// Converts a parsed Guitar Pro song into griff's canonical [`Score`] model.
///
/// The conversion builds the master timeline from measure headers, carries
/// tempos across bars that omit them, folds repeat markers into master bars, and
/// delegates per-track event extraction while collecting import losses.
fn gp_song_to_score(song: &guitarpro::Song) -> Score {
let mut loss = LossReport::new();

Expand Down Expand Up @@ -228,6 +233,11 @@ fn gp_song_to_score(song: &guitarpro::Song) -> Score {

// ── master bar construction ───────────────────────────────────────────────────

/// Builds canonical master bars from pre-normalised meter, start, tempo, and
/// repeat arrays.
///
/// Missing entries fall back to safe defaults so malformed or partially parsed
/// GP files still produce a structurally valid score where possible.
fn build_gp_master_bars(
meters: &[(u8, u8)],
starts: &[u32],
Expand Down Expand Up @@ -264,6 +274,11 @@ fn build_gp_master_bars(

// ── track construction ────────────────────────────────────────────────────────

/// Converts one Guitar Pro track into a canonical [`Track`].
///
/// Track-level metadata such as the MIDI channel and tuning is preserved, while
/// each populated GP voice is converted independently against the shared master
/// timeline starts.
fn build_gp_track(
gp_track: &guitarpro::Track,
song: &guitarpro::Song,
Expand Down Expand Up @@ -312,6 +327,11 @@ fn gp_tuning(strings: &[(i8, i8)]) -> Tuning {

// ── voice construction ────────────────────────────────────────────────────────

/// Converts a single Guitar Pro voice into a canonical [`Voice`].
///
/// Beats are positioned by accumulating their durations from each measure's
/// master-timeline start, and tied notes are resolved against per-string held
/// note state.
fn build_gp_voice(
gp_track: &guitarpro::Track,
voice_idx: usize,
Expand Down Expand Up @@ -379,6 +399,11 @@ struct StringCtx<'a> {
zero_indexed: bool,
}

/// Appends the canonical event representation for one Guitar Pro beat.
///
/// Normal and dead notes become note atoms, tied notes extend previously held
/// notes, unsupported note kinds are recorded as losses, and empty/rest beats
/// become rest groups.
fn append_beat(
beat: &guitarpro::Beat,
beat_start: u32,
Expand Down Expand Up @@ -507,6 +532,7 @@ fn extend_tie(
false
}

/// Creates a single-atom rest group spanning `duration` ticks from `start`.
fn rest_group(start: Ticks, duration: Ticks) -> EventGroup {
EventGroup {
kind: EventGroupKind::Single,
Expand Down
1 change: 1 addition & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ pub mod similarity;
pub mod slice;
pub mod split;
pub mod structure;
pub mod syncopation;
pub mod technique;
pub mod unfold;
101 changes: 101 additions & 0 deletions core/src/syncopation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! Deriving the `syncopated` rhythm tag from note-onset placement (#75).
//!
//! Tags [`SwancoreTag::Syncopated`] when a track's phrasing is *displaced*: the
//! off-beat eighth-note "and" just before a beat is struck while the beat tick
//! itself carries no onset — the classic anticipation / sustain-over feel. Steady
//! eighth- or sixteenth-note runs score zero (every beat is still struck), so the
//! metric does not over-tag busy-but-regular riffs.
//!
//! Unlike the threshold-free technique/harmony derivers, syncopation is a matter
//! of degree, so it leans on one documented constant
//! ([`SYNCOPATION_THRESHOLD`]) and otherwise stays a pure, deterministic function
//! of the score (SPEC §6). It derives the rhythm tag `Syncopated`, not the style
//! tag `SyncopatedRiff` (a passage-dominance judgement left to curation).

use std::collections::HashSet;

use crate::corpus::SwancoreTag;
use crate::score::{AtomEvent, MasterBar, Score, Track};

/// Fraction of beats that must be *displaced* for a track to read as syncopated.
///
/// A beat is displaced when the off-beat "and" before it is struck while the beat
/// itself is not. Balanced (#75) so characteristic displacement tags while
/// incidental anticipation does not; steady runs score zero by construction.
const SYNCOPATION_THRESHOLD: f64 = 0.25;

/// Derives the rhythm tag present in `track_index`.
///
/// Returns `[Syncopated]` when the displaced-beat share meets
/// [`SYNCOPATION_THRESHOLD`], otherwise empty — empty too when the track is out of
/// range or the score carries no bars to place onsets against.
#[must_use]
pub fn derive_syncopated(score: &Score, track_index: usize) -> Vec<SwancoreTag> {
Comment thread
PhysShell marked this conversation as resolved.
let Some(track) = score.tracks.get(track_index) else {
return Vec::new();
};
if score.master_bars.is_empty() {
return Vec::new();
}

let onsets = track_onsets(track);
let (displaced, total) = displaced_beats(&score.master_bars, &onsets);
if total == 0 {
return Vec::new();
}
// `total > 0`, so the divisor is non-zero and both casts are exact.
let ratio = f64::from(displaced) / f64::from(total);
if ratio >= SYNCOPATION_THRESHOLD {
vec![SwancoreTag::Syncopated]
} else {
Vec::new()
}
}

/// The set of note-onset ticks across every voice of the track.
///
/// Some importers split one track into several voices, so the rhythm is read as a
/// whole.
fn track_onsets(track: &Track) -> HashSet<u32> {
track
.voices
.iter()
.flat_map(|voice| &voice.event_groups)
.flat_map(|group| &group.atoms)
.filter_map(|atom| match atom {
AtomEvent::Note(note) => Some(note.absolute_start.0),
AtomEvent::Rest(_) => None,
})
.collect()
}

/// Counts `(displaced, total)` beats over the bars.
///
/// A beat is *displaced* when the eighth-note "and" immediately before it carries
/// an onset but the beat tick itself does not. Bars whose beat does not divide
/// into an even tick count (no clean "and" to test) are skipped.
fn displaced_beats(bars: &[MasterBar], onsets: &HashSet<u32>) -> (u32, u32) {
let mut displaced = 0_u32;
let mut total = 0_u32;
for bar in bars {
let bar_start = bar.tick_range.start.0;
let span = bar.tick_range.end.0.saturating_sub(bar_start);
let beats = u32::from(bar.time_signature.numerator);
let Some(beat_ticks) = span.checked_div(beats) else {
continue; // numerator 0, or no span
};
let half = beat_ticks.checked_div(2).unwrap_or(0);
if half == 0 || half.checked_mul(2) != Some(beat_ticks) {
continue; // zero-width or odd beat: no clean off-beat grid
}
for b in 0..beats {
let beat_tick = bar_start.saturating_add(b.saturating_mul(beat_ticks));
let and_tick = beat_tick.saturating_sub(half);
total = total.saturating_add(1);
if onsets.contains(&and_tick) && !onsets.contains(&beat_tick) {
displaced = displaced.saturating_add(1);
}
}
}
(displaced, total)
}
132 changes: 132 additions & 0 deletions core/tests/syncopation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Red → tests for the auto-derived `syncopated` rhythm tag (#75). It sets
//! [`SwancoreTag::Syncopated`] from note-onset *displacement*: an off-beat "and"
//! onset that anticipates a beat carrying no onset of its own. Steady runs (every
//! beat struck) score zero, so they are not tagged. References
//! `griff_core::syncopation`, which does not exist yet, so the suite fails to
//! compile until the green step.

#![allow(
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
clippy::missing_assert_message,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]

use griff_core::corpus::SwancoreTag;
use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity};
use griff_core::score::{
AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score,
Track, Voice,
};
use griff_core::slice::TickRange;
use griff_core::syncopation::derive_syncopated;

const PPQN: u16 = 480;
const BAR: u32 = 1920; // 4/4 at 480 PPQN

fn note_at(onset: u32) -> EventGroup {
EventGroup {
kind: EventGroupKind::Single,
atoms: vec![AtomEvent::Note(AtomNote {
absolute_start: Ticks(onset),
duration: Ticks(120),
pitch: Pitch(60),
velocity: Velocity(90),
marks: NoteMarks::empty(),
position: None,
})],
technique_spans: Vec::new(),
}
}

/// A 4/4, 480-PPQN score of `bars` bars whose single voice strikes a note at each
/// absolute tick in `onsets`.
fn score_with_onsets(bars: usize, onsets: &[u32]) -> Score {
let master_bars = (0..bars)
.map(|i| {
let start = u32::try_from(i).unwrap() * BAR;
MasterBar {
index: i,
tick_range: TickRange::new(Ticks(start), Ticks(start + BAR)).expect("ordered"),
time_signature: TimeSignature {
numerator: 4,
denominator: 4,
},
tempo: Tempo::new(120.0).expect("120 BPM"),
repeat: RepeatMarker::default(),
}
})
.collect();
Score {
ticks_per_quarter: PPQN,
master_bars,
tracks: vec![Track {
name: Some("g".to_owned()),
channel: 0,
voices: vec![Voice {
id: 0,
event_groups: onsets.iter().map(|&o| note_at(o)).collect(),
}],
tuning: Tuning::standard_e(),
}],
source_meta: None,
loss: LossReport::new(),
}
}

#[test]
fn steady_eighth_run_is_not_syncopated() {
// Half the onsets are off-beat, but every beat is still struck → nothing is
// displaced.
let onsets = [0, 240, 480, 720, 960, 1200, 1440, 1680];
assert!(derive_syncopated(&score_with_onsets(1, &onsets), 0).is_empty());
}

#[test]
fn on_beat_quarters_are_not_syncopated() {
assert!(derive_syncopated(&score_with_onsets(1, &[0, 480, 960, 1440]), 0).is_empty());
}

#[test]
fn anticipated_beats_derive_syncopated() {
// The "and of 2" (720) and "and of 3" (1200) anticipate beats 3 and 4, which
// carry no onset → 2 of 4 beats displaced (0.5).
let tags = derive_syncopated(&score_with_onsets(1, &[0, 720, 1200]), 0);
assert_eq!(tags, vec![SwancoreTag::Syncopated]);
}

#[test]
fn quarter_of_beats_displaced_meets_threshold() {
// One displaced beat of four = 0.25, exactly the (inclusive) threshold.
let tags = derive_syncopated(&score_with_onsets(1, &[0, 720]), 0);
assert_eq!(tags, vec![SwancoreTag::Syncopated]);
}

#[test]
fn light_displacement_below_threshold_is_not_tagged() {
// One displaced beat across two bars (8 beats) = 0.125 < 0.25. Bar 0: every
// beat struck. Bar 1: the "and of 3" (2640) anticipates beat 3 (2880).
let onsets = [0, 480, 960, 1440, 1920, 2400, 2640, 3360];
assert!(derive_syncopated(&score_with_onsets(2, &onsets), 0).is_empty());
}

#[test]
fn out_of_range_track_is_empty() {
assert!(derive_syncopated(&score_with_onsets(1, &[0, 720, 1200]), 9).is_empty());
}

#[test]
fn a_score_with_no_bars_is_empty() {
// No master timeline → nothing to place onsets against.
let mut score = score_with_onsets(1, &[0, 720, 1200]);
score.master_bars.clear();
assert!(derive_syncopated(&score, 0).is_empty());
}

#[test]
fn is_deterministic() {
let score = score_with_onsets(1, &[0, 720, 1200]);
assert_eq!(derive_syncopated(&score, 0), derive_syncopated(&score, 0));
}
21 changes: 21 additions & 0 deletions docs/decisions.log.md
Original file line number Diff line number Diff line change
Expand Up @@ -1282,3 +1282,24 @@ Architectural decisions go to [`adr/`](adr/) instead.
pass. Accepting that extended/altered chords and arpeggiated or cross-voice
voicings go unclassified in this first cut (under-tagging, never mis-tagging),
and that slash chords carry no tag until that follow-up lands.

- 2026-06-20 — In the context of auto-deriving the `syncopated` rhythm tag (#75's
last derive-style ask after chord-quality), facing that syncopation is a matter
of degree — so, unlike the threshold-free technique/harmony derivers, it cannot
be purely presence-based — we decided for a **displacement** metric: a beat is
displaced when the off-beat eighth-note "and" just before it is struck while the
beat tick itself is not (anticipation / sustain-over), and a track tags
`Syncopated` once the displaced-beat share meets the documented constant
`SYNCOPATION_THRESHOLD = 0.25` (the maintainer's balanced calibration). Prior
art: the displacement/anticipation idea is the Longuet-Higgins & Lee syncopation
notion, simplified to a two-level (beat / eighth-"and") grid and reimplemented
natively. Against a raw off-beat-onset ratio — steady eighth/sixteenth runs are
~50–75% off-beat yet not syncopated, so that would over-tag; the displacement
metric scores them zero because every beat is still struck. The fixed threshold
keeps it deterministic (SPEC §6) and is recorded here as a deliberate departure
from the threshold-free derivers. We derive the rhythm tag `Syncopated`, not the
style tag `SyncopatedRiff` (a passage-dominance call left to curation). Accepting
that accent-based syncopation, compound meters (the numerator is treated as the
beat count), finer-than-eighth and triplet grids, and bars whose beat is not an
even tick count go unmeasured in this first cut (under-tagging, never
mis-tagging).
Loading
Loading