Push 4b tranche 2: tuning becomes resolvable, in memory
A pitch now resolves to a frequency. The tuning-resolution vocabulary
(TuningSystem, TuningResolution, TuningOverride, TuningScope), a partial built-in
catalog, and the five-scope resolver land in epiphany-core, entirely off the
wire -- the reversible half of the remaining tuning work, exercised and proven
before the schema-major-3 bump freezes anything.
Nine of twenty systems resolve: the six tet-* (EqualTemperament, each paired to
the pitch space of matching chromatic cardinality -- tet-12/cmn-12,
tet-N/edo-N), and the three ji-static-5limit-{C,G,D}, whose twelve ratios are
COMPUTED from the lattice block {3^a 5^b | a in [-1,2], b in [-1,1]} in exact
integer arithmetic, never a pasted table. The other eleven fail closed with a
distinct NotYetSupported (vs UnknownTuningSystem): the ten historical
temperaments await tranche 2b, ji-adaptive-5limit awaits HarmonicContext.
overrides is added to ScoreTuningContext as the one field the scope walk needs,
in memory only. Its struct_codec! -- whose dec constructs a literal of exactly
the named fields -- is replaced by a hand-written Codec that encodes the three
wire fields and defaults overrides on decode, with a matching hand-written
TextValue.
Verified independently of the agent that wrote it. Through the real
Score::canonical_bytes() path: a populated overrides encodes byte-identically to
an empty one (268 bytes both) and decodes back to empty -- the field never
reaches the wire. tet-12 A4=440 resolves C5 to 523.2511306011972 Hz by hand.
ji-static-5limit-C's major third is 386.3137c against tet-12's 400.0000c, the
5/4 just third distinct by the expected 13.69c. The wire-invariant test
mutation-killed by leaking the override count from the codec (the first attempt,
encoding the field itself, was a compile error since TuningOverride has no Codec
-- meaningless as a mutation, redone). No golden or fuzz digest moved.
Also corrected a doc comment on pitch.rs's sounding_equivalent that this tranche
falsified: it said frequency resolution is "not modeled in this crate", which is
now untrue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0acacb951
commit
6fa14c76e3
|
|
@ -761,3 +761,116 @@ weakened, no wire byte or discriminant touched.
|
|||
**Requirement counts did not move.** No new requirement was added or cited that
|
||||
did not already exist; `crates/epiphany-testkit/tests/requirement_labels.rs`'s
|
||||
212/282/282 are unchanged.
|
||||
|
||||
## Push 4b tranche 2: the tuning resolver lands, in memory, resolving a pitch to a frequency
|
||||
|
||||
`spec/CONTRACT_PUSH4B_RESOLVER.md`. Same vertical-slice discipline as tranche
|
||||
1: `TuningSystem`, `TuningResolution`, `TuningOverride`, `TuningScope`, a
|
||||
partial built-in catalog, and the five-scope resolver land together, proven
|
||||
with real frequencies (`tet-12` C5 ≈ 523.2511 Hz off A4 = 440; a JI major
|
||||
third measurably distinct from the equal-tempered one), not `is_ok()`.
|
||||
|
||||
**New module `src/tuning.rs`.** `TuningResolution` is deliberately a
|
||||
**two**-of-six-variant enum: `EqualTemperament` and `PerPositionRatios` (plus
|
||||
`PositionRatio`, which the specification's own listing never spells out the
|
||||
fields of — defined here as a chromatic position plus a
|
||||
`crate::pitch_space::JiRatio`, reusing rather than inventing a second
|
||||
rational type). The other four variants (`Function`, `Overlay`, `Imported`,
|
||||
`Adaptive`) are not transcribed: nothing in this tranche's catalog
|
||||
constructs them, and their payload subtrees are exactly the unconsumed
|
||||
surface tranche 1 already declined twice over.
|
||||
|
||||
**The built-in catalog resolves nine of twenty, honestly.** The six `tet-*`
|
||||
equal temperaments (`tet-12` pairs with `cmn-12`, the default pairing;
|
||||
`tet-19/22/31/53/72` pair with the matching `edo-*` pitch spaces — forced by
|
||||
the built-in catalog's cardinalities, not chosen) and the three
|
||||
`ji-static-5limit-{C,G,D}` just-intonation systems. The latter's twelve
|
||||
ratios are *computed* — `ji_static_5limit_ratios`, exact integer arithmetic
|
||||
over the lattice block $\{3^a5^b \mid a\in[-1,2], b\in[-1,1]\}$, octave-reduced
|
||||
by doubling/halving (never a float comparison) and sorted by cross-
|
||||
multiplication (never a float division) — not pasted from
|
||||
`core_spec.tex:4034-4046`'s table. A dedicated test
|
||||
(`ji_static_5limit_lattice_matches_the_published_construction`) spot-checks
|
||||
the code's output against that table at all three anchors, proving the two
|
||||
state the same construction rather than merely agreeing to look similar.
|
||||
|
||||
The remaining eleven (the ten historical temperaments and
|
||||
`ji-adaptive-5limit`) are real catalog entries whose resolution this tranche
|
||||
**defers**, distinguished from a genuinely unknown identifier by
|
||||
`TuningCatalogEntry::{Resolved, Deferred}` — so `resolve_pitch_frequency`
|
||||
can report "not yet supported, here's why" for a known-but-deferred system
|
||||
and "not a built-in tuning system" for an unknown one, never the same error
|
||||
for both, and never a guessed frequency for either. Tranche 2b re-derives the
|
||||
ten temperaments from their ratified constructions (`core_spec.tex`
|
||||
§"Temperament Constructions"); `ji-adaptive-5limit` waits on `HarmonicContext`,
|
||||
out of scope per the spec itself.
|
||||
|
||||
**Anchoring, done as a ratio-of-ratios so the arbitrary anchor cancels.**
|
||||
`frequency_for_position` places both the target position and the reference
|
||||
position on one absolute integer coordinate (generalizing
|
||||
`Pitch::twelve_tet_semitone`'s idea from a fixed 12 to any tuning's own
|
||||
divisions, and from `Cmn` positions to `Integer` ones for the EDO spaces),
|
||||
computes each one's frequency ratio relative to coordinate 0 under the
|
||||
tuning's resolution, and takes `reference.frequency_hz() * ratio(position) /
|
||||
ratio(reference.position)`. Which position a construction calls "1/1" cancels
|
||||
out of that quotient — proven the hard way: the first draft of the JI-major-
|
||||
third test anchored the comparison at A4 = 440 Hz (the score's own default
|
||||
reference) and asserted the just third would be flatter than tet-12's; it
|
||||
failed, because JI-static-5limit-C retunes A relative to C differently than
|
||||
tet-12 does, so comparing frequencies referenced through A silently mixes
|
||||
"how A retunes" into "how E retunes." The fix anchors the reference at C4
|
||||
itself for both systems, isolating the C-to-E interval the test is actually
|
||||
about — the resolver's arithmetic was correct throughout; the first test
|
||||
design wasn't.
|
||||
|
||||
**The five-scope walk resolves each of pitch space, tuning system, and
|
||||
reference independently** (`req:tuning:tuning-resolution-order`), voice then
|
||||
staff then region then the score default, with an explicit
|
||||
`TuningReference::Explicit` short-circuiting the tuning-system component at
|
||||
step 1 (pitch space and reference have no step-1 concept of their own — an
|
||||
`AcousticPitch` carries no field for either) and `AcousticRealization::AbsoluteHz`
|
||||
short-circuiting the whole frequency, bypassing the walk and the catalog
|
||||
entirely. "Each region enclosing the pitch, innermost to outermost" turns out
|
||||
to be exactly **one** region in this data model: a `Voice` is owned by exactly
|
||||
one `StaffInstance`, owned by exactly one `Region` (containment, not a
|
||||
derived time-range query), so there is no nested-region multiplicity to walk.
|
||||
`TuningScope::Range` is defined (Chapter 4's fourth scope variant) but the
|
||||
walk never matches it — `req:tuning:tuning-resolution-order` enumerates
|
||||
exactly five steps and does not mention it, so inventing a sixth would be
|
||||
exactly the kind of unratified addition this project's process exists to
|
||||
catch; documented as a scope note, not silently dropped.
|
||||
|
||||
**The compatibility check accepts only exact `pitch_space` equality**
|
||||
(`req:tuning:tuning-system-compatibility`): no compatibility-mapping registry
|
||||
exists, matching how tranche 1 left the pitch-space registry unbuilt. A
|
||||
mismatch (e.g. `tet-19`'s declared `edo-19` against an unchanged `cmn-12`
|
||||
default) is rejected, not silently resolved — proof-of-life item 4.
|
||||
|
||||
**`ScoreTuningContext` gains `overrides: Vec<TuningOverride>`, in memory
|
||||
only.** This is the one wire-adjacent change, and it isn't a wire change: the
|
||||
type's canonical encoding stays exactly the three fields it always had
|
||||
(`default_pitch_space`, `default_tuning_system`, `reference`), because adding
|
||||
a fourth field to a `struct_codec!`-generated type breaks the macro outright
|
||||
— its generated `dec` ends in a struct literal naming every field it was
|
||||
given, so a fourth field cannot compile against it. The `struct_codec!` line
|
||||
is replaced with a hand-written `impl Codec` (`codec.rs`) and `impl TextValue`
|
||||
(`textvalue_graph.rs`) that encode/project exactly the three wire fields, in
|
||||
their original order, and construct `overrides: Vec::new()` unconditionally
|
||||
on decode/parse. Two round-trip tests prove the field never reaches either
|
||||
canonical surface: `codec::tests::score_tuning_context_overrides_do_not_reach_the_wire`
|
||||
(a context with a non-empty `overrides` encodes to byte-identical output as
|
||||
one with empty `overrides`, and decoding either reconstructs `overrides` as
|
||||
empty) and `textvalue_graph::tests::score_tuning_context_round_trips_and_overrides_do_not_project`
|
||||
(the same, for the text projection). Field order in the Rust struct is free
|
||||
(the manual codec fixes the wire order independently); the specification's
|
||||
eventual major-3 field order puts `overrides` last, after
|
||||
`accidental_extensions` and `smufl` — that pairing is the wire tranche's
|
||||
problem, not this one's.
|
||||
|
||||
**No `Codec` impl exists for anything new in `tuning.rs`.** These types are
|
||||
referenced only by id and by the one in-memory `ScoreTuningContext` field;
|
||||
they stay free to change once the wire tranche (schema major 3) discovers
|
||||
something about them.
|
||||
|
||||
**Requirement counts did not move again.** No `.tex` file was touched, no
|
||||
requirement added; the 212/282/282 counts stay put.
|
||||
|
|
|
|||
|
|
@ -1832,11 +1832,37 @@ struct_codec!(BeatGroup {
|
|||
subdivision,
|
||||
accent
|
||||
});
|
||||
struct_codec!(ScoreTuningContext {
|
||||
default_pitch_space,
|
||||
default_tuning_system,
|
||||
reference
|
||||
});
|
||||
// `ScoreTuningContext` gained a fourth, in-memory-only field (`overrides`,
|
||||
// Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`) that must **not**
|
||||
// reach the wire: schema major 3 has not been opened. `struct_codec!` cannot
|
||||
// express that — its generated `dec` ends in a struct literal naming every
|
||||
// field it was given, so a fourth field either goes on the wire (freezing an
|
||||
// in-memory-only type before it has a consumer) or the macro cannot build the
|
||||
// value at all. Hand-written instead: encode/decode exactly the three wire
|
||||
// fields, in their original order, and construct `overrides: Vec::new()` on
|
||||
// decode. A round-trip test just below
|
||||
// (`score_tuning_context_overrides_do_not_reach_the_wire`) proves a non-empty
|
||||
// `overrides` encodes to the same bytes as an empty one; the matching text-
|
||||
// projection proof is `textvalue_graph.rs`'s
|
||||
// `score_tuning_context_round_trips_and_overrides_do_not_project`.
|
||||
impl Codec for ScoreTuningContext {
|
||||
fn enc(&self, out: &mut Vec<u8>) {
|
||||
self.default_pitch_space.enc(out);
|
||||
self.default_tuning_system.enc(out);
|
||||
self.reference.enc(out);
|
||||
}
|
||||
fn dec(r: &mut Reader<'_>) -> Result<Self> {
|
||||
let default_pitch_space = Codec::dec(r)?;
|
||||
let default_tuning_system = Codec::dec(r)?;
|
||||
let reference = Codec::dec(r)?;
|
||||
Ok(ScoreTuningContext {
|
||||
default_pitch_space,
|
||||
default_tuning_system,
|
||||
reference,
|
||||
overrides: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Schema major 2: the cross-cutting bodies filled (appended fields); the
|
||||
// frozen prior layouts are read by the `dec_*_v1` sub-decoders.
|
||||
struct_codec!(Slur {
|
||||
|
|
@ -3381,6 +3407,44 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_tuning_context_overrides_do_not_reach_the_wire() {
|
||||
use crate::graph::ScoreTuningContext;
|
||||
use crate::ids::{ReplicaId, VoiceId};
|
||||
use crate::pitch::TuningSystemId;
|
||||
use crate::tuning::{TuningOverride, TuningScope};
|
||||
|
||||
let without_overrides = ScoreTuningContext::default();
|
||||
let mut with_overrides = ScoreTuningContext::default();
|
||||
with_overrides.overrides.push(TuningOverride {
|
||||
scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 1)),
|
||||
pitch_space: None,
|
||||
tuning_system: Some(TuningSystemId::new("tet-19")),
|
||||
reference: None,
|
||||
});
|
||||
// Sanity: the two in-memory values actually differ, so a
|
||||
// byte-identity assertion below is not vacuous.
|
||||
assert_ne!(
|
||||
with_overrides, without_overrides,
|
||||
"the fixture must actually carry a non-empty override in memory"
|
||||
);
|
||||
|
||||
let mut bytes_with = Vec::new();
|
||||
with_overrides.enc(&mut bytes_with);
|
||||
let mut bytes_without = Vec::new();
|
||||
without_overrides.enc(&mut bytes_without);
|
||||
assert_eq!(
|
||||
bytes_with, bytes_without,
|
||||
"overrides must not reach canonical bytes (Push 4b tranche 2, Ruling C)"
|
||||
);
|
||||
|
||||
// Decoding either stream reconstructs `overrides` as empty — the
|
||||
// field never round-trips, by construction.
|
||||
let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_with)).expect("decodes");
|
||||
assert_eq!(decoded, without_overrides);
|
||||
assert!(decoded.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_scores_round_trip() {
|
||||
for seed in 0..200u64 {
|
||||
|
|
|
|||
|
|
@ -1641,12 +1641,31 @@ pub struct ViewDefinition {
|
|||
|
||||
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
|
||||
/// the default pitch space, tuning system, and reference pitch every score must
|
||||
/// declare; per-scope overrides and accidental extensions are deferred.
|
||||
/// declare; per-scope overrides land here (Push 4b tranche 2), accidental
|
||||
/// extensions are still deferred.
|
||||
///
|
||||
/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).** The
|
||||
/// canonical encoding stays **exactly** `default_pitch_space`,
|
||||
/// `default_tuning_system`, `reference`, in that order — schema major 3 has
|
||||
/// not been opened, so `overrides` is *not* on the wire this tranche. See the
|
||||
/// hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
|
||||
/// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use,
|
||||
/// which named exactly three fields in its generated decoder and so cannot
|
||||
/// compile against a fourth). Where `overrides` sits in *this* Rust struct is
|
||||
/// free — the manual codec fixes the wire order independently of field
|
||||
/// declaration order — but the specification's eventual major-3 field order
|
||||
/// places `overrides` last, after `accidental_extensions` and `smufl`; adding
|
||||
/// those two remains the wire tranche's job, not this one's.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ScoreTuningContext {
|
||||
pub default_pitch_space: PitchSpaceId,
|
||||
pub default_tuning_system: TuningSystemId,
|
||||
pub reference: ReferencePitch,
|
||||
/// Per-scope overrides consulted by the tuning resolver
|
||||
/// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of
|
||||
/// `req:tuning:tuning-resolution-order`. **In memory only** this
|
||||
/// tranche — see the struct doc above.
|
||||
pub overrides: Vec<crate::tuning::TuningOverride>,
|
||||
}
|
||||
|
||||
impl Default for ScoreTuningContext {
|
||||
|
|
@ -1657,6 +1676,7 @@ impl Default for ScoreTuningContext {
|
|||
default_pitch_space: PitchSpaceId::new("cmn-12"),
|
||||
default_tuning_system: TuningSystemId::new("tet-12"),
|
||||
reference: ReferencePitch::a440(),
|
||||
overrides: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@
|
|||
//! [`TranspositionBehavior`]) and [`built_in_position_structure`], the
|
||||
//! built-in catalog `Pitch::transposed` resolves against. In-memory only —
|
||||
//! no `Codec` impl exists for anything here (Push 4b Ruling C).
|
||||
//! * `tuning` — the Chapter 4 tuning-resolution vocabulary
|
||||
//! ([`TuningSystem`], [`TuningResolution`], [`TuningOverride`],
|
||||
//! [`TuningScope`]), [`built_in_tuning_system`] (nine of the twenty
|
||||
//! catalog identifiers; the rest fail closed), and
|
||||
//! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a
|
||||
//! frequency in Hz. In-memory only, same discipline as `pitch_space`
|
||||
//! (Push 4b Ruling C).
|
||||
//! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5
|
||||
//! §"The Event Arena").
|
||||
//! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct
|
||||
|
|
@ -65,6 +72,7 @@ mod textvalue_impls;
|
|||
mod textvalue_pitch;
|
||||
mod textvalue_time;
|
||||
mod time;
|
||||
mod tuning;
|
||||
|
||||
pub mod fuzz;
|
||||
pub mod generators;
|
||||
|
|
@ -141,6 +149,12 @@ pub use tempo::{
|
|||
INVERSION_MAX_DENOMINATOR, INVERSION_MAX_ITERATIONS,
|
||||
};
|
||||
|
||||
pub use tuning::{
|
||||
built_in_tuning_system, frequency_for_position, resolve_pitch_frequency, resolve_tuning_scope,
|
||||
PositionRatio, ResolvedTuning, TuningCatalogEntry, TuningOverride, TuningResolution,
|
||||
TuningResolutionError, TuningScope, TuningSystem,
|
||||
};
|
||||
|
||||
pub use codec::{CanonicalValue, ScoreDecodeError};
|
||||
|
||||
pub use indexes::ScoreIndexes;
|
||||
|
|
|
|||
|
|
@ -633,12 +633,15 @@ impl Pitch {
|
|||
/// tolerance of any *other* class is a category error and never matches.
|
||||
///
|
||||
/// Frequency resolution in general depends on the full tuning-system catalog
|
||||
/// and reference pitch — a separate subsystem (the acoustic engine,
|
||||
/// Chapter 1; see `DECISIONS.md`), not modeled in this crate. Callers at that
|
||||
/// layer pass a `resolve` closure mapping a pitch to its frequency in Hertz
|
||||
/// (`None` if it cannot resolve it). An [`AcousticRealization::AbsoluteHz`]
|
||||
/// pitch resolves to its own stated frequency without the closure. Returns
|
||||
/// `false` if either frequency is unavailable.
|
||||
/// and reference pitch. The deterministic part of that now lives in this
|
||||
/// crate (`tuning::resolve_pitch_frequency`, Push 4b tranche 2); the full
|
||||
/// acoustic engine (Chapter 1; see `DECISIONS.md`) remains a separate
|
||||
/// subsystem. This method stays resolver-agnostic on purpose: callers pass a
|
||||
/// `resolve` closure mapping a pitch to its frequency in Hertz (`None` if it
|
||||
/// cannot resolve it), which may delegate to the in-crate resolver or any
|
||||
/// other source. An [`AcousticRealization::AbsoluteHz`] pitch resolves to its
|
||||
/// own stated frequency without the closure. Returns `false` if either
|
||||
/// frequency is unavailable.
|
||||
pub fn sounding_equivalent(
|
||||
&self,
|
||||
other: &Pitch,
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ use epiphany_determinism::CanonicalF64;
|
|||
|
||||
use crate::graph::{
|
||||
AnnotationAnchor, DecompositionSource, EventOrderingDAG, GestureAnchoring, KeySignature,
|
||||
MetadataValue, RegionContent, RegionTimeModel, RepeatKind, SoundConfiguration, SpaceUnit,
|
||||
SpannerKind, StaffGroupKind, TieClass, TimeSignature, TimeSignatureDisplay, Timestamp,
|
||||
TupletRatio, VoiceOrigin,
|
||||
MetadataValue, RegionContent, RegionTimeModel, RepeatKind, ScoreTuningContext,
|
||||
SoundConfiguration, SpaceUnit, SpannerKind, StaffGroupKind, TieClass, TimeSignature,
|
||||
TimeSignatureDisplay, Timestamp, TupletRatio, VoiceOrigin,
|
||||
};
|
||||
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
|
||||
use crate::textvalue_impls::class_of;
|
||||
|
|
@ -286,6 +286,47 @@ impl TextValue for TimeSignature {
|
|||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// A struct whose `Codec` is hand-written for a macro-incompatibility reason,
|
||||
// not a validating constructor.
|
||||
// ===========================================================================
|
||||
|
||||
/// `(score-tuning-context <default-pitch-space> <default-tuning-system>
|
||||
/// <reference>)` — exactly the three wire fields, in `fn enc` order
|
||||
/// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).
|
||||
///
|
||||
/// `ScoreTuningContext` gained a fourth field, `overrides`, that is
|
||||
/// deliberately **not** part of this projection: it is in-memory only (no
|
||||
/// schema major 3 has been opened), so it must never reach the wire, and the
|
||||
/// text projection is the same canonical surface the binary codec is — a
|
||||
/// value that omits it here would otherwise silently launder a
|
||||
/// non-empty-`overrides` context into one indistinguishable from an
|
||||
/// empty-`overrides` context, which is exactly the intended behavior, not an
|
||||
/// oversight. `parse` always constructs `overrides: Vec::new()`, mirroring
|
||||
/// `Codec::dec`.
|
||||
impl TextValue for ScoreTuningContext {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
Sexp::Symbol(kebab("ScoreTuningContext")),
|
||||
self.default_pitch_space.project(),
|
||||
self.default_tuning_system.project(),
|
||||
self.reference.project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct(&kebab("ScoreTuningContext"), 3)?;
|
||||
let default_pitch_space = TextValue::parse(&fields[0])?;
|
||||
let default_tuning_system = TextValue::parse(&fields[1])?;
|
||||
let reference = TextValue::parse(&fields[2])?;
|
||||
Ok(ScoreTuningContext {
|
||||
default_pitch_space,
|
||||
default_tuning_system,
|
||||
reference,
|
||||
overrides: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tagged unions.
|
||||
// ===========================================================================
|
||||
|
|
@ -888,6 +929,33 @@ mod tests {
|
|||
round_trip(EventOrderingDAG::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_tuning_context_round_trips_and_overrides_do_not_project() {
|
||||
// Empty overrides: ordinary identity round-trip.
|
||||
round_trip(ScoreTuningContext::default());
|
||||
|
||||
// Non-empty overrides: the text projection is identical to the
|
||||
// empty-overrides projection (the field is in-memory only, Push 4b
|
||||
// tranche 2 / Ruling C), and parsing always reconstructs `overrides`
|
||||
// as empty.
|
||||
let mut with_overrides = ScoreTuningContext::default();
|
||||
with_overrides
|
||||
.overrides
|
||||
.push(crate::tuning::TuningOverride {
|
||||
scope: crate::tuning::TuningScope::Staff(StaffId::new(ReplicaId(1), 1)),
|
||||
pitch_space: None,
|
||||
tuning_system: None,
|
||||
reference: None,
|
||||
});
|
||||
assert_eq!(
|
||||
with_overrides.project().render(),
|
||||
ScoreTuningContext::default().project().render(),
|
||||
"overrides must not appear in the text projection"
|
||||
);
|
||||
let parsed = ScoreTuningContext::parse(&with_overrides.project()).unwrap();
|
||||
assert!(parsed.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_unions_round_trip() {
|
||||
round_trip(SpannerKind::Generic);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,982 @@
|
|||
//! 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 this tranche resolves, and what it does not
|
||||
//!
|
||||
//! [`TuningResolution`] is a **six**-variant enum in the specification
|
||||
//! (`core_spec.tex:3309`); this tranche defines only the two variants the
|
||||
//! built-in catalog below actually constructs —
|
||||
//! [`TuningResolution::EqualTemperament`] and
|
||||
//! [`TuningResolution::PerPositionRatios`] — plus [`PositionRatio`]. The
|
||||
//! other four are transcribed only when a built-in needs them, so their
|
||||
//! unconstructed payload subtrees (`TuningParameters`, `ImportedTuningData`,
|
||||
//! `AdaptiveTuningParameters`, …) never become an unconsumed type surface
|
||||
//! (the `NOTEHEAD_ANCHORS` failure): `Function` waits on Push 4b tranche 2b,
|
||||
//! which re-derives the ten historical temperaments from their ratified
|
||||
//! constructions; `Adaptive` waits on `HarmonicContext`, which does not exist
|
||||
//! in Rust and whose completion `core_spec.tex` puts out of scope; `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 **nine** of the twenty catalog
|
||||
//! identifiers (`req:tuning:builtin-tuning-catalog`): the six `tet-*` equal
|
||||
//! temperaments and the three `ji-static-5limit-*` just-intonation systems.
|
||||
//! The ten historical temperaments and `ji-adaptive-5limit` are real catalog
|
||||
//! entries whose resolution this tranche defers
|
||||
//! ([`TuningCatalogEntry::Deferred`]) — never a guessed frequency.
|
||||
//!
|
||||
//! ## 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::{Score, ScoreTuningContext};
|
||||
use crate::ids::{RegionId, StaffId, VoiceId};
|
||||
use crate::pitch::{
|
||||
AcousticRealization, Pitch, PitchSpaceId, PitchSpacePosition, ReferencePitch, TuningReference,
|
||||
TuningSystemId, VoiceSelector,
|
||||
};
|
||||
use crate::pitch_space::{built_in_position_structure, JiRatio, PositionStructure};
|
||||
use crate::time::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,
|
||||
}
|
||||
|
||||
/// How a tuning system resolves pitch-space positions to frequencies
|
||||
/// (`core_spec.tex:3309-3348`). **Deliberately partial**: the specification
|
||||
/// names six variants; this tranche defines the two the built-in catalog
|
||||
/// constructs. See the module doc for which tranche completes each of the
|
||||
/// other four.
|
||||
#[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>),
|
||||
}
|
||||
|
||||
/// 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 this tranche
|
||||
/// defers. 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."
|
||||
#[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`).
|
||||
///
|
||||
/// Nine of the twenty resolve this tranche:
|
||||
///
|
||||
/// * 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 other eleven are real catalog entries whose resolution is deferred,
|
||||
/// not guessed ([`TuningCatalogEntry::Deferred`]):
|
||||
///
|
||||
/// * the ten historical temperaments (`pythagorean`, three `meantone-*`,
|
||||
/// `werckmeister-iii`/`-iv`, `vallotti`, `kirnberger-ii`/`-iii`,
|
||||
/// `young-ii`) — Push 4b tranche 2b re-derives each construction's
|
||||
/// twelve-fifth closure in code (see `spec/CONTRACT_PUSH4B_RESOLVER.md`'s
|
||||
/// closing section);
|
||||
/// * `ji-adaptive-5limit` — needs `HarmonicContext`
|
||||
/// (`req:tuning:adaptive-default-version`), which does not exist in Rust.
|
||||
///
|
||||
/// `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."
|
||||
)),
|
||||
})
|
||||
}
|
||||
const DEFERRED_TEMPERAMENT: &str = "historical temperament; construction re-derivation is \
|
||||
Push 4b tranche 2b (spec/CONTRACT_PUSH4B_RESOLVER.md, closing section)";
|
||||
const DEFERRED_ADAPTIVE: &str =
|
||||
"adaptive tuning needs HarmonicContext, which does not exist in Rust (out of scope this tranche)";
|
||||
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" | "meantone-1/4-comma" | "meantone-1/5-comma" | "meantone-1/6-comma"
|
||||
| "werckmeister-iii" | "werckmeister-iv" | "vallotti" | "kirnberger-ii"
|
||||
| "kirnberger-iii" | "young-ii" => Some(TuningCatalogEntry::Deferred(DEFERRED_TEMPERAMENT)),
|
||||
"ji-adaptive-5limit" => Some(TuningCatalogEntry::Deferred(DEFERRED_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 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,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?
|
||||
}
|
||||
};
|
||||
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 and staff 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.
|
||||
fn locate_voice(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffId)> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// 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").
|
||||
pub fn resolve_pitch_frequency(
|
||||
score: &Score,
|
||||
pitch: &Pitch,
|
||||
voice: VoiceId,
|
||||
) -> Result<f64, TuningResolutionError> {
|
||||
if let AcousticRealization::AbsoluteHz(hz) = pitch.acoustic.realization {
|
||||
return Ok(hz.get());
|
||||
}
|
||||
let (region, staff) =
|
||||
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 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,
|
||||
});
|
||||
}
|
||||
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, 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 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).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)
|
||||
.expect("ji-static-5limit-C resolves");
|
||||
let tet_freq =
|
||||
resolve_pitch_frequency(&tet_score, &e4, f.voice_a).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).expect("resolves under the override");
|
||||
let in_voice_b =
|
||||
resolve_pitch_frequency(&f.score, &a4, f.voice_b).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)
|
||||
.expect_err("must reject the mismatch");
|
||||
assert!(
|
||||
matches!(err, TuningResolutionError::IncompatiblePitchSpace { .. }),
|
||||
"expected IncompatiblePitchSpace, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Proof of life 5: deferred systems fail closed. -----------------------
|
||||
|
||||
#[test]
|
||||
fn deferred_systems_fail_closed_pythagorean_and_ji_adaptive() {
|
||||
let f = fixture();
|
||||
let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5);
|
||||
for deferred in ["pythagorean", "ji-adaptive-5limit"] {
|
||||
let mut score = f.score.clone();
|
||||
score.tuning_context.default_tuning_system = TuningSystemId::new(deferred);
|
||||
let err = resolve_pitch_frequency(&score, &c5, f.voice_a)
|
||||
.expect_err(&format!("{deferred} must not resolve to a frequency"));
|
||||
assert!(
|
||||
matches!(err, TuningResolutionError::NotYetSupported { .. }),
|
||||
"{deferred} must report NotYetSupported (a known-but-deferred identifier), got {err:?}"
|
||||
);
|
||||
}
|
||||
// A genuinely unknown identifier reports differently, so the two
|
||||
// failure modes never blur together.
|
||||
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)
|
||||
.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)
|
||||
.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).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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue