Repository navigation
Work plan: S13 harmonic context (stage done); S14 sub-bar period detection (schema v5) #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
2a5b55e
0da3bd6
9a9c398
0793d46
4d54257
5aa8e41
04c86f3
dfa274f
01e19c8
33ef917
7d4511c
ade3ee9
d91f7d4
29311e0
d65ec85
229caa3
4083ccc
4cdedf4
e52d00e
040ec19
951a074
d80169e
7d71319
0ebaf48
ed1cb7a
1a153ee
17f595c
77f10ce
1d55b17
f8f7757
a698a85
66db4f6
a5d0a45
0babec0
ef0e525
8d05ddd
a6726ea
66155d3
2587c52
fb842a7
aee734e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,45 @@ pub struct ComplementSpec { | |
| pub register_offset: i8, | ||
| } | ||
|
|
||
| /// Major or natural minor — the two scale shapes the key estimate considers. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub enum KeyMode { | ||
| /// The major (Ionian) scale. | ||
| Major, | ||
| /// The natural minor (Aeolian) scale. | ||
| Minor, | ||
| } | ||
|
|
||
| impl KeyMode { | ||
| /// Semitone offsets of this mode's scale above its tonic. | ||
| #[must_use] | ||
| pub const fn scale_offsets(self) -> [u8; 7] { | ||
| match self { | ||
| Self::Major => [0, 2, 4, 5, 7, 9, 11], | ||
| Self::Minor => [0, 2, 3, 5, 7, 8, 10], | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// The part's estimated key and how well its notes fit that key's scale — | ||
| /// the harmonic context of a part profile (glossary §8). | ||
| /// | ||
| /// Estimated with the Krumhansl–Schmuckler key-finding algorithm: the part's | ||
| /// duration-weighted pitch-class histogram is correlated (Pearson) against | ||
| /// the 24 rotated Krumhansl–Kessler tonal-hierarchy profiles and the best | ||
| /// correlation wins, ties resolving to the earliest key in the | ||
| /// major-then-minor, C-upward scan. `scale_fit` is a fact, not a verdict — | ||
| /// what counts as "fitting well enough" is corpus/S9 calibration territory. | ||
| #[derive(Debug, Clone, Copy, PartialEq)] | ||
| pub struct HarmonicContext { | ||
| /// Tonic pitch class: `0` = C … `11` = B. | ||
| pub tonic_pitch_class: u8, | ||
| /// Major or natural minor. | ||
| pub mode: KeyMode, | ||
| /// Duration-weighted fraction of notes on the key's scale, in `[0, 1]`. | ||
| pub scale_fit: f64, | ||
| } | ||
|
|
||
| /// The per-part feature summary derived from part A (glossary §8). | ||
| /// | ||
| /// Richer than `VoiceFeatures`: it is bucketed against the master-bar timeline | ||
|
|
@@ -101,6 +140,8 @@ pub struct PartProfile { | |
| /// Distinct technique labels across the part — per-note marks *and* spanning | ||
| /// techniques (ADR-0018), so the overlap axis sees both. | ||
| pub techniques: BTreeSet<&'static str>, | ||
| /// The estimated key and scale fit, or `None` when the part has no notes. | ||
| pub harmony: Option<HarmonicContext>, | ||
| } | ||
|
|
||
| /// Relation-as-provenance: per-axis scores describing how B relates to A. | ||
|
|
@@ -394,6 +435,8 @@ pub fn analyze_part(score: &Score, track_index: usize) -> Result<PartProfile, Co | |
| pitches.dedup(); | ||
|
|
||
| let techniques = voice_technique_labels(track); | ||
| let weighted: Vec<(u8, u32)> = notes.iter().map(|n| (n.pitch, n.duration.0)).collect(); | ||
| let harmony = estimate_harmony(&weighted); | ||
|
|
||
| let note_count = notes.len(); | ||
| let bar_count = score.master_bars.len(); | ||
|
|
@@ -406,9 +449,107 @@ pub fn analyze_part(score: &Score, track_index: usize) -> Result<PartProfile, Co | |
| density, | ||
| pitches, | ||
| techniques, | ||
| harmony, | ||
| }) | ||
| } | ||
|
|
||
| /// Krumhansl–Kessler major tonal-hierarchy profile (probe-tone ratings). | ||
| const KK_MAJOR: [f64; 12] = [ | ||
| 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, | ||
| ]; | ||
| /// Krumhansl–Kessler natural-minor tonal-hierarchy profile. | ||
| const KK_MINOR: [f64; 12] = [ | ||
| 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, | ||
| ]; | ||
|
|
||
| /// Estimates the part's key from its notes (Krumhansl–Schmuckler), or `None` | ||
| /// when there are none. | ||
| /// | ||
| /// Histogram weights are note durations in ticks; a part whose notes all have | ||
| /// zero duration falls back to counting notes so the estimate stays defined. | ||
| /// Takes `(pitch, duration-in-ticks)` pairs so other analyses (the S14 | ||
| /// complexity profile) reuse the one estimator. | ||
| // Float-only arithmetic over fixed 12-bin histograms; pitch-class indices are | ||
| // mod-12 by construction. | ||
| #[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] | ||
| pub(crate) fn estimate_harmony(notes: &[(u8, u32)]) -> Option<HarmonicContext> { | ||
| if notes.is_empty() { | ||
| return None; | ||
| } | ||
|
|
||
| let total_ticks: u64 = notes.iter().map(|&(_, d)| u64::from(d)).sum(); | ||
| let mut histogram = [0.0_f64; 12]; | ||
| for &(pitch, duration) in notes { | ||
| let pc = usize::from(pitch) % 12; | ||
| let weight = if total_ticks == 0 { | ||
| 1.0 | ||
| } else { | ||
| f64::from(duration) | ||
| }; | ||
| histogram[pc] += weight; | ||
| } | ||
|
|
||
| let mut best: Option<(f64, u8, KeyMode)> = None; | ||
| for mode in [KeyMode::Major, KeyMode::Minor] { | ||
| let profile = match mode { | ||
| KeyMode::Major => &KK_MAJOR, | ||
| KeyMode::Minor => &KK_MINOR, | ||
| }; | ||
| for tonic in 0..12_u8 { | ||
| let r = rotated_correlation(&histogram, profile, tonic); | ||
| if best.map_or(true, |(b, _, _)| r > b) { | ||
| best = Some((r, tonic, mode)); | ||
| } | ||
| } | ||
| } | ||
| let (_, tonic_pitch_class, mode) = best?; | ||
|
|
||
| let total: f64 = histogram.iter().sum(); | ||
| let on_scale: f64 = mode | ||
| .scale_offsets() | ||
| .iter() | ||
| .map(|&s| histogram[usize::from(tonic_pitch_class + s) % 12]) | ||
| .sum(); | ||
| let scale_fit = if total > 0.0 { on_scale / total } else { 0.0 }; | ||
|
|
||
| Some(HarmonicContext { | ||
| tonic_pitch_class, | ||
| mode, | ||
| scale_fit, | ||
| }) | ||
| } | ||
|
|
||
| /// Pearson correlation between `histogram` and `profile` rotated so the | ||
| /// profile's tonic sits on pitch class `tonic`. | ||
| // Float-only arithmetic over fixed 12-bin arrays; indices are mod-12. | ||
| #[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] | ||
| fn rotated_correlation(histogram: &[f64; 12], profile: &[f64; 12], tonic: u8) -> f64 { | ||
| let mut rotated = [0.0_f64; 12]; | ||
| for (pc, slot) in rotated.iter_mut().enumerate() { | ||
| *slot = profile[(pc + 12 - usize::from(tonic)) % 12]; | ||
| } | ||
|
|
||
| let n = 12.0_f64; | ||
| let mean_x: f64 = histogram.iter().sum::<f64>() / n; | ||
| let mean_y: f64 = rotated.iter().sum::<f64>() / n; | ||
| let mut numerator = 0.0_f64; | ||
| let mut var_x = 0.0_f64; | ||
| let mut var_y = 0.0_f64; | ||
| for (x, y) in histogram.iter().zip(rotated.iter()) { | ||
| let dx = x - mean_x; | ||
| let dy = y - mean_y; | ||
| numerator += dx * dy; | ||
| var_x += dx * dx; | ||
| var_y += dy * dy; | ||
| } | ||
| let denominator = (var_x * var_y).sqrt(); | ||
| if denominator > 0.0 { | ||
| numerator / denominator | ||
| } else { | ||
| 0.0 | ||
| } | ||
| } | ||
|
|
||
| /// Measures the complement relation between two *existing* tracks. | ||
| /// | ||
| /// The corpus-side counterpart of the per-mode `AxisScores` provenance, used | ||
|
|
@@ -518,7 +659,7 @@ pub fn arrange_complement( | |
| /// generator's `ConstrainedRandomWalk` — the one mode that synthesises a fresh | ||
| /// sequence instead of deriving B from A's grid. | ||
| /// | ||
| /// The request is compiled from A: A's pitch classes as the scale, A's band | ||
| /// The request is compiled from A: A's harmonic context as the scale, A's band | ||
| /// shifted by `spec.register_offset` as the pitch bounds, A's bar count / | ||
| /// meter / tempo / PPQN as constraints, A's bar rhythms as templates. The | ||
| /// generated line is lifted onto A's master bars, which therefore must share | ||
|
|
@@ -801,7 +942,7 @@ fn arrange_support_layer( | |
| /// first sound and the end of the last master bar (leading silence has no call | ||
| /// to answer). Every gap at least one quarter long gets exactly one answer: a | ||
| /// B note at the gap start sustaining through the gap, at the velocity of the | ||
| /// preceding A note, pitched seed-deterministically from A's pitch classes in | ||
| /// preceding A note, pitched seed-deterministically from A's harmonic context in | ||
| /// the band shifted by `spec.register_offset`. B's onsets are disjoint from | ||
| /// A's by construction. No qualifying gap is | ||
| /// [`ComplementError::NoGapsToAnswer`]. | ||
|
|
@@ -902,15 +1043,15 @@ fn shifted_band(register: PitchRange, offset: i8) -> (u8, u8) { | |
|
|
||
| /// B's event groups on A's exact onset grid: every B note keeps A's onset, | ||
| /// duration, and velocity, with the pitch substituted seed-deterministically | ||
| /// from A's pitch classes mapped into the `[band_lo, band_hi]` register band. | ||
| /// from A's harmonic context mapped into the `[band_lo, band_hi]` register band. | ||
| fn grid_locked_groups( | ||
| a_track: &Track, | ||
| profile: &PartProfile, | ||
| band_lo: u8, | ||
| band_hi: u8, | ||
| seed: GenerationSeed, | ||
| ) -> Vec<EventGroup> { | ||
| // Scale: A's distinct pitch classes, as intervals above the band's low note. | ||
| // Scale: A's harmonic context, as intervals above the band's low note. | ||
| let intervals = scale_intervals_from(profile); | ||
|
|
||
| voice_notes(a_track) | ||
|
|
@@ -1004,15 +1145,36 @@ fn voice_atom_notes(track: &Track) -> Vec<AtomNote> { | |
| notes | ||
| } | ||
|
|
||
| /// A's distinct pitch classes (semitone offsets from A's lowest pitch), sorted | ||
| /// and non-empty — the scale B substitutes pitches from. | ||
| /// The scale B substitutes pitches from, as semitone offsets above A's lowest | ||
| /// pitch (mod 12), sorted and non-empty: A's distinct pitch classes *plus* | ||
| /// the inferred key's scale (the harmonic context — enrich, don't replace), | ||
| /// so B can always echo A's actual notes and a sparse part still opens a | ||
| /// whole scale of material. | ||
| fn scale_intervals_from(profile: &PartProfile) -> Vec<u8> { | ||
| let min_a = profile.pitches.first().copied().unwrap_or(0); | ||
| let mut intervals: Vec<u8> = profile | ||
| .pitches | ||
| .iter() | ||
| .map(|&p| p.saturating_sub(min_a).checked_rem(12).unwrap_or(0)) | ||
| .collect(); | ||
| if let Some(harmony) = profile.harmony { | ||
| let min_pc = min_a.checked_rem(12).unwrap_or(0); | ||
| for offset in harmony.mode.scale_offsets() { | ||
| let pc = harmony | ||
| .tonic_pitch_class | ||
| .saturating_add(offset) | ||
| .checked_rem(12) | ||
| .unwrap_or(0); | ||
| // The scale pitch class as an offset above A's lowest pitch class, | ||
| // wrapped into [0, 12). | ||
| let interval = pc | ||
| .saturating_add(12) | ||
| .saturating_sub(min_pc) | ||
| .checked_rem(12) | ||
| .unwrap_or(0); | ||
| intervals.push(interval); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in fb842a7 (red → green): a C-major part with Generated by Claude Code |
||
| } | ||
| } | ||
| intervals.sort_unstable(); | ||
| intervals.dedup(); | ||
| if intervals.is_empty() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this repo's required
cargo clippy --all-targets -- -D warningscheck, these new accumulation expressions triggerclippy::suboptimal_flopsunder the enabled nursery lints, so the lint job fails before CI can pass. Rewriting the updates withmul_add(or explicitly allowing the lint here) keeps the harmonic-context implementation from blocking the documented lint gate.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checked against the documented lint gate on the pinned toolchain (
rust-toolchain.toml):cargo clippy --all-targets -- -D warningsexits 0 with zero warnings on this branch.clippy::suboptimal_flopsdoes not fire on compound-assignment accumulations (numerator += dx * dydesugars to+=, which the lint skips); it only flags expressions of the shapec + a * b, which is whystructure.rsusesmul_addthere but plain accumulation loops elsewhere in the crate pass clean. Leaving the loop as written — no allow needed.Generated by Claude Code