diff --git a/Cargo.lock b/Cargo.lock index 7785784b..37431dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1861,6 +1861,7 @@ dependencies = [ "proptest", "serde", "serde_json", + "sha2", "thiserror 2.0.18", ] diff --git a/cli/src/generation_input.rs b/cli/src/generation_input.rs index 37bc4f83..74f2ec61 100644 --- a/cli/src/generation_input.rs +++ b/cli/src/generation_input.rs @@ -13,7 +13,7 @@ use std::fs; use std::path::Path; -use griff_core::corpus::ChunkMeta; +use griff_core::corpus::{source_sha256, ChunkMeta}; use griff_core::generation_input::{corpus_material, prepare_chunk, LoadedChunk}; use griff_core::import; @@ -61,7 +61,15 @@ pub fn load_corpus_material(dir: &Path) -> Result Option { let meta: ChunkMeta = serde_json::from_str(&fs::read_to_string(dir.join(record_name)).ok()?).ok()?; - let source = - import::import_score_auto(&fs::read(dir.join(&meta.source.filename)).ok()?).ok()?; + let bytes = fs::read(dir.join(&meta.source.filename)).ok()?; + // A filename is not an identity: when the record pins the source's hash + // (schema v9), a same-named but different file must not silently supply the + // notes. A mismatch is a load failure, reported like a missing source. + if let Some(expected) = &meta.source.sha256 { + if &source_sha256(&bytes) != expected { + return None; + } + } + let source = import::import_score_auto(&bytes).ok()?; prepare_chunk(meta, &source) } diff --git a/cli/src/main.rs b/cli/src/main.rs index d8911086..c4921f9e 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, fmt, fs, io::{self, Error as IoError, Write as IoWrite}, ops::Range, @@ -16,13 +17,14 @@ use griff_core::{ classify::{self, BarClass}, complement, corpus::{ - Acquisition, BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, - PairRelation, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, - SourceRef, StyleCohort, SwancoreTag, SCHEMA_VERSION, + source_sha256, Acquisition, BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, + EnsembleGroup, EnsembleRef, PairRelation, QualityFlag, ReviewerDecision, RightsInfo, + RightsStatus, SourceFormat, SourceRef, StyleCohort, SwancoreTag, SCHEMA_VERSION, }, event::{NoteMarks, NotePosition, TechniqueSource, Ticks}, generate, gesture, harmony, import::{self, ImportError}, + ingest, midi::{self, MidiError}, novelty, rerank, score::{AtomEvent, Score, Track, Voice}, @@ -222,6 +224,23 @@ enum Command { output: Option, }, + /// Bulk-ingest a directory of MIDI / Guitar Pro files into corpus chunks: + /// each file's guitar (and optional bass) tracks are phrase-split, linked + /// as one per-file ensemble group, and stamped with community-tab rights. + /// Chunks are uncurated candidates — tags and reviewer are filled later. + Ingest { + /// Directory of source tab files to ingest. + #[arg(value_name = "DIR")] + dir: PathBuf, + /// Output directory for the chunk / group records (default: `corpus`). + #[arg(short, long, value_name = "OUT")] + output: Option, + /// Also ingest bass tracks (kept as separate parts, never mixed with + /// guitar). + #[arg(long)] + with_bass: 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). @@ -349,6 +368,11 @@ fn run() -> Result<(), CliError> { ensemble, } => cmd_curate(&path, output.as_deref(), ensemble), Command::Split { path, output } => cmd_split(&path, output.as_deref()), + Command::Ingest { + dir, + output, + with_bass, + } => cmd_ingest(&dir, output.as_deref(), with_bass), Command::Manifest { dir, output } => cmd_manifest(&dir, output.as_deref()), Command::Swang { command } => match command { SwangCommand::Check { input } => cmd_swang_check(&input), @@ -1298,6 +1322,20 @@ fn phrase_chunks( .ok_or_else(|| { CliError::Split("split needs a track with notes in its primary voice".to_owned()) })?; + phrase_chunks_for_track(path, score, inputs, track, None) +} + +/// Phrase-splits a specific `track` (rather than the first note-bearing one), +/// optionally linking every chunk to `ensemble`. `griff split` picks the track +/// automatically; `griff ingest` drives this per selected guitar so both parts +/// of one tab become chunks under a shared group. +fn phrase_chunks_for_track( + path: &Path, + score: &Score, + inputs: &CurateInputs, + track: usize, + ensemble: Option, +) -> Result, CliError> { let cuts: Vec = detect_boundaries(score, track) .iter() .map(|b| b.start_tick) @@ -1311,7 +1349,15 @@ fn phrase_chunks( return Err(CliError::Split("score has no bars to split".to_owned())); } - Ok(chunks_for_segments(path, score, inputs, track, &segments)) + let mut chunks = chunks_for_segments(path, score, inputs, track, &segments); + // Link every phrase to its ensemble group after the split — `ChunkMeta` + // owns the field, so no split-path signature has to carry it. + if let Some(link) = ensemble { + for chunk in &mut chunks { + chunk.meta.ensemble = Some(link.clone()); + } + } + Ok(chunks) } /// Builds one [`PhraseChunk`] per segment in which `track` sounds, renumbered @@ -1377,6 +1423,316 @@ fn chunks_for_segments( .collect() } +/// Assembles one source file into corpus records for bulk ingest: every +/// selected track is phrase-split, each phrase chunk is linked to a per-file +/// ensemble group (schema v4) so a reader can tell which chunks came from one +/// tab, and all carry the default community-tab rights. Returns the phrase +/// chunks and the group; the caller writes them. Relations stay empty here — +/// this slice records provenance, not measured inter-part dependencies. +#[allow(clippy::too_many_arguments)] // the ingest assembly seam: source, target ids, and the content hash +fn assemble_ingest_group( + path: &Path, + score: &Score, + selected: &[usize], + group_id: &str, + base_title: &str, + sha256: &str, +) -> Result<(Vec, EnsembleGroup), CliError> { + let mut records: Vec = Vec::new(); + let mut members: Vec = Vec::new(); + let mut part: u32 = 0; + for &track in selected { + let role = score + .tracks + .get(track) + .map_or(ingest::TrackRole::Guitar, ingest::classify_track_role); + let inputs = default_ingest_inputs(score, track, group_id, base_title, part, role); + let link = EnsembleRef { + group_id: group_id.to_owned(), + part_index: part, + }; + let chunks = phrase_chunks_for_track(path, score, &inputs, track, Some(link))?; + if chunks.is_empty() { + // A selected track that survives no phrase keeps its part index + // free, so indices stay contiguous and the group never names a part + // with no members. + continue; + } + let track_index = u32::try_from(track).ok(); + for mut chunk in chunks { + // The exact source track and content hash (schema v9) so a chunk + // reloads the right part from the right bytes, never the first + // note-bearing track. + chunk.meta.source.track_index = track_index; + chunk.meta.source.sha256 = Some(sha256.to_owned()); + members.push(chunk.meta.id.clone()); + records.push(chunk.meta); + } + part = part.saturating_add(1); + } + Ok(( + records, + EnsembleGroup { + id: group_id.to_owned(), + members, + relations: Vec::new(), + }, + )) +} + +/// The non-interactive curation inputs for a bulk-ingested guitar part: a +/// part-qualified id, the real tuning label, and the default rights for a +/// scraped community tab (copyrighted composition, not redistributable). Tags +/// and reviewer are left empty for the cockpit curation pass. +#[allow(clippy::too_many_arguments)] // the non-interactive curation defaults for one ingested part +fn default_ingest_inputs( + score: &Score, + track: usize, + group_id: &str, + base_title: &str, + part: u32, + role: ingest::TrackRole, +) -> CurateInputs { + let tuning = score.tracks.get(track).map_or_else( + || "standard_e".to_owned(), + |t| ingest::tuning_label(&t.tuning), + ); + let quality_flags = if score.loss.is_clean() { + vec![QualityFlag::Clean] + } else { + vec![QualityFlag::Lossy] + }; + // A bass part is never labelled a guitar (it is admitted only under + // --with-bass and kept separate). + let (id_tag, label) = match role { + ingest::TrackRole::Bass => ('b', "bass"), + ingest::TrackRole::Guitar | ingest::TrackRole::Other => ('g', "guitar"), + }; + CurateInputs { + id: format!("{group_id}_{id_tag}{part}"), + title: format!("{base_title} ({label} {part})"), + tuning, + style_cohort: StyleCohort::Core, + tags: Vec::new(), + quality_flags, + reviewer: None, + rights: RightsInfo { + rights_status: RightsStatus::CopyrightedComposition, + acquisition: Acquisition::CommunityTabSite, + redistributable: false, + notes: String::new(), + }, + } +} + +/// A group id unique within one ingest run: `base`, or `base_2`, `base_3`, … +/// when `base` (or a lower suffix) is already taken. Two source files that +/// slugify to the same stem — the same song as `.gp5` and `.gpx`, or two +/// versions — must not overwrite each other's chunk records. Records the +/// chosen id in `used`. +fn unique_group_id(base: &str, used: &mut HashSet) -> String { + if used.insert(base.to_owned()) { + return base.to_owned(); + } + let mut n = 2_usize; + loop { + let candidate = format!("{base}_{n}"); + if used.insert(candidate.clone()) { + return candidate; + } + n = n.saturating_add(1); + } +} + +/// What to do when placing a source file beside its chunks in the corpus. +#[derive(Debug, PartialEq, Eq)] +enum SourceCopy { + /// No file at the destination — write it. + Write, + /// A byte-identical file is already there — reuse it, no rewrite. + Reuse, + /// A *different* file already claims that name — a typed collision, never + /// an overwrite. Human filenames are not identities. + Collision, +} + +/// Decides the copy action from the destination's hash (None when absent) and +/// the source's hash. Pure, so the policy is tested without touching disk. +fn source_copy_decision(existing_sha: Option<&str>, new_sha: &str) -> SourceCopy { + match existing_sha { + None => SourceCopy::Write, + Some(sha) if sha == new_sha => SourceCopy::Reuse, + Some(_) => SourceCopy::Collision, + } +} + +/// Places a source file beside its chunks under the collision policy: writes it +/// when absent, reuses a byte-identical one, and refuses (no overwrite) when a +/// different file already claims the name. The loader reads the source from the +/// corpus dir, so a chunk without its source there is unusable. +fn place_source( + out: &Path, + filename: &str, + bytes: &[u8], + sha256: &str, +) -> Result { + let dest = out.join(filename); + let existing_sha = fs::read(&dest) + .ok() + .map(|existing| source_sha256(&existing)); + let action = source_copy_decision(existing_sha.as_deref(), sha256); + if action == SourceCopy::Write { + fs::write(&dest, bytes)?; + } + Ok(action) +} + +/// A filesystem-safe, stable id from a source stem: lowercase, runs of +/// non-alphanumerics collapsed to one `_`, edges trimmed. +fn slugify(stem: &str) -> String { + let mut slug = String::new(); + let mut gap = false; + for ch in stem.chars() { + if ch.is_ascii_alphanumeric() { + if gap && !slug.is_empty() { + slug.push('_'); + } + slug.push(ch.to_ascii_lowercase()); + gap = false; + } else { + gap = true; + } + } + // A stem with no ASCII alphanumerics ("曲", "---") must still get a stable, + // nonempty id; `unique_group_id` then disambiguates repeats. + if slug.is_empty() { + "untitled".to_owned() + } else { + slug + } +} + +/// Bulk-ingests every tab file in `dir` into corpus chunk + group records, +/// then builds the manifest and prints a skip report. +#[allow(clippy::too_many_lines)] // one bulk I/O orchestration: walk, import, select, assemble, place, report +fn cmd_ingest(dir: &Path, output: Option<&Path>, with_bass: bool) -> Result<(), CliError> { + let out = output.map_or_else(|| PathBuf::from("corpus"), Path::to_path_buf); + fs::create_dir_all(&out)?; + + let mut entries: Vec = fs::read_dir(dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_file()) + .collect(); + entries.sort(); + + let mut ingested = 0_usize; + let mut chunk_total = 0_usize; + let mut skipped: Vec<(String, String)> = Vec::new(); + // Seed the used ids from any group already in the corpus, so re-ingesting + // into an existing directory never reuses — and overwrites — a group id. + let mut used_ids: HashSet = HashSet::new(); + if let Ok(existing) = fs::read_dir(&out) { + for entry in existing.flatten() { + if let Some(stem) = entry + .file_name() + .to_str() + .and_then(|n| n.strip_suffix(".group.json")) + { + used_ids.insert(stem.to_owned()); + } + } + } + + for path in &entries { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_owned(); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("untitled"); + + let bytes = match fs::read(path) { + Ok(b) => b, + Err(err) => { + skipped.push((name, format!("read error: {err}"))); + continue; + } + }; + let score = match import::import_score_auto(&bytes) { + Ok(score) => score, + Err(err) => { + skipped.push((name, format!("import error: {err}"))); + continue; + } + }; + + let selected = ingest::select_ingest_tracks(&score, with_bass); + if selected.is_empty() { + skipped.push((name, "no guitar or bass track".to_owned())); + continue; + } + + let group_id = unique_group_id(&slugify(stem), &mut used_ids); + let sha256 = source_sha256(&bytes); + let (records, group) = + match assemble_ingest_group(path, &score, &selected, &group_id, stem, &sha256) { + Ok(pair) => pair, + Err(err) => { + skipped.push((name, format!("assembly error: {err}"))); + continue; + } + }; + if records.is_empty() { + skipped.push((name, "no phrase survived splitting".to_owned())); + continue; + } + + // Place the source beside its chunks — the loader reads it from here. + // A name that collides with a *different* source skips this file rather + // than overwrite or mislink. + match place_source(&out, &name, &bytes, &sha256) { + Ok(SourceCopy::Collision) => { + skipped.push(( + name, + "source filename collides with a different file".to_owned(), + )); + continue; + } + Ok(_) => {} + Err(err) => { + skipped.push((name, format!("source copy error: {err}"))); + continue; + } + } + + for meta in &records { + let json = serde_json::to_string_pretty(meta).map_err(CliError::Json)?; + write_output(&out.join(format!("{}.chunk.json", meta.id.0)), &json)?; + chunk_total = chunk_total.saturating_add(1); + } + let group_json = serde_json::to_string_pretty(&group).map_err(CliError::Json)?; + write_output(&out.join(format!("{group_id}.group.json")), &group_json)?; + ingested = ingested.saturating_add(1); + } + + println!( + "ingested {ingested} file(s) into {chunk_total} phrase chunk(s) -> {}", + out.display() + ); + if !skipped.is_empty() { + println!("skipped {} file(s):", skipped.len()); + for (file, reason) in &skipped { + println!(" {file}: {reason}"); + } + } + + // Refresh the manifest over the whole corpus directory. + cmd_manifest(&out, None) +} + /// Splits `score` into phrase chunks and writes each to `.p.chunk.json`. fn curate_phrases( path: &Path, @@ -1526,6 +1882,8 @@ fn build_chunk_meta( filename, format: source_format(score), bar_range: None, + track_index: None, + sha256: None, }, tempo_bpm, ticks_per_quarter: score.ticks_per_quarter, @@ -2429,6 +2787,195 @@ mod tests { assert_eq!(relations[0].parts, (0, 1)); } + #[test] + fn colliding_stems_get_distinct_group_ids() { + use super::unique_group_id; + use std::collections::HashSet; + let mut used = HashSet::new(); + // Two source files with the same stem (e.g. `Shark Dad.gp5` and + // `Shark Dad.gpx`) must not overwrite each other's chunks. + assert_eq!(unique_group_id("shark_dad", &mut used), "shark_dad"); + assert_eq!(unique_group_id("shark_dad", &mut used), "shark_dad_2"); + assert_eq!(unique_group_id("shark_dad", &mut used), "shark_dad_3"); + assert_eq!(unique_group_id("other", &mut used), "other"); + } + + #[test] + fn slugify_makes_a_stable_id_from_a_messy_stem() { + use super::slugify; + assert_eq!( + slugify("Dance Gavin Dance - Care (ver 2 by X)"), + "dance_gavin_dance_care_ver_2_by_x" + ); + assert_eq!(slugify("A Lot Like Birds"), "a_lot_like_birds"); + assert_eq!(slugify("--edge--"), "edge"); + } + + #[test] + fn slugify_falls_back_when_a_stem_has_no_ascii_alphanumerics() { + use super::slugify; + // Non-ASCII or punctuation-only stems must still get a stable, nonempty + // id, or the chunk file names would begin with `_g`. + assert_eq!(slugify("曲"), "untitled"); + assert_eq!(slugify("---"), "untitled"); + assert_eq!(slugify(""), "untitled"); + } + + #[test] + fn source_copy_decision_reuses_a_match_and_refuses_a_conflict() { + use super::{source_copy_decision, SourceCopy}; + assert_eq!(source_copy_decision(None, "aa"), SourceCopy::Write); + assert_eq!(source_copy_decision(Some("aa"), "aa"), SourceCopy::Reuse); + assert_eq!( + source_copy_decision(Some("bb"), "aa"), + SourceCopy::Collision + ); + } + + #[test] + fn a_selected_track_with_no_surviving_phrase_frees_its_part_index() { + use super::assemble_ingest_group; + use std::path::Path; + + // Track 0's single note is a trivial phrase (dropped); track 1 is real. + let empty_gtr = Track { + name: Some("Guitar 1".to_owned()), + channel: 0, + voices: vec![voice_of(0, vec![note(0, 480, 60)])], + tuning: Tuning::standard_e(), + }; + let real_gtr = Track { + name: Some("Guitar 2".to_owned()), + channel: 0, + voices: vec![voice_of( + 0, + vec![ + note(0, 480, 50), + note(1920, 480, 53), + note(3840, 480, 50), + note(5760, 480, 55), + ], + )], + tuning: Tuning::standard_e(), + }; + let score = bars_score(4, vec![empty_gtr, real_gtr]); + + let (chunks, group) = + assemble_ingest_group(Path::new("x.gp"), &score, &[0, 1], "x", "X", "hash") + .expect("assemble succeeds"); + + assert!(!chunks.is_empty(), "track 1 still yields phrases"); + for chunk in &chunks { + let link = chunk.ensemble.as_ref().expect("group link"); + assert_eq!(link.part_index, 0, "the empty track 0 freed part 0, no gap"); + assert_eq!( + chunk.source.track_index, + Some(1), + "cut from source track 1, not the ordinal" + ); + } + assert_eq!(group.members.len(), chunks.len()); + } + + #[test] + #[allow(clippy::too_many_lines)] // one end-to-end assertion of the assembled record + fn ingest_assembles_phrase_chunks_linked_as_one_group() { + use super::assemble_ingest_group; + use griff_core::corpus::{Acquisition, RightsStatus}; + use std::path::Path; + + let gtr1 = Track { + name: Some("Guitar 1".to_owned()), + channel: 0, + voices: vec![voice_of( + 0, + vec![ + note(0, 480, 52), + note(1920, 480, 55), + note(3840, 480, 52), + note(5760, 480, 57), + ], + )], + tuning: Tuning::standard_e(), + }; + let drop_d = Tuning::new( + [64_u8, 59, 55, 50, 45, 38] + .iter() + .map(|&m| Pitch::new(m).expect("valid pitch")) + .collect(), + ); + let gtr2 = Track { + name: Some("Guitar 2".to_owned()), + channel: 0, + voices: vec![voice_of( + 0, + vec![ + note(0, 480, 50), + note(1920, 480, 53), + note(3840, 480, 50), + note(5760, 480, 55), + ], + )], + tuning: drop_d, + }; + let score = bars_score(4, vec![gtr1, gtr2]); + + let (chunks, group) = assemble_ingest_group( + Path::new("Some Band - Song.gp"), + &score, + &[0, 1], + "some_band_song", + "Some Band - Song", + "abc123def", + ) + .expect("assemble succeeds"); + + assert!( + chunks.len() >= 2, + "each of the two guitars yields at least one phrase chunk" + ); + assert_eq!(group.id, "some_band_song"); + assert_eq!(group.members.len(), chunks.len()); + assert!( + group.relations.is_empty(), + "a provenance group records no measured relations in this slice" + ); + + for chunk in &chunks { + let link = chunk + .ensemble + .as_ref() + .expect("each chunk links to the group"); + assert_eq!(link.group_id, "some_band_song"); + assert!(link.part_index == 0 || link.part_index == 1); + let rights = chunk.rights.as_ref().expect("each chunk carries rights"); + assert_eq!(rights.rights_status, RightsStatus::CopyrightedComposition); + assert_eq!(rights.acquisition, Acquisition::CommunityTabSite); + assert!(!rights.redistributable); + // The exact source track and content hash travel on every chunk, so + // it reloads the right part from the right file (schema v9). + assert_eq!( + chunk.source.track_index, + Some(link.part_index), + "part {} was cut from source track {}", + link.part_index, + link.part_index + ); + assert_eq!(chunk.source.sha256.as_deref(), Some("abc123def")); + } + + let part0 = chunks + .iter() + .find(|c| c.ensemble.as_ref().is_some_and(|e| e.part_index == 0)) + .expect("a part-0 chunk"); + let part1 = chunks + .iter() + .find(|c| c.ensemble.as_ref().is_some_and(|e| e.part_index == 1)) + .expect("a part-1 chunk"); + assert_eq!(part0.tuning, "standard_e"); + assert_eq!(part1.tuning, "drop_d"); + } + #[test] fn group_relations_propagate_measure_errors() { // Track 1 has no notes in its primary voice: the pair measurement diff --git a/cockpit/web-test/cockpit.capture.test.js b/cockpit/web-test/cockpit.capture.test.js index 8bf58608..f1af444f 100644 --- a/cockpit/web-test/cockpit.capture.test.js +++ b/cockpit/web-test/cockpit.capture.test.js @@ -146,7 +146,7 @@ test('Manifest folds the OPFS corpus into a CorpusManifest', async () => { ]); assert.equal(manifestDownload.suggestedFilename(), 'manifest.json'); const manifest = JSON.parse(await readFile(await manifestDownload.path(), 'utf8')); - assert.equal(manifest.schema_version, 8, 'a schema-v8 manifest'); + assert.equal(manifest.schema_version, 9, 'a schema-v9 manifest'); assert.ok( manifest.chunks.some((c) => c.id === 'multi_track'), 'the captured chunk is folded into the manifest', diff --git a/core/Cargo.toml b/core/Cargo.toml index d96ca811..2a00af7a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,6 +15,7 @@ midly = { workspace = true } thiserror = { workspace = true } guitarpro = { version = "0.4", default-features = false, optional = true } serde = { workspace = true } +sha2 = "0.10" [features] default = ["gp"] diff --git a/core/src/corpus.rs b/core/src/corpus.rs index 14e91ac5..73817da9 100644 --- a/core/src/corpus.rs +++ b/core/src/corpus.rs @@ -42,6 +42,12 @@ use crate::structure::{ComplexityProfile, StructureMetrics}; /// key) keep loading and re-serialize losslessly. It records which earlier /// split phrase a later one repeats — a curation signal previously surfaced /// only in the UI/CLI and dropped on download. +/// - v9 — exact source track + integrity hash: [`SourceRef`] gains optional +/// `track_index` and `sha256` under the same pattern. Bulk multi-track ingest +/// needs the exact track (a second-guitar chunk must not reload as the first) +/// and a content hash (a filename is not an identity). Pre-v9 records lack +/// both and keep the legacy first-note-bearing, unverified behavior; the keys +/// are skipped when unset, so older files round-trip byte-identically. /// /// Tag taxonomy is intentionally *not* versioned here: [`SwancoreTag`] grows /// additively (e.g. `let_ring`, #75) and `SCHEMA_VERSION` tracks structural @@ -49,7 +55,7 @@ use crate::structure::{ComplexityProfile, StructureMetrics}; /// 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 = 8; +pub const SCHEMA_VERSION: u32 = 9; // ── identifiers ─────────────────────────────────────────────────────────────── @@ -57,6 +63,21 @@ pub const SCHEMA_VERSION: u32 = 8; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct ChunkId(pub String); +/// Lowercase-hex SHA-256 of a source file's bytes — the content identity a +/// filename cannot provide (schema v9 [`SourceRef::sha256`]). Shared by the +/// ingest that records it and the loader that verifies it. +#[must_use] +pub fn source_sha256(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + use std::fmt::Write as _; + Sha256::digest(bytes) + .iter() + .fold(String::new(), |mut acc, byte| { + write!(acc, "{byte:02x}").ok(); + acc + }) +} + // ── source provenance ───────────────────────────────────────────────────────── /// The import format a chunk was sourced from. @@ -81,6 +102,22 @@ pub struct SourceRef { pub format: SourceFormat, /// Inclusive `[first_bar, last_bar]` range within the source (0-indexed). pub bar_range: Option<(u32, u32)>, + /// Which source track this chunk was cut from (schema v9). Absent in pre-v9 + /// records, where the loader falls back to the first note-bearing track — + /// safe for single-track `griff split`, but a multi-track bulk ingest must + /// name the exact track so a second-guitar chunk is never reloaded as the + /// first. When present, the loader uses exactly this track and fails rather + /// than substitute another. The key is skipped when unset, so pre-v9 files + /// round-trip byte-identically. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub track_index: Option, + /// SHA-256 of the source file's bytes (schema v9), lowercase hex. Pins the + /// material: `filename` is a human name, not an identity, so the loader + /// verifies this before trusting a same-named file. Absent in pre-v9 + /// records (unverified, as before); skipped when unset for byte-identical + /// round-trip. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, } // ── swancore tag taxonomy ───────────────────────────────────────────────────── diff --git a/core/src/generation_input.rs b/core/src/generation_input.rs index 258335ad..91a80151 100644 --- a/core/src/generation_input.rs +++ b/core/src/generation_input.rs @@ -101,10 +101,24 @@ pub fn prepare_chunk(meta: ChunkMeta, source: &Score) -> Option { } None => source.clone(), }; - let track = sliced - .tracks - .iter() - .position(|t| primary_voice_note_count(t) > 0)?; + let track = match meta.source.track_index { + // The record names its exact source track (schema v9): use that track + // and only that. A silent or out-of-range named track is a load failure, + // never a fall back to another part — substituting one guitar for + // another is a quiet wrong-part corruption, the worst kind of success. + Some(index) => { + let index = usize::try_from(index).ok()?; + if primary_voice_note_count(sliced.tracks.get(index)?) == 0 { + return None; + } + index + } + // Legacy record (pre-v9): the first note-bearing track, as before. + None => sliced + .tracks + .iter() + .position(|t| primary_voice_note_count(t) > 0)?, + }; Some(LoadedChunk { meta, sliced, @@ -479,3 +493,153 @@ fn median(mut values: Vec) -> f64 { f64::midpoint(lo, hi) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::unwrap_used)] + + use super::prepare_chunk; + use crate::corpus::{ChunkId, ChunkMeta, SourceFormat, SourceRef}; + use crate::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; + use crate::score::{ + AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, + Score, Track, Voice, + }; + use crate::slice::TickRange; + + fn note(start: u32, pitch: u8) -> AtomEvent { + AtomEvent::Note(AtomNote { + absolute_start: Ticks(start), + duration: Ticks(480), + pitch: Pitch::new(pitch).expect("valid pitch"), + velocity: Velocity::new(90).expect("valid velocity"), + marks: NoteMarks::empty(), + position: None, + }) + } + + fn track_with(notes: &[(u32, u8)]) -> Track { + Track { + name: None, + channel: 0, + voices: vec![Voice { + id: 0, + event_groups: notes + .iter() + .map(|&(start, pitch)| EventGroup { + kind: EventGroupKind::Single, + atoms: vec![note(start, pitch)], + technique_spans: Vec::new(), + }) + .collect(), + }], + tuning: Tuning::standard_e(), + } + } + + /// Two 4/4 bars over two tracks: `t0` ("guitar A"), `t1` ("guitar B"). + fn two_track_source(t0: &[(u32, u8)], t1: &[(u32, u8)]) -> Score { + let master_bars = (0..2usize) + .map(|i| { + let start = u32::try_from(i).expect("small").saturating_mul(1920); + MasterBar { + index: i, + tick_range: TickRange::new(Ticks(start), Ticks(start.saturating_add(1920))) + .expect("ordered"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("120 BPM"), + repeat: RepeatMarker::default(), + } + }) + .collect(); + Score { + ticks_per_quarter: 480, + master_bars, + tracks: vec![track_with(t0), track_with(t1)], + source_meta: None, + loss: LossReport::new(), + } + } + + fn meta(track_index: Option) -> ChunkMeta { + ChunkMeta { + id: ChunkId("t".to_owned()), + title: String::new(), + source: SourceRef { + filename: "s.gp".to_owned(), + format: SourceFormat::Gp, + bar_range: Some((0, 1)), + track_index, + sha256: None, + }, + tempo_bpm: 120.0, + ticks_per_quarter: 480, + time_signature: (4, 4), + tuning: "standard_e".to_owned(), + tags: Vec::new(), + boundaries: Vec::new(), + techniques: Vec::new(), + quality_flags: Vec::new(), + reviewer: None, + structure: None, + gesture: None, + complexity: None, + duplicate: None, + style_cohort: None, + ensemble: None, + rights: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + fn track_pitches(score: &Score, track: usize) -> Vec { + score + .tracks + .get(track) + .into_iter() + .flat_map(|t| &t.voices) + .flat_map(|v| &v.event_groups) + .flat_map(|g| &g.atoms) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some(n.pitch.0), + AtomEvent::Rest(_) => None, + }) + .collect() + } + + #[test] + fn a_track_index_selects_exactly_that_track_not_the_first() { + // Both guitars sound in bars 0-1; a chunk cut from track 1 must reload + // track 1's notes, never track 0's (the P1 corruption). + let source = two_track_source(&[(0, 60), (1920, 62)], &[(0, 72), (1920, 74)]); + let loaded = prepare_chunk(meta(Some(1)), &source).expect("loads"); + assert_eq!(loaded.track, 1, "the named track is used, not the first"); + let pitches = track_pitches(&loaded.sliced, loaded.track); + assert!(pitches.contains(&72), "guitar B's material is loaded"); + assert!(!pitches.contains(&60), "guitar A must not leak in"); + } + + #[test] + fn a_record_without_a_track_index_keeps_legacy_first_note_bearing() { + let source = two_track_source(&[(0, 60), (1920, 62)], &[(0, 72), (1920, 74)]); + let loaded = prepare_chunk(meta(None), &source).expect("loads"); + assert_eq!(loaded.track, 0, "legacy fallback: first note-bearing track"); + } + + #[test] + fn a_named_track_silent_in_the_slice_fails_rather_than_substitute() { + // Track 1 is silent; a Some(1) record must NOT fall back to track 0. + let source = two_track_source(&[(0, 60), (1920, 62)], &[]); + assert!(prepare_chunk(meta(Some(1)), &source).is_none()); + } + + #[test] + fn an_out_of_range_track_index_fails_rather_than_substitute() { + let source = two_track_source(&[(0, 60)], &[(0, 72)]); + assert!(prepare_chunk(meta(Some(9)), &source).is_none()); + } +} diff --git a/core/src/ingest.rs b/core/src/ingest.rs new file mode 100644 index 00000000..1059a7b4 --- /dev/null +++ b/core/src/ingest.rs @@ -0,0 +1,423 @@ +//! Ingest-time interpretation of an imported [`Score`], for building the +//! corpus from bulk Guitar Pro / MIDI sources. +//! +//! The first concern is **track role**: a multitrack tab mixes guitars, bass, +//! drums and vocals, but only some parts belong in a riff corpus. This module +//! classifies each track from evidence already on the imported model — its +//! name, MIDI channel, and open-string tuning — so a bulk ingest can keep the +//! guitars (both of them, for the two-guitar writing this corpus is full of), +//! optionally the bass, and skip the rest. + +use crate::event::Tuning; +use crate::score::{Score, Track}; + +/// The instrumental role of a track, as far as ingest can tell. +/// +/// Deliberately coarse: only the distinctions a corpus build acts on today. +/// Drums and vocals both fall under [`TrackRole::Other`] — the corpus does not +/// use them yet, and telling them apart without a reliable name is guesswork. +/// When that scope arrives the enum extends additively. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrackRole { + /// A fretted six-or-more-string guitar part. + Guitar, + /// A bass part (typically four strings, or five with a low B). + Bass, + /// Anything the corpus does not ingest today: drums, vocals, percussion, + /// or a part with no readable fretted tuning. + Other, +} + +/// The lowest MIDI pitch a guitar's low string is expected to reach; a fretted +/// part whose lowest open string is at or below this is read as a bass. E1. +const BASS_LOW_STRING_CEILING: u8 = 28; + +/// Classifies a track's instrumental role from its name, channel, and tuning. +/// +/// Precedence, most reliable first: +/// 1. **Name.** An explicit part name is trusted over structure — a track +/// called "Bass" with a guitar's tuning is still a bass. "Bass" is checked +/// before "guitar" so "Bass Guitar" reads as bass. +/// 2. **Channel 9**, the General MIDI percussion channel, is drums. +/// 3. **Tuning.** A placeholder tuning (empty, or every string the same pitch — +/// the all-`C-1` shape a non-fretted track imports as) is not an instrument +/// we ingest. Otherwise the string count separates bass from guitar: +/// four or fewer is a bass, six or more a guitar, and a five-string is a +/// bass only if its lowest string reaches into bass range. +#[must_use] +pub fn classify_track_role(track: &Track) -> TrackRole { + if let Some(name) = track.name.as_deref() { + // Match whole words, not substrings, so "bassoon" is not a bass; and + // test the non-fretted roles first, so "Bass Drum" is a drum. + let lower = name.to_lowercase(); + let has = |word: &str| { + lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|token| token == word) + }; + if [ + "drum", + "drums", + "perc", + "percussion", + "vocal", + "vocals", + "voice", + "vox", + "sing", + ] + .iter() + .any(|w| has(w)) + { + return TrackRole::Other; + } + if has("bass") { + return TrackRole::Bass; + } + if has("guitar") || has("guitars") || has("gtr") { + return TrackRole::Guitar; + } + } + + if track.channel == 9 { + return TrackRole::Other; + } + + let open = track.tuning.open_strings(); + let Some(first) = open.first().map(|p| p.0) else { + return TrackRole::Other; + }; + if open.iter().all(|p| p.0 == first) { + return TrackRole::Other; + } + + match open.len() { + 0..=4 => TrackRole::Bass, + 5 => { + let lowest = open.iter().map(|p| p.0).min().unwrap_or(first); + if lowest <= BASS_LOW_STRING_CEILING { + TrackRole::Bass + } else { + TrackRole::Guitar + } + } + _ => TrackRole::Guitar, + } +} + +/// The indices of the tracks a bulk ingest should take from `score`. +/// +/// Every guitar, and the bass parts too when `include_bass` is set. Order is +/// preserved. Empty when the file has no part worth ingesting — the caller +/// treats that as a skip, not an error. +#[must_use] +pub fn select_ingest_tracks(score: &Score, include_bass: bool) -> Vec { + score + .tracks + .iter() + .enumerate() + .filter(|(_, track)| match classify_track_role(track) { + TrackRole::Guitar => true, + TrackRole::Bass => include_bass, + TrackRole::Other => false, + }) + .map(|(index, _)| index) + .collect() +} + +/// A stable, human-readable label for a tuning, for `ChunkMeta.tuning`. +/// +/// Common tunings resolve to their conventional `snake_case` name +/// (`standard_e`, `drop_d`, …); anything else falls back to a deterministic +/// low-to-high spelling of its open strings (`b1_e2_a2_d3_g3_b3_e4`), so an +/// unusual tuning is still recorded exactly and never lost. The named set is +/// deliberately small and meant to grow as the corpus turns up more. +#[must_use] +pub fn tuning_label(tuning: &Tuning) -> String { + // Canonicalise low-to-high so the label ignores the source's string order + // (Guitar Pro stores it both ways). Named tunings are keyed by that + // ascending pitch set; the spelling reads low string to high. + let mut low_to_high: Vec = tuning.open_strings().iter().map(|p| p.0).collect(); + low_to_high.sort_unstable(); + match low_to_high.as_slice() { + [40, 45, 50, 55, 59, 64] => return "standard_e".to_owned(), + [38, 45, 50, 55, 59, 64] => return "drop_d".to_owned(), + [35, 40, 45, 50, 55, 59, 64] => return "standard_b_7".to_owned(), + [28, 33, 38, 43] => return "bass_standard".to_owned(), + _ => {} + } + low_to_high + .iter() + .map(|&midi| note_name(midi)) + .collect::>() + .join("_") +} + +/// A MIDI pitch as a `snake_case` note name with octave, e.g. `e2`, `ds4` +/// (D#4). Octave numbering follows scientific pitch (MIDI 60 = `c4`). +fn note_name(midi: u8) -> String { + let name = match midi % 12 { + 0 => "c", + 1 => "cs", + 2 => "d", + 3 => "ds", + 4 => "e", + 5 => "f", + 6 => "fs", + 7 => "g", + 8 => "gs", + 9 => "a", + 10 => "as", + _ => "b", + }; + let octave = i16::from(midi / 12).saturating_sub(1); + format!("{name}{octave}") +} + +#[cfg(test)] +mod tests { + use super::{classify_track_role, select_ingest_tracks, tuning_label, TrackRole}; + use crate::event::{Pitch, Tuning}; + use crate::score::{LossReport, Score, Track}; + + fn track(name: Option<&str>, channel: u8, strings: &[u8]) -> Track { + Track { + name: name.map(str::to_owned), + channel, + voices: Vec::new(), + tuning: Tuning::new(strings.iter().map(|&m| Pitch(m)).collect()), + } + } + + fn score_of(tracks: Vec) -> Score { + Score { + ticks_per_quarter: 480, + master_bars: Vec::new(), + tracks, + source_meta: None, + loss: LossReport::new(), + } + } + + // Standard E, string 1 (high) first: E4 B3 G3 D3 A2 E2. + const STANDARD_6: [u8; 6] = [64, 59, 55, 50, 45, 40]; + // 7-string with a low B1. + const STANDARD_7: [u8; 7] = [64, 59, 55, 50, 45, 40, 35]; + // 4-string bass, standard: G2 D2 A1 E1. + const BASS_4: [u8; 4] = [43, 38, 33, 28]; + // 5-string bass with a low B0 (23). + const BASS_5: [u8; 5] = [43, 38, 33, 28, 23]; + + #[test] + fn six_string_tuning_is_a_guitar() { + assert_eq!( + classify_track_role(&track(None, 0, &STANDARD_6)), + TrackRole::Guitar + ); + } + + #[test] + fn seven_string_tuning_is_a_guitar() { + assert_eq!( + classify_track_role(&track(None, 0, &STANDARD_7)), + TrackRole::Guitar + ); + } + + #[test] + fn four_string_tuning_is_a_bass() { + assert_eq!( + classify_track_role(&track(None, 0, &BASS_4)), + TrackRole::Bass + ); + } + + #[test] + fn five_string_with_a_low_b_is_a_bass() { + assert_eq!( + classify_track_role(&track(None, 0, &BASS_5)), + TrackRole::Bass + ); + } + + #[test] + fn all_strings_at_one_pitch_is_a_placeholder_not_an_instrument() { + // The all-`C-1` (MIDI 0) shape a drum/vocal track imports as. + assert_eq!( + classify_track_role(&track(None, 0, &[0, 0, 0, 0, 0, 0])), + TrackRole::Other + ); + } + + #[test] + fn empty_tuning_is_other() { + assert_eq!(classify_track_role(&track(None, 0, &[])), TrackRole::Other); + } + + #[test] + fn name_bass_beats_a_guitar_tuning() { + // "Bass Guitar" contains both words; bass must win. + assert_eq!( + classify_track_role(&track(Some("Bass Guitar"), 0, &STANDARD_6)), + TrackRole::Bass + ); + } + + #[test] + fn name_guitar_beats_a_non_guitar_tuning() { + assert_eq!( + classify_track_role(&track(Some("Rhythm Guitar"), 0, &BASS_4)), + TrackRole::Guitar + ); + } + + #[test] + fn name_drums_is_other_even_with_a_fretted_tuning() { + assert_eq!( + classify_track_role(&track(Some("Drums"), 0, &STANDARD_6)), + TrackRole::Other + ); + } + + #[test] + fn name_vocals_is_other() { + assert_eq!( + classify_track_role(&track(Some("Lead Vocals"), 0, &STANDARD_6)), + TrackRole::Other + ); + } + + #[test] + fn percussion_channel_is_other_without_a_name() { + assert_eq!( + classify_track_role(&track(None, 9, &STANDARD_6)), + TrackRole::Other + ); + } + + #[test] + fn a_bassoon_is_not_matched_as_a_bass() { + // "bassoon" contains "bass" as a substring but is a different word; the + // name must not force it to Bass. With a guitar tuning it reads Guitar. + assert_eq!( + classify_track_role(&track(Some("Bassoon"), 0, &STANDARD_6)), + TrackRole::Guitar + ); + } + + #[test] + fn a_bass_drum_is_percussion_not_bass() { + // "Bass Drum" is a drum; the drum token must win over the bass token. + assert_eq!( + classify_track_role(&track(Some("Bass Drum"), 0, &STANDARD_6)), + TrackRole::Other + ); + } + + // ── select_ingest_tracks ────────────────────────────────────────────────── + + fn two_guitars_bass_and_drums() -> Score { + score_of(vec![ + track(Some("Guitar 1"), 0, &STANDARD_6), + track(Some("Guitar 2"), 0, &STANDARD_7), + track(Some("Bass"), 0, &BASS_4), + track(Some("Drums"), 9, &[0, 0, 0, 0, 0, 0]), + ]) + } + + #[test] + fn selects_both_guitars_and_skips_bass_and_drums_by_default() { + assert_eq!( + select_ingest_tracks(&two_guitars_bass_and_drums(), false), + vec![0, 1] + ); + } + + #[test] + fn includes_bass_when_asked_still_skipping_drums() { + assert_eq!( + select_ingest_tracks(&two_guitars_bass_and_drums(), true), + vec![0, 1, 2] + ); + } + + #[test] + fn a_file_with_no_fretted_part_selects_nothing() { + let score = score_of(vec![ + track(Some("Drums"), 9, &[0, 0, 0, 0, 0, 0]), + track(Some("Lead Vocals"), 0, &[0, 0, 0, 0, 0, 0]), + ]); + assert!(select_ingest_tracks(&score, true).is_empty()); + } + + #[test] + fn a_single_guitar_is_selected() { + let score = score_of(vec![track(None, 0, &STANDARD_6)]); + assert_eq!(select_ingest_tracks(&score, false), vec![0]); + } + + // ── tuning_label ────────────────────────────────────────────────────────── + + fn tuning_of(strings: &[u8]) -> Tuning { + Tuning::new(strings.iter().map(|&m| Pitch(m)).collect()) + } + + #[test] + fn names_standard_e() { + assert_eq!(tuning_label(&tuning_of(&STANDARD_6)), "standard_e"); + } + + #[test] + fn names_drop_d() { + // High-to-low: E4 B3 G3 D3 A2 D2 — the low E dropped to D. + assert_eq!( + tuning_label(&tuning_of(&[64, 59, 55, 50, 45, 38])), + "drop_d" + ); + } + + #[test] + fn names_seven_string_standard_b() { + assert_eq!(tuning_label(&tuning_of(&STANDARD_7)), "standard_b_7"); + } + + #[test] + fn names_four_string_bass_standard() { + assert_eq!(tuning_label(&tuning_of(&BASS_4)), "bass_standard"); + } + + #[test] + fn an_unknown_tuning_spells_its_open_strings_low_to_high() { + // Drop C, high-to-low: D4 A3 F3 C3 G2 C2 -> spelled low to high. + assert_eq!( + tuning_label(&tuning_of(&[62, 57, 53, 48, 43, 36])), + "c2_g2_c3_f3_a3_d4" + ); + } + + #[test] + fn a_sharp_pitch_class_spells_with_an_s() { + // A single D#2 open string (contrived) exercises the sharp spelling. + assert_eq!(tuning_label(&tuning_of(&[39, 39])), "ds2_ds2"); + } + + #[test] + fn names_a_tuning_regardless_of_stored_string_order() { + // Real Guitar Pro files store some tunings low-string-first and others + // high-string-first; the label must not depend on that. Standard E with + // the low E first is still standard E. + assert_eq!( + tuning_label(&tuning_of(&[40, 45, 50, 55, 59, 64])), + "standard_e" + ); + } + + #[test] + fn the_same_tuning_gets_one_label_either_way_round() { + // Drop C, high-string-first and low-string-first, must collapse to one + // spelling rather than two mirror-image labels. + let high_first = tuning_of(&[62, 57, 53, 48, 43, 36]); + let low_first = tuning_of(&[36, 43, 48, 53, 57, 62]); + assert_eq!(tuning_label(&high_first), tuning_label(&low_first)); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 1ebed8c3..26786c38 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -21,6 +21,7 @@ pub mod gesture; pub mod gp; pub mod harmony; pub mod import; +pub mod ingest; pub mod layered_path; pub mod midi; pub mod novelty; diff --git a/core/tests/corpus_schema.rs b/core/tests/corpus_schema.rs index b53ca970..6ec77feb 100644 --- a/core/tests/corpus_schema.rs +++ b/core/tests/corpus_schema.rs @@ -69,6 +69,8 @@ fn minimal_chunk() -> ChunkMeta { filename: "test.mid".to_owned(), format: SourceFormat::Midi, bar_range: Some((0, 4)), + track_index: None, + sha256: None, }, tempo_bpm: 140.0, ticks_per_quarter: 960, @@ -95,13 +97,13 @@ fn minimal_chunk() -> ChunkMeta { } } -// ── schema v8: near-duplicate link (#76) ────────────────────────────────────── +// ── schema v9: exact source track + integrity hash ─────────────────────────── #[test] -fn schema_version_is_8() { +fn schema_version_is_9() { assert_eq!( - SCHEMA_VERSION, 8, - "the persisted near-duplicate link bumps the corpus schema" + SCHEMA_VERSION, 9, + "SourceRef.track_index + sha256 bump the corpus schema to v9" ); } @@ -810,6 +812,8 @@ proptest! { filename, format: fmt, bar_range: None, + track_index: None, + sha256: None, }, tempo_bpm: f64::from(tempo_bpm_int), ticks_per_quarter: tpq, diff --git a/core/tests/similarity.rs b/core/tests/similarity.rs index 3c21dae2..813118cf 100644 --- a/core/tests/similarity.rs +++ b/core/tests/similarity.rs @@ -131,6 +131,8 @@ fn chunk( filename: format!("{id}.mid"), format: SourceFormat::Midi, bar_range: Some((0, 4)), + track_index: None, + sha256: None, }, tempo_bpm: 140.0, ticks_per_quarter: 960, diff --git a/corpus.zip b/corpus.zip new file mode 100644 index 00000000..31934202 Binary files /dev/null and b/corpus.zip differ diff --git a/ui-core/src/capture.rs b/ui-core/src/capture.rs index cf525336..8e855cff 100644 --- a/ui-core/src/capture.rs +++ b/ui-core/src/capture.rs @@ -227,6 +227,8 @@ pub fn build_chunk( filename, format: source_format(score), bar_range: None, + track_index: None, + sha256: None, }, tempo_bpm, ticks_per_quarter: score.ticks_per_quarter, diff --git a/ui-core/src/dock.rs b/ui-core/src/dock.rs index fe04ab07..48f853ad 100644 --- a/ui-core/src/dock.rs +++ b/ui-core/src/dock.rs @@ -195,6 +195,8 @@ mod tests { filename: "x.mid".to_owned(), format: SourceFormat::Midi, bar_range: None, + track_index: None, + sha256: None, }, tempo_bpm: 120.0, ticks_per_quarter: 480, diff --git a/ui-core/tests/curation.rs b/ui-core/tests/curation.rs index 7319ec23..64471cb0 100644 --- a/ui-core/tests/curation.rs +++ b/ui-core/tests/curation.rs @@ -27,6 +27,8 @@ fn record() -> ChunkMeta { filename: "cur.mid".to_owned(), format: SourceFormat::Midi, bar_range: Some((0, 4)), + track_index: None, + sha256: None, }, tempo_bpm: 140.0, ticks_per_quarter: 960,