diff --git a/core/src/complement.rs b/core/src/complement.rs index 97e7aa1c..205268a6 100644 --- a/core/src/complement.rs +++ b/core/src/complement.rs @@ -443,6 +443,7 @@ const fn span_label(technique: SpanTechnique) -> &'static str { SpanTechnique::HammerOn => "hammer_on", SpanTechnique::PullOff => "pull_off", SpanTechnique::Vibrato => "vibrato", + SpanTechnique::LetRing => "let_ring", } } diff --git a/core/src/corpus.rs b/core/src/corpus.rs index 2a463e48..ac0b142c 100644 --- a/core/src/corpus.rs +++ b/core/src/corpus.rs @@ -36,6 +36,13 @@ use crate::structure::{ComplexityProfile, StructureMetrics}; /// `rights` key) keep loading and re-serialize losslessly. Rights status is /// not derivable from content, so it is captured at curation time and cannot /// be backfilled. +/// +/// Tag taxonomy is intentionally *not* versioned here: [`SwancoreTag`] grows +/// additively (e.g. `let_ring`, #75) and `SCHEMA_VERSION` tracks structural +/// `ChunkMeta` changes (the optional-field, forward-compatible pattern above), +/// not the tag set — a new tag only breaks readers that hard-reject unknown +/// variants, a curation-tooling concern, not a corpus-structure one +/// (decisions 2026-06-19). pub const SCHEMA_VERSION: u32 = 7; // ── identifiers ─────────────────────────────────────────────────────────────── @@ -106,6 +113,8 @@ pub enum SwancoreTag { Bend, Vibrato, PalmMute, + /// Let ring — notes left to sustain into following beats. + LetRing, NaturalHarmonic, ArtificialHarmonic, // ── rhythm ───────────────────────────────────────────────────────────── @@ -144,6 +153,7 @@ impl SwancoreTag { Self::Bend, Self::Vibrato, Self::PalmMute, + Self::LetRing, Self::NaturalHarmonic, Self::ArtificialHarmonic, Self::Syncopated, diff --git a/core/src/dump.rs b/core/src/dump.rs index 66f9d429..97ebf1ee 100644 --- a/core/src/dump.rs +++ b/core/src/dump.rs @@ -225,6 +225,7 @@ const fn span_name(technique: SpanTechnique) -> &'static str { SpanTechnique::HammerOn => "hammer_on", SpanTechnique::PullOff => "pull_off", SpanTechnique::Vibrato => "vibrato", + SpanTechnique::LetRing => "let_ring", } } diff --git a/core/src/event.rs b/core/src/event.rs index 631eb3bd..6943a968 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -147,6 +147,8 @@ pub enum SpanTechnique { PullOff, /// Vibrato. Vibrato, + /// Let ring — the note is left to sustain into following beats. + LetRing, } /// Where a technique came from — import-side provenance (ADR-0018; the diff --git a/core/src/gp.rs b/core/src/gp.rs index 5435e998..b1408927 100644 --- a/core/src/gp.rs +++ b/core/src/gp.rs @@ -597,6 +597,9 @@ fn map_gp_note_marks( if effect.vibrato { push_span(SpanTechnique::Vibrato); } + if effect.let_ring { + push_span(SpanTechnique::LetRing); + } let mut marks = NoteMarks::empty(); if effect.accentuated_note || effect.heavy_accentuated_note { @@ -1090,6 +1093,21 @@ mod tests { assert_eq!(spans[0].evidence, TechniqueEvidence::explicit()); } + #[test] + fn gp_let_ring_emits_explicit_span() { + // let-ring is a per-note sustain technique GP records (#75); it must + // surface as a LetRing TechniqueSpan so derivation/curation can see it. + let effect = GpNoteEffect { + let_ring: true, + ..GpNoteEffect::default() + }; + let mut spans = Vec::new(); + let _ = map_gp_note_marks(&effect, Ticks(0), Ticks(480), &mut spans); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].technique, SpanTechnique::LetRing); + assert_eq!(spans[0].evidence, TechniqueEvidence::explicit()); + } + #[test] fn dead_note_imports_as_dead_marked_note() { // A muted "X" note (NoteType::Dead) still carries a real (string, fret): diff --git a/core/src/technique.rs b/core/src/technique.rs index cc03225f..9341f13a 100644 --- a/core/src/technique.rs +++ b/core/src/technique.rs @@ -35,7 +35,7 @@ pub struct DerivedTechniques { } /// Spanning techniques in canonical (declaration) order. -const SPANS: [SpanTechnique; 7] = [ +const SPANS: [SpanTechnique; 8] = [ SpanTechnique::Slide, SpanTechnique::Bend, SpanTechnique::Legato, @@ -43,6 +43,7 @@ const SPANS: [SpanTechnique; 7] = [ SpanTechnique::HammerOn, SpanTechnique::PullOff, SpanTechnique::Vibrato, + SpanTechnique::LetRing, ]; /// `lower_snake_case` name for a spanning technique. @@ -55,6 +56,7 @@ const fn span_name(t: SpanTechnique) -> &'static str { SpanTechnique::HammerOn => "hammer_on", SpanTechnique::PullOff => "pull_off", SpanTechnique::Vibrato => "vibrato", + SpanTechnique::LetRing => "let_ring", } } @@ -68,6 +70,7 @@ const fn span_tag(t: SpanTechnique) -> Option { SpanTechnique::HammerOn => Some(SwancoreTag::HammerOn), SpanTechnique::PullOff => Some(SwancoreTag::PullOff), SpanTechnique::Vibrato => Some(SwancoreTag::Vibrato), + SpanTechnique::LetRing => Some(SwancoreTag::LetRing), SpanTechnique::Legato => None, } } diff --git a/core/tests/technique_tags.rs b/core/tests/technique_tags.rs index ea30daac..289d4ab2 100644 --- a/core/tests/technique_tags.rs +++ b/core/tests/technique_tags.rs @@ -91,6 +91,15 @@ fn maps_spans_and_marks_to_direct_tags_and_a_superset_name_list() { } } +#[test] +fn let_ring_span_derives_the_let_ring_tag_and_name() { + // let-ring is newly parsed from GP (#75): a LetRing span must auto-derive + // both the dedicated SwancoreTag::LetRing and the "let_ring" name. + let d = derive_techniques(&score_with(&[SpanTechnique::LetRing], NoteMarks::empty()), 0); + assert_eq!(d.tags, vec![SwancoreTag::LetRing]); + assert_eq!(d.names, vec!["let_ring".to_owned()]); +} + #[test] fn presence_only_so_repeats_dedupe_and_output_is_deterministic() { // The same technique across groups/notes is "present", counted once. diff --git a/docs/decisions.log.md b/docs/decisions.log.md index e3f73f18..ec568c00 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -1251,3 +1251,14 @@ Architectural decisions go to [`adr/`](adr/) instead. is duplicated and kept in step (both fronts now test the track-consistency rule). Accepted: `arrange` generation is untouched, and `web/dist` stays gitignored (CI rebuilds it on deploy). + +- 2026-06-19 — In the context of auto-deriving the `let_ring` tag (and the rest + of #75's tag taxonomy to come), facing Codex's point that a new serialized + `SwancoreTag` value is unreadable by older `SCHEMA_VERSION = 7` tooling (serde + rejects unknown enum variants), we decided for keeping the version at 7 and + growing the tag taxonomy additively, and against bumping per tag or once for + the whole #75 expansion, to keep `SCHEMA_VERSION` meaning what v1–v7 set it to + mean — structural `ChunkMeta` field additions under the forward-compatible + 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).