419 lines
17 KiB
Rust
419 lines
17 KiB
Rust
//! Music-theory engraving primitives — the pure mapping from notated content to
|
||
//! SMuFL glyph names and staff positions, with no layout-IR plumbing.
|
||
//!
|
||
//! This is the "engraving decision" layer Chapter 7 assigns to the layout IR:
|
||
//! which glyph notates a note value, where a pitch sits on the staff under a
|
||
//! clef, which accidentals a spelling draws, and which accidentals a key
|
||
//! signature places. The constrained-layout pass consumes these; the renderer
|
||
//! never makes these choices (Chapter 7 §"Non-overreach").
|
||
//!
|
||
//! Every function here is total and deterministic. Glyph names are the SMuFL
|
||
//! canonical names bundled in [`crate::glyph`].
|
||
|
||
use epiphany_core::{
|
||
AccidentalId, Clef, ClefShape, CmnNominal, KeySignature, NoteValue, StemDirection,
|
||
};
|
||
|
||
/// A staff position in **half-staff-space steps from the bottom staff line**:
|
||
/// the bottom line is `0`, the space just above it is `1`, the second line is
|
||
/// `2`, … (negative below the bottom line). One step is half a staff space, so
|
||
/// the visual `y` of a position is `position as f32 * 0.5` staff spaces.
|
||
///
|
||
/// This is a **diatonic** position: it depends only on the nominal letter and
|
||
/// octave, never on accidentals — a C-sharp sits on the same line as a
|
||
/// C-natural. Accidentals are drawn to the *left* of the notehead, they do not
|
||
/// move it vertically.
|
||
pub type StaffStep = i32;
|
||
|
||
/// The diatonic index of a `(nominal, octave)`: `octave * 7 + letter`, with the
|
||
/// nominal letter C=0 … B=6 ([`CmnNominal`]). Octaves are scientific-pitch
|
||
/// (C4 = middle C), so each whole octave is exactly seven diatonic steps.
|
||
fn diatonic_index(nominal: CmnNominal, octave: i8) -> i32 {
|
||
octave as i32 * 7 + nominal as i32
|
||
}
|
||
|
||
/// The diatonic index of a clef shape's reference pitch — the pitch the clef
|
||
/// glyph fixes on its line: G clef → G4, F clef → F3, C clef → middle C (C4).
|
||
/// A percussion clef has no diatonic reference.
|
||
fn clef_reference_diatonic(shape: ClefShape) -> Option<i32> {
|
||
match shape {
|
||
ClefShape::G => Some(diatonic_index(CmnNominal::G, 4)),
|
||
ClefShape::F => Some(diatonic_index(CmnNominal::F, 3)),
|
||
ClefShape::C => Some(diatonic_index(CmnNominal::C, 4)),
|
||
ClefShape::Percussion => None,
|
||
}
|
||
}
|
||
|
||
/// The staff position of a diatonic pitch under `clef` (see [`StaffStep`]).
|
||
///
|
||
/// The clef fixes its reference pitch on `clef.line` (line 1 = the bottom line);
|
||
/// every diatonic step away from that pitch is one half-staff-space step.
|
||
/// `clef.octave_shift` transposes the written position: a `+1` (8va) clef writes
|
||
/// a sounding pitch an octave *lower* on the staff, a `-1` (8vb) clef an octave
|
||
/// *higher*. A percussion clef has no diatonic mapping and reports its own line
|
||
/// (a neutral mid-staff position) for any pitch.
|
||
pub fn staff_position(nominal: CmnNominal, octave: i8, clef: &Clef) -> StaffStep {
|
||
let reference_line_step = (clef.line as i32 - 1) * 2;
|
||
match clef_reference_diatonic(clef.shape) {
|
||
Some(reference_diatonic) => {
|
||
reference_line_step + (diatonic_index(nominal, octave) - reference_diatonic)
|
||
- clef.octave_shift as i32 * 7
|
||
}
|
||
None => reference_line_step,
|
||
}
|
||
}
|
||
|
||
/// The [`CmnNominal`] for a diatonic letter index `0..=6` (`C..=B`); callers pass
|
||
/// `rem_euclid(7)`, so out-of-range values are unreachable and fold to `B`.
|
||
fn cmn_nominal(letter: i32) -> CmnNominal {
|
||
match letter {
|
||
0 => CmnNominal::C,
|
||
1 => CmnNominal::D,
|
||
2 => CmnNominal::E,
|
||
3 => CmnNominal::F,
|
||
4 => CmnNominal::G,
|
||
5 => CmnNominal::A,
|
||
_ => CmnNominal::B,
|
||
}
|
||
}
|
||
|
||
/// The diatonic pitch `(nominal, octave)` written at staff position `step` under
|
||
/// `clef` — the inverse of [`staff_position`], used to turn a clicked staff height
|
||
/// into the pitch to insert. `None` for a percussion clef (no diatonic mapping — it
|
||
/// collapses every pitch onto its reference line), and `None` when the position is
|
||
/// so far off the staff that its octave falls outside the representable `i8` range
|
||
/// (rather than silently wrapping to a wrong-looking octave). Inverse only of the
|
||
/// *diatonic* position; the accidental is a separate, caller-chosen concern.
|
||
pub fn staff_step_pitch(step: StaffStep, clef: &Clef) -> Option<(CmnNominal, i8)> {
|
||
let reference_line_step = (clef.line as i64 - 1) * 2;
|
||
let reference_diatonic = clef_reference_diatonic(clef.shape)? as i64;
|
||
// i64 so an extreme `step` cannot overflow the intermediate before the octave
|
||
// range-check rejects it.
|
||
let diatonic =
|
||
step as i64 - reference_line_step + reference_diatonic + clef.octave_shift as i64 * 7;
|
||
let octave = i8::try_from(diatonic.div_euclid(7)).ok()?;
|
||
Some((cmn_nominal(diatonic.rem_euclid(7) as i32), octave))
|
||
}
|
||
|
||
/// The SMuFL notehead glyph for a note value: a hollow whole/half notehead, else
|
||
/// the filled black notehead.
|
||
pub fn notehead_glyph(value: NoteValue) -> &'static str {
|
||
match value {
|
||
NoteValue::Whole => "noteheadWhole",
|
||
NoteValue::Half => "noteheadHalf",
|
||
_ => "noteheadBlack",
|
||
}
|
||
}
|
||
|
||
/// The SMuFL rest glyph for a note value, if one is bundled. Only whole/half/
|
||
/// quarter/eighth rests ship in the bundled metrics; a sixteenth-or-shorter rest
|
||
/// reports `None` so the caller surfaces the missing glyph coverage rather than
|
||
/// misrendering it as an eighth rest.
|
||
pub fn rest_glyph(value: NoteValue) -> Option<&'static str> {
|
||
Some(match value {
|
||
NoteValue::Whole => "restWhole",
|
||
NoteValue::Half => "restHalf",
|
||
NoteValue::Quarter => "restQuarter",
|
||
NoteValue::Eighth => "rest8th",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Whether a note value is drawn with a stem — every value but the whole note.
|
||
pub fn has_stem(value: NoteValue) -> bool {
|
||
!matches!(value, NoteValue::Whole)
|
||
}
|
||
|
||
/// The SMuFL flag glyph for an *unbeamed* stemmed note value, if one is bundled.
|
||
/// Only the eighth-note flag ships in the bundled metrics; shorter values need
|
||
/// their own flag glyphs (or beaming, deferred past I-1) and report `None`.
|
||
pub fn flag_glyph(value: NoteValue, stem: StemDirection) -> Option<&'static str> {
|
||
match value {
|
||
NoteValue::Eighth => Some(match stem {
|
||
StemDirection::Up => "flag8thUp",
|
||
StemDirection::Down => "flag8thDown",
|
||
}),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The SMuFL clef glyph for a clef shape, if one is bundled. A percussion clef
|
||
/// reports `None` until its glyph is bundled — returning a G clef for it would
|
||
/// be a semantic false positive, so the caller surfaces the gap instead.
|
||
pub fn clef_glyph(shape: ClefShape) -> Option<&'static str> {
|
||
Some(match shape {
|
||
ClefShape::G => "gClef",
|
||
ClefShape::F => "fClef",
|
||
ClefShape::C => "cClef",
|
||
ClefShape::Percussion => return None,
|
||
})
|
||
}
|
||
|
||
/// The SMuFL accidental glyph for a spelling accidental, if one is bundled.
|
||
/// `None` for an accidental the bundled metrics don't carry (e.g. microtonal),
|
||
/// which the caller surfaces rather than papers over.
|
||
pub fn accidental_glyph(accidental: &AccidentalId) -> Option<&'static str> {
|
||
Some(match accidental.as_str() {
|
||
"sharp" => "accidentalSharp",
|
||
"flat" => "accidentalFlat",
|
||
"natural" => "accidentalNatural",
|
||
"doublesharp" | "double-sharp" => "accidentalDoubleSharp",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// One accidental in a key signature: the SMuFL glyph and the staff position it
|
||
/// occupies under the active clef.
|
||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||
pub struct KeyAccidental {
|
||
pub glyph: &'static str,
|
||
pub position: StaffStep,
|
||
}
|
||
|
||
// The conventional staff-step offsets, from the first accidental, of the
|
||
// key-signature "zigzag" — invariant across clefs (only the start position is
|
||
// clef-dependent). Sharp order F C G D A E B; flat order B E A D G C F.
|
||
const SHARP_OFFSETS: [StaffStep; 7] = [0, -3, 1, -2, -5, -1, -4];
|
||
const FLAT_OFFSETS: [StaffStep; 7] = [0, 3, -1, 2, -2, 1, -3];
|
||
|
||
/// The staff position of the first key-signature accidental of `letter` under
|
||
/// `clef`: the octave of `letter` whose position is closest to the conventional
|
||
/// band (sharps high, near the top line; flats mid, near the middle line),
|
||
/// breaking ties toward the higher position. This reproduces the standard
|
||
/// treble and bass placements; other clefs follow the same principle.
|
||
fn first_accidental_position(letter: CmnNominal, clef: &Clef, sharp: bool) -> StaffStep {
|
||
let target = if sharp { 8 } else { 4 };
|
||
(-2i8..=10)
|
||
.map(|octave| staff_position(letter, octave, clef))
|
||
.min_by_key(|&p| ((p - target).abs(), -p))
|
||
.unwrap_or(target)
|
||
}
|
||
|
||
/// The ordered accidentals a key signature places under `clef`. A positive
|
||
/// `key.fifths()` places that many sharps (order F C G D A E B); a negative one
|
||
/// places that many flats (order B E A D G C F); `0` (and a percussion clef)
|
||
/// places none. The count needs no clamping — [`KeySignature`] already
|
||
/// guarantees `fifths` is within the conventional `-7..=7` (I-0 invariant).
|
||
pub fn key_signature(key: KeySignature, clef: &Clef) -> Vec<KeyAccidental> {
|
||
let fifths = key.fifths();
|
||
if fifths == 0 || matches!(clef.shape, ClefShape::Percussion) {
|
||
return Vec::new();
|
||
}
|
||
let count = fifths.unsigned_abs() as usize;
|
||
let (glyph, start, offsets) = if fifths > 0 {
|
||
(
|
||
"accidentalSharp",
|
||
first_accidental_position(CmnNominal::F, clef, true),
|
||
&SHARP_OFFSETS,
|
||
)
|
||
} else {
|
||
(
|
||
"accidentalFlat",
|
||
first_accidental_position(CmnNominal::B, clef, false),
|
||
&FLAT_OFFSETS,
|
||
)
|
||
};
|
||
(0..count)
|
||
.map(|i| KeyAccidental {
|
||
glyph,
|
||
position: start + offsets[i],
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn staff_position_is_diatonic_and_clef_relative() {
|
||
let treble = Clef::treble();
|
||
// Treble: E4 is the bottom line (0); G4 the second line (2); F5 the top
|
||
// line (8); A4 the second space (3); C4 a ledger below (−2).
|
||
assert_eq!(staff_position(CmnNominal::E, 4, &treble), 0);
|
||
assert_eq!(staff_position(CmnNominal::G, 4, &treble), 2);
|
||
assert_eq!(staff_position(CmnNominal::F, 5, &treble), 8);
|
||
assert_eq!(staff_position(CmnNominal::A, 4, &treble), 3);
|
||
assert_eq!(staff_position(CmnNominal::C, 4, &treble), -2);
|
||
|
||
// Bass: F3 the fourth line (6); G2 the bottom line (0); middle C4 the
|
||
// first ledger above (10).
|
||
let bass = Clef::bass();
|
||
assert_eq!(staff_position(CmnNominal::F, 3, &bass), 6);
|
||
assert_eq!(staff_position(CmnNominal::G, 2, &bass), 0);
|
||
assert_eq!(staff_position(CmnNominal::C, 4, &bass), 10);
|
||
|
||
// Alto: middle C4 the middle line (4). Tenor: middle C4 the fourth line (6).
|
||
assert_eq!(staff_position(CmnNominal::C, 4, &Clef::alto()), 4);
|
||
assert_eq!(staff_position(CmnNominal::C, 4, &Clef::tenor()), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn staff_step_pitch_inverts_staff_position() {
|
||
let nominals = [
|
||
CmnNominal::C,
|
||
CmnNominal::D,
|
||
CmnNominal::E,
|
||
CmnNominal::F,
|
||
CmnNominal::G,
|
||
CmnNominal::A,
|
||
CmnNominal::B,
|
||
];
|
||
// Plain clefs and octave-shifted (8va/8vb) trebles — the editor inverse
|
||
// depends on the same octave_shift sign convention as the forward map.
|
||
let octave_treble = |shift: i8| Clef {
|
||
shape: ClefShape::G,
|
||
line: 2,
|
||
octave_shift: shift,
|
||
};
|
||
for clef in [
|
||
Clef::treble(),
|
||
Clef::bass(),
|
||
Clef::alto(),
|
||
Clef::tenor(),
|
||
octave_treble(1),
|
||
octave_treble(-1),
|
||
] {
|
||
for octave in 1..8i8 {
|
||
for nominal in nominals {
|
||
let step = staff_position(nominal, octave, &clef);
|
||
assert_eq!(
|
||
staff_step_pitch(step, &clef),
|
||
Some((nominal, octave)),
|
||
"round trip failed for {nominal:?}{octave} under {clef:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// A percussion clef has no diatonic mapping, so no inverse.
|
||
let percussion = Clef {
|
||
shape: ClefShape::Percussion,
|
||
line: 3,
|
||
octave_shift: 0,
|
||
};
|
||
assert_eq!(staff_step_pitch(0, &percussion), None);
|
||
}
|
||
|
||
#[test]
|
||
fn staff_step_pitch_refuses_an_unrepresentable_octave() {
|
||
// A click so far off the staff that its octave overflows i8 is refused, not
|
||
// wrapped to a plausible-but-wrong octave.
|
||
let treble = Clef::treble();
|
||
assert_eq!(staff_step_pitch(i32::MAX, &treble), None);
|
||
assert_eq!(staff_step_pitch(i32::MIN, &treble), None);
|
||
assert_eq!(staff_step_pitch(10_000, &treble), None);
|
||
assert_eq!(staff_step_pitch(-10_000, &treble), None);
|
||
// …but a position just inside the i8 octave range still resolves.
|
||
assert!(staff_step_pitch(staff_position(CmnNominal::C, 9, &treble), &treble).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn accidentals_never_change_staff_position() {
|
||
// The position is purely diatonic — the same nominal+octave lands on the
|
||
// same line regardless of any accidental the spelling carries.
|
||
let treble = Clef::treble();
|
||
let c_natural = staff_position(CmnNominal::C, 5, &treble);
|
||
let c_sharp = staff_position(CmnNominal::C, 5, &treble); // spelling differs, position must not
|
||
assert_eq!(c_natural, c_sharp);
|
||
}
|
||
|
||
#[test]
|
||
fn octave_shift_sign_is_explicit() {
|
||
// 8va (+1) writes a sounding pitch an octave LOWER on the staff; 8vb (−1)
|
||
// an octave HIGHER. A sounding G5 under treble-8va sits where G4 sits on
|
||
// a plain treble clef (step 2); under treble-8vb, where G6 would (step 16).
|
||
let plain = Clef::treble();
|
||
let up = Clef {
|
||
octave_shift: 1,
|
||
..Clef::treble()
|
||
};
|
||
let down = Clef {
|
||
octave_shift: -1,
|
||
..Clef::treble()
|
||
};
|
||
let plain_g5 = staff_position(CmnNominal::G, 5, &plain); // 9
|
||
assert_eq!(plain_g5, 9);
|
||
assert_eq!(staff_position(CmnNominal::G, 5, &up), plain_g5 - 7);
|
||
assert_eq!(staff_position(CmnNominal::G, 5, &down), plain_g5 + 7);
|
||
}
|
||
|
||
#[test]
|
||
fn note_value_glyphs() {
|
||
assert_eq!(notehead_glyph(NoteValue::Whole), "noteheadWhole");
|
||
assert_eq!(notehead_glyph(NoteValue::Half), "noteheadHalf");
|
||
assert_eq!(notehead_glyph(NoteValue::Quarter), "noteheadBlack");
|
||
assert_eq!(notehead_glyph(NoteValue::Sixteenth), "noteheadBlack");
|
||
assert_eq!(rest_glyph(NoteValue::Whole), Some("restWhole"));
|
||
assert_eq!(rest_glyph(NoteValue::Eighth), Some("rest8th"));
|
||
assert_eq!(rest_glyph(NoteValue::Sixteenth), None);
|
||
assert!(!has_stem(NoteValue::Whole));
|
||
assert!(has_stem(NoteValue::Quarter));
|
||
assert_eq!(flag_glyph(NoteValue::Quarter, StemDirection::Up), None);
|
||
assert_eq!(
|
||
flag_glyph(NoteValue::Eighth, StemDirection::Up),
|
||
Some("flag8thUp")
|
||
);
|
||
assert_eq!(
|
||
flag_glyph(NoteValue::Eighth, StemDirection::Down),
|
||
Some("flag8thDown")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clef_and_accidental_glyphs() {
|
||
assert_eq!(clef_glyph(ClefShape::G), Some("gClef"));
|
||
assert_eq!(clef_glyph(ClefShape::F), Some("fClef"));
|
||
assert_eq!(clef_glyph(ClefShape::C), Some("cClef"));
|
||
assert_eq!(clef_glyph(ClefShape::Percussion), None);
|
||
assert_eq!(
|
||
accidental_glyph(&AccidentalId::new("sharp")),
|
||
Some("accidentalSharp")
|
||
);
|
||
assert_eq!(
|
||
accidental_glyph(&AccidentalId::new("flat")),
|
||
Some("accidentalFlat")
|
||
);
|
||
assert_eq!(
|
||
accidental_glyph(&AccidentalId::new("natural")),
|
||
Some("accidentalNatural")
|
||
);
|
||
assert_eq!(accidental_glyph(&AccidentalId::new("quarter-sharp")), None);
|
||
}
|
||
|
||
#[test]
|
||
fn key_signature_positions_match_the_conventional_pattern() {
|
||
let key = |fifths: i8| KeySignature::new(fifths).expect("fifths in range");
|
||
|
||
// C major / A minor: nothing.
|
||
assert!(key_signature(key(0), &Clef::treble()).is_empty());
|
||
|
||
// Treble sharps F C G D A E B → steps 8 5 9 6 3 7 4.
|
||
let treble_sharps: Vec<StaffStep> = key_signature(key(7), &Clef::treble())
|
||
.iter()
|
||
.map(|a| a.position)
|
||
.collect();
|
||
assert_eq!(treble_sharps, vec![8, 5, 9, 6, 3, 7, 4]);
|
||
assert!(key_signature(key(7), &Clef::treble())
|
||
.iter()
|
||
.all(|a| a.glyph == "accidentalSharp"));
|
||
|
||
// Treble flats B E A D G C F → steps 4 7 3 6 2 5 1.
|
||
let treble_flats: Vec<StaffStep> = key_signature(key(-7), &Clef::treble())
|
||
.iter()
|
||
.map(|a| a.position)
|
||
.collect();
|
||
assert_eq!(treble_flats, vec![4, 7, 3, 6, 2, 5, 1]);
|
||
assert!(key_signature(key(-7), &Clef::treble())
|
||
.iter()
|
||
.all(|a| a.glyph == "accidentalFlat"));
|
||
|
||
// Bass: first sharp F#3 on the fourth line (6); first flat B♭2 on the
|
||
// second line (2).
|
||
assert_eq!(key_signature(key(1), &Clef::bass())[0].position, 6);
|
||
assert_eq!(key_signature(key(-1), &Clef::bass())[0].position, 2);
|
||
|
||
// A two-sharp signature places exactly two accidentals.
|
||
assert_eq!(key_signature(key(2), &Clef::treble()).len(), 2);
|
||
}
|
||
}
|