Skip to main content

music21_rs/tuningsystem/
adaptive.rs

1use crate::{FloatType, TuningSystem};
2
3/// Adaptive tuning systems whose note frequencies depend on harmonic context.
4#[derive(Clone, Copy, Debug, PartialEq)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6#[must_use]
7pub enum AdaptiveTuningSystem {
8    /// A recursive tuning system:
9    ///
10    /// ```text
11    /// frequency = base * root_tuning[context] * local_tuning[index]
12    /// ```
13    ///
14    /// `context` is the absolute root index, such as E above C.
15    /// `index` is the local interval above that root, such as a major third.
16    Recursive {
17        /// Tuning system used to place the chord root.
18        root_tuning_system: TuningSystem,
19
20        /// Tuning system used inside the chord root.
21        local_tuning_system: TuningSystem,
22    },
23}
24
25/// Recursive just intonation:
26///
27/// `frequency = C_base * FiveLimit[root] * FiveLimit[local_degree]`
28///
29/// Both tables are the classical five-limit scale, so a fourth above the
30/// root is 4/3 and a minor sixth 8/5, and a triad is just in every
31/// inversion: the table's fourth, minor sixth and major sixth are the
32/// octave complements of its fifth, major third and minor third.
33pub const RECURSIVE_JI: AdaptiveTuningSystem = AdaptiveTuningSystem::Recursive {
34    root_tuning_system: TuningSystem::FiveLimit,
35    local_tuning_system: TuningSystem::FiveLimit,
36};
37
38impl AdaptiveTuningSystem {
39    /// Returns the frequency in hertz for a local degree inside a harmonic context.
40    ///
41    /// For example, in recursive JI:
42    ///
43    /// ```text
44    /// context = 4  // E above C
45    /// index   = 4  // major third above E
46    ///
47    /// frequency = C * 5/4 * 5/4
48    ///           = C * 25/16
49    /// ```
50    pub fn frequency_at(self, context: FloatType, index: FloatType) -> FloatType {
51        match self {
52            Self::Recursive {
53                root_tuning_system,
54                local_tuning_system,
55            } => {
56                let root_ratio = root_tuning_system.ratio_at(context);
57                let local_frequency = local_tuning_system.frequency_at(index);
58
59                root_ratio * local_frequency
60            }
61        }
62    }
63
64    /// Returns cents offset against equal temperament for the resulting absolute pitch.
65    ///
66    /// This assumes `index` is a local interval above `context`, so the equal-tempered
67    /// comparison pitch is `context + index`.
68    pub fn cents_at(self, context: FloatType, index: FloatType) -> FloatType {
69        let reference_frequency = match self {
70            Self::Recursive {
71                root_tuning_system, ..
72            } => TuningSystem::edo(root_tuning_system.degrees_per_period())
73                .frequency_at(context + index),
74        };
75
76        let comparison_frequency = self.frequency_at(context, index);
77
78        1200.0 * (comparison_frequency / reference_frequency).log2()
79    }
80
81    /// Returns cents offset against a fixed tuning table for the same absolute pitch.
82    ///
83    /// Useful for comparing recursive JI against fixed-C JI.
84    pub fn cents_vs_fixed_at(
85        self,
86        fixed_tuning_system: TuningSystem,
87        context: FloatType,
88        index: FloatType,
89    ) -> FloatType {
90        let fixed_frequency = fixed_tuning_system.frequency_at(context + index);
91        let comparison_frequency = self.frequency_at(context, index);
92
93        1200.0 * (comparison_frequency / fixed_frequency).log2()
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::tuningsystem::{AnyTuningSystem, CN1};
101
102    /// How near two frequencies must be to count as the same pitch.
103    const CLOSE: FloatType = 1e-9;
104
105    /// The whole point of an adaptive system, and the example its own
106    /// documentation gives: a third above a third is two *just* thirds, not
107    /// whatever the fixed table happens to hold eight steps up.
108    #[test]
109    fn a_third_above_a_third_is_two_just_thirds() {
110        // Context 4 is E above C, index 4 a major third above that E.
111        let stacked = RECURSIVE_JI.frequency_at(4.0, 4.0);
112        assert!(
113            (stacked - CN1 * 25.0 / 16.0).abs() < CLOSE,
114            "5/4 of 5/4 is 25/16, got {stacked}"
115        );
116
117        // The fixed table's own eighth degree is 8/5 — a different pitch,
118        // forty-one cents up. That difference is what makes the system
119        // adaptive.
120        let fixed = RECURSIVE_JI.frequency_at(0.0, 8.0);
121        assert!((fixed - CN1 * 8.0 / 5.0).abs() < CLOSE, "got {fixed}");
122        assert!(
123            (1200.0 * (fixed / stacked).log2() - 41.059).abs() < 1e-3,
124            "the recursive and fixed readings of the same step should differ"
125        );
126    }
127
128    /// A triad is just whichever of its notes is the root: the table's
129    /// fourth and sixths are the octave complements of its fifth and thirds,
130    /// so the same three pitches come out of any inversion. The harmonic
131    /// series this once recursed on has no such property — its fourth is
132    /// 21/16 and its minor sixth 13/8 — which made a second-inversion chord
133    /// sound sour and a fourth above the bass a quarter tone sharp.
134    #[test]
135    fn a_triad_is_just_from_any_of_its_notes() {
136        let just =
137            |context: FloatType, index: FloatType| RECURSIVE_JI.frequency_at(context, index) / CN1;
138        // C E G, then E G C above it, then G C E above that.
139        assert!((just(0.0, 4.0) - 5.0 / 4.0).abs() < CLOSE);
140        assert!((just(0.0, 7.0) - 3.0 / 2.0).abs() < CLOSE);
141        assert!(
142            (just(4.0, 3.0) - 3.0 / 2.0).abs() < CLOSE,
143            "{}",
144            just(4.0, 3.0)
145        );
146        assert!((just(4.0, 8.0) - 2.0).abs() < CLOSE, "{}", just(4.0, 8.0));
147        assert!((just(7.0, 5.0) - 2.0).abs() < CLOSE, "{}", just(7.0, 5.0));
148        assert!(
149            (just(7.0, 9.0) - 5.0 / 2.0).abs() < CLOSE,
150            "{}",
151            just(7.0, 9.0)
152        );
153        // And the minor triad C Eb G from its third and its fifth.
154        assert!((just(3.0, 4.0) - 3.0 / 2.0).abs() < CLOSE);
155        assert!((just(3.0, 9.0) - 2.0).abs() < CLOSE);
156        assert!((just(7.0, 8.0) - 12.0 / 5.0).abs() < CLOSE);
157    }
158
159    /// Nothing recurses out of the tonic, so the system answers the fixed
160    /// table there. Anything else would make the tonic special.
161    #[test]
162    fn a_context_of_nothing_is_the_fixed_table() {
163        for index in [0.0, 4.0, 7.0, 11.0] {
164            let adaptive = RECURSIVE_JI.frequency_at(0.0, index);
165            let fixed = TuningSystem::FiveLimit.frequency_at(index);
166            assert!(
167                (adaptive - fixed).abs() < CLOSE,
168                "at index {index}: {adaptive} against {fixed}"
169            );
170            // And measured against the very table it came from, it is nought
171            // cents away — exactly, not nearly.
172            assert_eq!(
173                RECURSIVE_JI.cents_vs_fixed_at(TuningSystem::FiveLimit, 0.0, index),
174                0.0
175            );
176        }
177    }
178
179    /// Against equal temperament, which is what `cents_at` measures.
180    #[test]
181    fn cents_at_says_how_far_the_stack_lands_from_the_piano() {
182        // 25/16 is 772.63 cents; the piano's minor sixth is 800.
183        let stacked = RECURSIVE_JI.cents_at(4.0, 4.0);
184        assert!((stacked + 27.373).abs() < 1e-3, "{stacked}");
185
186        // A just fifth from the tonic is the familiar two cents sharp.
187        let fifth = RECURSIVE_JI.cents_at(0.0, 7.0);
188        assert!((fifth - 1.955).abs() < 1e-3, "{fifth}");
189
190        // The tonic is the tonic in any tuning.
191        assert!(RECURSIVE_JI.cents_at(0.0, 0.0).abs() < CLOSE);
192    }
193
194    /// Against a fixed table for the same written note, which is the
195    /// comparison the module exists to make.
196    #[test]
197    fn cents_vs_fixed_shows_what_recursing_costs() {
198        // Two just thirds land 41 cents under five-limit's own minor sixth,
199        // which is 8/5. That gap is why a fixed table cannot spell this.
200        let against_five_limit = RECURSIVE_JI.cents_vs_fixed_at(TuningSystem::FiveLimit, 4.0, 4.0);
201        assert!(
202            (against_five_limit + 41.059).abs() < 1e-3,
203            "{against_five_limit}"
204        );
205
206        // Measured against equal temperament, `cents_vs_fixed_at` agrees with
207        // `cents_at`, since that is the table `cents_at` compares against.
208        let twelve = TuningSystem::EQUAL_TEMPERAMENT;
209        for (context, index) in [(4.0, 4.0), (0.0, 7.0), (7.0, 3.0)] {
210            assert!(
211                (RECURSIVE_JI.cents_vs_fixed_at(twelve, context, index)
212                    - RECURSIVE_JI.cents_at(context, index))
213                .abs()
214                    < 1e-9,
215                "at {context}, {index}"
216            );
217        }
218    }
219
220    /// A system need not recurse on the same table both ways.
221    #[test]
222    fn the_root_and_the_local_table_are_separate() {
223        let mixed = AdaptiveTuningSystem::Recursive {
224            root_tuning_system: TuningSystem::PythagoreanTuning,
225            local_tuning_system: TuningSystem::CarlosHarmonic,
226        };
227        // A Pythagorean fifth to place the root, a harmonic-series minor
228        // seventh above it, which five-limit does not hold.
229        let sounded = mixed.frequency_at(7.0, 10.0);
230        assert!(
231            (sounded - CN1 * (3.0 / 2.0) * (7.0 / 4.0)).abs() < CLOSE,
232            "{sounded}"
233        );
234        assert_ne!(mixed, RECURSIVE_JI);
235    }
236
237    /// The wrapper that lets a caller hold either kind of system.
238    #[test]
239    fn an_adaptive_system_answers_through_the_wrapper_too() {
240        let any: AnyTuningSystem = RECURSIVE_JI.into();
241        assert!(any.is_adaptive());
242        assert!((any.frequency_at(4.0, 4.0) - RECURSIVE_JI.frequency_at(4.0, 4.0)).abs() < CLOSE);
243        assert!((any.cents_at(4.0, 4.0) - RECURSIVE_JI.cents_at(4.0, 4.0)).abs() < 1e-9);
244
245        // A fixed system ignores the context it is handed; an adaptive one
246        // does not, which is the whole distinction the wrapper draws.
247        let fixed: AnyTuningSystem = TuningSystem::FiveLimit.into();
248        assert!(!fixed.is_adaptive());
249        assert!((fixed.frequency_at(4.0, 4.0) - fixed.frequency_at(0.0, 4.0)).abs() < CLOSE);
250        assert!((any.frequency_at(4.0, 4.0) - any.frequency_at(0.0, 4.0)).abs() > 0.4);
251    }
252}