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}