diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 7d2320b5..24958367 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -34,6 +34,20 @@ jobs: ~/.cargo/git web/target key: web-wasm-${{ hashFiles('web/Cargo.toml', 'core/Cargo.toml') }} + # GP support needs wasm-bindgen (ADR-0025); the CLI must match the crate + # version pinned in web/Cargo.toml. Cache the built binary across runs. + - name: Cache wasm-bindgen-cli + uses: actions/cache@v4 + with: + path: ~/.cargo/bin/wasm-bindgen + key: wasm-bindgen-cli-${{ hashFiles('web/Cargo.toml') }} + - name: Install wasm-bindgen-cli + run: | + if command -v wasm-bindgen >/dev/null; then + echo "cached: $(wasm-bindgen --version)"; exit 0 + fi + ver=$(grep -m1 'wasm-bindgen = ' web/Cargo.toml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + cargo install wasm-bindgen-cli --version "$ver" --locked - name: Build playground run: ./web/build.sh - uses: actions/upload-pages-artifact@v3 diff --git a/cli/src/main.rs b/cli/src/main.rs index f3fcd85a..d2870afb 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -11,8 +11,9 @@ use griff_core::{ classify::{self, BarClass}, complement, corpus::{ - ChunkId, ChunkMeta, EnsembleGroup, EnsembleRef, PairRelation, QualityFlag, - ReviewerDecision, SourceFormat, SourceRef, StyleCohort, SwancoreTag, + Acquisition, BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, + PairRelation, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, + SourceRef, StyleCohort, SwancoreTag, SCHEMA_VERSION, }, event::{NoteMarks, NotePosition, Pitch, TechniqueSource, Ticks}, generate, gesture, @@ -139,6 +140,18 @@ enum Command { #[arg(long)] ensemble: bool, }, + + /// Build a corpus manifest from a directory of curated `*.chunk.json` / + /// `*.group.json` records and print a coverage summary (count toward the + /// S7 ~100-phrase gate, cohort mix, rights, and review status). + Manifest { + /// Directory holding the curated chunk and group JSON records. + #[arg(value_name = "DIR")] + dir: PathBuf, + /// Output path for the manifest JSON (default: `/manifest.json`). + #[arg(short, long, value_name = "OUTPUT")] + output: Option, + }, } fn run() -> Result<(), CliError> { @@ -168,6 +181,7 @@ fn run() -> Result<(), CliError> { output, ensemble, } => cmd_curate(&path, output.as_deref(), ensemble), + Command::Manifest { dir, output } => cmd_manifest(&dir, output.as_deref()), } } @@ -854,6 +868,26 @@ fn source_format(score: &Score) -> SourceFormat { } } +/// Detects phrase boundaries (S4) for `track_index`, scaling the detector's +/// tick gaps to the score's PPQN exactly as `griff phrases` does, and maps them +/// to corpus [`BoundaryEntry`] records. +fn detect_boundaries(score: &Score, track_index: usize) -> Vec { + let ppqn = u32::from(score.ticks_per_quarter); + let config = boundary::BoundaryConfig { + min_gap: Ticks(ppqn.saturating_mul(2)), + quantize_ticks: Ticks(ppqn.checked_div(4).unwrap_or(1).max(1)), + ..boundary::BoundaryConfig::default() + }; + boundary::detect_phrase_boundaries(score, track_index, &config) + .into_iter() + .map(|b| BoundaryEntry { + start_tick: b.start_tick.0, + end_tick: b.end_tick.0, + score: b.score, + }) + .collect() +} + /// Measures `track_index` (when present) and assembles one chunk record. #[allow(clippy::too_many_arguments)] // a private assembly seam shared by both curate modes fn build_chunk_meta( @@ -883,6 +917,7 @@ fn build_chunk_meta( let structure = track_index.and_then(|idx| structure::measure_structure(score, idx).ok()); 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)); let now = "2026-05-20T00:00:00Z".to_owned(); ChunkMeta { @@ -898,7 +933,7 @@ fn build_chunk_meta( time_signature, tuning: inputs.tuning.clone(), tags: inputs.tags.clone(), - boundaries: Vec::new(), + boundaries, techniques: Vec::new(), quality_flags: inputs.quality_flags.clone(), reviewer: inputs.reviewer, @@ -907,6 +942,7 @@ fn build_chunk_meta( complexity, style_cohort: Some(inputs.style_cohort), ensemble, + rights: Some(inputs.rights.clone()), created_at: now.clone(), updated_at: now, } @@ -942,6 +978,86 @@ fn write_output(path: &Path, json: &str) -> Result<(), CliError> { Ok(()) } +/// Builds a [`CorpusManifest`] from a directory of curated records, prints a +/// coverage summary, and writes the manifest. Globs `*.chunk.json` into chunks +/// and `*.group.json` into groups (both sorted, so the manifest is +/// deterministic regardless of directory order). +fn cmd_manifest(dir: &Path, output: Option<&Path>) -> Result<(), CliError> { + let mut chunk_paths: Vec = Vec::new(); + let mut group_paths: Vec = Vec::new(); + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.ends_with(".group.json") { + group_paths.push(path); + } else if name.ends_with(".chunk.json") { + chunk_paths.push(path); + } + } + chunk_paths.sort(); + group_paths.sort(); + + let mut chunks = Vec::with_capacity(chunk_paths.len()); + for path in &chunk_paths { + let json = fs::read_to_string(path)?; + chunks.push(serde_json::from_str::(&json).map_err(CliError::Json)?); + } + let mut groups = Vec::with_capacity(group_paths.len()); + for path in &group_paths { + let json = fs::read_to_string(path)?; + groups.push(serde_json::from_str::(&json).map_err(CliError::Json)?); + } + + let manifest = CorpusManifest { + schema_version: SCHEMA_VERSION, + chunks, + groups, + }; + print_manifest_summary(&manifest); + + let out_path = output.map_or_else(|| dir.join("manifest.json"), PathBuf::from); + let json = serde_json::to_string_pretty(&manifest).map_err(CliError::Json)?; + write_output(&out_path, &json) +} + +/// Prints corpus coverage: progress toward the S7 ~100-phrase gate, cohort mix +/// (decisions 2026-06-11 targets ~70-80% core), rights coverage, and reviews. +fn print_manifest_summary(manifest: &CorpusManifest) { + let chunks = &manifest.chunks; + let n = chunks.len(); + let core = chunks + .iter() + .filter(|c| c.style_cohort == Some(StyleCohort::Core)) + .count(); + let adjacent = chunks + .iter() + .filter(|c| c.style_cohort == Some(StyleCohort::Adjacent)) + .count(); + let accepted = chunks + .iter() + .filter(|c| c.reviewer == Some(ReviewerDecision::Accepted)) + .count(); + let with_rights = chunks.iter().filter(|c| c.rights.is_some()).count(); + let redistributable = chunks + .iter() + .filter(|c| c.rights.as_ref().is_some_and(|r| r.redistributable)) + .count(); + + println!("Corpus manifest (schema v{})", manifest.schema_version); + println!("Chunks : {n} (S7 graph layer recommended at ~100)"); + println!( + "Cohort : {core} core / {adjacent} adjacent / {} unlabeled", + n.saturating_sub(core).saturating_sub(adjacent) + ); + println!("Review : {accepted}/{n} accepted"); + println!("Rights : {with_rights}/{n} recorded · {redistributable} redistributable"); + if !manifest.groups.is_empty() { + println!("Groups : {}", manifest.groups.len()); + } +} + struct CurateInputs { id: String, title: String, @@ -950,6 +1066,7 @@ struct CurateInputs { tags: Vec, quality_flags: Vec, reviewer: Option, + rights: RightsInfo, } fn gather_curate_inputs(ensemble: bool) -> Result { @@ -1011,6 +1128,8 @@ fn gather_curate_inputs(ensemble: bool) -> Result { _ => None, }; + let rights = gather_rights(&mut input_buf)?; + Ok(CurateInputs { id, title, @@ -1019,6 +1138,52 @@ fn gather_curate_inputs(ensemble: bool) -> Result { tags, quality_flags, reviewer, + rights, + }) +} + +/// Prompts for the schema-v7 rights record (decisions 2026-06-12). Defaults +/// match the common case — a scraped community tab of a copyrighted modern-metal +/// composition, not redistributable — so a blank answer is the safe one. +fn gather_rights(input_buf: &mut String) -> Result { + println!( + "Rights status: 0=public_domain 1=cc_by 2=cc_by_sa \ + 3=copyrighted_composition 4=unknown" + ); + let status_input = prompt_line(input_buf, "Rights [3=copyrighted_composition]")?; + let rights_status = match status_input.trim() { + "0" => RightsStatus::PublicDomain, + "1" => RightsStatus::CcBy, + "2" => RightsStatus::CcBySa, + "4" => RightsStatus::Unknown, + _ => RightsStatus::CopyrightedComposition, + }; + + println!( + "Acquisition: 0=community_tab_site 1=purchased_official 2=self_transcribed \ + 3=omr_from_scan 4=artist_provided" + ); + let acq_input = prompt_line(input_buf, "Acquisition [0=community_tab_site]")?; + let acquisition = match acq_input.trim() { + "1" => Acquisition::PurchasedOfficial, + "2" => Acquisition::SelfTranscribed, + "3" => Acquisition::OmrFromScan, + "4" => Acquisition::ArtistProvided, + _ => Acquisition::CommunityTabSite, + }; + + let redist_input = prompt_line(input_buf, "Redistributable? 0=no 1=yes [0=no]")?; + let redistributable = redist_input.trim() == "1"; + + let notes = prompt_line(input_buf, "Rights notes (source URL, date, publisher)")? + .trim() + .to_owned(); + + Ok(RightsInfo { + rights_status, + acquisition, + redistributable, + notes, }) } diff --git a/cli/tests/curate_cmd.rs b/cli/tests/curate_cmd.rs index 863e1aa4..fc945779 100644 --- a/cli/tests/curate_cmd.rs +++ b/cli/tests/curate_cmd.rs @@ -18,8 +18,13 @@ use std::{ }; use griff_core::{ + boundary, complement::measure_pair_axes, - corpus::{ChunkId, ChunkMeta, EnsembleGroup, EnsembleRef, StyleCohort}, + corpus::{ + Acquisition, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, RightsInfo, + RightsStatus, StyleCohort, SCHEMA_VERSION, + }, + event::Ticks, gesture, midi, score::AtomEvent, structure, @@ -382,3 +387,187 @@ fn curate_records_complexity_of_the_first_note_bearing_track() { "curate persists the measured complexity profile" ); } + +/// Schema v7: the rights prompt (after the reviewer decision) is recorded — +/// status, acquisition, redistributable flag, and free-form notes. +#[test] +fn curate_records_rights() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/simple_4_4.mid"); + let out_path = + std::env::temp_dir().join(format!("griff_curate_v7_{}.chunk.json", std::process::id())); + + let mut child = Command::new(griff_bin()) + .arg("curate") + .arg(&fixture) + .arg("--output") + .arg(&out_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn griff curate"); + child + .stdin + .as_mut() + .expect("piped stdin") + // id, title, tuning, cohort, tags, flags, decision (all blank/default), + // then rights: status 0=public_domain, acquisition 2=self_transcribed, + // redistributable 1=yes, notes. + .write_all(b"rt_001\nRights Chunk\n\n\n\n\n\n0\n2\n1\npdmx.example/score, 2026-06-16\n") + .expect("write curate answers"); + let out = child.wait_with_output().expect("wait for curate"); + assert!( + out.status.success(), + "curate must exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json = std::fs::read_to_string(&out_path).expect("curate wrote the record"); + let _cleanup = std::fs::remove_file(&out_path); + let meta: ChunkMeta = serde_json::from_str(&json).expect("record parses as ChunkMeta"); + + assert_eq!( + meta.rights, + Some(RightsInfo { + rights_status: RightsStatus::PublicDomain, + acquisition: Acquisition::SelfTranscribed, + redistributable: true, + notes: "pdmx.example/score, 2026-06-16".to_owned(), + }) + ); +} + +/// S4 wiring: curate persists the measured track's detected phrase boundaries +/// (no longer hardcoded empty), matching what the detector reports. +#[test] +fn curate_records_phrase_boundaries() { + // two_phrases has detectable boundaries (unlike the single-phrase fixtures). + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/two_phrases.mid"); + let out_path = std::env::temp_dir().join(format!( + "griff_curate_bnd_{}.chunk.json", + std::process::id() + )); + + let mut child = Command::new(griff_bin()) + .arg("curate") + .arg(&fixture) + .arg("--output") + .arg(&out_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn griff curate"); + child + .stdin + .as_mut() + .expect("piped stdin") + .write_all(b"bnd_001\nBoundaries\n\n\n\n\n\n") + .expect("write curate answers"); + let out = child.wait_with_output().expect("wait for curate"); + assert!( + out.status.success(), + "curate must exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let json = std::fs::read_to_string(&out_path).expect("curate wrote the record"); + let _cleanup = std::fs::remove_file(&out_path); + let meta: ChunkMeta = serde_json::from_str(&json).expect("record parses as ChunkMeta"); + + // Recompute with the same PPQN-scaled config the curate path applies. + let bytes = std::fs::read(&fixture).expect("fixture bytes"); + let score = midi::import_score(&bytes).expect("fixture imports"); + let track = score + .tracks + .iter() + .position(|t| { + t.voices + .iter() + .flat_map(|v| &v.event_groups) + .flat_map(|g| &g.atoms) + .any(|a| matches!(a, AtomEvent::Note(_))) + }) + .expect("fixture has a note-bearing track"); + let ppqn = u32::from(score.ticks_per_quarter); + let config = boundary::BoundaryConfig { + min_gap: Ticks(ppqn.saturating_mul(2)), + quantize_ticks: Ticks(ppqn.checked_div(4).unwrap_or(1).max(1)), + ..Default::default() + }; + let expected: Vec<(u32, u32)> = boundary::detect_phrase_boundaries(&score, track, &config) + .into_iter() + .map(|b| (b.start_tick.0, b.end_tick.0)) + .collect(); + let got: Vec<(u32, u32)> = meta + .boundaries + .iter() + .map(|b| (b.start_tick, b.end_tick)) + .collect(); + + assert!( + !got.is_empty(), + "two_phrases fixture has detectable boundaries" + ); + assert_eq!(got, expected, "curate persists the detector's boundaries"); +} + +/// `griff manifest` assembles a `CorpusManifest` from a directory of curated +/// chunk records at the current schema version. +#[test] +fn manifest_builds_from_curated_chunks() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/two_phrases.mid"); + let dir = std::env::temp_dir().join(format!("griff_manifest_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create corpus dir"); + + for id in ["m_001", "m_002"] { + let mut child = Command::new(griff_bin()) + .arg("curate") + .arg(&fixture) + .arg("--output") + .arg(dir.join(format!("{id}.chunk.json"))) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn curate"); + child + .stdin + .as_mut() + .expect("piped stdin") + .write_all(format!("{id}\nChunk {id}\n\n\n\n\n\n").as_bytes()) + .expect("write curate answers"); + assert!( + child + .wait_with_output() + .expect("wait for curate") + .status + .success(), + "curate must exit 0" + ); + } + + let out = Command::new(griff_bin()) + .arg("manifest") + .arg(&dir) + .output() + .expect("run griff manifest"); + assert!( + out.status.success(), + "manifest must exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let manifest_json = + std::fs::read_to_string(dir.join("manifest.json")).expect("manifest was written"); + let _cleanup = std::fs::remove_dir_all(&dir); + let manifest: CorpusManifest = + serde_json::from_str(&manifest_json).expect("manifest parses as CorpusManifest"); + + assert_eq!(manifest.schema_version, SCHEMA_VERSION); + assert_eq!( + manifest.chunks.len(), + 2, + "both curated chunks land in the manifest" + ); +} diff --git a/core/src/corpus.rs b/core/src/corpus.rs index 2535cbd1..2a463e48 100644 --- a/core/src/corpus.rs +++ b/core/src/corpus.rs @@ -31,7 +31,12 @@ use crate::structure::{ComplexityProfile, StructureMetrics}; /// - v6 — the per-axis complexity profile (S14): `ChunkMeta` gains optional /// measured [`ComplexityProfile`] under the same pattern; pre-v6 records /// load it as `None` and re-serialize losslessly. -pub const SCHEMA_VERSION: u32 = 6; +/// - v7 — per-chunk rights + provenance (decisions 2026-06-12): `ChunkMeta` +/// gains optional [`RightsInfo`] under the same pattern; pre-v7 records (no +/// `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. +pub const SCHEMA_VERSION: u32 = 7; // ── identifiers ─────────────────────────────────────────────────────────────── @@ -243,6 +248,62 @@ pub struct EnsembleGroup { pub relations: Vec, } +// ── rights and provenance (schema v7) ───────────────────────────────────────── + +/// Rights status of a chunk's underlying material (decisions 2026-06-12). +/// +/// Not an OSS licence — this records the *composition* rights status, paired +/// with [`Acquisition`] provenance. Most scraped, purchased, or self-transcribed +/// modern-metal tabs are `CopyrightedComposition`; only public-domain sources +/// (e.g. PDMX `MusicXML`) are freely redistributable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RightsStatus { + /// Public-domain composition (e.g. PDMX `MusicXML`). + PublicDomain, + /// Creative Commons Attribution. + CcBy, + /// Creative Commons Attribution-ShareAlike. + CcBySa, + /// Composition under copyright (the common case for modern-metal tabs). + CopyrightedComposition, + /// Rights status not yet determined. + Unknown, +} + +/// How a chunk's source file was acquired (decisions 2026-06-12). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Acquisition { + /// Scraped from a community tab site (Ultimate Guitar, Songsterr, …). + CommunityTabSite, + /// Purchased from an official publisher (e.g. Sheet Happens GP). + PurchasedOfficial, + /// Transcribed by the curator (own transcription does not transfer the + /// underlying composition rights). + SelfTranscribed, + /// Optical music recognition from a scan. + OmrFromScan, + /// Provided directly by the artist. + ArtistProvided, +} + +/// Per-chunk rights and provenance (schema v7, decisions 2026-06-12). +/// +/// `redistributable` is a typed fact — not a freeform note — because +/// `novelty.rs` and any future export gate must filter on it without scanning +/// prose. Captured at curation time: rights status cannot be derived from the +/// notes, so backfilling would mean re-researching provenance per source. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RightsInfo { + pub rights_status: RightsStatus, + pub acquisition: Acquisition, + /// Whether the chunk's source material may be redistributed. + pub redistributable: bool, + /// Free-form provenance note (source URL, acquisition date, publisher). + pub notes: String, +} + // ── chunk metadata ──────────────────────────────────────────────────────────── /// Full annotation for one corpus chunk. @@ -292,6 +353,11 @@ pub struct ChunkMeta { /// the key is skipped when unset. #[serde(default, skip_serializing_if = "Option::is_none")] pub ensemble: Option, + /// Rights and provenance (schema v7, decisions 2026-06-12). Absent in pre-v7 + /// records — the key is skipped when unset, so older files round-trip + /// byte-identically. Captured at curation time; cannot be backfilled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rights: Option, /// ISO 8601 creation timestamp. pub created_at: String, /// ISO 8601 last-modified timestamp. diff --git a/core/tests/corpus_schema.rs b/core/tests/corpus_schema.rs index 20692720..5cf6def3 100644 --- a/core/tests/corpus_schema.rs +++ b/core/tests/corpus_schema.rs @@ -9,9 +9,9 @@ use griff_core::complement::AxisScores; use griff_core::corpus::{ - BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, PairRelation, - QualityFlag, ReviewerDecision, SourceFormat, SourceRef, StyleCohort, SwancoreTag, - SCHEMA_VERSION, + Acquisition, BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, + PairRelation, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, SourceRef, + StyleCohort, SwancoreTag, SCHEMA_VERSION, }; use griff_core::gesture::GestureStats; use griff_core::structure::{ComplexityProfile, StructureMetrics}; @@ -87,21 +87,100 @@ fn minimal_chunk() -> ChunkMeta { complexity: None, style_cohort: None, ensemble: None, + rights: None, created_at: "2026-05-20T00:00:00Z".to_owned(), updated_at: "2026-05-20T00:00:00Z".to_owned(), } } -// ── schema v6: the per-axis complexity profile (S14) ────────────────────────── +// ── schema v7: rights + provenance (decisions 2026-06-12) ───────────────────── #[test] -fn schema_version_is_6() { +fn schema_version_is_7() { assert_eq!( - SCHEMA_VERSION, 6, - "the complexity profile bumps the corpus schema" + SCHEMA_VERSION, 7, + "the rights record bumps the corpus schema" + ); +} + +/// A representative rights record (the common scraped-community-tab case). +fn sample_rights() -> RightsInfo { + RightsInfo { + rights_status: RightsStatus::CopyrightedComposition, + acquisition: Acquisition::CommunityTabSite, + redistributable: false, + notes: "ultimate-guitar.com, 2026-06-12".to_owned(), + } +} + +#[test] +fn chunk_meta_with_rights_roundtrips() { + let mut meta = minimal_chunk(); + meta.rights = Some(sample_rights()); + + let json = serde_json::to_string(&meta).expect("serialize"); + assert!( + json.contains("\"rights\""), + "v7 records carry the rights key" + ); + assert!( + json.contains("\"copyrighted_composition\"") && json.contains("\"community_tab_site\""), + "rights enums serialize snake_case: {json}" + ); + let back: ChunkMeta = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.rights, Some(sample_rights())); + let json2 = serde_json::to_string(&back).expect("re-serialize"); + assert_eq!(json, json2, "JSON round-trip must be byte-identical"); +} + +#[test] +fn pre_v7_record_without_rights_loads_as_none() { + // A pre-v7 record has no rights key; it parses as None and re-serializes + // without inventing the key. + let old_json = serde_json::to_string(&minimal_chunk()).expect("serialize"); + assert!( + !old_json.contains("\"rights\""), + "an absent rights record must not introduce a key: {old_json}" ); + + let back: ChunkMeta = serde_json::from_str(&old_json).expect("pre-v7 record must parse"); + assert!(back.rights.is_none()); + let json2 = serde_json::to_string(&back).expect("re-serialize"); + assert_eq!(old_json, json2, "JSON round-trip must be byte-identical"); +} + +#[test] +fn all_rights_statuses_roundtrip() { + for status in [ + RightsStatus::PublicDomain, + RightsStatus::CcBy, + RightsStatus::CcBySa, + RightsStatus::CopyrightedComposition, + RightsStatus::Unknown, + ] { + let json = serde_json::to_string(&status).expect("serialize"); + let back: RightsStatus = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(status, back); + } } +#[test] +fn all_acquisitions_roundtrip() { + for acq in [ + Acquisition::CommunityTabSite, + Acquisition::PurchasedOfficial, + Acquisition::SelfTranscribed, + Acquisition::OmrFromScan, + Acquisition::ArtistProvided, + ] { + let json = serde_json::to_string(&acq).expect("serialize"); + let back: Acquisition = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(acq, back); + } +} + +// ── schema v6: the per-axis complexity profile (S14) ────────────────────────── + #[test] fn chunk_meta_with_complexity_roundtrips() { let mut meta = minimal_chunk(); @@ -620,6 +699,39 @@ fn arb_ensemble() -> impl Strategy> { prop_opt(link) } +fn arb_rights() -> impl Strategy> { + let status = prop_oneof![ + Just(RightsStatus::PublicDomain), + Just(RightsStatus::CcBy), + Just(RightsStatus::CcBySa), + Just(RightsStatus::CopyrightedComposition), + Just(RightsStatus::Unknown), + ]; + let acquisition = prop_oneof![ + Just(Acquisition::CommunityTabSite), + Just(Acquisition::PurchasedOfficial), + Just(Acquisition::SelfTranscribed), + Just(Acquisition::OmrFromScan), + Just(Acquisition::ArtistProvided), + ]; + // ASCII notes (no quotes/backslashes) keep the JSON round-trip byte-identical. + let info = ( + status, + acquisition, + any::(), + "[A-Za-z0-9 :/._-]{0,40}", + ) + .prop_map( + |(rights_status, acquisition, redistributable, notes)| RightsInfo { + rights_status, + acquisition, + redistributable, + notes, + }, + ); + prop_opt(info) +} + proptest! { #[test] fn prop_chunk_meta_json_roundtrip( @@ -640,6 +752,7 @@ proptest! { complexity in arb_complexity(), style_cohort in arb_cohort(), ensemble in arb_ensemble(), + rights in arb_rights(), ) { let meta = ChunkMeta { id: ChunkId(id), @@ -663,6 +776,7 @@ proptest! { complexity, style_cohort, ensemble, + rights, created_at: "2026-05-20T00:00:00Z".to_owned(), updated_at: "2026-05-20T00:00:00Z".to_owned(), }; diff --git a/core/tests/similarity.rs b/core/tests/similarity.rs index b1699a27..8c600d45 100644 --- a/core/tests/similarity.rs +++ b/core/tests/similarity.rs @@ -146,6 +146,7 @@ fn chunk( complexity, style_cohort: None, ensemble: None, + rights: None, created_at: "2026-06-10T00:00:00Z".to_owned(), updated_at: "2026-06-10T00:00:00Z".to_owned(), } diff --git a/docs/adr/0025-guitar-pro-in-browser-needs-wasm-bindgen.md b/docs/adr/0025-guitar-pro-in-browser-needs-wasm-bindgen.md new file mode 100644 index 00000000..58a2cc2e --- /dev/null +++ b/docs/adr/0025-guitar-pro-in-browser-needs-wasm-bindgen.md @@ -0,0 +1,73 @@ +# ADR 0025: Guitar Pro in the browser needs wasm-bindgen (supersede ADR-0024's import-free web build) + +Date: 2026-06-17 +Status: Accepted + +Supersedes ADR-0024 §2–§3 and §6 (the import-free `cdylib`, the `gp`-off wasm +build, and the no-`wasm-bindgen` toolchain). ADR-0024's other decisions — egui as +the M2 canonical web frontend, WebAudio, determinism — stand. + +## Context + +ADR-0024 shipped the M1 web playground as an *import-free* `cdylib`: `griff-core` +built with `default-features = false` (GP off), exporting C-ABI functions, loaded +with `WebAssembly.instantiate(bytes, {})` — no `wasm-bindgen`, ~90 KiB. That kept +the build trivial but made the browser **MIDI-only**. + +The corpus is swancore-first (ADR-0005), and swancore tabs are overwhelmingly +**Guitar Pro**, not MIDI. Phone-side curation — the reason the web front exists — +is dead without GP loading: the maintainer works from a phone and cannot feed the +corpus real material there. Loading GP in the browser is the unblocker. + +The Rust GP reader is not import-free-compatible. `guitarpro` → `zip` (a +non-optional dependency; `.gpx` is a zip container) → `time` → `js-sys` → +`wasm-bindgen`, and `zip` → `getrandom`, whose wasm support also routes through +`wasm-bindgen`. A `getrandom` *custom* backend (to dodge that) fails to compile on +`wasm32-unknown-unknown` in getrandom 0.4.2 (a `WEB_CRYPTO` bug), and `time` pulls +`wasm-bindgen` independently regardless. There is no lean shortcut: GP through the +shared Rust parser requires the `wasm-bindgen` toolchain. + +The alternative — parse GP in JavaScript (e.g. alphaTab) and feed notes to the +wasm — was rejected: it forks parsing out of `griff-core`, so the browser and the +CLI would disagree on coverage and bugs, and it adds a heavy JS dependency. + +## Decision + +1. **The web build uses `wasm-bindgen`** (`--target web`), not the import-free + `cdylib`. `griff-web` exports two `#[wasm_bindgen]` functions returning JSON + strings (`arrange`, `load_score(bytes)`); the manual linear-memory marshalling + is gone. The page loads the generated ES module (` + diff --git a/web/static/style.css b/web/static/style.css index aa078c77..77b61a56 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -64,6 +64,16 @@ select { /* Big touch targets for phones. */ input[type="range"] { height: 40px; } +input[type="file"] { + width: 100%; + color: var(--ink); + background: #0d1017; + border: 1px solid #2a3146; + border-radius: 10px; + padding: 10px; + font-size: 0.9rem; +} + .transport { display: flex; gap: 10px;