From 162bcdabe8eaa4dd37a50b2047df7198d6c02c04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:12:44 +0000 Subject: [PATCH 1/6] =?UTF-8?q?test(core):=20red=20=E2=80=94=20derive=20ch?= =?UTF-8?q?ord-quality=20tags=20from=20chord=20voicings=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing tests for a new `harmony::derive_harmony` deriver that reads each chord's pitch-class set and tags the voicing: power_chord (a bare-fifth dyad), and maj7/min7/sus2/add9 (exact root-relative templates, inversions included). Presence-only and threshold-free, mirroring the technique deriver. References `griff_core::harmony`, which does not exist yet, so the suite fails to compile until the green step. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- core/tests/harmony_tags.rs | 181 +++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 core/tests/harmony_tags.rs diff --git a/core/tests/harmony_tags.rs b/core/tests/harmony_tags.rs new file mode 100644 index 00000000..664d89c4 --- /dev/null +++ b/core/tests/harmony_tags.rs @@ -0,0 +1,181 @@ +//! Red → tests for auto-derived chord-quality tags (#75). The harmony tags +//! (`maj7`/`min7`/`sus2`/`add9`/`power_chord`) already exist in the taxonomy but +//! were curator-only; griff should read the voicings the tab already spells out. +//! References `griff_core::harmony`, 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::{NoteMarks, Pitch, Ticks, Tuning, Velocity}; +use griff_core::harmony::derive_harmony; +use griff_core::score::{ + AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, Score, Track, Voice, +}; + +/// A chord-bearing [`EventGroup`] whose notes sound `pitches` (MIDI) together. +fn chord_group(pitches: &[u8]) -> EventGroup { + let atoms = pitches + .iter() + .map(|&p| { + AtomEvent::Note(AtomNote { + absolute_start: Ticks(0), + duration: Ticks(480), + pitch: Pitch(p), + velocity: Velocity(90), + marks: NoteMarks::empty(), + position: None, + }) + }) + .collect(); + EventGroup { + kind: EventGroupKind::Chord, + atoms, + technique_spans: Vec::new(), + } +} + +/// A one-track, one-voice score built from `groups`. +fn score_with_groups(groups: Vec) -> Score { + 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: groups, + }], + tuning: Tuning::standard_e(), + }], + source_meta: None, + loss: LossReport::new(), + } +} + +/// A one-track score whose single chord sounds `pitches` together. +fn chord_score(pitches: &[u8]) -> Score { + score_with_groups(vec![chord_group(pitches)]) +} + +#[test] +fn power_chord_fifth_dyad_derives_power_chord() { + // E5 = E2 + B2, a bare perfect fifth — the defining swancore voicing. + assert_eq!( + derive_harmony(&chord_score(&[40, 47]), 0), + vec![SwancoreTag::PowerChord] + ); +} + +#[test] +fn power_chord_ignores_octave_doubling() { + // Root + fifth + octave root still reduces to the {root, fifth} dyad. + assert_eq!( + derive_harmony(&chord_score(&[40, 47, 52]), 0), + vec![SwancoreTag::PowerChord] + ); +} + +#[test] +fn maj7_voicing_derives_maj7() { + // C E G B = {0, 4, 7, 11}. + assert_eq!( + derive_harmony(&chord_score(&[60, 64, 67, 71]), 0), + vec![SwancoreTag::Maj7] + ); +} + +#[test] +fn min7_voicing_derives_min7() { + // A C E G = {0, 3, 7, 10} rooted on A. + assert_eq!( + derive_harmony(&chord_score(&[57, 60, 64, 67]), 0), + vec![SwancoreTag::Min7] + ); +} + +#[test] +fn sus2_voicing_derives_sus2() { + // C D G = {0, 2, 7}. + assert_eq!( + derive_harmony(&chord_score(&[60, 62, 67]), 0), + vec![SwancoreTag::Sus2] + ); +} + +#[test] +fn add9_voicing_derives_add9() { + // C E G D = {0, 2, 4, 7} (the 9th folds to a 2nd by pitch class). + assert_eq!( + derive_harmony(&chord_score(&[60, 64, 67, 74]), 0), + vec![SwancoreTag::Add9] + ); +} + +#[test] +fn inverted_maj7_still_derives_maj7() { + // Cmaj7 with E in the bass: the voicing is present regardless of inversion, + // so presence-only derivation still tags Maj7. (Dedicated slash-chord + // detection is a deliberate follow-up — see docs/decisions.log.md.) + assert_eq!( + derive_harmony(&chord_score(&[52, 55, 59, 60]), 0), + vec![SwancoreTag::Maj7] + ); +} + +#[test] +fn lone_note_and_plain_triad_derive_nothing() { + // A single note is no chord… + assert!(derive_harmony(&chord_score(&[60]), 0).is_empty()); + // …and a plain major triad has no tag in the taxonomy. + assert!(derive_harmony(&chord_score(&[60, 64, 67]), 0).is_empty()); +} + +#[test] +fn presence_only_dedupes_and_is_deterministic() { + // The same quality across groups is counted once; a pure fn of the score + // (SPEC §6). + let score = score_with_groups(vec![ + chord_group(&[60, 64, 67, 71]), + chord_group(&[60, 64, 67, 71]), + ]); + let first = derive_harmony(&score, 0); + assert_eq!(first, derive_harmony(&score, 0)); + assert_eq!(first, vec![SwancoreTag::Maj7]); +} + +#[test] +fn output_follows_canonical_taxonomy_order() { + // A power chord then a maj7 → output in enum order (Maj7 before PowerChord). + let score = score_with_groups(vec![chord_group(&[40, 47]), chord_group(&[60, 64, 67, 71])]); + assert_eq!( + derive_harmony(&score, 0), + vec![SwancoreTag::Maj7, SwancoreTag::PowerChord] + ); +} + +#[test] +fn out_of_range_track_is_empty() { + assert!(derive_harmony(&chord_score(&[60, 64, 67, 71]), 9).is_empty()); +} + +#[test] +fn derives_from_every_voice() { + // A chord in a secondary voice is still derived (the track is measured whole). + let mut score = chord_score(&[60]); // voice 0: a lone note + score.tracks[0].voices.push(Voice { + id: 1, + event_groups: vec![chord_group(&[40, 47])], + }); + assert_eq!( + derive_harmony(&score, 0), + vec![SwancoreTag::PowerChord] + ); +} From c41f3e0823dfcb4c8cab5179333951fd34d9d886 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:19:19 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(core):=20green=20=E2=80=94=20derive=20?= =?UTF-8?q?chord-quality=20tags=20from=20chord=20voicings=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `harmony::derive_harmony` scans a track's chord-bearing groups and tags the voicing the tab spells out: power_chord (a bare perfect-fifth dyad) and maj7/min7/sus2/add9 (exact root-relative pitch-class templates, tried from every chord tone so inversions still match their quality). Presence-only and threshold-free — a pure function of the score (SPEC §6), mirroring `derive_techniques`. Slash-chord detection is deliberately deferred: the common plain-triad slash (e.g. G/B) is not expressible with the seventh/sus/add templates and needs its own bass-vs-root model. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- core/src/harmony.rs | 136 ++++++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 1 + 2 files changed, 137 insertions(+) create mode 100644 core/src/harmony.rs diff --git a/core/src/harmony.rs b/core/src/harmony.rs new file mode 100644 index 00000000..ea152a70 --- /dev/null +++ b/core/src/harmony.rs @@ -0,0 +1,136 @@ +//! Deriving chord-quality evidence from a track's notation (#75). +//! +//! The harmony tags (`maj7`/`min7`/`sus2`/`add9`/`power_chord`) name chord +//! *voicings*. Guitar Pro records a chord as simultaneous notes in one +//! [`EventGroup`](crate::score::EventGroup) of kind +//! [`Chord`](crate::score::EventGroupKind::Chord); rather than ask a curator to +//! name what the tab already spells out, [`derive_harmony`] reads each chord's +//! pitch-class set and matches it against the voicing templates. +//! +//! Like [`derive_techniques`](crate::technique::derive_techniques) it is +//! **presence-only** and template-exact — a pure function of the score with no +//! thresholds (SPEC §6). Inversions are tagged by their quality (the voicing is +//! present whichever tone is in the bass); dedicated **slash-chord** detection is +//! a deliberate follow-up (see `docs/decisions.log.md`), because the common +//! plain-triad slash (e.g. `G/B`) is not expressible with the seventh/sus/add +//! templates and would need its own bass-vs-root model. + +use std::collections::HashSet; + +use crate::corpus::SwancoreTag; +use crate::event::Pitch; +use crate::score::{AtomEvent, EventGroup, EventGroupKind, Score}; + +/// Chord-quality tags in canonical (taxonomy-declaration) order. +const HARMONY_TAGS: [SwancoreTag; 5] = [ + SwancoreTag::Maj7, + SwancoreTag::Min7, + SwancoreTag::Sus2, + SwancoreTag::Add9, + SwancoreTag::PowerChord, +]; + +/// Root-anchored pitch-class templates (root = 0) for the seventh/sus/add +/// voicings, each sorted ascending. Power chords are matched separately as a +/// bare-fifth dyad, so they are not listed here. +const TEMPLATES: [(&[u8], SwancoreTag); 4] = [ + (&[0, 4, 7, 11], SwancoreTag::Maj7), + (&[0, 3, 7, 10], SwancoreTag::Min7), + (&[0, 2, 7], SwancoreTag::Sus2), + (&[0, 2, 4, 7], SwancoreTag::Add9), +]; + +/// Derives the chord-quality tags present in `track_index`. +/// +/// Scans every voice's chord-bearing groups, classifies each chord's +/// pitch-class set, and returns the matched [`SwancoreTag`]s in canonical order, +/// deduplicated. Empty when the track index is out of range or nothing matches. +#[must_use] +pub fn derive_harmony(score: &Score, track_index: usize) -> Vec { + let Some(track) = score.tracks.get(track_index) else { + return Vec::new(); + }; + + // Every voice — some importers split one track into several voices, so a + // chord in a secondary voice must still be seen (cf. the technique deriver). + let mut found: HashSet = HashSet::new(); + for voice in &track.voices { + for group in &voice.event_groups { + if let Some(tag) = classify_group(group) { + found.insert(tag); + } + } + } + + HARMONY_TAGS + .into_iter() + .filter(|tag| found.contains(tag)) + .collect() +} + +/// Classifies the chord borne by a group, if it is a chord-bearing group with a +/// recognisable voicing. +fn classify_group(group: &EventGroup) -> Option { + // Only genuinely simultaneous groups are chords; melody (`Single`) and + // rhythmic/ornamental groupings are not. Arpeggios and chords split across + // voices are a deliberate follow-up. + if !matches!(group.kind, EventGroupKind::Chord | EventGroupKind::Strum) { + return None; + } + classify(&pitch_classes(group)) +} + +/// The distinct pitch classes (0–11) sounding in a group, sorted ascending. +fn pitch_classes(group: &EventGroup) -> Vec { + let mut classes: Vec = group + .atoms + .iter() + .filter_map(|atom| match atom { + AtomEvent::Note(note) => Some(pitch_class(note.pitch)), + AtomEvent::Rest(_) => None, + }) + .collect(); + classes.sort_unstable(); + classes.dedup(); + classes +} + +/// Matches a sorted, distinct pitch-class set against the voicing templates. +fn classify(classes: &[u8]) -> Option { + // A power chord is the bare perfect-fifth dyad (root + fifth, any octave): + // two distinct classes a fifth (7) — or its inversion, a fourth (5) — apart. + if let &[low, high] = classes { + let interval = high.wrapping_sub(low); + return (interval == 5 || interval == 7).then_some(SwancoreTag::PowerChord); + } + // Otherwise try each chord tone as the root — the root of a maj7/min7/sus2/ + // add9 is always a chord tone — so inversions match their quality too. + classes + .iter() + .find_map(|&root| match_templates(classes, root)) +} + +/// The template tag whose interval set, rooted at `root`, equals `classes` +/// reduced to root-relative intervals. +fn match_templates(classes: &[u8], root: u8) -> Option { + let mut intervals: Vec = classes.iter().map(|&pc| interval_above(root, pc)).collect(); + intervals.sort_unstable(); + TEMPLATES + .into_iter() + .find_map(|(template, tag)| (intervals.as_slice() == template).then_some(tag)) +} + +/// The pitch class `pc` expressed as an interval above `root`, in `0..12`. Both +/// arguments are pitch classes in `0..12`, so `pc + 12 - root` lies in `1..24` +/// and the reduction never underflows. +fn interval_above(root: u8, pc: u8) -> u8 { + pc.wrapping_add(12) + .wrapping_sub(root) + .checked_rem(12) + .unwrap_or(0) +} + +/// The pitch class (0–11) of a MIDI pitch. +fn pitch_class(pitch: Pitch) -> u8 { + pitch.0.checked_rem(12).unwrap_or(0) +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 504ebcd0..2ed96e7e 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -17,6 +17,7 @@ pub mod generate; pub mod gesture; #[cfg(feature = "gp")] pub mod gp; +pub mod harmony; pub mod import; pub mod midi; pub mod novelty; From 1c12b9eaf3af2c6a8e2e729e8a8a88ffe6ea298a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:19:25 +0000 Subject: [PATCH 3/6] feat(cli): merge auto-derived chord-quality tags during curation (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_chunk_meta` now also calls `harmony::derive_harmony` and folds the result into the chunk's tags, additively over the curator's choices — the same merge treatment the derived technique tags already get. Curation reads the chord voicings the tab states instead of asking a human to re-tag them. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- cli/src/main.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 9c22789e..0fe6d91f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -17,7 +17,7 @@ use griff_core::{ SourceRef, StyleCohort, SwancoreTag, SCHEMA_VERSION, }, event::{NoteMarks, NotePosition, Pitch, TechniqueSource, Ticks}, - generate, gesture, + generate, gesture, harmony, import::{self, ImportError}, midi::{self, MidiError}, score::{AtomEvent, Score, Track, Voice}, @@ -1051,6 +1051,11 @@ fn build_chunk_meta( let derived = track_index .map(|idx| technique::derive_techniques(score, idx)) .unwrap_or_default(); + // Auto-derive chord-quality tags from the same notation (#75); they merge + // additively too, like the technique tags. + let derived_harmony = track_index + .map(|idx| harmony::derive_harmony(score, idx)) + .unwrap_or_default(); let now = "2026-05-20T00:00:00Z".to_owned(); ChunkMeta { @@ -1065,7 +1070,12 @@ fn build_chunk_meta( ticks_per_quarter: score.ticks_per_quarter, time_signature, tuning: inputs.tuning.clone(), - tags: technique::merge_tags(&inputs.tags, &derived.tags), + tags: { + // Additive: curator choices first, then derived technique tags, then + // derived chord-quality tags (#75) — none overrides the others. + let with_techniques = technique::merge_tags(&inputs.tags, &derived.tags); + technique::merge_tags(&with_techniques, &derived_harmony) + }, boundaries, techniques: derived.names, quality_flags: inputs.quality_flags.clone(), From 14e0fbbe899695c06e3a10cdbfcf365373f12a23 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:20:14 +0000 Subject: [PATCH 4/6] docs(harmony): record the chord-quality derivation policy (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Y-statement for the presence-only, template-exact chord-quality deriver: the prior art (pitch-class-set / chord-template matching), why it stays threshold-free (SPEC §6) rather than a confidence recogniser, why it is distinct from `complement::estimate_harmony` (key, not voicing), and the deferral of slash_chord (plain-triad-over-bass is not template-expressible). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- docs/decisions.log.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/decisions.log.md b/docs/decisions.log.md index ec568c00..73bcbd46 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -1262,3 +1262,23 @@ Architectural decisions go to [`adr/`](adr/) instead. optional-field pattern, not a tag counter — accepting that a pinned pre-tag build hard-rejects a chunk carrying a newer tag (a curation-tooling concern, since griff's reader and writer ship together). + +- 2026-06-19 — In the context of auto-deriving the chord-quality tags + (`maj7`/`min7`/`sus2`/`add9`/`power_chord`, #75's next taxonomy ask after + `let_ring`), facing that Guitar Pro records only notes — never chord labels — + and that these tags were curator-only despite the voicing being spelled out in + the tab, we decided for a presence-only `harmony::derive_harmony` that matches + each chord group's pitch-class set against exact root-relative templates (power + chord = a bare perfect-fifth dyad; maj7/min7/sus2/add9 = fixed interval sets, + tried from every chord tone so inversions tag their quality), mirroring + `technique::derive_techniques`. Prior art: pitch-class-set / chord-template + matching is the standard MIR approach, reimplemented natively (no dependency). + Against a confidence-thresholded recogniser — it would forfeit the "pure + function of the score, no thresholds" property (SPEC §6) the technique deriver + set — and against reusing `complement::estimate_harmony`, which answers "what + key?" (Krumhansl–Kessler key-fit), not "what voicing?". We defer `slash_chord`: + its common case is a plain triad over a non-root bass (e.g. G/B), which the + seventh/sus/add templates cannot express and which needs its own bass-vs-root + 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. From ca4501f4701c72831bea79907d671b66dbcbd5f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 17:21:37 +0000 Subject: [PATCH 5/6] style(core): rustfmt the harmony_tags assertions (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse an assert_eq! that fits within max_width onto one line — a formatting-only follow-up to the red-test commit; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- core/tests/harmony_tags.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/core/tests/harmony_tags.rs b/core/tests/harmony_tags.rs index 664d89c4..46d49b05 100644 --- a/core/tests/harmony_tags.rs +++ b/core/tests/harmony_tags.rs @@ -174,8 +174,5 @@ fn derives_from_every_voice() { id: 1, event_groups: vec![chord_group(&[40, 47])], }); - assert_eq!( - derive_harmony(&score, 0), - vec![SwancoreTag::PowerChord] - ); + assert_eq!(derive_harmony(&score, 0), vec![SwancoreTag::PowerChord]); } From bf7404deff371cb458a5be529367d06b24d7a8ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 23:16:03 +0000 Subject: [PATCH 6/6] fix(web): mirror chord-quality derivation in browser curation (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser/phone capture path (`build_chunk_meta_record`) derived and merged only technique tags, so a GP chord the CLI now serializes with `maj7`/`power_chord` was emitted without those harmony tags — making corpus tags depend on which curation front produced the chunk (Codex P2 on #88). Mirror the CLI: derive harmony tags and merge them additively, with a parity test. Also fixes the adjacent technique-parity test, whose hardcoded `tags_idx` "21" no longer selected Intro: #87's LetRing shifted Intro to index 22 in `all_variants()` (web host tests aren't CI-gated, so it went unnoticed). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TTUbGjzD8ysnVnCJnZJE95 --- web/src/lib.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/web/src/lib.rs b/web/src/lib.rs index 0c364e70..05dfe101 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -42,7 +42,7 @@ use griff_core::corpus::{ Acquisition, BoundaryEntry, ChunkId, ChunkMeta, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, SourceRef, StyleCohort, SwancoreTag, }; -use griff_core::{gesture, structure, technique}; +use griff_core::{gesture, harmony, structure, technique}; const PPQN: u16 = 480; const BAR: u32 = 1920; // 4/4 at 480 PPQN @@ -533,7 +533,11 @@ fn build_chunk_meta_record( // 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 derived_harmony = harmony::derive_harmony(score, track_index); + let technique_tags = technique::merge_tags(&chosen_tags, &derived.tags); + // Chord-quality tags (#75) merge additively too, keeping this capture front + // in step with the CLI `build_chunk_meta`. + let tags = technique::merge_tags(&technique_tags, &derived_harmony); let all_flags = [ QualityFlag::Clean, QualityFlag::Lossy, @@ -1202,9 +1206,10 @@ mod tests { n.marks.insert(NoteMark::HarmonicPinch); } - // The curator hand-picks only "intro" (index 21); derivation adds the rest. + // The curator hand-picks only "intro" (index 22 in `all_variants()` after + // LetRing shifted the structure tags, #87); derivation adds the rest. let meta = build_chunk_meta_record( - &score, 0, "dgd_001", "Riff", "riff.gp5", "standard_e", 0, "21", "", -1, 3, 0, + &score, 0, "dgd_001", "Riff", "riff.gp5", "standard_e", 0, "22", "", -1, 3, 0, false, "", "t", "t", ) .expect("record builds"); @@ -1222,6 +1227,48 @@ mod tests { assert!(meta.tags.contains(&SwancoreTag::ArtificialHarmonic), "{:?}", meta.tags); } + #[test] + fn build_chunk_record_auto_fills_harmony_tags_from_chords() { + use griff_core::corpus::SwancoreTag; + use griff_core::event::{NoteMarks, Pitch, Ticks, Velocity}; + use griff_core::score::{AtomEvent, AtomNote, EventGroup, EventGroupKind}; + + // Inject a bare-fifth power chord (E2 + B2) as a Chord group in voice 0. + let mut score = sample_part_a(); + let chord = |p: u8| { + AtomEvent::Note(AtomNote { + absolute_start: Ticks(0), + duration: Ticks(240), + pitch: Pitch(p), + velocity: Velocity(90), + marks: NoteMarks::empty(), + position: None, + }) + }; + score + .tracks + .first_mut() + .expect("track") + .voices + .first_mut() + .expect("voice") + .event_groups + .push(EventGroup { + kind: EventGroupKind::Chord, + atoms: vec![chord(40), chord(47)], + technique_spans: Vec::new(), + }); + + let meta = build_chunk_meta_record( + &score, 0, "dgd_001", "Riff", "riff.gp5", "standard_e", 0, "", "", -1, 3, 0, false, + "", "t", "t", + ) + .expect("record builds"); + + // The chord voicing the tab states is auto-tagged, mirroring the CLI front. + assert!(meta.tags.contains(&SwancoreTag::PowerChord), "{:?}", meta.tags); + } + #[test] fn build_chunk_record_rejects_out_of_range_track() { let score = sample_part_a();