Skip to main content

music21_rs/tuningsystem/
adaptive.rs

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