epiphany/crates/epiphany-core/src/tuning.rs

2308 lines
104 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Chapter 4 tuning-resolution vocabulary and the resolver
//! (`core_spec.tex` §"Tuning Systems", `sec:tuning:system`, `:3279` onward),
//! Push 4b tranche 2 (`spec/CONTRACT_PUSH4B_RESOLVER.md`).
//!
//! Like [`crate::pitch_space`] this is a **vertical slice that stays in
//! memory**: [`TuningSystem`], [`TuningResolution`], [`TuningOverride`],
//! [`TuningScope`], the built-in catalog, and [`resolve_pitch_frequency`]
//! (the resolver) land together, with a behavioural proof of life (real
//! frequencies asserted, not `is_ok()`) rather than as three separate
//! passes. **No `Codec` impl exists, or may be added, for anything in this
//! module** (Ruling C, `spec/PLAN_PUSH4B_TUNING.md`): these types are
//! referenced only by id from canonical score state (`ScoreTuningContext`'s
//! wire form stays the three fields it always had — see the hand-written
//! codec in `codec.rs`), so they stay free to change once a later tranche
//! discovers they are wrong.
//!
//! ## What resolves, and what still does not
//!
//! [`TuningResolution`] is a **six**-variant enum in the specification
//! (`core_spec.tex:3309`); this module defines four —
//! [`TuningResolution::EqualTemperament`], [`TuningResolution::PerPositionRatios`],
//! [`TuningResolution::Function`] (Push 4b tranche 2b), and, since Push 4b's
//! adaptive tranche, [`TuningResolution::Adaptive`] (plus [`PositionRatio`],
//! the marker [`TuningParameters`], and the minimal [`HarmonicContext`]). The
//! other two are transcribed only when a built-in needs them, so their
//! unconstructed payload subtrees (`ImportedTuningData`, …) never become an
//! unconsumed type surface (the `NOTEHEAD_ANCHORS` failure): `Overlay` and
//! `Imported` wait on a built-in that needs a split-accidental keyboard or an
//! imported `.scl`/MTS tuning respectively — nothing in the twenty-item
//! catalog constructs either.
//!
//! [`built_in_tuning_system`] resolves **all twenty** catalog identifiers
//! (`req:tuning:builtin-tuning-catalog`): the six `tet-*` equal temperaments,
//! the three `ji-static-5limit-*` just-intonation systems, the ten historical
//! temperaments (`pythagorean`, the three `meantone-*`,
//! `werckmeister-iii`/`-iv`, `vallotti`, `kirnberger-ii`/`-iii`, `young-ii`,
//! Push 4b tranche 2b) — each built from its ratified fifth-tempering
//! construction (`core_spec.tex` §"Temperament Constructions",
//! `:3696`-`4011`) by `temperament_ratios`, never from a pasted cents table —
//! and, finally, `ji-adaptive-5limit`: [`TuningResolution::Adaptive`] bound to
//! the one registered [`crate::pitch::AdaptiveTuningFunctionId`],
//! `"default-v1"`, which is *exactly* `ji_static_5limit_ratios` re-anchored to
//! the harmonic context's tonal centre (C, chromatic position 0, when none is
//! supplied) — the same lattice construction the three static-JI built-ins
//! use, never a second construction or a transcribed cents table. (An earlier
//! draft of this module claimed adaptive resolution "needs `HarmonicContext`,
//! which does not exist in Rust" — that was wrong even at the time: version 1
//! is a pure function of position and anchor pitch class alone, per
//! `req:tuning:adaptive-default-version`, and needed only the one-field
//! `HarmonicContext` this module now defines.)
//!
//! ## The compatibility check, narrowed the same way tranche 1 narrowed it
//!
//! `req:tuning:tuning-system-compatibility` (`:3581`) allows a resolved tuning
//! system's `pitch_space` to differ from the resolved pitch space when a
//! *registered compatibility mapping* declares them compatible. **No such
//! registry exists** in this tranche (matching how tranche 1 left the
//! pitch-space-mapping registry unbuilt, `spec/PLAN_PUSH4B_TUNING.md` Ruling
//! C): [`resolve_pitch_frequency`] accepts only exact `pitch_space` equality
//! and fails closed on any mismatch, a deliberate deferral, not an oversight.
use core::num::NonZeroU32;
use crate::graph::{KeySignature, Score, ScoreTuningContext, StaffInstance};
use crate::ids::{RegionId, StaffId, VoiceId};
use crate::pitch::{
AcousticRealization, AdaptiveTuningFunctionId, ChromaticPitchClass, Pitch, PitchSpaceId,
PitchSpacePosition, ReferencePitch, TuningFunctionId, TuningReference, TuningSystemId,
VoiceSelector,
};
use crate::pitch_space::{built_in_position_structure, JiRatio, PositionStructure};
use crate::time::{EventPosition, MusicalPosition, TimeAnchor};
// ===========================================================================
// Types (Chapter 4 §"Tuning Systems" / §"Score Tuning Context and
// Hierarchical Resolution").
// ===========================================================================
/// One entry of a [`TuningResolution::PerPositionRatios`] catalog
/// (`core_spec.tex:3314-3316`): "Explicit per-position ratios. Each entry is
/// a ratio relative to the reference position." The specification's own
/// listing writes only `PerPositionRatios(Vec<PositionRatio>)` and never
/// spells out `PositionRatio`'s fields — nothing in `core_spec.tex`
/// constructs one but the three `ji-static-5limit-*` built-ins — so this
/// tranche defines it as narrowly as those three need: a chromatic position
/// plus the exact ratio-to-anchor at that position, reusing
/// [`JiRatio`](crate::pitch_space::JiRatio) rather than inventing a second
/// rational type.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct PositionRatio {
/// The chromatic position this ratio governs: `0..divisions_per_octave`
/// of the enclosing pitch space's chromatic layer (for the built-ins
/// below, always `0..12`, `cmn-12`'s chromatic layer).
pub position: i32,
/// The exact frequency ratio of `position` relative to the tuning's own
/// 1/1 (its anchor), octave-reduced into `[1, 2)`.
pub ratio: JiRatio,
}
/// Placeholder for [`TuningResolution::Function`]'s parameter schema
/// (`core_spec.tex:3318-3324`). The specification never gives this type's
/// shape — Chapter 4's own forward-reference list (`:4083`) singles out the
/// sibling `AdaptiveTuningParameters` as "likewise undefined," for the same
/// reason: no built-in in the twenty-item catalog parameterizes a `Function`
/// resolution, since each of the ten historical temperaments this module
/// builds is fixed entirely by its [`TuningFunctionId`] alone. The same
/// discipline as [`SpellingParameters`](crate::pitch_space::SpellingParameters),
/// one level down: a documented zero-field marker so
/// `TuningResolution::Function`'s field list matches the specification's own
/// listing, carrying no state and inventing no schema.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct TuningParameters;
/// Harmonic context supplied to adaptive tuning resolution (`core_spec.tex`
/// §"Adaptive Tuning", `:3397-3428`). **Minimal**, not the four-field
/// listing at `core_spec.tex:3411`
/// (`concurrent: Vec<PitchId>`, `recent: Vec<(PitchId, f64)>`,
/// `key_context: Option<KeyContext>`, `hints: Vec<ContextHint>`). Two of
/// those fields are unimplementable by construction: `key_context` and
/// `hints` are typed on `KeyContext` and `ContextHint`, which the
/// specification leaves undefined on purpose (Forward References,
/// `core_spec.tex:4111`: "defining them now would freeze a type surface on a
/// chapter with no consumer"). The other two, `concurrent` and `recent`, are
/// ignored by version 1 of the one adaptive function this module registers
/// (`req:tuning:adaptive-default-version`: "`concurrent`, `recent`, `hints`,
/// `parameters`, and mode are ignored by version 1") — carrying them here
/// would mint exactly the unconsumed type surface the module doc's
/// `NOTEHEAD_ANCHORS` note warns against. Each field arrives with the first
/// function that actually consumes it.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct HarmonicContext {
/// The active tonal centre as a chromatic pitch class (`0..=11`), if
/// known. `None` when no tonal centre could be determined (e.g.
/// [`derive_tonal_centre`] found no applicable key-signature change) —
/// the resolver then defaults to C (`req:tuning:adaptive-default-version`),
/// a defined default, never an error.
pub tonal_centre: Option<ChromaticPitchClass>,
}
/// How a tuning system resolves pitch-space positions to frequencies
/// (`core_spec.tex:3309-3348`). **Deliberately partial**: the specification
/// names six variants; this module defines four. See the module doc for
/// which tranche completes each of the other two.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TuningResolution {
/// N-tone equal temperament: each step is the Nth root of the octave
/// ratio. Includes 12-TET when `divisions_per_octave == 12`.
EqualTemperament { divisions_per_octave: u16 },
/// Explicit per-position ratios, each relative to the reference
/// position.
PerPositionRatios(Vec<PositionRatio>),
/// Procedural definition: a registered tuning function that computes
/// frequencies from a reference (`core_spec.tex:3318-3324`: "Historical
/// temperaments (Werckmeister, Vallotti, meantone variants) live here").
/// The ten historical temperaments are reserved built-in
/// [`TuningFunctionId`]s, each re-derived from its ratified
/// fifth-tempering construction (`core_spec.tex` §"Temperament
/// Constructions", `:3696`-`4011`) by `temperament_ratios` rather than
/// transcribed from a cents table. No parameter schema exists yet
/// ([`TuningParameters`] is a documented marker); an unreserved
/// `TuningFunctionId` has no registry to resolve against and fails
/// closed (`coordinate_ratio` returns `None`) — `Function` is an
/// extension point, not a registry this module builds.
Function {
function: TuningFunctionId,
parameters: TuningParameters,
},
/// Adaptive resolution: frequency from position *plus* [`HarmonicContext`]
/// (`core_spec.tex:3397-3472`). The one built-in,
/// `"default-v1"` (`req:tuning:adaptive-default-version`), is a pure
/// function of (position, anchor pitch class): the position resolves
/// through `ji_static_5limit_ratios`, transposed so the harmonic
/// context's tonal centre takes the role of `1/1` (C, chromatic position
/// 0, when no context or no tonal centre is supplied — a defined
/// default, not a fallback). An unregistered `function` is a hard error
/// with no silent fallback, exactly as an unreserved `Function`
/// [`TuningFunctionId`] fails closed above.
Adaptive { function: AdaptiveTuningFunctionId },
}
/// A tuning system: a map from pitch-space positions to frequencies, given a
/// reference pitch (`core_spec.tex:3287-3303`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TuningSystem {
pub id: TuningSystemId,
/// Human-readable name.
pub name: String,
/// The pitch space whose positions this tuning resolves.
pub pitch_space: PitchSpaceId,
/// How the tuning resolves positions to frequencies, given a reference
/// pitch.
pub resolution: TuningResolution,
/// Optional historical or provenance notes.
pub description: Option<String>,
}
/// The scope a [`TuningOverride`] applies to (`core_spec.tex:3534-3539`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TuningScope {
Voice(VoiceId),
Staff(StaffId),
Region(RegionId),
Range {
start: TimeAnchor,
end: TimeAnchor,
voices: VoiceSelector,
},
}
/// A per-scope override of one or more tuning components
/// (`core_spec.tex:3527-3532`). `None` fields inherit from the next-outer
/// scope, per `req:tuning:tuning-resolution-order`.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct TuningOverride {
pub scope: TuningScope,
pub pitch_space: Option<PitchSpaceId>,
pub tuning_system: Option<TuningSystemId>,
pub reference: Option<ReferencePitch>,
}
// ===========================================================================
// The built-in catalog, as data (partial, honestly) — item 2.
// ===========================================================================
/// A built-in catalog lookup result: a real, resolved [`TuningSystem`], or a
/// real catalog identifier (`req:tuning:builtin-tuning-catalog` still
/// requires it to resolve *eventually*) whose resolution some tranche has
/// not yet built. Distinguishing this from "not a built-in identifier at
/// all" ([`built_in_tuning_system`] returning `None`) is what lets
/// [`resolve_pitch_frequency`] report a genuinely unknown identifier and a
/// known-but-deferred one differently, per the contract's "a clear 'not yet
/// supported' error, never a fallback frequency." As of Push 4b's adaptive
/// tranche, [`built_in_tuning_system`] constructs no `Deferred` entry — all
/// twenty catalog identifiers resolve — but the variant stays: a future
/// built-in (the 21st) could still land as a real, honestly-deferred entry
/// rather than a silent guess.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TuningCatalogEntry {
Resolved(TuningSystem),
/// Why this identifier's resolution is deferred, and to what.
Deferred(&'static str),
}
/// Looks up a built-in [`TuningSystem`] (Chapter 4 §"Built-in Catalog",
/// `core_spec.tex:3656-3694`, `req:tuning:builtin-tuning-catalog`).
///
/// All twenty resolve:
///
/// * the six `tet-*` — [`TuningResolution::EqualTemperament`] from the
/// identifier's divisions. `tet-12` pairs with `cmn-12` (the default
/// pairing, `core_spec.tex:4058-4068`); `tet-19/22/31/53/72` pair with the
/// built-in `edo-19/22/31/53/72` pitch spaces — the only built-in pitch
/// spaces whose [`PositionStructure::Chromatic`] cardinality matches, so
/// the pairing is forced by the catalog rather than chosen;
/// * the three `ji-static-5limit-{C,G,D}` — [`TuningResolution::PerPositionRatios`]
/// computed by `ji_static_5limit_ratios` from the lattice block
/// (`req:tuning:ji-static-construction`), over `cmn-12`'s twelve chromatic
/// positions (`core_spec.tex:4019-4021`: "assigned in ascending order to
/// the twelve chromatic positions of `cmn-12`");
/// * the ten historical temperaments (`pythagorean`, three `meantone-*`,
/// `werckmeister-iii`/`-iv`, `vallotti`, `kirnberger-ii`/`-iii`,
/// `young-ii`, Push 4b tranche 2b) — [`TuningResolution::Function`], each
/// built by `temperament_ratios` from its ratified fifth-tempering
/// construction (`core_spec.tex` §"Temperament Constructions",
/// `:3696`-`4011`), over `cmn-12`'s twelve chromatic positions exactly as
/// the static-JI systems are;
/// * `ji-adaptive-5limit` — [`TuningResolution::Adaptive`] bound to the one
/// registered [`AdaptiveTuningFunctionId`], `"default-v1"`
/// (`req:tuning:adaptive-default-version`), over `cmn-12`'s twelve
/// chromatic positions exactly as the static-JI systems. Resolving it to a
/// frequency needs a harmonic context (or defaults to C without one) — see
/// [`resolve_pitch_frequency`].
///
/// `None` for any other identifier: not one of the twenty at all.
pub fn built_in_tuning_system(id: &TuningSystemId) -> Option<TuningCatalogEntry> {
fn tet(
name: &'static str,
pitch_space: &'static str,
divisions: u16,
desc: &'static str,
) -> TuningCatalogEntry {
TuningCatalogEntry::Resolved(TuningSystem {
id: TuningSystemId::new(name),
name: name.to_owned(),
pitch_space: PitchSpaceId::new(pitch_space),
resolution: TuningResolution::EqualTemperament {
divisions_per_octave: divisions,
},
description: Some(desc.to_owned()),
})
}
fn ji_static(
name: &'static str,
anchor_chromatic_degree: i32,
tonic: &'static str,
) -> TuningCatalogEntry {
TuningCatalogEntry::Resolved(TuningSystem {
id: TuningSystemId::new(name),
name: name.to_owned(),
pitch_space: PitchSpaceId::new("cmn-12"),
resolution: TuningResolution::PerPositionRatios(ji_static_5limit_ratios(
anchor_chromatic_degree,
)),
description: Some(format!(
"Static 5-limit just intonation anchored to {tonic} tonic."
)),
})
}
/// A historical temperament's catalog entry: [`TuningResolution::Function`]
/// naming `name` as its own [`TuningFunctionId`] (item 3 of
/// `spec/CONTRACT_PUSH4B_TEMPERAMENTS.md`: "`pitch_space` = `cmn-12`").
/// `desc` is transcribed from the built-in catalog table
/// (`core_spec.tex:3669-3679`).
fn temperament(name: &'static str, desc: &'static str) -> TuningCatalogEntry {
TuningCatalogEntry::Resolved(TuningSystem {
id: TuningSystemId::new(name),
name: name.to_owned(),
pitch_space: PitchSpaceId::new("cmn-12"),
resolution: TuningResolution::Function {
function: TuningFunctionId::new(name),
parameters: TuningParameters,
},
description: Some(desc.to_owned()),
})
}
/// `ji-adaptive-5limit`'s catalog entry: [`TuningResolution::Adaptive`]
/// bound to the one registered [`AdaptiveTuningFunctionId`],
/// `"default-v1"` (`req:tuning:adaptive-default-version`), over
/// `cmn-12`'s twelve chromatic positions exactly as the static-JI
/// systems.
fn ji_adaptive() -> TuningCatalogEntry {
TuningCatalogEntry::Resolved(TuningSystem {
id: TuningSystemId::new("ji-adaptive-5limit"),
name: "ji-adaptive-5limit".to_owned(),
pitch_space: PitchSpaceId::new("cmn-12"),
resolution: TuningResolution::Adaptive {
function: AdaptiveTuningFunctionId::new("default-v1"),
},
description: Some(
"Adaptive 5-limit just intonation: the static 5-limit construction, \
re-anchored per resolution to the prevailing tonal centre (C by default)."
.to_owned(),
),
})
}
match id.as_str() {
"tet-12" => Some(tet(
"tet-12",
"cmn-12",
12,
"12-tone equal temperament. The default.",
)),
"tet-19" => Some(tet("tet-19", "edo-19", 19, "19-tone equal temperament.")),
"tet-22" => Some(tet("tet-22", "edo-22", 22, "22-tone equal temperament.")),
"tet-31" => Some(tet("tet-31", "edo-31", 31, "31-tone equal temperament.")),
"tet-53" => Some(tet("tet-53", "edo-53", 53, "53-tone equal temperament.")),
"tet-72" => Some(tet("tet-72", "edo-72", 72, "72-tone equal temperament.")),
"ji-static-5limit-C" => Some(ji_static("ji-static-5limit-C", 0, "C")),
"ji-static-5limit-G" => Some(ji_static("ji-static-5limit-G", 7, "G")),
"ji-static-5limit-D" => Some(ji_static("ji-static-5limit-D", 2, "D")),
"pythagorean" => Some(temperament(
"pythagorean",
"Pure-fifth (3:2) Pythagorean tuning.",
)),
"meantone-1/4-comma" => Some(temperament(
"meantone-1/4-comma",
"Quarter-comma meantone (pure major thirds).",
)),
"meantone-1/6-comma" => Some(temperament("meantone-1/6-comma", "Sixth-comma meantone.")),
"meantone-1/5-comma" => Some(temperament("meantone-1/5-comma", "Fifth-comma meantone.")),
"werckmeister-iii" => Some(temperament(
"werckmeister-iii",
"Werckmeister III well temperament.",
)),
"werckmeister-iv" => Some(temperament(
"werckmeister-iv",
"Werckmeister IV well temperament.",
)),
"vallotti" => Some(temperament("vallotti", "Vallotti well temperament.")),
"kirnberger-ii" => Some(temperament(
"kirnberger-ii",
"Kirnberger II well temperament.",
)),
"kirnberger-iii" => Some(temperament(
"kirnberger-iii",
"Kirnberger III well temperament.",
)),
"young-ii" => Some(temperament(
"young-ii",
"Thomas Young's second temperament.",
)),
"ji-adaptive-5limit" => Some(ji_adaptive()),
_ => None,
}
}
/// The greatest common divisor of two positive integers (Euclid's
/// algorithm), used to keep [`ji_static_5limit_ratios`]'s fractions in
/// lowest terms.
fn gcd(a: i64, b: i64) -> i64 {
if b == 0 {
a
} else {
gcd(b, a % b)
}
}
/// Computes the twelve, anchor-relative ratios of the static 5-limit
/// construction (`req:tuning:ji-static-construction`, `core_spec.tex:4015-4024`,
/// read and verified before citing): the lattice block
/// $\{3^a 5^b \mid a \in [-1,2],\ b \in [-1,1]\}$ — twelve cells, generated
/// by its bounds, nothing selected or discarded — octave-reduced into
/// `[1, 2)` and assigned in ascending order starting from the anchor, which
/// takes the role of `1/1`. Computed here in code, in exact integer
/// arithmetic (never a pasted cents or ratio table), exactly the same
/// construction the specification states in prose.
///
/// `anchor_chromatic_degree` is the `cmn-12` chromatic position (0 = C,
/// 7 = G, 2 = D, …) playing the role of `1/1`; the returned table's
/// `position` fields are `(anchor_chromatic_degree + step) mod 12` for the
/// construction's ascending step order, so indexing the result by
/// *chromatic position* (not by lattice step) gives each position's
/// ratio-to-anchor directly.
fn ji_static_5limit_ratios(anchor_chromatic_degree: i32) -> Vec<PositionRatio> {
let mut cells: Vec<(i64, i64)> = Vec::with_capacity(12);
for a in -1..=2i32 {
for b in -1..=1i32 {
let mut num: i64 = 1;
let mut den: i64 = 1;
if a >= 0 {
num *= 3i64.pow(a as u32);
} else {
den *= 3i64.pow((-a) as u32);
}
if b >= 0 {
num *= 5i64.pow(b as u32);
} else {
den *= 5i64.pow((-b) as u32);
}
// Octave-reduce into [1, 2) by exact integer doubling/halving —
// never a float comparison, so the reduction cannot introduce
// rounding error of its own.
while num >= 2 * den {
den *= 2;
}
while num < den {
num *= 2;
}
let g = gcd(num, den);
cells.push((num / g, den / g));
}
}
// Sort ascending by value, comparing by cross-multiplication so the
// ordering is exact (no float division).
cells.sort_by(|(n1, d1), (n2, d2)| (n1 * d2).cmp(&(n2 * d1)));
cells
.into_iter()
.enumerate()
.map(|(step, (num, den))| PositionRatio {
position: (anchor_chromatic_degree + step as i32).rem_euclid(12),
ratio: JiRatio {
numerator: num as i32,
denominator: NonZeroU32::new(den as u32)
.expect("an octave-reduced denominator is a positive power of two times an odd factor, never zero"),
},
})
.collect()
}
// ===========================================================================
// The ten historical temperaments (item 2 of
// `spec/CONTRACT_PUSH4B_TEMPERAMENTS.md`): the circle-of-fifths walk and the
// ten ratified constructions it is walked over.
// ===========================================================================
/// The pure 3/2 fifth, the Pythagorean comma, the syntonic comma, and the
/// schisma, each as `1200 · log2(exact ratio)` — never a hardcoded rounded
/// cents value (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 2 and "Do not":
/// "Hardcode a rounded comma value"). `f64` throughout is correct here:
/// these feed only non-canonical frequencies
/// (`req:determinism:canonical-floating-point` binds *stored* canonical
/// floats, not values computed and discarded in memory — read and confirmed
/// before citing), and several fifths in the walk below (meantone's 1/5- and
/// 1/6-comma variants) are irrational by construction, so there is no
/// exact-rational alternative to begin with.
fn pure_fifth_cents() -> f64 {
1200.0 * (3.0_f64 / 2.0).log2()
}
/// The Pythagorean comma, `531441/524288 ≈ 23.460` c (`core_spec.tex:3713`).
fn pythagorean_comma_cents() -> f64 {
1200.0 * (531441.0_f64 / 524288.0).log2()
}
/// The syntonic comma, `81/80 ≈ 21.506` c (`core_spec.tex:3765`).
fn syntonic_comma_cents() -> f64 {
1200.0 * (81.0_f64 / 80.0).log2()
}
/// The schisma, `32805/32768 ≈ 1.954` c (`core_spec.tex:3896`) — the
/// Pythagorean comma minus the syntonic comma exactly: `syntonic + schisma =
/// pythagorean`, which is why a Kirnberger construction's regular
/// syntonic-tempered fifths plus its one schisma-tempered closing fifth
/// reach exactly one Pythagorean comma of total tempering. Computed here
/// directly from `32805/32768`, independently of [`pythagorean_comma_cents`]
/// and [`syntonic_comma_cents`], so the closure tests below *prove* that
/// identity rather than assume it.
fn schisma_cents() -> f64 {
1200.0 * (32805.0_f64 / 32768.0).log2()
}
/// How one of the twelve fifths in the circle-of-fifths chain
/// `CGDAEBF♯C♯G♯E♭B♭F(C)` is tempered by a historical
/// construction (`core_spec.tex` §"Temperament Constructions",
/// `:3696`-`4011`). This — which fifths, by what fraction of which comma —
/// *is* the construction; the derived cents tables the specification also
/// gives are checked against the walk below, never pasted in as data
/// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 2).
#[derive(Copy, Clone, Debug)]
enum FifthTempering {
/// The untempered 3/2 ratio.
Pure,
/// Narrowed by `fraction` of the *Pythagorean* comma (Werckmeister
/// III/IV, Vallotti, Young II — the well temperaments).
NarrowPythagorean(f64),
/// Widened by `fraction` of the Pythagorean comma (Werckmeister IV's two
/// wide fifths, `G♯E♭` and `E♭B♭`).
WidePythagorean(f64),
/// Narrowed by `fraction` of the *syntonic* comma (meantone; Kirnberger's
/// regular tempered fifths).
NarrowSyntonic(f64),
/// Narrowed by exactly one schisma — Kirnberger's closing `F♯D♭` fifth,
/// "the single most commonly omitted element of these ... constructions"
/// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md`'s schisma-fifth trap).
NarrowSchisma,
/// The closing/residual fifth: given no fraction of its own, but
/// whatever cents value brings the twelve-fifth chain to exactly seven
/// octaves (8400 c) — the wolf, for the four non-circulating
/// constructions (`pythagorean`, the three `meantone-*`).
Residual,
}
/// A temperament's construction: the twelve fifths of the circle
/// `CGDAEBF♯C♯G♯E♭B♭F(C)`
/// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 2), in that fixed arc order —
/// index 0 is `CG`, index 1 is `GD`, …, index 8 is `G♯E♭` (the
/// conventional wolf/closing position), index 11 is the final `FC`.
type Construction = [FifthTempering; 12];
/// The `cmn-12` chromatic degree reached after each of the twelve chain
/// arcs, in chain order starting from `C` itself: `C`(0), `G`(1), `D`(2),
/// `A`(3), `E`(4), `B`(5), `F♯`(6), `C♯`(7), `G♯`(8), `E♭`(9), `B♭`(10),
/// `F`(11) — i.e. `CHAIN_CHROMATIC_DEGREE[k]` is chain position `k`'s
/// `cmn-12` degree (`C=0, C♯=1, D=2, …, B=11`).
const CHAIN_CHROMATIC_DEGREE: [usize; 12] = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5];
/// The result of walking a construction: the deliverable the closure tests
/// below recompute from, never a hardcoded constant
/// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md`'s "closure invariant, recomputed
/// in code").
struct TemperamentWalk {
/// The twelve ratios relative to `C` (`1/1`), indexed by `cmn-12`
/// chromatic degree (`0..12`). The one field production code
/// ([`temperament_ratios`]) consumes.
ratios: [f64; 12],
/// The twelve individual fifths' actual cents, in chain order (index 0
/// is `CG`, …, index 8 is `G♯E♭`, index 11 is `FC`) — read only by
/// the closure/wolf tests below, hence `#[cfg(test)]`: no production
/// code needs a single fifth's cents in isolation.
#[cfg(test)]
fifth_cents: [f64; 12],
/// The *raw* (unreduced) cumulative cents after walking all twelve
/// fifths forward from `C` — exactly `8400.0` (seven octaves) if and
/// only if the construction closes. Also test-only, for the same reason.
#[cfg(test)]
raw_closure_cents: f64,
}
/// Walks the circle-of-fifths chain under `construction`
/// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 2: "one walk that places the
/// twelve notes and derives their ratios"). At most one arc may be
/// [`FifthTempering::Residual`] (the four non-circulating constructions);
/// its cents are computed as whatever value brings the other eleven
/// arcs' sum to seven octaves (`core_spec.tex:3749-3759`'s own framing:
/// "any assignment of twelve distinct pitch classes must sum to
/// [8400 c] ... by construction").
fn walk_temperament(construction: &Construction) -> TemperamentWalk {
let pure = pure_fifth_cents();
// Pass 1: every fixed (non-residual) arc's actual cents, plus the index
// of the one residual (wolf) arc, if any.
let mut fifth_cents = [0.0f64; 12];
let mut residual_index: Option<usize> = None;
for (i, tempering) in construction.iter().enumerate() {
let deviation = match *tempering {
FifthTempering::Pure => 0.0,
FifthTempering::NarrowPythagorean(fraction) => fraction * pythagorean_comma_cents(),
FifthTempering::WidePythagorean(fraction) => -fraction * pythagorean_comma_cents(),
FifthTempering::NarrowSyntonic(fraction) => fraction * syntonic_comma_cents(),
FifthTempering::NarrowSchisma => schisma_cents(),
FifthTempering::Residual => {
debug_assert!(
residual_index.is_none(),
"a construction may have at most one residual (wolf) fifth"
);
residual_index = Some(i);
continue;
}
};
fifth_cents[i] = pure - deviation;
}
// Pass 2: the residual (wolf) arc, if any, closes the chain to exactly
// seven octaves.
if let Some(i) = residual_index {
let sum_of_others: f64 = fifth_cents
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, c)| *c)
.sum();
fifth_cents[i] = 8400.0 - sum_of_others;
}
// Walk forward from C, accumulating *raw* (unreduced) cents; reducing
// only when reading off each chain note's pitch class below is what
// lets the wolf (for the non-circulating four) fall out of the same
// walk as everything else, rather than needing separate handling.
let mut cumulative = [0.0f64; 13];
for i in 0..12 {
cumulative[i + 1] = cumulative[i] + fifth_cents[i];
}
let mut ratios = [0.0f64; 12];
for (chain_pos, &degree) in CHAIN_CHROMATIC_DEGREE.iter().enumerate() {
let pitch_class_cents = cumulative[chain_pos].rem_euclid(1200.0);
ratios[degree] = 2f64.powf(pitch_class_cents / 1200.0);
}
TemperamentWalk {
ratios,
#[cfg(test)]
fifth_cents,
#[cfg(test)]
raw_closure_cents: cumulative[12],
}
}
/// `pythagorean` (`core_spec.tex:3709-3759`): eleven pure fifths; the
/// conventional `E♭G♯` cut leaves the wolf at `G♯E♭` (chain arc 8).
fn pythagorean_construction() -> Construction {
use FifthTempering::{Pure, Residual};
[
Pure, Pure, Pure, Pure, Pure, Pure, Pure, Pure, Residual, Pure, Pure, Pure,
]
}
/// `meantone-1/n-comma` (`core_spec.tex:3761-3812`): all eleven non-wolf
/// fifths narrowed uniformly by `1/n` of the *syntonic* comma; same
/// `E♭G♯` cut as `pythagorean`, so the wolf is again chain arc 8.
fn meantone_construction(comma_fraction: f64) -> Construction {
use FifthTempering::{NarrowSyntonic, Residual};
let n = NarrowSyntonic(comma_fraction);
[n, n, n, n, n, n, n, n, Residual, n, n, n]
}
/// `werckmeister-iii` (`core_spec.tex:3814-3838`): `CG, GD, DA, BF♯`
/// narrowed 1/4 Pythagorean comma; the other eight pure. Circulating — no
/// residual arc.
fn werckmeister_iii_construction() -> Construction {
use FifthTempering::{NarrowPythagorean, Pure};
let quarter = NarrowPythagorean(0.25);
[
quarter, // 0: C-G
quarter, // 1: G-D
quarter, // 2: D-A
Pure, // 3: A-E
Pure, // 4: E-B
quarter, // 5: B-F#
Pure, // 6: F#-C#
Pure, // 7: C#-G#
Pure, // 8: G#-Eb
Pure, // 9: Eb-Bb
Pure, // 10: Bb-F
Pure, // 11: F-C
]
}
/// `werckmeister-iv` (`core_spec.tex:3840-3866`): `CG, DA, EB, F♯C♯,
/// B♭F` narrowed 1/3 Pythagorean comma; `G♯E♭, E♭B♭` widened 1/3
/// Pythagorean comma; the other five pure. Circulating.
fn werckmeister_iv_construction() -> Construction {
use FifthTempering::{NarrowPythagorean, Pure, WidePythagorean};
let narrow = NarrowPythagorean(1.0 / 3.0);
let wide = WidePythagorean(1.0 / 3.0);
[
narrow, // 0: C-G
Pure, // 1: G-D
narrow, // 2: D-A
Pure, // 3: A-E
narrow, // 4: E-B
Pure, // 5: B-F#
narrow, // 6: F#-C#
Pure, // 7: C#-G#
wide, // 8: G#-Eb
wide, // 9: Eb-Bb
narrow, // 10: Bb-F
Pure, // 11: F-C
]
}
/// `vallotti` (`core_spec.tex:3868-3888`): `FC, CG, GD, DA, AE, EB`
/// (six consecutive) narrowed 1/6 Pythagorean comma; the other six pure.
/// Circulating.
fn vallotti_construction() -> Construction {
use FifthTempering::{NarrowPythagorean, Pure};
let sixth = NarrowPythagorean(1.0 / 6.0);
[
sixth, // 0: C-G
sixth, // 1: G-D
sixth, // 2: D-A
sixth, // 3: A-E
sixth, // 4: E-B
Pure, // 5: B-F#
Pure, // 6: F#-C#
Pure, // 7: C#-G#
Pure, // 8: G#-Eb
Pure, // 9: Eb-Bb
Pure, // 10: Bb-F
sixth, // 11: F-C
]
}
/// `young-ii` (`core_spec.tex:3985-4010`): `CG, GD, DA, AE, EB, BF♯`
/// (six consecutive) narrowed 1/6 Pythagorean comma; the other six pure —
/// the same construction as `vallotti`, rotated to start at `C` instead of
/// `F` (compare this construction's tempered run, arcs 0-5, against
/// `vallotti`'s, arcs 11,0-4). Circulating.
fn young_ii_construction() -> Construction {
use FifthTempering::{NarrowPythagorean, Pure};
let sixth = NarrowPythagorean(1.0 / 6.0);
[
sixth, // 0: C-G
sixth, // 1: G-D
sixth, // 2: D-A
sixth, // 3: A-E
sixth, // 4: E-B
sixth, // 5: B-F#
Pure, // 6: F#-C#
Pure, // 7: C#-G#
Pure, // 8: G#-Eb
Pure, // 9: Eb-Bb
Pure, // 10: Bb-F
Pure, // 11: F-C
]
}
/// `kirnberger-ii` (`core_spec.tex:3890-3937`): `DA, AE` narrowed 1/2
/// syntonic comma; the closing fifth (named `F♯D♭` in the specification,
/// since Kirnberger's own chain is built outward from `D♭` — the *same
/// physical arc* as chain position 6, `F♯C♯`, `D♭` and `C♯` being one
/// enharmonic pitch class) narrowed one schisma — the trap
/// `spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` calls out by name; the other nine
/// pure. Circulating.
fn kirnberger_ii_construction() -> Construction {
use FifthTempering::{NarrowSchisma, NarrowSyntonic, Pure};
let half = NarrowSyntonic(0.5);
[
Pure, // 0: C-G
Pure, // 1: G-D
half, // 2: D-A
half, // 3: A-E
Pure, // 4: E-B
Pure, // 5: B-F#
NarrowSchisma, // 6: F#-C# (the closing F#-Db fifth)
Pure, // 7: C#-G#
Pure, // 8: G#-Eb
Pure, // 9: Eb-Bb
Pure, // 10: Bb-F
Pure, // 11: F-C
]
}
/// `kirnberger-iii` (`core_spec.tex:3939-3983`): `CG, GD, DA, AE` (four
/// consecutive) narrowed 1/4 syntonic comma; the same closing `F♯D♭`
/// (chain position 6) narrowed one schisma as `kirnberger-ii`; the other
/// seven pure. Circulating.
fn kirnberger_iii_construction() -> Construction {
use FifthTempering::{NarrowSchisma, NarrowSyntonic, Pure};
let quarter = NarrowSyntonic(0.25);
[
quarter, // 0: C-G
quarter, // 1: G-D
quarter, // 2: D-A
quarter, // 3: A-E
Pure, // 4: E-B
Pure, // 5: B-F#
NarrowSchisma, // 6: F#-C# (the closing F#-Db fifth)
Pure, // 7: C#-G#
Pure, // 8: G#-Eb
Pure, // 9: Eb-Bb
Pure, // 10: Bb-F
Pure, // 11: F-C
]
}
/// The twelve ratios (indexed by `cmn-12` chromatic degree) of the
/// reserved-built-in temperament named by `function`, or `None` for any
/// other id — the extension point's fail-closed path
/// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 1: "An unknown
/// `TuningFunctionId` returns `None` ... `Function` is an extension point and
/// no registry exists"). Never memoized: each of the ten constructions is
/// cheap arithmetic on twelve `f64`s, so there is no cache to keep coherent.
fn temperament_ratios(function: &TuningFunctionId) -> Option<[f64; 12]> {
let construction = match function.as_str() {
"pythagorean" => pythagorean_construction(),
"meantone-1/4-comma" => meantone_construction(0.25),
"meantone-1/5-comma" => meantone_construction(0.2),
"meantone-1/6-comma" => meantone_construction(1.0 / 6.0),
"werckmeister-iii" => werckmeister_iii_construction(),
"werckmeister-iv" => werckmeister_iv_construction(),
"vallotti" => vallotti_construction(),
"kirnberger-ii" => kirnberger_ii_construction(),
"kirnberger-iii" => kirnberger_iii_construction(),
"young-ii" => young_ii_construction(),
_ => return None,
};
Some(walk_temperament(&construction).ratios)
}
// ===========================================================================
// The frequency resolver (item 3): (position, TuningSystem, ReferencePitch)
// -> Hz.
// ===========================================================================
/// Why a tuning could not be resolved to a frequency. Every variant is a
/// closed failure, never a fallback frequency
/// (`req:tuning:tuning-resolution-determinism`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TuningResolutionError {
/// `voice` is not the id of any voice reachable from the score's
/// `canvas.regions` — a caller error (an orphaned or foreign
/// [`VoiceId`]), not a tuning failure.
VoiceNotFound(VoiceId),
/// The resolved tuning-system identifier is not in the built-in catalog
/// at all (no registry for score-defined tuning systems exists, Ruling C
/// of `spec/PLAN_PUSH4B_TUNING.md`).
UnknownTuningSystem(TuningSystemId),
/// The resolved tuning-system identifier is a real catalog entry whose
/// resolution this tranche defers (see [`built_in_tuning_system`]); the
/// reason names which tranche completes it.
NotYetSupported {
id: TuningSystemId,
reason: &'static str,
},
/// The resolved pitch space does not resolve structurally at all
/// ([`built_in_position_structure`] returned `None`) — an unknown
/// identifier or one of the six built-in spaces the specification names
/// but does not structurally determine (`crate::pitch_space`).
UnresolvedPitchSpace(PitchSpaceId),
/// `req:tuning:tuning-system-compatibility`: the resolved tuning
/// system's declared `pitch_space` differs from the resolved pitch
/// space, and no compatibility-mapping registry exists this tranche —
/// a deliberate deferral (see the module doc), not a guess.
IncompatiblePitchSpace {
pitch_space: PitchSpaceId,
tuning_system_pitch_space: PitchSpaceId,
},
/// The pitch's (or the reference's) position could not be placed on the
/// tuning system's coordinate frame: a structural mismatch between the
/// resolved pitch space's [`PositionStructure`] and the
/// [`PitchSpacePosition`] variant in play, or between its chromatic
/// cardinality and the tuning system's own divisions/table length.
PositionUnavailable,
/// The resolved tuning system is [`TuningResolution::Adaptive`] naming an
/// [`AdaptiveTuningFunctionId`] other than the one registered built-in,
/// `"default-v1"` (`req:tuning:adaptive-default-version`'s final clause:
/// "An unregistered or unknown `AdaptiveTuningFunctionId` MUST be a hard
/// error; there is no silent fallback"). Never falls back to the C
/// default.
UnregisteredAdaptiveFunction(AdaptiveTuningFunctionId),
/// A [`crate::graph::KeySignatureChange`]'s anchor could not be ordered
/// against a pitch's onset while deriving an adaptive tonal centre
/// ([`derive_tonal_centre`], `req:tuning:adaptive-anchor-derivation`) —
/// either the two live on clocks that cannot be compared (a wall-clock
/// anchor against a musical onset, or vice versa), or the anchor is an
/// indirect form (`Event`/`Measure`/`Region`) the caller's injected
/// resolver could not place. A **deliberate deferral**, like
/// [`Self::IncompatiblePitchSpace`]: it says "this score uses an anchor
/// form the adaptive resolver does not order yet," never "your key
/// signature is wrong" — so it is never confused with a malformed score.
AnchorNotOrderable {
anchor: TimeAnchor,
reason: &'static str,
},
}
impl core::fmt::Display for TuningResolutionError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::VoiceNotFound(v) => write!(f, "voice {v:?} is not reachable from the score graph"),
Self::UnknownTuningSystem(id) => write!(f, "'{id}' is not a built-in tuning system"),
Self::NotYetSupported { id, reason } => {
write!(f, "'{id}' is not yet supported: {reason}")
}
Self::UnresolvedPitchSpace(id) => {
write!(f, "'{id}' does not resolve to a structurally determined pitch space")
}
Self::IncompatiblePitchSpace {
pitch_space,
tuning_system_pitch_space,
} => write!(
f,
"resolved pitch space '{pitch_space}' is incompatible with the tuning system's declared \
pitch space '{tuning_system_pitch_space}' (no compatibility mapping registry exists)"
),
Self::PositionUnavailable => {
f.write_str("the pitch space position could not be placed on the tuning system's coordinate frame")
}
Self::UnregisteredAdaptiveFunction(id) => write!(
f,
"'{id}' is not a registered adaptive tuning function (only \"default-v1\" is built in)"
),
Self::AnchorNotOrderable { anchor, reason } => write!(
f,
"cannot order the key-signature-change anchor {anchor:?} against the pitch's onset: {reason}"
),
}
}
}
impl std::error::Error for TuningResolutionError {}
/// The absolute tuning-coordinate of `position`, in the coordinate frame a
/// tuning system with `divisions` positions-per-octave uses — the same
/// absolute chromatic-coordinate idea [`Pitch::twelve_tet_semitone`]
/// (`crate::pitch`) uses for `cmn-12`, generalized from a fixed 12 to any
/// `N` and to [`PitchSpacePosition::Integer`] (the EDO pitch spaces'
/// position kind) as well as [`PitchSpacePosition::Cmn`].
fn absolute_coordinate(
position: &PitchSpacePosition,
structure: &PositionStructure,
divisions: u32,
) -> Option<i64> {
match (position, structure) {
(
PitchSpacePosition::Cmn {
nominal,
alteration,
octave,
},
PositionStructure::DiatonicOverChromatic {
chromatic_positions_per_octave,
nominal_to_chromatic,
..
},
) if u32::from(*chromatic_positions_per_octave) == divisions => {
let degree = i64::from(nominal_to_chromatic[*nominal as usize]);
Some(i64::from(*octave) * i64::from(divisions) + degree + i64::from(*alteration))
}
(
PitchSpacePosition::Integer { space_size, index },
PositionStructure::Chromatic {
positions_per_octave,
},
) if u32::from(*space_size) == divisions
&& u32::from(*positions_per_octave) == divisions =>
{
Some(i64::from(*index))
}
_ => None,
}
}
/// The chromatic cardinality of `structure` — the "positions per octave"
/// figure [`TuningResolution::Function`] borrows from the pitch space
/// rather than carrying itself (the variant has no division-count field,
/// `core_spec.tex:3318-3324`). Both `Chromatic` and `DiatonicOverChromatic`
/// name one; `JiLattice` and `Registered` do not (a lattice or a grammar
/// plugin has no single "positions per octave" scalar), so `None` there is
/// a structural refusal, not an oversight.
fn chromatic_cardinality(structure: &PositionStructure) -> Option<u32> {
match structure {
PositionStructure::Chromatic {
positions_per_octave,
} => Some(u32::from(*positions_per_octave)),
PositionStructure::DiatonicOverChromatic {
chromatic_positions_per_octave,
..
} => Some(u32::from(*chromatic_positions_per_octave)),
PositionStructure::JiLattice { .. } | PositionStructure::Registered(_) => None,
}
}
/// The full-register frequency ratio of absolute coordinate `s`, relative to
/// coordinate `0` under `resolution`.
fn coordinate_ratio(resolution: &TuningResolution, s: i64) -> Option<f64> {
match resolution {
TuningResolution::EqualTemperament {
divisions_per_octave,
} => {
if *divisions_per_octave == 0 {
return None;
}
Some(2f64.powf(s as f64 / f64::from(*divisions_per_octave)))
}
TuningResolution::PerPositionRatios(table) => {
let n = i64::try_from(table.len()).ok().filter(|n| *n > 0)?;
let degree = i32::try_from(s.rem_euclid(n)).ok()?;
let octave = i32::try_from(s.div_euclid(n)).ok()?;
let entry = table.iter().find(|pr| pr.position == degree)?;
let base = f64::from(entry.ratio.numerator) / f64::from(entry.ratio.denominator.get());
Some(base * 2f64.powi(octave))
}
TuningResolution::Function { function, .. } => {
// `degree = s.rem_euclid(12)`, `octave = s.div_euclid(12)`,
// `ratio = temperament_ratios[degree] · 2^octave`
// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 1). An unknown
// `TuningFunctionId` has no registry to consult, so
// `temperament_ratios` returns `None` and this fails closed —
// never a fallback frequency.
let ratios = temperament_ratios(function)?;
let degree = i32::try_from(s.rem_euclid(12)).ok()?;
let octave = i32::try_from(s.div_euclid(12)).ok()?;
Some(ratios[degree as usize] * 2f64.powi(octave))
}
TuningResolution::Adaptive { .. } => {
// An `Adaptive` resolution carries no anchor of its own: the
// anchor comes from a `HarmonicContext` this function is never
// given. `resolve_pitch_frequency` always converts `Adaptive` to
// a concrete `PerPositionRatios` (via `ji_static_5limit_ratios`)
// before reaching this layer; one arriving here unconverted (a
// caller invoking `frequency_for_position` directly) has no
// anchor to consult, so this fails closed exactly like an
// unregistered `Function` id, never guessing C.
None
}
}
}
/// Computes the frequency in Hz at which `position` sounds under `system`,
/// anchored by `reference` (Chapter 4 §"Tuning Systems" / §"Reference
/// Pitch"; `req:tuning:tuning-resolution-determinism`).
///
/// **Anchoring** — the one subtlety `spec/CONTRACT_PUSH4B_RESOLVER.md`
/// singles out: a [`TuningResolution`]'s ratios are relative to the
/// tuning's own 1/1 (its anchor), but `reference` fixes a *different*
/// position's absolute frequency. Both `position` and `reference.position`
/// are placed on the same absolute coordinate frame
/// (`absolute_coordinate`), and the frequency is
/// `reference.frequency_hz() * ratio(position) / ratio(reference.position)`.
/// The arbitrary choice of which position the construction calls "1/1"
/// cancels out of that quotient, so this is correct regardless of anchor —
/// for [`TuningResolution::EqualTemperament`] it reduces exactly to
/// `ref_freq · 2^((position ref_position)/N)`.
pub fn frequency_for_position(
position: &PitchSpacePosition,
system: &TuningSystem,
reference: &ReferencePitch,
) -> Result<f64, TuningResolutionError> {
let structure = built_in_position_structure(&system.pitch_space)
.ok_or_else(|| TuningResolutionError::UnresolvedPitchSpace(system.pitch_space.clone()))?;
let divisions =
match &system.resolution {
TuningResolution::EqualTemperament {
divisions_per_octave,
} => u32::from(*divisions_per_octave),
TuningResolution::PerPositionRatios(table) => u32::try_from(table.len())
.map_err(|_| TuningResolutionError::PositionUnavailable)?,
// `Function` carries no division count of its own (unlike the other
// two variants); divisions comes from the *pitch space*'s chromatic
// cardinality instead — 12 for `cmn-12`
// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 1).
TuningResolution::Function { .. } => chromatic_cardinality(&structure)
.ok_or(TuningResolutionError::PositionUnavailable)?,
// Same reasoning as `Function`: `Adaptive` borrows the pitch
// space's chromatic cardinality rather than carrying its own.
// (In practice `resolve_pitch_frequency` never leaves `Adaptive`
// unconverted this far — see `coordinate_ratio`.)
TuningResolution::Adaptive { .. } => chromatic_cardinality(&structure)
.ok_or(TuningResolutionError::PositionUnavailable)?,
};
let s = absolute_coordinate(position, &structure, divisions)
.ok_or(TuningResolutionError::PositionUnavailable)?;
let s_ref = absolute_coordinate(&reference.position, &structure, divisions)
.ok_or(TuningResolutionError::PositionUnavailable)?;
let ratio_p = coordinate_ratio(&system.resolution, s)
.ok_or(TuningResolutionError::PositionUnavailable)?;
let ratio_ref = coordinate_ratio(&system.resolution, s_ref)
.ok_or(TuningResolutionError::PositionUnavailable)?;
Ok(reference.frequency_hz() * ratio_p / ratio_ref)
}
// ===========================================================================
// The five-scope resolution walk (item 4).
// ===========================================================================
/// The independently-resolved pitch space, tuning system, and reference
/// pitch that govern a pitch at a given location (`req:tuning:tuning-resolution-order`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ResolvedTuning {
pub pitch_space: PitchSpaceId,
pub tuning_system: TuningSystemId,
pub reference: ReferencePitch,
}
/// The first value `field` supplies from an override whose scope matches
/// `voice`, else `staff`, else `region`, in that priority order (scopes 2-4
/// of `req:tuning:tuning-resolution-order`) — the same lookup used
/// identically for all three components the walk resolves. Within a tier,
/// overrides are consulted in `context.overrides`' own declared order ("in
/// the order they apply", `core_spec.tex:3523`), skipping past a
/// scope-matching override that leaves this particular component `None`
/// rather than stopping at it, so a later override at the same scope can
/// still supply the value this one didn't.
fn override_value<T: Clone>(
context: &ScoreTuningContext,
voice: VoiceId,
staff: StaffId,
region: RegionId,
field: impl Fn(&TuningOverride) -> &Option<T>,
) -> Option<T> {
let tier = |matches_scope: &dyn Fn(&TuningScope) -> bool| -> Option<T> {
context
.overrides
.iter()
.filter(|o| matches_scope(&o.scope))
.find_map(|o| field(o).clone())
};
tier(&|s| matches!(s, TuningScope::Voice(v) if *v == voice))
.or_else(|| tier(&|s| matches!(s, TuningScope::Staff(st) if *st == staff)))
.or_else(|| {
// Step 4: "each region enclosing the pitch, innermost to
// outermost". In this data model a `Voice` is owned by exactly
// one `StaffInstance`, owned by exactly one `Region`
// (containment, not a derived time-range query) — there is no
// nested-region concept, so "innermost to outermost" is exactly
// this one region.
//
// `TuningScope::Range` is part of the type (Chapter 4 defines
// it) but `req:tuning:tuning-resolution-order` enumerates
// exactly five steps and does not include it; this tranche's
// walk never matches it (see the module doc's scope note).
tier(&|s| matches!(s, TuningScope::Region(r) if *r == region))
})
}
/// The five-scope walk of `req:tuning:tuning-resolution-order`
/// (`core_spec.tex:3549`, read and verified before citing): resolves each of
/// `pitch_space`, `tuning_system`, and `reference` independently, walking
/// from the pitch's own [`crate::pitch::AcousticPitch`] outward through
/// voice, staff, and region overrides to the score default.
///
/// Step 1 supplies a value only for `tuning_system` (an explicit
/// [`TuningReference::Explicit`] short-circuits) — [`AcousticPitch`] carries
/// no pitch-space or reference field of its own, so those two components
/// always proceed to step 2. (A pitch's [`AcousticRealization::AbsoluteHz`]
/// short-circuits the *whole* frequency, bypassing this walk entirely; see
/// [`resolve_pitch_frequency`].)
///
/// [`AcousticPitch`]: crate::pitch::AcousticPitch
pub fn resolve_tuning_scope(
pitch: &Pitch,
voice: VoiceId,
staff: StaffId,
region: RegionId,
context: &ScoreTuningContext,
) -> ResolvedTuning {
let tuning_system = match &pitch.acoustic.tuning {
TuningReference::Explicit(id) => id.clone(),
TuningReference::Inherit => {
override_value(context, voice, staff, region, |o| &o.tuning_system)
.unwrap_or_else(|| context.default_tuning_system.clone())
}
};
let pitch_space = override_value(context, voice, staff, region, |o| &o.pitch_space)
.unwrap_or_else(|| context.default_pitch_space.clone());
let reference = override_value(context, voice, staff, region, |o| &o.reference)
.unwrap_or_else(|| context.reference.clone());
ResolvedTuning {
pitch_space,
tuning_system,
reference,
}
}
// ===========================================================================
// The top-level pipeline: walk, catalog lookup, compatibility check (item
// 5), frequency.
// ===========================================================================
/// Locates the region, staff, and **staff instance** that structurally own
/// `voice`: a `Voice` belongs to exactly one `StaffInstance`, which belongs
/// to exactly one `Region` (Chapter 5's containment tree — ownership, not a
/// derived time-range query). `None` if no region in `score.canvas.regions`
/// owns a voice with this id.
///
/// Returns the instance itself, not just its id: `key_sequence`
/// (`req:tuning:adaptive-anchor-derivation`) lives on the *instance*
/// (`StaffInstance::key_sequence`), not on the underlying `Staff`. An earlier
/// version of this function returned only `(RegionId, StaffId)`, discarding
/// the instance it already had in hand — the one structural blocker that
/// made adaptive resolution impossible before [`derive_tonal_centre`] existed.
fn locate_voice(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffId, &StaffInstance)> {
for region in &score.canvas.regions {
for instance in region.staff_instances() {
if instance.voices.iter().any(|v| v.id == voice) {
return Some((region.id, instance.staff, instance));
}
}
}
None
}
/// A [`TimeAnchor`] or [`EventPosition`] placed on one common, directly
/// comparable timeline — either absolute wall-clock nanoseconds or a
/// region-relative [`MusicalPosition`]. [`derive_tonal_centre`] only ever
/// compares two `Coordinate`s built from the *same* onset (see
/// `anchor_coordinate`), so the two variants are never compared against each
/// other in practice.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
enum Coordinate {
WallClock(i64),
Musical(MusicalPosition),
}
/// Places `anchor` on the same clock as `onset`, or fails closed with a
/// distinct, reported [`TuningResolutionError::AnchorNotOrderable`] — never
/// a guessed ordering (`req:tuning:adaptive-anchor-derivation`).
///
/// Two shapes are unambiguously determinable: a [`TimeAnchor::WallClock`]
/// anchor against a [`EventPosition::WallClock`] onset compares directly by
/// nanosecond; any other anchor against a [`EventPosition::Musical`] onset is
/// placed via the caller-injected `resolve` — the same `impl
/// Fn(&TimeAnchor) -> Option<MusicalPosition>` shape
/// `TempoMap::musical_to_wallclock_with` injects (`tempo.rs:331`), reused
/// here rather than reinvented so this module stays out of the time-model
/// business. Everything else — a wall-clock anchor against a musical onset
/// or vice versa (cross-clock), or an indirect anchor the injected resolver
/// declines to place — fails closed rather than guessing.
fn anchor_coordinate(
anchor: &TimeAnchor,
onset: &EventPosition,
resolve: &impl Fn(&TimeAnchor) -> Option<MusicalPosition>,
) -> Result<Coordinate, TuningResolutionError> {
match (anchor, onset) {
(TimeAnchor::WallClock { time }, EventPosition::WallClock(_)) => {
Ok(Coordinate::WallClock(time.0))
}
(TimeAnchor::WallClock { .. }, EventPosition::Musical(_)) => {
Err(TuningResolutionError::AnchorNotOrderable {
anchor: anchor.clone(),
reason: "a wall-clock key-signature anchor cannot be ordered against a \
musical-time onset (cross-clock comparison)",
})
}
(_, EventPosition::WallClock(_)) => Err(TuningResolutionError::AnchorNotOrderable {
anchor: anchor.clone(),
reason: "a non-wall-clock key-signature anchor cannot be ordered against a \
wall-clock onset (cross-clock comparison)",
}),
(_, EventPosition::Musical(_)) => {
resolve(anchor).map(Coordinate::Musical).ok_or_else(|| {
TuningResolutionError::AnchorNotOrderable {
anchor: anchor.clone(),
reason: "the key-signature anchor is an indirect form (Event/Measure/Region) \
the injected resolver could not place on the musical timeline",
}
})
}
}
}
/// Derives the adaptive tonal centre from the score graph
/// (`req:tuning:adaptive-anchor-derivation`, `core_spec.tex:3474-3489`): the
/// prevailing key signature on the staff instance containing `voice` —
/// specifically, the *latest* `KeySignatureChange` in that instance's
/// `key_sequence` whose anchor is at or before `onset` — converted to a
/// chromatic pitch class by `anchor_pc = (7 * fifths).rem_euclid(12)` (never
/// `%`: `fifths` runs negative for flat keys, `KeySignature::MIN_FIFTHS ==
/// -7`, and `%` would yield a negative pitch class). Mode is **not**
/// consulted: `KeySignature` carries no mode field at all, so a signature of
/// 0 anchors at C whether the prevailing key is C major or A minor —
/// structurally guaranteed, not merely by omission.
///
/// `resolve` places any non-wall-clock `KeySignatureChange` anchor on the
/// musical timeline for comparison against a musical `onset` — see
/// `anchor_coordinate`, which this function uses for every entry in
/// `key_sequence`; if *any* entry's anchor cannot be ordered against
/// `onset`, this fails closed rather than risk skipping past the one that
/// would have been the true prevailing signature.
///
/// `Ok(None)` when no key-signature change applies at or before `onset` —
/// the resolver then defaults to C (`req:tuning:adaptive-default-version`);
/// that default is applied in [`resolve_pitch_frequency`], not here, since a
/// missing tonal centre is a defined default, not this function's failure to
/// report. `Err` only for a caller error (`voice` unreachable from the score
/// graph) or an anchor this function cannot order.
pub fn derive_tonal_centre(
score: &Score,
voice: VoiceId,
onset: &EventPosition,
resolve: impl Fn(&TimeAnchor) -> Option<MusicalPosition>,
) -> Result<Option<ChromaticPitchClass>, TuningResolutionError> {
let (_, _, instance) =
locate_voice(score, voice).ok_or(TuningResolutionError::VoiceNotFound(voice))?;
let onset_coordinate = match onset {
EventPosition::WallClock(t) => Coordinate::WallClock(t.0),
EventPosition::Musical(p) => Coordinate::Musical(p.clone()),
};
let mut prevailing: Option<(Coordinate, KeySignature)> = None;
for change in &instance.key_sequence {
let coordinate = anchor_coordinate(&change.anchor, onset, &resolve)?;
if coordinate <= onset_coordinate {
let is_later = match &prevailing {
Some((best, _)) => coordinate > *best,
None => true,
};
if is_later {
prevailing = Some((coordinate, change.key));
}
}
}
Ok(prevailing.map(|(_, key)| {
let anchor_pc = (7 * i32::from(key.fifths())).rem_euclid(12);
ChromaticPitchClass::new(anchor_pc as u8)
.expect("(7 * fifths).rem_euclid(12) is always in 0..=11")
}))
}
/// The anchor pitch class for adaptive resolution: a hard error if `function`
/// is not the one reserved built-in, `"default-v1"`
/// (`req:tuning:adaptive-default-version`'s final clause — no silent
/// fallback to C), else `context`'s tonal centre when supplied, else C
/// (chromatic position 0 — `core_spec.tex:3452-3453`'s spec-mandated
/// default, not a fail-closed case).
fn adaptive_anchor(
function: &AdaptiveTuningFunctionId,
context: Option<&HarmonicContext>,
) -> Result<i32, TuningResolutionError> {
if function.as_str() != "default-v1" {
return Err(TuningResolutionError::UnregisteredAdaptiveFunction(
function.clone(),
));
}
Ok(context
.and_then(|c| c.tonal_centre)
.map(|pc| i32::from(pc.get()))
.unwrap_or(0))
}
/// The full resolver: walks the five scopes, checks compatibility, and
/// computes the frequency in Hz at which `pitch` sounds, given its location
/// (`voice`) in `score`. `context` supplies the harmonic context adaptive
/// tuning consumes (`core_spec.tex:3426-3428`: "The harmonic context is
/// constructed by the audio engine ... and passed to the tuning resolution
/// function"); static systems ignore it entirely.
///
/// Step 1's other short-circuit — [`AcousticRealization::AbsoluteHz`] —
/// bypasses everything else: the frequency is already fixed, so neither the
/// scope walk nor the built-in catalog is consulted at all.
/// [`AcousticRealization::CentsOffset`] is applied multiplicatively on top
/// of the resolved base frequency, per its own documented semantics ("an
/// explicit offset in cents from the tuning system's result").
///
/// Resolution of [`TuningResolution::Adaptive`]: an unregistered `function`
/// is a hard error (see `adaptive_anchor`); otherwise the anchor is
/// `context`'s tonal centre, or C when none is supplied, and the position
/// resolves through `ji_static_5limit_ratios(anchor)` — *exactly* the call
/// the three static `ji-static-5limit-*` built-ins make, transposed to a
/// runtime anchor rather than one of the three fixed ones. No second lattice
/// construction, no cents table.
pub fn resolve_pitch_frequency(
score: &Score,
pitch: &Pitch,
voice: VoiceId,
context: Option<&HarmonicContext>,
) -> Result<f64, TuningResolutionError> {
if let AcousticRealization::AbsoluteHz(hz) = pitch.acoustic.realization {
return Ok(hz.get());
}
let (region, staff, _instance) =
locate_voice(score, voice).ok_or(TuningResolutionError::VoiceNotFound(voice))?;
let resolved = resolve_tuning_scope(pitch, voice, staff, region, &score.tuning_context);
let entry = built_in_tuning_system(&resolved.tuning_system).ok_or_else(|| {
TuningResolutionError::UnknownTuningSystem(resolved.tuning_system.clone())
})?;
let mut system = match entry {
TuningCatalogEntry::Resolved(system) => system,
TuningCatalogEntry::Deferred(reason) => {
return Err(TuningResolutionError::NotYetSupported {
id: resolved.tuning_system,
reason,
});
}
};
// req:tuning:tuning-system-compatibility (`:3581`): accept only exact
// equality this tranche — see the module doc's compatibility note.
if system.pitch_space != resolved.pitch_space {
return Err(TuningResolutionError::IncompatiblePitchSpace {
pitch_space: resolved.pitch_space,
tuning_system_pitch_space: system.pitch_space,
});
}
if let TuningResolution::Adaptive { function } = &system.resolution {
let anchor = adaptive_anchor(function, context)?;
// Reuse the static-JI construction verbatim, re-anchored at runtime
// — see `ji_static_5limit_ratios`'s own doc for why this is the same
// call the three static built-ins make, not a second construction.
system.resolution = TuningResolution::PerPositionRatios(ji_static_5limit_ratios(anchor));
}
let base =
frequency_for_position(&pitch.scale_position.position, &system, &resolved.reference)?;
match pitch.acoustic.realization {
AcousticRealization::Implicit => Ok(base),
AcousticRealization::CentsOffset(c) => Ok(base * 2f64.powf(c.get() / 1200.0)),
AcousticRealization::AbsoluteHz(_) => unreachable!("handled by the early return above"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::{
Canvas, KeySignatureChange, MetricTimeModel, Region, RegionContent, RegionTimeModel,
StaffBasedContent, StaffExtent, StaffInstance, TimeExtent, Voice,
};
use crate::ids::{IdentityContext, ReplicaId, StaffInstanceId};
use crate::pitch::{AcousticPitch, CmnNominal, ScalePosition};
use crate::time::WallClockTime;
use epiphany_determinism::{Tolerance, ToleranceClass, ToleranceGovernance};
fn cents(c: f64) -> Tolerance {
Tolerance::absolute(
ToleranceClass::AcousticCents,
c,
ToleranceGovernance::Validation,
)
.unwrap()
}
/// Absolute cents between two frequencies — the metric the proof-of-life
/// tests assert against, per the contract's own instruction ("a
/// cents-level check is right").
fn cents_between(a: f64, b: f64) -> f64 {
1200.0 * (a / b).log2().abs()
}
fn cmn_pitch(space: &str, nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch {
Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new(space),
position: PitchSpacePosition::Cmn {
nominal,
alteration,
octave,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
}
}
fn wc_extent(a: i64, b: i64) -> TimeExtent {
TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(a),
},
end: TimeAnchor::WallClock {
time: WallClockTime(b),
},
}
}
/// A `KeySignatureChange` anchored at wall-clock nanosecond `t`, for the
/// adaptive-anchor-derivation tests below.
fn key_change(t: i64, fifths: i8) -> KeySignatureChange {
KeySignatureChange {
anchor: TimeAnchor::WallClock {
time: WallClockTime(t),
},
key: KeySignature::new(fifths).expect("fifths within -7..=7"),
}
}
/// A resolver that never places a `TimeAnchor` on the musical timeline --
/// sufficient for the wall-clock-only fixtures below, where
/// `derive_tonal_centre` never needs it.
fn no_musical_resolve(_: &TimeAnchor) -> Option<MusicalPosition> {
None
}
/// A minimal score: one region, one staff instance, two voices — enough
/// for `locate_voice` and the scope walk, nothing more.
struct Fixture {
score: Score,
voice_a: VoiceId,
voice_b: VoiceId,
}
fn fixture() -> Fixture {
let r = ReplicaId(1);
let voice_a = VoiceId::new(r, 1);
let voice_b = VoiceId::new(r, 2);
let staff = StaffId::new(r, 1);
let mut instance = StaffInstance::new(StaffInstanceId::new(r, 1), staff);
instance.voices = vec![Voice::user(voice_a), Voice::user(voice_b)];
let region = Region {
id: RegionId::new(r, 1),
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![instance],
..Default::default()
}),
time_extent: wc_extent(0, 1000),
staff_extent: StaffExtent {
staves: vec![staff],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
let mut score = Score::empty(IdentityContext::new(r));
score.canvas = Canvas {
regions: vec![region],
..Default::default()
};
Fixture {
score,
voice_a,
voice_b,
}
}
// -- Proof of life 1: tet-12, A4 = 440 Hz -> C5. --------------------------
#[test]
fn tet12_a4_440_resolves_c5_to_523_2511_hz() {
let f = fixture();
let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5);
let freq =
resolve_pitch_frequency(&f.score, &c5, f.voice_a, None).expect("tet-12 resolves");
assert!(
cents(0.01).within(cents_between(freq, 523.2511), 0.0),
"expected ~523.2511 Hz, got {freq}"
);
}
// -- Proof of life 2: ji-static-5limit-C's major third differs from --
// -- tet-12's by the syntonic comma (~13.7 c). --
#[test]
fn ji_static_5limit_c_major_third_distinct_from_tet12() {
// Anchor the reference *at the tonic itself* (C4), so both systems'
// "1/1" and the comparison point coincide: the ratio this measures
// is then exactly each system's C-to-E interval, with no confound
// from how the two systems otherwise retune A differently (both
// being referenced against the *same* absolute A4 would silently
// mix "how A retunes" into "how E retunes" — a trap this test's
// first draft fell into).
let f = fixture();
let c4_ref = ReferencePitch::new(
PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 4,
},
264.0,
)
.unwrap();
let mut ji_score = f.score.clone();
ji_score.tuning_context.default_tuning_system = TuningSystemId::new("ji-static-5limit-C");
ji_score.tuning_context.reference = c4_ref.clone();
let mut tet_score = f.score.clone();
tet_score.tuning_context.reference = c4_ref;
let e4 = cmn_pitch("cmn-12", CmnNominal::E, 0, 4);
let ji_freq = resolve_pitch_frequency(&ji_score, &e4, f.voice_a, None)
.expect("ji-static-5limit-C resolves");
let tet_freq =
resolve_pitch_frequency(&tet_score, &e4, f.voice_a, None).expect("tet-12 resolves");
// Just major third 5/4 (386.31 c) vs equal-tempered (400 c): the just
// third is *flatter*, by the syntonic comma (~13.7 c).
assert!(
ji_freq < tet_freq,
"the just major third ({ji_freq} Hz) must be flatter than the equal-tempered one ({tet_freq} Hz)"
);
let diff = cents_between(ji_freq, tet_freq);
assert!(
cents(0.05).within(diff, 13.6864),
"expected a ~13.6864 c syntonic-comma difference, got {diff}"
);
}
#[test]
fn ji_static_5limit_lattice_matches_the_published_construction() {
// `core_spec.tex:4034-4046`'s table, spot-checked at all three
// anchors: this proves the *code's* lattice-block construction
// reproduces the specification's own worked values, rather than the
// production code and the spec merely agreeing to look similar.
let ratio_at = |system: &str, degree: i32| -> (i32, u32) {
let TuningCatalogEntry::Resolved(sys) =
built_in_tuning_system(&TuningSystemId::new(system)).unwrap()
else {
panic!("{system} must resolve")
};
let TuningResolution::PerPositionRatios(table) = sys.resolution else {
panic!("{system} must be PerPositionRatios")
};
let entry = table.iter().find(|pr| pr.position == degree).unwrap();
(entry.ratio.numerator, entry.ratio.denominator.get())
};
// ji-static-5limit-C: anchor at C (chromatic 0). C=1/1, E=5/4 (step 4), G=3/2 (step 7).
assert_eq!(ratio_at("ji-static-5limit-C", 0), (1, 1));
assert_eq!(ratio_at("ji-static-5limit-C", 4), (5, 4));
assert_eq!(ratio_at("ji-static-5limit-C", 7), (3, 2));
// ji-static-5limit-G: anchor at G (chromatic 7), so G itself is 1/1.
assert_eq!(ratio_at("ji-static-5limit-G", 7), (1, 1));
// ji-static-5limit-D: anchor at D (chromatic 2), so D itself is 1/1.
assert_eq!(ratio_at("ji-static-5limit-D", 2), (1, 1));
}
// -- Proof of life 3: scope precedence. -----------------------------------
#[test]
fn voice_scope_override_changes_resolution_for_its_voice_but_not_others() {
let mut f = fixture();
f.score.tuning_context.overrides.push(TuningOverride {
scope: TuningScope::Voice(f.voice_a),
pitch_space: None,
tuning_system: None,
reference: Some(
ReferencePitch::new(
PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: 0,
octave: 4,
},
415.0,
)
.unwrap(),
),
});
let a4 = cmn_pitch("cmn-12", CmnNominal::A, 0, 4);
let in_voice_a = resolve_pitch_frequency(&f.score, &a4, f.voice_a, None)
.expect("resolves under the override");
let in_voice_b = resolve_pitch_frequency(&f.score, &a4, f.voice_b, None)
.expect("resolves under the default");
assert!(
cents(0.01).within(cents_between(in_voice_a, 415.0), 0.0),
"voice A's own reference override must apply: got {in_voice_a}"
);
assert!(
cents(0.01).within(cents_between(in_voice_b, 440.0), 0.0),
"voice B must still see the score default (440 Hz): got {in_voice_b}"
);
}
// -- Proof of life 4: compatibility rejects a mismatch. -------------------
#[test]
fn compatibility_check_rejects_a_pitch_space_mismatch() {
let mut f = fixture();
// tet-19's declared pitch_space is edo-19; the score's default pitch
// space stays cmn-12 (unchanged) — a genuine, catchable mismatch.
f.score.tuning_context.default_tuning_system = TuningSystemId::new("tet-19");
let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5);
let err = resolve_pitch_frequency(&f.score, &c5, f.voice_a, None)
.expect_err("must reject the mismatch");
assert!(
matches!(err, TuningResolutionError::IncompatiblePitchSpace { .. }),
"expected IncompatiblePitchSpace, got {err:?}"
);
}
// -- Proof of life 5: `ji-adaptive-5limit` now resolves; a genuinely -----
// -- unknown identifier still fails closed, distinctly. ---
#[test]
fn ji_adaptive_5limit_resolves_and_unknown_ids_still_fail_closed() {
// Push 4b's adaptive tranche: `ji-adaptive-5limit` is no longer
// deferred (`TuningCatalogEntry::Deferred`) -- it resolves like every
// other built-in, closing the last entry of the twenty-item catalog.
// This test used to prove the opposite (a `NotYetSupported` error);
// it inverts rather than being deleted, per the contract.
let f = fixture();
let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5);
let mut score = f.score.clone();
score.tuning_context.default_tuning_system = TuningSystemId::new("ji-adaptive-5limit");
let freq = resolve_pitch_frequency(&score, &c5, f.voice_a, None)
.expect("ji-adaptive-5limit must now resolve to a frequency");
assert!(
freq.is_finite() && freq > 0.0,
"expected a real, positive frequency, got {freq}"
);
// A genuinely unknown identifier still reports differently, so the
// two failure modes this test used to distinguish don't blur now
// that the catalog's one deferred entry is gone.
let mut score = f.score.clone();
score.tuning_context.default_tuning_system = TuningSystemId::new("not-a-built-in-system");
let err = resolve_pitch_frequency(&score, &c5, f.voice_a, None)
.expect_err("unknown id must not resolve");
assert!(matches!(err, TuningResolutionError::UnknownTuningSystem(_)));
}
// -- Extra: the whole-walk AbsoluteHz short-circuit. ----------------------
#[test]
fn absolute_hz_realization_short_circuits_the_whole_walk() {
let mut f = fixture();
// An unresolvable tuning system: if the shortcut were skipped, this
// would return an error, not 500.0.
f.score.tuning_context.default_tuning_system = TuningSystemId::new("not-a-built-in-system");
let mut pinned = cmn_pitch("cmn-12", CmnNominal::C, 0, 5);
pinned.acoustic.realization = AcousticRealization::absolute_hz(500.0).unwrap();
let freq = resolve_pitch_frequency(&f.score, &pinned, f.voice_a, None)
.expect("AbsoluteHz must resolve without consulting the tuning system at all");
assert_eq!(freq, 500.0);
}
// -- Extra: an EDO built-in resolves through Integer positions. -----------
#[test]
fn tet_19_resolves_edo_integer_positions() {
let mut f = fixture();
f.score.tuning_context.default_pitch_space = PitchSpaceId::new("edo-19");
f.score.tuning_context.default_tuning_system = TuningSystemId::new("tet-19");
f.score.tuning_context.reference = ReferencePitch::new(
PitchSpacePosition::Integer {
space_size: 19,
index: 0,
},
440.0,
)
.unwrap();
let one_step = Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("edo-19"),
position: PitchSpacePosition::Integer {
space_size: 19,
index: 1,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
};
let freq =
resolve_pitch_frequency(&f.score, &one_step, f.voice_a, None).expect("tet-19 resolves");
let expected = 440.0 * 2f64.powf(1.0 / 19.0);
assert!(
cents(0.01).within(cents_between(freq, expected), 0.0),
"expected ~{expected} Hz, got {freq}"
);
}
// =========================================================================
// Push 4b tranche 2b: the ten historical temperaments. This is the
// tranche's reason to exist, per `spec/CONTRACT_PUSH4B_TEMPERAMENTS.md`'s
// "Proof of life" section — every assertion below recomputes its expected
// value from the walk (or from an exact comma ratio), never from a
// hardcoded cents constant copied out of the spec's tables.
// =========================================================================
/// `1200 · log2(ratio)` for chromatic `degree` in `walk` — the cents this
/// module's own walk assigns that degree, relative to C.
fn cents_of(walk: &TemperamentWalk, degree: usize) -> f64 {
1200.0 * walk.ratios[degree].log2()
}
// -- Closure 1: the six circulating temperaments sum to one Pythagorean --
// -- comma, and none of their twelve fifths is a wolf. -------------------
#[test]
fn circulating_temperaments_close_to_one_pythagorean_comma_with_no_wolf() {
let comma = pythagorean_comma_cents();
let cases: [(&str, Construction); 6] = [
("werckmeister-iii", werckmeister_iii_construction()),
("werckmeister-iv", werckmeister_iv_construction()),
("vallotti", vallotti_construction()),
("kirnberger-ii", kirnberger_ii_construction()),
("kirnberger-iii", kirnberger_iii_construction()),
("young-ii", young_ii_construction()),
];
for (name, construction) in cases {
let walk = walk_temperament(&construction);
// The sum of the twelve fifths' deviations from pure equals one
// Pythagorean comma (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md`'s
// closure invariant for the circulating six), recomputed from
// the walk's raw (unreduced) closing cents rather than asserted
// directly against a constant.
let total_deviation = 12.0 * pure_fifth_cents() - walk.raw_closure_cents;
assert!(
(total_deviation - comma).abs() < 1e-9,
"{name}: twelve fifths should deviate from pure by exactly one \
Pythagorean comma ({comma} c), computed {total_deviation} c"
);
// No fifth is a wolf: every one of the twelve stays within 15 c
// of a pure fifth. 15 c comfortably exceeds this construction's
// largest single tempering (kirnberger-ii's half-syntonic-comma
// fifths, ~10.75 c) while comfortably staying below any real
// historical wolf (pythagorean's is ~23.5 c narrow; meantone's
// are 16-36 c wide).
for (i, &fifth) in walk.fifth_cents.iter().enumerate() {
let deviation = (fifth - pure_fifth_cents()).abs();
assert!(
deviation < 15.0,
"{name}: chain arc {i} deviates from pure by {deviation} c -- too large, looks like a wolf"
);
}
}
}
// -- Closure 2: the four non-circulating temperaments' residual wolf ------
// -- matches the spec's ratified value. --
#[test]
fn noncirculating_temperaments_wolf_matches_the_ratified_spec_value() {
// Chain arc 8 (`G♯E♭`) is the one `Residual` arc in each of these
// four constructions -- see each `*_construction` function's own
// comment.
const WOLF_ARC: usize = 8;
let cases: [(&str, Construction, f64); 4] = [
("pythagorean", pythagorean_construction(), 678.495),
("meantone-1/4-comma", meantone_construction(0.25), 737.637),
("meantone-1/5-comma", meantone_construction(0.2), 725.809),
(
"meantone-1/6-comma",
meantone_construction(1.0 / 6.0),
717.923,
),
];
for (name, construction, expected_wolf) in cases {
let walk = walk_temperament(&construction);
let wolf = walk.fifth_cents[WOLF_ARC];
assert!(
(wolf - expected_wolf).abs() < 0.001,
"{name}: expected the wolf at {expected_wolf} c (`core_spec.tex`'s ratified value), computed {wolf} c"
);
}
}
// -- Discriminators: spot values the walk must reproduce against the -----
// -- spec's own independently-derived cents tables. ----
#[test]
fn pythagorean_e_and_fsharp_match_the_published_cents() {
let walk = walk_temperament(&pythagorean_construction());
let e = cents_of(&walk, CmnNominal::E.chromatic() as usize);
let fsharp = cents_of(&walk, 6); // F# = chromatic degree 6, no plain CmnNominal for it
assert!(
(e - 407.820).abs() < 0.001,
"pythagorean E: expected 407.820 c, got {e}"
);
assert!(
(fsharp - 611.730).abs() < 0.001,
"pythagorean F#: expected 611.730 c, got {fsharp}"
);
}
#[test]
fn meantone_quarter_comma_major_third_c_to_e_is_the_just_5_4() {
let walk = walk_temperament(&meantone_construction(0.25));
let c = cents_of(&walk, CmnNominal::C.chromatic() as usize);
let e = cents_of(&walk, CmnNominal::E.chromatic() as usize);
let third = e - c;
assert!(
(third - 386.31).abs() < 0.01,
"expected the just 5/4 major third (386.31 c), got {third} c"
);
}
#[test]
fn kirnberger_ii_and_iii_d_differ_though_the_chain_skeleton_is_the_same() {
// Same chain skeleton (D-A/A-E tempered, F#-Db schisma-tempered),
// different comma fraction -- a test that passes both proves the two
// are not secretly the same construction.
let ii = walk_temperament(&kirnberger_ii_construction());
let iii = walk_temperament(&kirnberger_iii_construction());
let d = CmnNominal::D.chromatic() as usize;
let d_ii = cents_of(&ii, d);
let d_iii = cents_of(&iii, d);
assert!(
(d_ii - 203.910).abs() < 0.001,
"kirnberger-ii D: expected 203.910 c, got {d_ii}"
);
assert!(
(d_iii - 193.157).abs() < 0.001,
"kirnberger-iii D: expected 193.157 c, got {d_iii}"
);
assert!(
(d_ii - d_iii).abs() > 1.0,
"kirnberger-ii and -iii should disagree audibly on D, got {d_ii} vs {d_iii}"
);
}
// -- Resolver-level: all ten resolve to a real frequency. -----------------
#[test]
fn all_ten_temperaments_resolve_and_produce_a_real_frequency() {
const TEN: [&str; 10] = [
"pythagorean",
"meantone-1/4-comma",
"meantone-1/5-comma",
"meantone-1/6-comma",
"werckmeister-iii",
"werckmeister-iv",
"vallotti",
"kirnberger-ii",
"kirnberger-iii",
"young-ii",
];
let f = fixture();
let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5);
for id in TEN {
let mut score = f.score.clone();
score.tuning_context.default_tuning_system = TuningSystemId::new(id);
let freq = resolve_pitch_frequency(&score, &c5, f.voice_a, None)
.unwrap_or_else(|e| panic!("{id} must resolve to a frequency, got error: {e}"));
assert!(
freq.is_finite() && freq > 0.0,
"{id}: expected a real, positive frequency, got {freq}"
);
}
}
#[test]
fn werckmeister_iii_c_sharp_resolves_distinctly_from_tet_12_c_sharp() {
let f = fixture();
let c_sharp = cmn_pitch("cmn-12", CmnNominal::C, 1, 5);
let mut wm_score = f.score.clone();
wm_score.tuning_context.default_tuning_system = TuningSystemId::new("werckmeister-iii");
// `f.score` already defaults to tet-12.
let wm_freq = resolve_pitch_frequency(&wm_score, &c_sharp, f.voice_a, None)
.expect("werckmeister-iii resolves");
let tet_freq =
resolve_pitch_frequency(&f.score, &c_sharp, f.voice_a, None).expect("tet-12 resolves");
let diff = cents_between(wm_freq, tet_freq);
assert!(
diff > 0.5,
"expected werckmeister-iii's C# to differ audibly from tet-12's, diff was only {diff} c"
);
}
// -- Fail-closed: an unreserved TuningFunctionId never yields a frequency. -
#[test]
fn unknown_tuning_function_id_fails_closed() {
// Unit level: `coordinate_ratio` returns `None` directly -- the
// extension point has no registry
// (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 1).
let bogus_resolution = TuningResolution::Function {
function: TuningFunctionId::new("not-a-real-temperament"),
parameters: TuningParameters,
};
assert_eq!(coordinate_ratio(&bogus_resolution, 0), None);
// Full-pipeline level: a `TuningSystem` carrying that resolution
// never produces a frequency, only an error -- `PositionUnavailable`,
// since `coordinate_ratio`'s `None` is exactly what
// `frequency_for_position` reports that way for.
let bogus_system = TuningSystem {
id: TuningSystemId::new("bogus"),
name: "bogus".to_owned(),
pitch_space: PitchSpaceId::new("cmn-12"),
resolution: bogus_resolution,
description: None,
};
let c5_position = PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 5,
};
let err = frequency_for_position(&c5_position, &bogus_system, &ReferencePitch::a440())
.expect_err("an unknown TuningFunctionId must never resolve to a frequency");
assert!(
matches!(err, TuningResolutionError::PositionUnavailable),
"expected PositionUnavailable, got {err:?}"
);
}
// =========================================================================
// Push 4b's adaptive tranche: `ji-adaptive-5limit`, `HarmonicContext`, and
// `derive_tonal_centre`. Every assertion below either recomputes its
// expected value from the arithmetic (`req:tuning:adaptive-anchor-derivation`'s
// `(7 * fifths).rem_euclid(12)`) or cross-checks against the already-tested
// static-JI built-ins, never a hardcoded frequency copied from nowhere.
// =========================================================================
/// An `Adaptive` `frequency_for_position` reached directly (never through
/// `resolve_pitch_frequency`, which always converts `Adaptive` to a
/// concrete `PerPositionRatios` first) has no anchor to consult and must
/// fail closed, not guess C -- the same shape as
/// `unknown_tuning_function_id_fails_closed`.
#[test]
fn adaptive_resolution_reaching_frequency_for_position_directly_fails_closed() {
assert_eq!(
coordinate_ratio(
&TuningResolution::Adaptive {
function: AdaptiveTuningFunctionId::new("default-v1"),
},
0,
),
None
);
let system = TuningSystem {
id: TuningSystemId::new("bogus-adaptive"),
name: "bogus-adaptive".to_owned(),
pitch_space: PitchSpaceId::new("cmn-12"),
resolution: TuningResolution::Adaptive {
function: AdaptiveTuningFunctionId::new("default-v1"),
},
description: None,
};
let c5_position = PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 5,
};
let err = frequency_for_position(&c5_position, &system, &ReferencePitch::a440())
.expect_err("an unconverted Adaptive resolution must never resolve to a frequency");
assert!(
matches!(err, TuningResolutionError::PositionUnavailable),
"expected PositionUnavailable, got {err:?}"
);
}
/// `req:tuning:adaptive-default-version`'s final clause: an unregistered
/// `AdaptiveTuningFunctionId` is a hard error, never a silent fallback to
/// the C default.
#[test]
fn unregistered_adaptive_function_id_fails_closed_with_no_fallback_to_c() {
let bogus = AdaptiveTuningFunctionId::new("not-default-v1");
let err = adaptive_anchor(&bogus, None)
.expect_err("an unregistered adaptive function id must be a hard error");
assert!(
matches!(err, TuningResolutionError::UnregisteredAdaptiveFunction(_)),
"expected UnregisteredAdaptiveFunction, got {err:?}"
);
}
/// The spec-mandated default (`core_spec.tex:3452-3453`): no context, or
/// a context whose `tonal_centre` is `None`, resolves at anchor C --
/// bit-identical to `ji-static-5limit-C`'s result for the same position.
/// This pins the default against a future "fail closed when context is
/// missing" refactor -- a missing tonal centre is not an error.
#[test]
fn adaptive_with_no_context_defaults_to_c_matching_ji_static_5limit_c() {
let f = fixture();
let e4 = cmn_pitch("cmn-12", CmnNominal::E, 0, 4);
let mut adaptive_score = f.score.clone();
adaptive_score.tuning_context.default_tuning_system =
TuningSystemId::new("ji-adaptive-5limit");
let mut static_score = f.score.clone();
static_score.tuning_context.default_tuning_system =
TuningSystemId::new("ji-static-5limit-C");
let no_context = resolve_pitch_frequency(&adaptive_score, &e4, f.voice_a, None)
.expect("adaptive resolves with no context at all");
let empty_tonal_centre = resolve_pitch_frequency(
&adaptive_score,
&e4,
f.voice_a,
Some(&HarmonicContext { tonal_centre: None }),
)
.expect("adaptive resolves with an empty tonal centre");
let static_c = resolve_pitch_frequency(&static_score, &e4, f.voice_a, None)
.expect("ji-static-5limit-C resolves");
assert_eq!(
no_context.to_bits(),
static_c.to_bits(),
"no context: {no_context} != ji-static-5limit-C's {static_c}"
);
assert_eq!(
empty_tonal_centre.to_bits(),
static_c.to_bits(),
"empty tonal centre: {empty_tonal_centre} != ji-static-5limit-C's {static_c}"
);
}
/// The transposition identity the reviewer will check by hand: adaptive
/// at anchor 0 (C) matches `ji-static-5limit-C` position-for-position,
/// and at anchor 7 (G) matches `ji-static-5limit-G` -- because adaptive
/// resolution *is* `ji_static_5limit_ratios` called with a runtime
/// anchor, not a second construction.
#[test]
fn adaptive_transposition_identity_matches_static_c_and_g() {
let f = fixture();
let naturals = [
CmnNominal::C,
CmnNominal::D,
CmnNominal::E,
CmnNominal::F,
CmnNominal::G,
CmnNominal::A,
CmnNominal::B,
];
for (anchor_pc, static_id) in [(0u8, "ji-static-5limit-C"), (7u8, "ji-static-5limit-G")] {
let mut adaptive_score = f.score.clone();
adaptive_score.tuning_context.default_tuning_system =
TuningSystemId::new("ji-adaptive-5limit");
let mut static_score = f.score.clone();
static_score.tuning_context.default_tuning_system = TuningSystemId::new(static_id);
let ctx = HarmonicContext {
tonal_centre: ChromaticPitchClass::new(anchor_pc),
};
for nominal in naturals {
let pitch = cmn_pitch("cmn-12", nominal, 0, 4);
let adaptive_freq =
resolve_pitch_frequency(&adaptive_score, &pitch, f.voice_a, Some(&ctx))
.unwrap_or_else(|e| panic!("adaptive must resolve {nominal:?}: {e}"));
let static_freq = resolve_pitch_frequency(&static_score, &pitch, f.voice_a, None)
.unwrap_or_else(|e| panic!("{static_id} must resolve {nominal:?}: {e}"));
assert_eq!(
adaptive_freq.to_bits(),
static_freq.to_bits(),
"anchor {anchor_pc}, {nominal:?}: adaptive {adaptive_freq} != {static_id} {static_freq}"
);
}
}
}
/// `req:tuning:adaptive-default-version`'s closing clause: "No adjustment
/// is ever carried forward from a previous resolution." Resolve the same
/// position repeatedly, interleaved with other resolutions in between --
/// a stateful (comma-drifting) implementation would let those leak in;
/// this one never does, by construction (every call recomputes from the
/// anchor and reference alone).
#[test]
fn adaptive_resolution_is_comma_drift_free_by_shape() {
let f = fixture();
let mut score = f.score.clone();
score.tuning_context.default_tuning_system = TuningSystemId::new("ji-adaptive-5limit");
let e4 = cmn_pitch("cmn-12", CmnNominal::E, 0, 4);
let c4 = cmn_pitch("cmn-12", CmnNominal::C, 0, 4);
let g4 = cmn_pitch("cmn-12", CmnNominal::G, 0, 4);
let ctx = HarmonicContext {
tonal_centre: ChromaticPitchClass::new(0),
};
let first =
resolve_pitch_frequency(&score, &e4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
let _ =
resolve_pitch_frequency(&score, &c4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
let _ =
resolve_pitch_frequency(&score, &g4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
let second =
resolve_pitch_frequency(&score, &e4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
let _ =
resolve_pitch_frequency(&score, &g4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
let _ =
resolve_pitch_frequency(&score, &c4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
let third =
resolve_pitch_frequency(&score, &e4, f.voice_a, Some(&ctx)).expect("adaptive resolves");
assert_eq!(first.to_bits(), second.to_bits());
assert_eq!(second.to_bits(), third.to_bits());
}
/// `req:tuning:adaptive-anchor-derivation`: mode is not consulted.
/// `KeySignature` carries no mode field at all, so `fifths = 0` anchors
/// at C whether the prevailing key is conceived as C major or A minor --
/// there is no mode input `derive_tonal_centre` could consult even if it
/// tried. This pins the arithmetic for that no-mode-information case.
#[test]
fn key_signature_zero_fifths_anchors_at_c_regardless_of_mode() {
let mut f = fixture();
{
let region = &mut f.score.canvas.regions[0];
let instances = region.content.staff_instances_mut().unwrap();
instances[0].key_sequence = vec![key_change(0, 0)];
}
let onset = EventPosition::WallClock(WallClockTime(500));
let pc = derive_tonal_centre(&f.score, f.voice_a, &onset, no_musical_resolve)
.expect("must resolve")
.expect("a prevailing key exists");
assert_eq!(
pc.get(),
0,
"fifths=0 must anchor at C (pc 0), got {}",
pc.get()
);
}
/// `req:tuning:adaptive-anchor-derivation`'s sign-bug trap: `fifths = -1`
/// (one flat) gives `(7 * -1).rem_euclid(12) = 5` (F). A buggy `%`
/// implementation would yield `-7`, an invalid pitch class -- this test
/// dies under that mutation.
#[test]
fn flat_key_signature_anchor_uses_rem_euclid_not_percent() {
let mut f = fixture();
{
let region = &mut f.score.canvas.regions[0];
let instances = region.content.staff_instances_mut().unwrap();
instances[0].key_sequence = vec![key_change(0, -1)];
}
let onset = EventPosition::WallClock(WallClockTime(100));
let pc = derive_tonal_centre(&f.score, f.voice_a, &onset, no_musical_resolve)
.expect("must resolve")
.expect("a prevailing key exists");
assert_eq!(pc.get(), 5, "expected F (pc 5), got {}", pc.get());
}
/// `req:tuning:adaptive-anchor-derivation`'s "prevailing" selection: with
/// two `KeySignatureChange`s on a staff, a pitch between them takes the
/// earlier; a pitch after both takes the later.
#[test]
fn prevailing_key_signature_selects_the_latest_at_or_before_onset() {
let mut f = fixture();
{
let region = &mut f.score.canvas.regions[0];
let instances = region.content.staff_instances_mut().unwrap();
instances[0].key_sequence = vec![
key_change(0, 0), // C major/A minor at t=0.
key_change(500, 1), // G major/E minor at t=500.
];
}
let onset_between = EventPosition::WallClock(WallClockTime(250));
let pc = derive_tonal_centre(&f.score, f.voice_a, &onset_between, no_musical_resolve)
.expect("must resolve")
.expect("a prevailing key exists");
assert_eq!(
pc.get(),
0,
"a pitch between the two changes takes the earlier (C)"
);
let onset_after = EventPosition::WallClock(WallClockTime(600));
let pc = derive_tonal_centre(&f.score, f.voice_a, &onset_after, no_musical_resolve)
.expect("must resolve")
.expect("a prevailing key exists");
assert_eq!(
pc.get(),
7,
"a pitch after both changes takes the later (G)"
);
}
/// No key-signature change at all ⇒ no tonal centre -- `Ok(None)`, never
/// an error. `resolve_pitch_frequency` is the one that defaults to C;
/// `derive_tonal_centre` just reports what it found.
#[test]
fn no_key_signature_change_yields_no_tonal_centre() {
let f = fixture();
let onset = EventPosition::WallClock(WallClockTime(0));
let centre = derive_tonal_centre(&f.score, f.voice_a, &onset, no_musical_resolve)
.expect("must resolve (absence of a tonal centre is not an error)");
assert_eq!(centre, None);
}
/// Cross-clock and indirect anchors fail closed with a distinct,
/// reported error rather than a guessed ordering.
#[test]
fn anchor_ordering_fails_closed_on_cross_clock_and_indirect_anchors() {
let mut f = fixture();
{
let region = &mut f.score.canvas.regions[0];
let instances = region.content.staff_instances_mut().unwrap();
// A wall-clock-anchored key change, but the pitch's onset is
// musical: cross-clock, unorderable.
instances[0].key_sequence = vec![key_change(0, 0)];
}
let musical_onset = EventPosition::Musical(MusicalPosition::origin());
let err = derive_tonal_centre(&f.score, f.voice_a, &musical_onset, no_musical_resolve)
.expect_err("a wall-clock anchor against a musical onset must not silently order");
assert!(
matches!(err, TuningResolutionError::AnchorNotOrderable { .. }),
"expected AnchorNotOrderable, got {err:?}"
);
// An indirect (Event-anchored) key change, with a musical onset and
// a resolver that declines to place it.
let mut f2 = fixture();
{
let region = &mut f2.score.canvas.regions[0];
let instances = region.content.staff_instances_mut().unwrap();
instances[0].key_sequence = vec![KeySignatureChange {
anchor: TimeAnchor::Event {
id: crate::ids::EventId::new(ReplicaId(1), 1),
offset: crate::time::AnchorOffset::Zero,
},
key: KeySignature::new(0).unwrap(),
}];
}
let err = derive_tonal_centre(&f2.score, f2.voice_a, &musical_onset, no_musical_resolve)
.expect_err("an indirect anchor the resolver declines must not silently order");
assert!(
matches!(err, TuningResolutionError::AnchorNotOrderable { .. }),
"expected AnchorNotOrderable, got {err:?}"
);
}
/// `derive_tonal_centre` reports the same caller error
/// (`VoiceNotFound`) as `resolve_pitch_frequency` for an orphaned voice.
#[test]
fn derive_tonal_centre_reports_voice_not_found_for_an_orphaned_voice() {
let f = fixture();
let orphan = VoiceId::new(ReplicaId(1), 999);
let onset = EventPosition::WallClock(WallClockTime(0));
let err = derive_tonal_centre(&f.score, orphan, &onset, no_musical_resolve)
.expect_err("an orphaned voice must not resolve");
assert!(matches!(err, TuningResolutionError::VoiceNotFound(_)));
}
}