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
82 changes: 79 additions & 3 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use griff_core::{
midi::{self, MidiError},
score::{AtomEvent, Score, Track, Voice},
slice::TickRange,
structure, unfold,
structure, technique, unfold,
};

/// griff — guitar riff engine.
Expand Down Expand Up @@ -918,6 +918,11 @@ fn build_chunk_meta(
let gesture = track_index.and_then(|idx| gesture::measure_gesture(score, idx).ok());
let complexity = track_index.and_then(|idx| structure::measure_complexity(score, idx).ok());
let boundaries = track_index.map_or_else(Vec::new, |idx| detect_boundaries(score, idx));
// Auto-derive techniques from the track's notation (ADR-0018): the tags
// merge with the curator's choices, the free-form list fills `techniques`.
let derived = track_index
.map(|idx| technique::derive_techniques(score, idx))
.unwrap_or_default();

let now = "2026-05-20T00:00:00Z".to_owned();
ChunkMeta {
Expand All @@ -932,9 +937,9 @@ fn build_chunk_meta(
ticks_per_quarter: score.ticks_per_quarter,
time_signature,
tuning: inputs.tuning.clone(),
tags: inputs.tags.clone(),
tags: technique::merge_tags(&inputs.tags, &derived.tags),
boundaries,
techniques: Vec::new(),
techniques: derived.names,
quality_flags: inputs.quality_flags.clone(),
reviewer: inputs.reviewer,
structure,
Expand Down Expand Up @@ -1386,6 +1391,77 @@ mod tests {
}
}

#[test]
fn build_chunk_meta_auto_fills_techniques_and_merges_tags() {
use super::{build_chunk_meta, CurateInputs};
use griff_core::corpus::{
Acquisition, QualityFlag, RightsInfo, RightsStatus, StyleCohort, SwancoreTag,
};
use griff_core::event::{NoteMark, SpanTechnique, TechniqueEvidence};
use griff_core::score::TechniqueSpan;
use std::path::Path;

// Voice 0: one pinch-harmonic note under a hammer-on span.
let note = AtomEvent::Note(AtomNote {
absolute_start: Ticks(0),
duration: Ticks(480),
pitch: Pitch::new(60).expect("pitch"),
velocity: Velocity::new(90).expect("velocity"),
marks: NoteMarks::empty().with(NoteMark::HarmonicPinch),
position: None,
});
let track = track_of(vec![Voice {
id: 0,
event_groups: vec![EventGroup {
kind: EventGroupKind::Single,
atoms: vec![note],
technique_spans: vec![TechniqueSpan {
technique: SpanTechnique::HammerOn,
tick_range: TickRange::new(Ticks(0), Ticks(480)).expect("range"),
evidence: TechniqueEvidence::explicit(),
}],
}],
}]);
let score = one_bar_score(vec![track]);

let inputs = CurateInputs {
id: "dgd_001".to_owned(),
title: "Riff".to_owned(),
tuning: "standard_e".to_owned(),
style_cohort: StyleCohort::Core,
tags: vec![SwancoreTag::Intro], // the curator picked one tag by hand
quality_flags: vec![QualityFlag::Clean],
reviewer: None,
rights: RightsInfo {
rights_status: RightsStatus::CopyrightedComposition,
acquisition: Acquisition::CommunityTabSite,
redistributable: false,
notes: String::new(),
},
};
let meta = build_chunk_meta(
&score,
Path::new("riff.gp5"),
Some(0),
inputs.id.clone(),
inputs.title.clone(),
&inputs,
None,
);

// `techniques` is auto-filled from the notation…
assert!(meta.techniques.contains(&"hammer_on".to_owned()), "{:?}", meta.techniques);
assert!(
meta.techniques.contains(&"pinch_harmonic".to_owned()),
"{:?}",
meta.techniques
);
// …and the curator's hand-picked tag survives alongside the derived ones.
assert!(meta.tags.contains(&SwancoreTag::Intro), "{:?}", meta.tags);
assert!(meta.tags.contains(&SwancoreTag::HammerOn), "{:?}", meta.tags);
assert!(meta.tags.contains(&SwancoreTag::ArtificialHarmonic), "{:?}", meta.tags);
}

#[test]
fn primary_voice_note_count_ignores_secondary_voices() {
// Notes only in voice 1: every analysis module reads voice 0, so the
Expand Down
1 change: 1 addition & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ pub mod scoring;
pub mod similarity;
pub mod slice;
pub mod structure;
pub mod technique;
pub mod unfold;
168 changes: 168 additions & 0 deletions core/src/technique.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! Deriving guitar-technique evidence from a track's notation (ADR-0018).
//!
//! Guitar Pro import records techniques explicitly: spanning techniques (slide,
//! bend, legato, palm-mute, hammer-on, pull-off, vibrato) land on
//! [`TechniqueSpan`](crate::score::TechniqueSpan)s, and per-note ones (accent,
//! ghost, staccato, dead-note, natural/pinch harmonic, tap) on
//! [`NoteMarks`](crate::event::NoteMarks). Curation reads that for free instead
//! of asking a human to re-tag what the tab already states.
//!
//! [`derive_techniques`] scans one track and returns both the [`SwancoreTag`]s
//! that have a *direct, per-occurrence* tag and the full free-form
//! technique-name list for `ChunkMeta::techniques`. It is presence-only — no
//! thresholds or heuristics — so it stays a pure function of the score (SPEC
//! §6). The passage-level tags (`TappingPassage`, `LegatoPassage`) are about
//! dominance, not presence, so they are deliberately *not* derived here.

use std::collections::BTreeSet;

use crate::corpus::SwancoreTag;
use crate::event::{NoteMark, SpanTechnique};
use crate::score::{AtomEvent, Score};

/// Techniques derived from one track's notation.
///
/// `tags` are the [`SwancoreTag`]s with a dedicated per-occurrence variant;
/// `names` is the superset of `lower_snake_case` technique names (it also
/// records legato, accent, ghost, staccato, dead-note and tap, which have no
/// dedicated tag). Both are in a stable canonical order.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DerivedTechniques {
/// Directly-taggable techniques present in the track.
pub tags: Vec<SwancoreTag>,
/// Names of every technique present — a superset of `tags`.
pub names: Vec<String>,
}

/// Spanning techniques in canonical (declaration) order.
const SPANS: [SpanTechnique; 7] = [
SpanTechnique::Slide,
SpanTechnique::Bend,
SpanTechnique::Legato,
SpanTechnique::PalmMute,
SpanTechnique::HammerOn,
SpanTechnique::PullOff,
SpanTechnique::Vibrato,
];

/// `lower_snake_case` name for a spanning technique.
const fn span_name(t: SpanTechnique) -> &'static str {
match t {
SpanTechnique::Slide => "slide",
SpanTechnique::Bend => "bend",
SpanTechnique::Legato => "legato",
SpanTechnique::PalmMute => "palm_mute",
SpanTechnique::HammerOn => "hammer_on",
SpanTechnique::PullOff => "pull_off",
SpanTechnique::Vibrato => "vibrato",
}
}

/// The dedicated tag for a spanning technique, if one exists. Legato has only
/// the passage-level `LegatoPassage`, which needs dominance — not derived here.
const fn span_tag(t: SpanTechnique) -> Option<SwancoreTag> {
match t {
SpanTechnique::Slide => Some(SwancoreTag::Slide),
SpanTechnique::Bend => Some(SwancoreTag::Bend),
SpanTechnique::PalmMute => Some(SwancoreTag::PalmMute),
SpanTechnique::HammerOn => Some(SwancoreTag::HammerOn),
SpanTechnique::PullOff => Some(SwancoreTag::PullOff),
SpanTechnique::Vibrato => Some(SwancoreTag::Vibrato),
SpanTechnique::Legato => None,
}
}

/// `lower_snake_case` name for a per-note mark.
const fn mark_name(m: NoteMark) -> &'static str {
match m {
NoteMark::Accent => "accent",
NoteMark::Ghost => "ghost",
NoteMark::Staccato => "staccato",
NoteMark::DeadNote => "dead_note",
NoteMark::HarmonicNatural => "natural_harmonic",
NoteMark::HarmonicPinch => "pinch_harmonic",
NoteMark::Tap => "tap",
}
}

