diff --git a/cli/src/main.rs b/cli/src/main.rs index d2870afb..3976dff0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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. @@ -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 { @@ -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, @@ -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 diff --git a/core/src/lib.rs b/core/src/lib.rs index 947b11b6..a1f22679 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -25,4 +25,5 @@ pub mod scoring; pub mod similarity; pub mod slice; pub mod structure; +pub mod technique; pub mod unfold; diff --git a/core/src/technique.rs b/core/src/technique.rs new file mode 100644 index 00000000..cc03225f --- /dev/null +++ b/core/src/technique.rs @@ -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, + /// Names of every technique present — a superset of `tags`. + pub names: Vec, +} + +/// 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 { + 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 { + 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 = BTreeSet::new(); + let mut marks: BTreeSet = 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 { + let mut out = chosen.to_vec(); + for &tag in derived { + if !out.contains(&tag) { + out.push(tag); + } + } + out +} diff --git a/core/tests/technique_tags.rs b/core/tests/technique_tags.rs new file mode 100644 index 00000000..ea30daac --- /dev/null +++ b/core/tests/technique_tags.rs @@ -0,0 +1,160 @@ +//! Red → tests for auto-derived technique tags from notation (curation +//! automation, feature #1). Guitar Pro import already records techniques +//! explicitly (ADR-0018); curation should read them instead of re-asking a +//! human. References `griff_core::technique`, 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 +)] + +use griff_core::corpus::SwancoreTag; +use griff_core::event::{ + NoteMark, NoteMarks, Pitch, SpanTechnique, TechniqueEvidence, Ticks, Tuning, Velocity, +}; +use griff_core::score::{ + AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, Score, TechniqueSpan, Track, Voice, +}; +use griff_core::slice::TickRange; +use griff_core::technique::{derive_techniques, merge_tags, DerivedTechniques}; + +/// A one-track score whose single note carries `marks` and whose group carries +/// `spans` — the minimal shape `derive_techniques` scans. +fn score_with(spans: &[SpanTechnique], marks: NoteMarks) -> Score { + let note = AtomEvent::Note(AtomNote { + absolute_start: Ticks(0), + duration: Ticks(480), + pitch: Pitch(60), + velocity: Velocity(90), + marks, + position: None, + }); + let technique_spans = spans + .iter() + .map(|&technique| TechniqueSpan { + technique, + tick_range: TickRange::new(Ticks(0), Ticks(480)).expect("ordered range"), + evidence: TechniqueEvidence::explicit(), + }) + .collect(); + Score { + ticks_per_quarter: 480, + master_bars: Vec::new(), + tracks: vec![Track { + name: Some("g".to_owned()), + channel: 0, + voices: vec![Voice { + id: 0, + event_groups: vec![EventGroup { + kind: EventGroupKind::Single, + atoms: vec![note], + technique_spans, + }], + }], + tuning: Tuning::standard_e(), + }], + source_meta: None, + loss: LossReport::new(), + } +} + +#[test] +fn maps_spans_and_marks_to_direct_tags_and_a_superset_name_list() { + let marks = NoteMarks::empty() + .with(NoteMark::HarmonicPinch) + .with(NoteMark::Accent); + let score = score_with( + &[ + SpanTechnique::HammerOn, + SpanTechnique::PalmMute, + SpanTechnique::Legato, + ], + marks, + ); + let d = derive_techniques(&score, 0); + + // Direct tags: the two spans with a dedicated tag + the pinch harmonic. + assert!(d.tags.contains(&SwancoreTag::HammerOn)); + assert!(d.tags.contains(&SwancoreTag::PalmMute)); + assert!(d.tags.contains(&SwancoreTag::ArtificialHarmonic)); + // Legato and accent have no dedicated SwancoreTag — they must NOT be tagged… + assert!(!d.tags.contains(&SwancoreTag::NaturalHarmonic)); + assert_eq!(d.tags.len(), 3, "only the directly-taggable techniques: {:?}", d.tags); + + // …but the free-form name list is the superset and records them anyway. + for name in ["hammer_on", "palm_mute", "legato", "pinch_harmonic", "accent"] { + assert!(d.names.contains(&name.to_owned()), "names missing {name}: {:?}", d.names); + } +} + +#[test] +fn presence_only_so_repeats_dedupe_and_output_is_deterministic() { + // The same technique across groups/notes is "present", counted once. + let score = score_with(&[SpanTechnique::Slide, SpanTechnique::Slide], NoteMarks::empty()); + let a = derive_techniques(&score, 0); + let b = derive_techniques(&score, 0); + assert_eq!(a, b, "pure function of the score (SPEC §6)"); + assert_eq!(a.names, vec!["slide".to_owned()]); + assert_eq!(a.tags, vec![SwancoreTag::Slide]); +} + +#[test] +fn empty_for_plain_notes_and_out_of_range_track() { + let plain = score_with(&[], NoteMarks::empty()); + assert_eq!(derive_techniques(&plain, 0), DerivedTechniques::default()); + assert_eq!(derive_techniques(&plain, 9), DerivedTechniques::default()); +} + +#[test] +fn merge_tags_keeps_chosen_order_and_appends_only_new_derived() { + let chosen = [SwancoreTag::Intro, SwancoreTag::HammerOn]; + let derived = [SwancoreTag::HammerOn, SwancoreTag::PalmMute]; + // HammerOn was already chosen → not duplicated; PalmMute is appended. + assert_eq!( + merge_tags(&chosen, &derived), + vec![SwancoreTag::Intro, SwancoreTag::HammerOn, SwancoreTag::PalmMute] + ); + // Idempotent: merging the result again changes nothing. + let once = merge_tags(&chosen, &derived); + assert_eq!(merge_tags(&once, &derived), once); +} + +#[test] +fn derives_from_all_voices_like_structure_measures() { + // A technique in a secondary voice (voice 1) IS derived: structure and + // complexity (track_notes/technique_share) measure the track as a whole, so + // technique metadata must not omit it (Codex P2 on PR #69; cf. PR #38). + let mut score = score_with(&[], NoteMarks::empty()); // voice 0: plain note + score.tracks[0].voices.push(Voice { + id: 1, + event_groups: vec![EventGroup { + kind: EventGroupKind::Single, + atoms: vec![AtomEvent::Note(AtomNote { + absolute_start: Ticks(0), + duration: Ticks(480), + pitch: Pitch(60), + velocity: Velocity(90), + marks: NoteMarks::empty().with(NoteMark::HarmonicPinch), + position: None, + })], + technique_spans: vec![TechniqueSpan { + technique: SpanTechnique::PalmMute, + tick_range: TickRange::new(Ticks(0), Ticks(480)).expect("ordered range"), + evidence: TechniqueEvidence::explicit(), + }], + }], + }); + let d = derive_techniques(&score, 0); + assert!(d.tags.contains(&SwancoreTag::PalmMute), "secondary-voice span: {:?}", d.tags); + assert!( + d.tags.contains(&SwancoreTag::ArtificialHarmonic), + "secondary-voice mark: {:?}", + d.tags + ); + assert!(d.names.contains(&"palm_mute".to_owned())); + assert!(d.names.contains(&"pinch_harmonic".to_owned())); +} diff --git a/web/src/lib.rs b/web/src/lib.rs index f2146d9e..75cb3f20 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -39,7 +39,7 @@ use griff_core::corpus::{ Acquisition, BoundaryEntry, ChunkId, ChunkMeta, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, SourceRef, StyleCohort, SwancoreTag, }; -use griff_core::{gesture, structure}; +use griff_core::{gesture, structure, technique}; const PPQN: u16 = 480; const BAR: u32 = 1920; // 4/4 at 480 PPQN @@ -514,7 +514,11 @@ fn build_chunk_meta_record( } else { tuning.trim().to_owned() }; - let tags = parse_indices(tags_idx, SwancoreTag::all_variants()); + let chosen_tags = parse_indices(tags_idx, SwancoreTag::all_variants()); + // Fold in techniques the notation already states (ADR-0018): merge their + // tags with the curator's choices, fill the free-form `techniques` list. + let derived = technique::derive_techniques(score, track_index); + let tags = technique::merge_tags(&chosen_tags, &derived.tags); let all_flags = [ QualityFlag::Clean, QualityFlag::Lossy, @@ -546,7 +550,7 @@ fn build_chunk_meta_record( tuning, tags, boundaries: detect_boundaries(score, track_index), - techniques: Vec::new(), + techniques: derived.names, quality_flags, reviewer: reviewer_from(reviewer), structure: structure::measure_structure(score, track_index).ok(), @@ -910,6 +914,54 @@ mod tests { assert_eq!(back, meta); } + #[test] + fn build_chunk_record_auto_fills_techniques_from_notation() { + use griff_core::corpus::SwancoreTag; + use griff_core::event::{NoteMark, SpanTechnique, TechniqueEvidence, Ticks}; + use griff_core::score::{AtomEvent, TechniqueSpan}; + use griff_core::slice::TickRange; + + // Inject a hammer-on span + a pinch-harmonic note into the sample's voice 0. + let mut score = sample_part_a(); + let group = score + .tracks + .first_mut() + .expect("track") + .voices + .first_mut() + .expect("voice") + .event_groups + .first_mut() + .expect("group"); + group.technique_spans.push(TechniqueSpan { + technique: SpanTechnique::HammerOn, + tick_range: TickRange::new(Ticks(0), Ticks(240)).expect("range"), + evidence: TechniqueEvidence::explicit(), + }); + if let AtomEvent::Note(n) = group.atoms.first_mut().expect("atom") { + n.marks.insert(NoteMark::HarmonicPinch); + } + + // The curator hand-picks only "intro" (index 21); derivation adds the rest. + let meta = build_chunk_meta_record( + &score, 0, "dgd_001", "Riff", "riff.gp5", "standard_e", 0, "21", "", -1, 3, 0, + false, "", "t", "t", + ) + .expect("record builds"); + + // `techniques` filled from notation (superset includes the pinch harmonic). + assert!(meta.techniques.contains(&"hammer_on".to_owned()), "{:?}", meta.techniques); + assert!( + meta.techniques.contains(&"pinch_harmonic".to_owned()), + "{:?}", + meta.techniques + ); + // Curator's chosen tag survives; derived technique tags are merged in. + assert!(meta.tags.contains(&SwancoreTag::Intro), "chosen tag kept: {:?}", meta.tags); + assert!(meta.tags.contains(&SwancoreTag::HammerOn), "{:?}", meta.tags); + assert!(meta.tags.contains(&SwancoreTag::ArtificialHarmonic), "{:?}", meta.tags); + } + #[test] fn build_chunk_record_rejects_out_of_range_track() { let score = sample_part_a();