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
|
**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
|
did not already exist; `crates/epiphany-testkit/tests/requirement_labels.rs`'s
|
||||||
212/282/282 are unchanged.
|
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,
|
subdivision,
|
||||||
accent
|
accent
|
||||||
});
|
});
|
||||||
struct_codec!(ScoreTuningContext {
|
// `ScoreTuningContext` gained a fourth, in-memory-only field (`overrides`,
|
||||||
default_pitch_space,
|
// Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`) that must **not**
|
||||||
default_tuning_system,
|
// reach the wire: schema major 3 has not been opened. `struct_codec!` cannot
|
||||||
reference
|
// 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
|
// Schema major 2: the cross-cutting bodies filled (appended fields); the
|
||||||
// frozen prior layouts are read by the `dec_*_v1` sub-decoders.
|
// frozen prior layouts are read by the `dec_*_v1` sub-decoders.
|
||||||
struct_codec!(Slur {
|
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]
|
#[test]
|
||||||
fn generator_scores_round_trip() {
|
fn generator_scores_round_trip() {
|
||||||
for seed in 0..200u64 {
|
for seed in 0..200u64 {
|
||||||
|
|
|
||||||
|
|
@ -1641,12 +1641,31 @@ pub struct ViewDefinition {
|
||||||
|
|
||||||
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
|
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
|
||||||
/// the default pitch space, tuning system, and reference pitch every score must
|
/// 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)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub struct ScoreTuningContext {
|
pub struct ScoreTuningContext {
|
||||||
pub default_pitch_space: PitchSpaceId,
|
pub default_pitch_space: PitchSpaceId,
|
||||||
pub default_tuning_system: TuningSystemId,
|
pub default_tuning_system: TuningSystemId,
|
||||||
pub reference: ReferencePitch,
|
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 {
|
impl Default for ScoreTuningContext {
|
||||||
|
|
@ -1657,6 +1676,7 @@ impl Default for ScoreTuningContext {
|
||||||
default_pitch_space: PitchSpaceId::new("cmn-12"),
|
default_pitch_space: PitchSpaceId::new("cmn-12"),
|
||||||
default_tuning_system: TuningSystemId::new("tet-12"),
|
default_tuning_system: TuningSystemId::new("tet-12"),
|
||||||
reference: ReferencePitch::a440(),
|
reference: ReferencePitch::a440(),
|
||||||
|
overrides: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@
|
||||||
//! [`TranspositionBehavior`]) and [`built_in_position_structure`], the
|
//! [`TranspositionBehavior`]) and [`built_in_position_structure`], the
|
||||||
//! built-in catalog `Pitch::transposed` resolves against. In-memory only —
|
//! built-in catalog `Pitch::transposed` resolves against. In-memory only —
|
||||||
//! no `Codec` impl exists for anything here (Push 4b Ruling C).
|
//! 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
|
//! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5
|
||||||
//! §"The Event Arena").
|
//! §"The Event Arena").
|
||||||
//! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct
|
//! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct
|
||||||
|
|
@ -65,6 +72,7 @@ mod textvalue_impls;
|
||||||
mod textvalue_pitch;
|
mod textvalue_pitch;
|
||||||
mod textvalue_time;
|
mod textvalue_time;
|
||||||
mod time;
|
mod time;
|
||||||
|
mod tuning;
|
||||||
|
|
||||||
pub mod fuzz;
|
pub mod fuzz;
|
||||||
pub mod generators;
|
pub mod generators;
|
||||||
|
|
@ -141,6 +149,12 @@ pub use tempo::{
|
||||||
INVERSION_MAX_DENOMINATOR, INVERSION_MAX_ITERATIONS,
|
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 codec::{CanonicalValue, ScoreDecodeError};
|
||||||
|
|
||||||
pub use indexes::ScoreIndexes;
|
pub use indexes::ScoreIndexes;
|
||||||
|
|
|
||||||
|
|
@ -633,12 +633,15 @@ impl Pitch {
|
||||||
/// tolerance of any *other* class is a category error and never matches.
|
/// tolerance of any *other* class is a category error and never matches.
|
||||||
///
|
///
|
||||||
/// Frequency resolution in general depends on the full tuning-system catalog
|
/// Frequency resolution in general depends on the full tuning-system catalog
|
||||||
/// and reference pitch — a separate subsystem (the acoustic engine,
|
/// and reference pitch. The deterministic part of that now lives in this
|
||||||
/// Chapter 1; see `DECISIONS.md`), not modeled in this crate. Callers at that
|
/// crate (`tuning::resolve_pitch_frequency`, Push 4b tranche 2); the full
|
||||||
/// layer pass a `resolve` closure mapping a pitch to its frequency in Hertz
|
/// acoustic engine (Chapter 1; see `DECISIONS.md`) remains a separate
|
||||||
/// (`None` if it cannot resolve it). An [`AcousticRealization::AbsoluteHz`]
|
/// subsystem. This method stays resolver-agnostic on purpose: callers pass a
|
||||||
/// pitch resolves to its own stated frequency without the closure. Returns
|
/// `resolve` closure mapping a pitch to its frequency in Hertz (`None` if it
|
||||||
/// `false` if either frequency is unavailable.
|
/// 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(
|
pub fn sounding_equivalent(
|
||||||
&self,
|
&self,
|
||||||
other: &Pitch,
|
other: &Pitch,
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ use epiphany_determinism::CanonicalF64;
|
||||||
|
|
||||||
use crate::graph::{
|
use crate::graph::{
|
||||||
AnnotationAnchor, DecompositionSource, EventOrderingDAG, GestureAnchoring, KeySignature,
|
AnnotationAnchor, DecompositionSource, EventOrderingDAG, GestureAnchoring, KeySignature,
|
||||||
MetadataValue, RegionContent, RegionTimeModel, RepeatKind, SoundConfiguration, SpaceUnit,
|
MetadataValue, RegionContent, RegionTimeModel, RepeatKind, ScoreTuningContext,
|
||||||
SpannerKind, StaffGroupKind, TieClass, TimeSignature, TimeSignatureDisplay, Timestamp,
|
SoundConfiguration, SpaceUnit, SpannerKind, StaffGroupKind, TieClass, TimeSignature,
|
||||||
TupletRatio, VoiceOrigin,
|
TimeSignatureDisplay, Timestamp, TupletRatio, VoiceOrigin,
|
||||||
};
|
};
|
||||||
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
|
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
|
||||||
use crate::textvalue_impls::class_of;
|
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.
|
// Tagged unions.
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -888,6 +929,33 @@ mod tests {
|
||||||
round_trip(EventOrderingDAG::default());
|
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]
|
#[test]
|
||||||
fn tagged_unions_round_trip() {
|
fn tagged_unions_round_trip() {
|
||||||
round_trip(SpannerKind::Generic);
|
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