/// The dedicated tag for a per-note mark, if one exists. Only the two harmonics
/// have one; accent/ghost/staccato/dead-note/tap are names-only.
const fn mark_tag(m: NoteMark) -> Option<SwancoreTag> {
match m {
NoteMark::HarmonicNatural => Some(SwancoreTag::NaturalHarmonic),
NoteMark::HarmonicPinch => Some(SwancoreTag::ArtificialHarmonic),
NoteMark::Accent
| NoteMark::Ghost
| NoteMark::Staccato
| NoteMark::DeadNote
| NoteMark::Tap => None,
}
}

/// Derives the techniques present in `track_index`.
///
/// Scans every voice, matching `track_notes`/`technique_share`, which measure a
/// track as a whole (some importers split one track into several voices) — so
/// the `techniques` metadata can't disagree with the technical metric. Empty
/// when the index is out of range.
#[must_use]
pub fn derive_techniques(score: &Score, track_index: usize) -> DerivedTechniques {
let Some(track) = score.tracks.get(track_index) else {
return DerivedTechniques::default();
};

// Every voice — like track_notes/technique_share, which measure a track as a
// whole (some importers split one track into several voices).
let mut spans: BTreeSet<SpanTechnique> = BTreeSet::new();
let mut marks: BTreeSet<NoteMark> = BTreeSet::new();
for voice in &track.voices {
for group in &voice.event_groups {
for span in &group.technique_spans {
spans.insert(span.technique);
}
for atom in &group.atoms {
if let AtomEvent::Note(note) = atom {
for mark in note.marks.iter() {
marks.insert(mark);
}
}
}
}
}

let mut tags = Vec::new();
let mut names = Vec::new();
for t in SPANS {
if spans.contains(&t) {
names.push(span_name(t).to_owned());
if let Some(tag) = span_tag(t) {
tags.push(tag);
}
}
}
for m in NoteMark::ALL {
if marks.contains(&m) {
names.push(mark_name(m).to_owned());
if let Some(tag) = mark_tag(m) {
tags.push(tag);
}
}
}
DerivedTechniques { tags, names }
}

/// Unions curator-chosen tags with derived ones.
///
/// The chosen order is kept, then any derived tag not already present is
/// appended — so auto-derivation *adds* technique tags without overriding or
/// reordering the curator's choices. Stable and idempotent.
#[must_use]
pub fn merge_tags(chosen: &[SwancoreTag], derived: &[SwancoreTag]) -> Vec<SwancoreTag> {
let mut out = chosen.to_vec();
for &tag in derived {
if !out.contains(&tag) {
out.push(tag);
}
}
out
}
Loading