Skip to main content

music21_rs/tuningsystem/
temperament.rs

1//! Regular temperaments — a period, some generators, and what they mean.
2//!
3//! A regular temperament is a decision to stop telling two intervals apart.
4//! Meantone decides that four fifths and a major third are the same note,
5//! which is to say it *tempers out* the syntonic comma, and everything else
6//! about meantone follows: the fifth has to shrink to about 697 cents, every
7//! 5-limit interval is then some number of those fifths and octaves, and the
8//! scales that come out are the pentatonic, the diatonic and the chromatic.
9//!
10//! What a temperament is, concretely, is a *mapping*: how many periods and how
11//! many of each generator every prime is worth. The wiki writes meantone's as
12//! `1; 1 4 10` — one period to the octave, and the fifth reached in one
13//! generator, the third in four, the harmonic seventh in ten.
14//! [`Temperament::from_mapping`] takes exactly that, and works the period row
15//! out for itself.
16//!
17//! Two things here are more general than the usual account, because the data
18//! is. A temperament need not have **one** generator: marvel has two, so its
19//! mapping is two rows and [`Temperament::from_mapping_rows`] takes them.
20//! And a temperament need not repeat at the **octave**: a subgroup written
21//! `3.5.7` has no 2 in it at all, and its period divides a tritave instead.
22//! The interval a temperament repeats at is its *equave*, and the octave is
23//! only the usual one.
24//!
25//! ```
26//! use music21_rs::tuningsystem::{Monzo, Temperament};
27//!
28//! // Meantone: one period to the octave, generator a fifth of 696.7 cents.
29//! let meantone = Temperament::from_mapping(1, &[1, 4, 10], 696.7, &[2, 3, 5, 7])?;
30//! assert!(meantone.tempers_out(&Monzo::from_ratio(81, 80)?));
31//! assert_eq!(meantone.pattern(7)?.to_string(), "5L 2s");
32//! # Ok::<(), music21_rs::Error>(())
33//! ```
34
35use crate::defaults::{FloatType, IntegerType, UnsignedIntegerType};
36use crate::error::{Error, Result};
37use crate::tuningsystem::monzo::{Monzo, PRIMES};
38use crate::tuningsystem::mos::{Mos, MosScale, OCTAVE_CENTS};
39
40use std::fmt::{Display, Formatter};
41
42/// How far a derived period count may sit from a whole number.
43///
44/// Measured rather than guessed: across the 95 mappings the Xenharmonic Wiki
45/// publishes, the worst any prime lands from a whole number of periods is
46/// 0.113, and all but a handful are inside 0.06. A fifth of a step therefore
47/// leaves most of a step of headroom over real data while still refusing a
48/// generator that does not go with its mapping.
49///
50/// It is deliberately not looser than that. [`Temperament::from_published`]
51/// tries two readings, which doubles the chance of accepting a pairing that is
52/// simply wrong, and a wrong pairing is not always far out: meantone's mapping
53/// against porcupine's generator lands 0.279 away when negated, so a tolerance
54/// of a third of a step would wave it through.
55const MAPPING_TOLERANCE: FloatType = 0.2;
56
57/// A regular temperament: a period, some generators, and a mapping onto primes.
58///
59/// The mapping says how many periods and how many of each generator every
60/// prime of the subgroup is worth. Everything else — how wide a ratio comes
61/// out, whether a comma vanishes, which scales the generator makes — is read
62/// off that.
63///
64/// The number of rows is the temperament's [`Temperament::rank`]: one for the
65/// period plus one for each generator. Rank 2 is the common case and the only
66/// one where moments of symmetry mean anything, since those come of stacking a
67/// single generator.
68///
69/// ```
70/// use music21_rs::tuningsystem::{Monzo, Temperament};
71///
72/// // Porcupine, from its own infobox: three generators to a fourth.
73/// let porcupine = Temperament::from_mapping(1, &[-3, -5], 163.6, &[2, 3, 5])?;
74/// assert!(porcupine.tempers_out(&Monzo::from_ratio(250, 243)?));
75/// assert_eq!(porcupine.pattern(7)?.to_string(), "1L 6s");
76/// assert_eq!(porcupine.rank(), 2);
77/// # Ok::<(), music21_rs::Error>(())
78/// ```
79#[derive(Clone, Debug, PartialEq)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81#[must_use]
82pub struct Temperament {
83    primes: Vec<IntegerType>,
84    periods: Vec<IntegerType>,
85    generators: Vec<Vec<IntegerType>>,
86    equave_cents: FloatType,
87    period_cents: FloatType,
88    generator_cents: Vec<FloatType>,
89}
90
91impl Temperament {
92    /// Builds a rank-2 temperament from the mapping line the literature publishes.
93    ///
94    /// `periods_per_equave` and `generator_steps` are the two halves of the
95    /// wiki's `1; 1 4 10`: one period to the octave, then how many generators
96    /// each prime *after the first* is worth. `primes` is the subgroup, whose
97    /// first prime is the equave — usually 2, but `3.5.7` repeats at a tritave.
98    /// A subgroup need not be every prime up to its largest: mavila is written
99    /// over `2.3.5.11`, with no 7 in it.
100    pub fn from_mapping(
101        periods_per_equave: UnsignedIntegerType,
102        generator_steps: &[IntegerType],
103        generator_cents: FloatType,
104        primes: &[IntegerType],
105    ) -> Result<Self> {
106        Self::from_mapping_rows(
107            periods_per_equave,
108            &[generator_steps],
109            &[generator_cents],
110            primes,
111        )
112    }
113
114    /// Builds a temperament of any rank from one mapping row per generator.
115    ///
116    /// Each row says how many of *that* generator each prime after the equave
117    /// is worth, so every row is one shorter than the subgroup. Marvel's two
118    /// rows over `2.3.5.7.11` are `1 0 2 -1` and `0 1 2 -3`, tuned to a fifth
119    /// and a major third.
120    ///
121    /// The period row is not asked for, because the generators already decide
122    /// it: whatever they leave over has to be made up in whole periods. Errors
123    /// when it cannot be — when some prime needs a fraction of a period, which
124    /// means the generators and the mapping do not go together.
125    pub fn from_mapping_rows(
126        periods_per_equave: UnsignedIntegerType,
127        generator_rows: &[&[IntegerType]],
128        generator_cents: &[FloatType],
129        primes: &[IntegerType],
130    ) -> Result<Self> {
131        if periods_per_equave == 0 {
132            return Err(Error::TuningSystem(
133                "a temperament needs at least one period to the equave".to_owned(),
134            ));
135        }
136        let Some(&equave) = primes.first() else {
137            return Err(Error::TuningSystem(
138                "a subgroup needs at least the prime it repeats at".to_owned(),
139            ));
140        };
141        if let Some(&unknown) = primes.iter().find(|prime| !PRIMES.contains(prime)) {
142            return Err(Error::TuningSystem(format!(
143                "{unknown} is not a prime this module carries"
144            )));
145        }
146        if primes.windows(2).any(|pair| pair[0] >= pair[1]) {
147            return Err(Error::TuningSystem(
148                "a subgroup is written lowest prime first, with no prime twice".to_owned(),
149            ));
150        }
151        if generator_rows.len() != generator_cents.len() {
152            return Err(Error::TuningSystem(format!(
153                "{} mapping rows do not go with {} generator tunings",
154                generator_rows.len(),
155                generator_cents.len()
156            )));
157        }
158        for row in generator_rows {
159            if row.len() + 1 != primes.len() {
160                return Err(Error::TuningSystem(format!(
161                    "a mapping row of {} steps does not map a subgroup of {} primes",
162                    row.len(),
163                    primes.len()
164                )));
165            }
166        }
167        if let Some(bad) = generator_cents
168            .iter()
169            .find(|cents| !cents.is_finite() || **cents == 0.0)
170        {
171            return Err(Error::TuningSystem(format!(
172                "{bad} is not a width a generator can have"
173            )));
174        }
175
176        let equave_cents = OCTAVE_CENTS * FloatType::from(equave).log2();
177        let period_cents = equave_cents / FloatType::from(periods_per_equave);
178        let mut periods = vec![IntegerType::try_from(periods_per_equave).map_err(|_| {
179            Error::TuningSystem(format!(
180                "{periods_per_equave} periods is more than a mapping holds"
181            ))
182        })?];
183        for (place, &prime) in primes.iter().enumerate().skip(1) {
184            let just = OCTAVE_CENTS * FloatType::from(prime).log2();
185            let left_over = generator_rows
186                .iter()
187                .zip(generator_cents)
188                .map(|(row, cents)| FloatType::from(row[place - 1]) * cents)
189                .fold(just, |left, taken| left - taken);
190            let count = left_over / period_cents;
191            if (count - count.round()).abs() > MAPPING_TOLERANCE {
192                return Err(Error::TuningSystem(format!(
193                    "the generators leave prime {prime} {count:.3} periods away, \
194                     which is no whole mapping"
195                )));
196            }
197            periods.push(count.round() as IntegerType);
198        }
199
200        Ok(Self {
201            primes: primes.to_vec(),
202            periods,
203            // The equave takes none of any generator: it is what the periods
204            // divide, so its column is the period count and nothing else.
205            generators: generator_rows
206                .iter()
207                .map(|row| std::iter::once(0).chain(row.iter().copied()).collect())
208                .collect(),
209            equave_cents,
210            period_cents,
211            generator_cents: generator_cents.to_vec(),
212        })
213    }
214
215    /// Builds a rank-2 temperament from a published mapping, taking the
216    /// generator either way round.
217    ///
218    /// A generator and its inverse inside the period reach the same notes — a
219    /// fifth up and a fourth down are the same chain — so a mapping written
220    /// for one of them and a tuning quoted for the other describe one
221    /// temperament and only *look* inconsistent. Sources do mix the two:
222    /// the Xenharmonic Wiki's `Mabilic and trismegistus` gives the mapping
223    /// `1; -15 -3 5`, which wants a generator of 672.8 cents, beside a tuning
224    /// of 526.7, which wants `1; 15 3 -5`.
225    ///
226    /// So this tries the mapping as written, and failing that tries it negated
227    /// against the inverse generator. [`Temperament::from_mapping`] is the
228    /// strict reading and stays strict; reach for that when the caller knows
229    /// which way round it meant. The error reported on failure is the one from
230    /// the mapping as written, since that is what the caller handed over.
231    pub fn from_published(
232        periods_per_equave: UnsignedIntegerType,
233        generator_steps: &[IntegerType],
234        generator_cents: FloatType,
235        primes: &[IntegerType],
236    ) -> Result<Self> {
237        let as_written = match Self::from_mapping(
238            periods_per_equave,
239            generator_steps,
240            generator_cents,
241            primes,
242        ) {
243            Ok(temperament) => return Ok(temperament),
244            Err(as_written) => as_written,
245        };
246        // Exactly one of the two is turned round. Negating the mapping *and*
247        // inverting the generator is the identity — it describes the same
248        // chain read the same way, and fails identically — so this negates the
249        // mapping and keeps the tuning as published. That is also the reading
250        // the prose of such a page usually agrees with, since the tuning is
251        // what its interval table is written in.
252        let flipped: Vec<IntegerType> = generator_steps.iter().map(|step| -step).collect();
253        Self::from_mapping(periods_per_equave, &flipped, generator_cents, primes)
254            .map_err(|_| as_written)
255    }
256
257    /// How many rows the mapping has: one for the period, one per generator.
258    ///
259    /// Two is the usual case, and the only one where a moment of symmetry
260    /// means anything.
261    #[must_use]
262    pub fn rank(&self) -> usize {
263        1 + self.generators.len()
264    }
265
266    /// The primes the temperament is written over, its subgroup.
267    #[must_use]
268    pub fn primes(&self) -> &[IntegerType] {
269        &self.primes
270    }
271
272    /// The prime the temperament repeats at — 2 for an octave, 3 for a tritave.
273    #[must_use]
274    pub fn equave(&self) -> IntegerType {
275        self.primes.first().copied().unwrap_or(2)
276    }
277
278    /// How wide the equave is, in cents.
279    #[must_use]
280    pub fn equave_cents(&self) -> FloatType {
281        self.equave_cents
282    }
283
284    /// Whether the temperament repeats at the octave, as most do.
285    #[must_use]
286    pub fn repeats_at_the_octave(&self) -> bool {
287        self.equave() == 2
288    }
289
290    /// How many periods each prime is worth, in the subgroup's own order.
291    #[must_use]
292    pub fn period_map(&self) -> &[IntegerType] {
293        &self.periods
294    }
295
296    /// How many of each generator every prime is worth, one row per generator.
297    #[must_use]
298    pub fn generator_map(&self) -> &[Vec<IntegerType>] {
299        &self.generators
300    }
301
302    /// How many periods there are to an equave.
303    #[must_use]
304    pub fn periods_per_equave(&self) -> IntegerType {
305        self.periods.first().copied().unwrap_or(1)
306    }
307
308    /// The period, in cents.
309    #[must_use]
310    pub fn period_cents(&self) -> FloatType {
311        self.period_cents
312    }
313
314    /// Each generator's width, in cents.
315    #[must_use]
316    pub fn generator_cents(&self) -> &[FloatType] {
317        &self.generator_cents
318    }
319
320    /// How many periods and how many of each generator `interval` comes to.
321    ///
322    /// The answer is one number per mapping row, the period's first. Errors on
323    /// an interval using a prime outside the subgroup, since the temperament
324    /// has said nothing about it — reading that as nought steps would be an
325    /// answer it has not got.
326    pub fn map(&self, interval: &Monzo) -> Result<Vec<IntegerType>> {
327        let mut steps = vec![0; self.rank()];
328        for (index, &exponent) in interval.exponents().iter().enumerate() {
329            if exponent == 0 {
330                continue;
331            }
332            let prime = PRIMES[index];
333            let place = self
334                .primes
335                .iter()
336                .position(|&known| known == prime)
337                .ok_or_else(|| {
338                    Error::TuningSystem(format!(
339                        "{prime} is outside the subgroup {}",
340                        self.subgroup_name()
341                    ))
342                })?;
343            steps[0] += exponent * self.periods[place];
344            for (row, generator) in self.generators.iter().enumerate() {
345                steps[row + 1] += exponent * generator[place];
346            }
347        }
348        Ok(steps)
349    }
350
351    /// How wide `interval` comes out once tempered, in cents.
352    pub fn cents(&self, interval: &Monzo) -> Result<FloatType> {
353        let steps = self.map(interval)?;
354        Ok(FloatType::from(steps[0]) * self.period_cents
355            + steps[1..]
356                .iter()
357                .zip(&self.generator_cents)
358                .map(|(count, cents)| FloatType::from(*count) * cents)
359                .sum::<FloatType>())
360    }
361
362    /// How far off just `interval` sounds here, in cents; positive is sharp.
363    pub fn error_cents(&self, interval: &Monzo) -> Result<FloatType> {
364        Ok(self.cents(interval)? - interval.cents())
365    }
366
367    /// Whether `comma` vanishes — no periods and no generators at all.
368    ///
369    /// A comma using a prime the subgroup has not got is not tempered out; the
370    /// temperament has said nothing about it either way.
371    #[must_use]
372    pub fn tempers_out(&self, comma: &Monzo) -> bool {
373        self.map(comma)
374            .is_ok_and(|steps| steps.iter().all(|count| *count == 0))
375    }
376
377    /// The single generator, for a rank-2 temperament.
378    ///
379    /// Errors for any other rank: a moment of symmetry comes of stacking *one*
380    /// interval, so there is nothing to stack when there are two.
381    fn sole_generator(&self) -> Result<FloatType> {
382        match self.generator_cents.as_slice() {
383            [only] => Ok(*only),
384            other => Err(Error::TuningSystem(format!(
385                "a temperament with {} generators makes no moment of symmetry; \
386                 that needs exactly one",
387                other.len()
388            ))),
389        }
390    }
391
392    /// The scale of `notes` notes to an equave that this generator makes.
393    ///
394    /// The count is notes per **equave**, the way the literature counts them.
395    ///
396    /// A temperament with more than one period to the equave repeats its
397    /// pattern in each, so the scale this returns covers one period and the
398    /// count has to divide by [`Temperament::periods_per_equave`] — augmented
399    /// has three periods, and its `3L 3s` is one large and one small step in
400    /// each of them. Errors on a count that does not divide, on a rank other
401    /// than 2, and where [`MosScale::new`] does.
402    pub fn mos(&self, notes: UnsignedIntegerType) -> Result<MosScale> {
403        let generator = self.sole_generator()?;
404        let periods = self.periods_per_equave().unsigned_abs();
405        if periods == 0 || !notes.is_multiple_of(periods) {
406            return Err(Error::TuningSystem(format!(
407                "{notes} notes do not divide into {periods} periods to the equave"
408            )));
409        }
410        MosScale::new(generator, self.period_cents, notes / periods)
411    }
412
413    /// The step pattern of `notes` notes to the equave.
414    ///
415    /// This is what a temperament's published MOS list names. It is the
416    /// pattern of one period repeated in each, so augmented's two-note period
417    /// over three periods is `3L 3s` and not `1L 1s`.
418    pub fn pattern(&self, notes: UnsignedIntegerType) -> Result<Mos> {
419        let period = self.mos(notes)?.pattern()?;
420        let periods = self.periods_per_equave().unsigned_abs();
421        Mos::new(period.large() * periods, period.small() * periods)
422    }
423
424    /// Every note count to the equave, up to `most`, that makes a moment of symmetry.
425    ///
426    /// This is the temperament's list of MOS scales, and it starts lower than a
427    /// published one does: a two- or three-note moment is real but nobody
428    /// bothers writing it down.
429    pub fn moments(&self, most: UnsignedIntegerType) -> Result<Vec<UnsignedIntegerType>> {
430        let generator = self.sole_generator()?;
431        let periods = self.periods_per_equave().unsigned_abs();
432        Ok(crate::tuningsystem::mos::moment_of_symmetry_sizes(
433            generator,
434            self.period_cents,
435            most / periods.max(1),
436        )?
437        .into_iter()
438        .map(|notes| notes * periods)
439        .collect())
440    }
441
442    /// The subgroup written the way the literature writes it, `2.3.5.11`.
443    #[must_use]
444    pub fn subgroup_name(&self) -> String {
445        self.primes
446            .iter()
447            .map(IntegerType::to_string)
448            .collect::<Vec<_>>()
449            .join(".")
450    }
451}
452
453impl Display for Temperament {
454    /// Writes the subgroup, the mapping rows and the generators, as an infobox does.
455    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
456        write!(f, "{} {}", self.subgroup_name(), self.periods_per_equave())?;
457        for row in &self.generators {
458            write!(f, ";")?;
459            for steps in row.iter().skip(1) {
460                write!(f, " {steps}")?;
461            }
462        }
463        write!(f, " (generator")?;
464        if self.generator_cents.len() > 1 {
465            write!(f, "s")?;
466        }
467        for (position, cents) in self.generator_cents.iter().enumerate() {
468            write!(f, "{} {cents:.1}¢", if position == 0 { "" } else { "," })?;
469        }
470        write!(f, ")")
471    }
472}
473
474impl crate::tuningsystem::NamedTemperament {
475    /// Builds the temperament this entry describes.
476    ///
477    /// Errors where [`Temperament::from_mapping_rows`] does, which for a
478    /// collected entry means the wiki's generators and its mapping disagree —
479    /// a mistranscription, or a page that has changed under us.
480    pub fn temperament(&self) -> Result<Temperament> {
481        // A rank-2 entry is read either way round, because a published mapping
482        // and a published generator are not always written for the same
483        // direction of the same chain. Above rank 2 there is no single
484        // generator to invert, and every such entry here reads as written.
485        match self.generator_rows {
486            [only] => Temperament::from_published(
487                self.periods_per_equave,
488                only,
489                self.generator_cents[0],
490                self.subgroup,
491            ),
492            rows => Temperament::from_mapping_rows(
493                self.periods_per_equave,
494                rows,
495                self.generator_cents,
496                self.subgroup,
497            ),
498        }
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    /// The mapping, generator and MOS list of each temperament's own infobox on
507    /// the Xenharmonic Wiki, read back off the crate.
508    ///
509    /// The MOS lists are the ones the wiki publishes. Ours starts lower — a
510    /// two-note moment is real and nobody writes it down — so the published
511    /// list is checked to be the tail of what we find.
512    #[test]
513    fn published_temperaments_read_back_the_way_they_are_published() {
514        struct Published {
515            name: &'static str,
516            periods_per_equave: UnsignedIntegerType,
517            generator_steps: &'static [IntegerType],
518            generator_cents: FloatType,
519            primes: &'static [IntegerType],
520            comma: (IntegerType, IntegerType),
521            period_map: &'static [IntegerType],
522            moments: &'static [(UnsignedIntegerType, &'static str)],
523        }
524
525        for published in [
526            Published {
527                name: "meantone",
528                periods_per_equave: 1,
529                generator_steps: &[1, 4, 10],
530                generator_cents: 696.7,
531                primes: &[2, 3, 5, 7],
532                comma: (81, 80),
533                period_map: &[1, 1, 0, -3],
534                moments: &[(5, "2L 3s"), (7, "5L 2s"), (12, "7L 5s"), (19, "12L 7s")],
535            },
536            Published {
537                name: "mavila",
538                periods_per_equave: 1,
539                generator_steps: &[1, -3, -1],
540                generator_cents: 679.0,
541                primes: &[2, 3, 5, 11],
542                comma: (135, 128),
543                period_map: &[1, 1, 4, 4],
544                moments: &[(5, "2L 3s"), (7, "2L 5s"), (9, "7L 2s")],
545            },
546            Published {
547                name: "porcupine",
548                periods_per_equave: 1,
549                generator_steps: &[-3, -5, 6, -4],
550                generator_cents: 163.6,
551                primes: &[2, 3, 5, 7, 11],
552                comma: (250, 243),
553                period_map: &[1, 2, 3, 2, 4],
554                moments: &[(7, "1L 6s"), (8, "7L 1s"), (15, "7L 8s")],
555            },
556        ] {
557            let temperament = Temperament::from_mapping(
558                published.periods_per_equave,
559                published.generator_steps,
560                published.generator_cents,
561                published.primes,
562            )
563            .unwrap_or_else(|error| panic!("{} did not build: {error}", published.name));
564
565            // The period row is derived, not given, so it is worth checking.
566            assert_eq!(
567                temperament.period_map(),
568                published.period_map,
569                "{}'s period map",
570                published.name
571            );
572
573            let comma = Monzo::from_ratio(published.comma.0, published.comma.1)
574                .expect("a factorable comma");
575            assert!(
576                temperament.tempers_out(&comma),
577                "{} should temper out {}/{}",
578                published.name,
579                published.comma.0,
580                published.comma.1
581            );
582
583            for &(notes, pattern) in published.moments {
584                assert_eq!(
585                    temperament.pattern(notes).expect("a moment").to_string(),
586                    pattern,
587                    "{} at {notes} notes",
588                    published.name
589                );
590            }
591
592            // Every published moment is one we find, in the same order.
593            let largest = published.moments.last().expect("a published list").0;
594            let found = temperament.moments(largest).expect("a generator");
595            let published_counts: Vec<_> =
596                published.moments.iter().map(|&(notes, _)| notes).collect();
597            for notes in &published_counts {
598                assert!(
599                    found.contains(notes),
600                    "{} should find its published {notes}-note moment among {found:?}",
601                    published.name
602                );
603            }
604        }
605    }
606
607    /// Every temperament collected from the wiki, checked against what the
608    /// wiki says about it.
609    ///
610    /// The collected table is not trusted: each entry has to build, each comma
611    /// the wiki lists has to actually vanish under the mapping, and each MOS
612    /// pattern it lists has to actually come out of the generator. A number
613    /// mistyped on the way in fails here rather than shipping.
614    #[test]
615    fn wiki_temperaments_build_and_agree_with_the_wiki() {
616        use crate::tuningsystem::{Mos, WIKI_TEMPERAMENTS};
617
618        // A temperament's published MOS list describes the whole range of
619        // tunings it covers, while its generator is one chosen optimum. Which
620        // step is the large one flips as the generator crosses an equal
621        // temperament, so at a boundary the two halves of one infobox can
622        // disagree about which is which — the notes and the count are the
623        // same either way. Two of the 277 published patterns are on the far
624        // side of such a boundary from their own generator: catakleismic's
625        // 34-note scale flips at 34edo (317.647 cents) and its generator is
626        // 316.7, and tritikleismic's 12-note scale likewise. Pinned by name so
627        // a third one fails rather than being waved through.
628        const INVERTED: [(&str, &str); 2] =
629            [("CATAKLEISMIC", "15L 19s"), ("TRITIKLEISMIC", "9L 3s")];
630
631        assert!(!WIKI_TEMPERAMENTS.is_empty(), "the table is empty");
632        let mut commas = 0;
633        let mut moments = 0;
634        for entry in &WIKI_TEMPERAMENTS {
635            let temperament = entry
636                .temperament()
637                .unwrap_or_else(|error| panic!("{} did not build: {error}", entry.name));
638
639            for comma in entry.commas {
640                let (numerator, denominator) = comma
641                    .split_once('/')
642                    .unwrap_or_else(|| panic!("{}: {comma} is not a ratio", entry.name));
643                let Ok(monzo) = Monzo::from_ratio(
644                    numerator.parse().expect("a whole numerator"),
645                    denominator.parse().expect("a whole denominator"),
646                ) else {
647                    // A comma past the 97-limit is not one this crate factors;
648                    // that is a limit of `PRIMES`, not a bad entry.
649                    continue;
650                };
651                assert!(
652                    temperament.tempers_out(&monzo),
653                    "{} should temper out {comma}, which the wiki lists for it",
654                    entry.name
655                );
656                commas += 1;
657            }
658
659            for pattern in entry.moments {
660                let published: Mos = pattern
661                    .parse()
662                    .unwrap_or_else(|_| panic!("{}: {pattern} is not a pattern", entry.name));
663                let made = temperament
664                    .pattern(published.notes())
665                    .unwrap_or_else(|error| panic!("{} at {pattern}: {error}", entry.name));
666                if INVERTED.contains(&(entry.name, pattern)) {
667                    assert_eq!(
668                        made,
669                        published.inverted(),
670                        "{} at {pattern} is pinned as inverted and no longer is;                          drop it from INVERTED",
671                        entry.name
672                    );
673                } else {
674                    assert_eq!(
675                        made, published,
676                        "{} should make {pattern}, which the wiki lists for it",
677                        entry.name
678                    );
679                }
680                moments += 1;
681            }
682        }
683        // Guard against the checks quietly becoming vacuous.
684        assert!(commas > 100, "only {commas} commas were checked");
685        assert!(moments > 200, "only {moments} moments were checked");
686
687        // The two shapes that took generalizing to carry at all have to still
688        // be in the table, or a regression could drop them unnoticed.
689        let rank_three = WIKI_TEMPERAMENTS.iter().filter(|e| e.rank() == 3).count();
690        let non_octave = WIKI_TEMPERAMENTS
691            .iter()
692            .filter(|entry| entry.equave() != 2)
693            .count();
694        assert_eq!(rank_three, 13, "rank-3 temperaments carried");
695        assert_eq!(non_octave, 5, "temperaments repeating at something else");
696        assert_eq!(WIKI_TEMPERAMENTS.len(), 95, "every infobox on the wiki");
697        assert!(
698            crate::tuningsystem::UNMODELLED_TEMPERAMENTS.is_empty(),
699            "nothing is left out today; if something is, say why in the TOML"
700        );
701    }
702
703    /// A published mapping and a published tuning are not always written for
704    /// the same end of the same chain.
705    #[test]
706    fn a_mapping_written_for_the_other_end_of_the_chain_still_reads() {
707        // The Xenharmonic Wiki's `Mabilic and trismegistus`, verbatim: the
708        // mapping wants a generator of about 672.8 cents, and the tuning
709        // beside it is 526.7, which wants the mapping negated. Strictly, that
710        // does not resolve.
711        let steps = [-15, -3, 5];
712        let subgroup = [2, 3, 5, 7];
713        assert!(Temperament::from_mapping(1, &steps, 526.7, &subgroup).is_err());
714
715        // Read either way round, it is a perfectly ordinary temperament.
716        let read = Temperament::from_published(1, &steps, 526.7, &subgroup)
717            .expect("a mapping written the other way round");
718        assert_eq!(read.rank(), 2);
719        assert_eq!(read.period_map(), [1, -5, 1, 5]);
720        // Trismegistus finds 3 at fifteen generators and 5 at three, which is
721        // what its page says of it.
722        assert_eq!(
723            read.map(&Monzo::from_ratio(3, 1).expect("the twelfth"))
724                .expect("in the subgroup"),
725            [-5, 15]
726        );
727        assert!(read.tempers_out(&Monzo::from_ratio(1029, 1024).expect("a comma")));
728        assert!(read.tempers_out(&Monzo::from_ratio(3125, 3072).expect("a comma")));
729
730        // Turning both round is the identity, not a second reading, so a
731        // mapping that is simply wrong stays wrong.
732        assert!(Temperament::from_published(1, &[1, 4, 10], 163.6, &subgroup).is_err());
733
734        // And a mapping that reads as written is not disturbed by the fallback.
735        let meantone =
736            Temperament::from_published(1, &[1, 4, 10], 696.7, &subgroup).expect("meantone");
737        assert_eq!(
738            meantone,
739            Temperament::from_mapping(1, &[1, 4, 10], 696.7, &subgroup).expect("meantone")
740        );
741    }
742
743    /// Marvel has two generators, which is what rank 3 means and what the
744    /// old rank-2-only `Temperament` could not hold.
745    #[test]
746    fn a_rank_three_temperament_maps_through_both_its_generators() {
747        // From marvel's own infobox: 1; 1 0 2 -1; 0 1 2 -3 over 2.3.5.7.11,
748        // tuned to a fifth of 700.6 cents and a major third of 383.5.
749        let marvel = Temperament::from_mapping_rows(
750            1,
751            &[&[1, 0, 2, -1], &[0, 1, 2, -3]],
752            &[700.6, 383.5],
753            &[2, 3, 5, 7, 11],
754        )
755        .expect("marvel");
756        assert_eq!(marvel.rank(), 3);
757        assert_eq!(marvel.period_map(), [1, 1, 2, 1, 5]);
758        // A fifth is one of the first generator and none of the second.
759        assert_eq!(
760            marvel
761                .map(&Monzo::from_ratio(3, 2).expect("the fifth"))
762                .expect("in the subgroup"),
763            [0, 1, 0]
764        );
765        // Marvel is the temperament that tempers out 225/224, and does.
766        assert!(marvel.tempers_out(&Monzo::from_ratio(225, 224).expect("the comma")));
767
768        // Two generators make no moment of symmetry: there is no single
769        // interval to stack, and saying so beats answering with one of them.
770        assert!(marvel.mos(7).is_err());
771        assert!(marvel.pattern(7).is_err());
772        assert!(marvel.moments(12).is_err());
773    }
774
775    /// A subgroup with no 2 in it repeats at something other than the octave,
776    /// and every count is taken to that equave.
777    #[test]
778    fn a_temperament_can_repeat_at_a_tritave() {
779        // Canopus, from its own infobox: 1; -5 -4 over 3.5.7, generator 7/5.
780        let canopus =
781            Temperament::from_mapping(1, &[-5, -4], 583.986, &[3, 5, 7]).expect("canopus");
782        assert_eq!(canopus.equave(), 3);
783        assert!(!canopus.repeats_at_the_octave());
784        assert!((canopus.equave_cents() - 1901.955).abs() < 1e-3);
785        assert!((canopus.period_cents() - 1901.955).abs() < 1e-3);
786        assert_eq!(canopus.period_map(), [1, 3, 3]);
787        assert_eq!(canopus.subgroup_name(), "3.5.7");
788
789        // It tempers out the comma its page names, and its MOS scales are
790        // counted to the tritave rather than to an octave — the wiki writes
791        // them `3L 1s <3/1>`.
792        assert!(canopus.tempers_out(&Monzo::from_ratio(16875, 16807).expect("the comma")));
793        assert_eq!(canopus.pattern(4).expect("a moment").to_string(), "3L 1s");
794
795        // The octave is not in its subgroup at all, so it has nothing to say
796        // about one.
797        assert!(
798            canopus
799                .map(&Monzo::from_ratio(2, 1).expect("the octave"))
800                .is_err()
801        );
802    }
803
804    #[test]
805    fn meantone_maps_the_intervals_it_is_named_for() {
806        let meantone =
807            Temperament::from_mapping(1, &[1, 4, 10], 696.7, &[2, 3, 5, 7]).expect("meantone");
808        let fifth = Monzo::from_ratio(3, 2).expect("the fifth");
809        let third = Monzo::from_ratio(5, 4).expect("the third");
810
811        // One generator up, one period down: the fifth is the generator.
812        assert_eq!(meantone.map(&fifth).expect("the fifth"), [0, 1]);
813        // Four fifths less two octaves is the major third.
814        assert_eq!(meantone.map(&third).expect("the third"), [-2, 4]);
815        assert!((meantone.cents(&fifth).expect("the fifth") - 696.7).abs() < 1e-9);
816
817        // Four fifths of 696.7 less two octaves is 386.8 cents, half a cent
818        // sharp of a just third — which is what the comma costs when it is
819        // paid off over four fifths instead of landing on one interval.
820        let error = meantone.error_cents(&third).expect("the third");
821        assert!((error - 0.486).abs() < 0.01, "{error}");
822
823        // The Pythagorean comma does not vanish in meantone; the syntonic does.
824        assert!(!meantone.tempers_out(&Monzo::from_ratio(531_441, 524_288).expect("a comma")));
825        assert!(meantone.tempers_out(&Monzo::from_ratio(81, 80).expect("a comma")));
826    }
827
828    #[test]
829    fn a_prime_outside_the_subgroup_has_no_answer_rather_than_a_wrong_one() {
830        // Mavila is written over 2.3.5.11, with no 7 in it.
831        let mavila =
832            Temperament::from_mapping(1, &[1, -3, -1], 679.0, &[2, 3, 5, 11]).expect("mavila");
833        assert_eq!(mavila.subgroup_name(), "2.3.5.11");
834        let septimal = Monzo::from_ratio(7, 4).expect("the harmonic seventh");
835        assert!(mavila.map(&septimal).is_err());
836        assert!(mavila.cents(&septimal).is_err());
837        // A comma it cannot see is not one it tempers out.
838        assert!(!mavila.tempers_out(&Monzo::from_ratio(64, 63).expect("a comma")));
839        // One it can see, it does.
840        assert!(mavila.tempers_out(&Monzo::from_ratio(135, 128).expect("a comma")));
841    }
842
843    #[test]
844    fn a_generator_that_does_not_go_with_its_mapping_is_refused() {
845        // Meantone's mapping with porcupine's generator maps nothing whole.
846        assert!(Temperament::from_mapping(1, &[1, 4, 10], 163.6, &[2, 3, 5, 7]).is_err());
847        // A subgroup has to hold primes this module carries, written up.
848        assert!(Temperament::from_mapping(1, &[1, 4], 696.7, &[2, 3, 9]).is_err());
849        assert!(Temperament::from_mapping(1, &[1, 4], 696.7, &[2, 5, 3]).is_err());
850        assert!(Temperament::from_mapping(1, &[1, 4], 696.7, &[2, 3, 3]).is_err());
851        assert!(Temperament::from_mapping(1, &[1, 4], 696.7, &[]).is_err());
852        // It need *not* start at 2, though: the first prime is the equave, and
853        // a subgroup with no 2 in it repeats at something else.
854        let tritave = Temperament::from_mapping(1, &[4, 10], 696.7, &[3, 5, 7])
855            .expect("a subgroup repeating at a tritave");
856        assert_eq!(tritave.equave(), 3);
857        assert!(!tritave.repeats_at_the_octave());
858        assert!((tritave.equave_cents() - 1901.955).abs() < 1e-3);
859        // The mapping has to be as long as the subgroup, less its first prime.
860        assert!(Temperament::from_mapping(1, &[1, 4], 696.7, &[2, 3, 5, 7]).is_err());
861        // A period and a generator both have to be something.
862        assert!(Temperament::from_mapping(0, &[1, 4, 10], 696.7, &[2, 3, 5, 7]).is_err());
863        assert!(Temperament::from_mapping(1, &[1, 4, 10], 0.0, &[2, 3, 5, 7]).is_err());
864    }
865
866    #[test]
867    fn a_temperament_writes_its_infobox_line_back() {
868        let meantone =
869            Temperament::from_mapping(1, &[1, 4, 10], 696.7, &[2, 3, 5, 7]).expect("meantone");
870        assert_eq!(meantone.to_string(), "2.3.5.7 1; 1 4 10 (generator 696.7¢)");
871        assert_eq!(meantone.periods_per_equave(), 1);
872        assert_eq!(meantone.primes(), [2, 3, 5, 7]);
873        assert_eq!(meantone.generator_map(), [vec![0, 1, 4, 10]]);
874        assert!((meantone.period_cents() - OCTAVE_CENTS).abs() < 1e-9);
875    }
876}