Moment of Symmetry (MOS)

Contents

Moment of Symmetry (MOS)#

Moment-of-symmetry — equivalently, well-formed — scales: generated by stacking a single interval, with exactly two step sizes distributed as evenly as two sizes can be. Almost every scale anyone plays is one, plus a large microtonal hinterland ordinary keyboards cannot reach.

This subpackage implements Milne, Carlé, Sethares, Noll & Holland (2011), Scratching the Scale Labyrinth (LNAI 6726, 180–195) — the combinatorics, the labyrinth visualisation, the modal algebra, the Fourier Scratching performance technique and the Dynamic Tonality timbre matching — and adds the piece the paper does not have: fitting these scales to a biosignal.

Quick start#

from biotuner.mos import mos, fit_mos, forward_scales, plot_labyrinth

d = mos(3 / 2, 7)              # stack fifths, take seven notes
d.signature                    # '5L2s'
print(d.summary())             # steps, landmarks, propriety, inverse, embedding

plot_labyrinth(18)             # the whole scale universe

fit = fit_mos(peak_ratios)[0]  # which well-formed scale is this signal in?
fit.signature, fit.error_cents, fit.improvement

# the other direction: an interval the signal states, used as the generator
read = forward_scales(peak_ratios)[0]
read.interval_pair, read.signature, read.error_cents

From a compute_biotuner:

bt.fit_mos()                   # -> MOSFit, also sets bt.mos_scale / bt.mos_fits
bt.fit_mos(source="diss_curve")  # any get_tuning() derivation can feed the fit
bt.compare_mos_sources()       # all of them at once, ranked by evidence
bt.get_tuning("mos")           # the fitted scale as ratios
bt.plot_labyrinth()            # the signal's peaks on the labyrinth
bt.mos_trajectory(...)         # a path through the labyrinth over time

from biotuner.mos.derive import mos_from_biotuner
mos_from_biotuner(bt, mode="forward")   # -> list of ForwardScale

Reading the labyrinth#

Angle is the generator as a fraction of the period, zero at the top. Ring is cardinality. Spokes are equal temperaments, each touching without crossing the ring giving its note count. Arcs are valid tuning ranges, thickened where the scale is also coherent. The picture is left–right symmetric because a generator and its complement build the same scale.

That symmetry is why the fit reports only the bright half (0.5, 1) of the period: g and period - g are one solution, not two, so overlaid generators all land on one side. The emptiness of the other side is bookkeeping, not a property of the signal.

Relation to scale_construction#

The older MOS helpers in biotuner.scale_construction (find_MOS, tuning_MOS_info, Stern_Brocot, tuning_range_to_MOS) still work unchanged. They brute-force what this package derives exactly, and several of them silently assume a 2/1 period. biotuner.mos.scale.mos_family() is the direct replacement for find_MOS. vizs.plot_labyrinth and vizs.MOS_interactive now delegate here.

See also the architecture note, docs/mos_architecture.md, and the worked notebook under Examples.

theory#

Stern–Brocot walk, landmarks, Christoffel words. Standard library only — exact Fraction arithmetic, no floating-point comparison decides a combinatorial fact.

Number theory of moment-of-symmetry (well-formed) scales.

This module is deliberately dependency-free – it imports nothing but the standard library, so its correctness can be checked in isolation from the rest of biotuner. Everything here works on exact Fraction arithmetic; no floating-point comparison decides a combinatorial fact.

The central object of study is the generator fraction

\[g = \log(\text{generator}) / \log(\text{period}) \in (0, 1)\]

i.e. the size of the generator expressed as a fraction of the period (the “generator/period tuning ratio” of Milne et al., 2011). A generator of 702 cents against a 1200-cent period gives \(g = 0.585\).

The key fact, due to the three-distance theorem and exploited throughout Milne et al. §3, is that stacking a generator produces a scale with exactly two step sizes precisely at the cardinalities that appear as denominators along the Stern-Brocot path toward \(g\). So the entire MOS series of a generator is obtained by walking the tree – no brute-force search over cardinalities is needed.

References

Milne, A.J., Carlé, M., Sethares, W.A., Noll, T., Holland, S. (2011). Scratching the Scale Labyrinth. In Mathematics and Computation in Music, LNAI 6726, 180–195. https://doi.org/10.1007/978-3-642-21590-2_14

Carey, N., Clampitt, D. (1989). Aspects of Well-formed Scales. Music Theory Spectrum 11, 187–206.

mediant(x: Fraction, y: Fraction) Fraction[source]#

The mediant (a+c)/(b+d) of a/b and c/d.

Note this is not the same as (x + y) / 2; the mediant depends on the representation, which is why both arguments must already be in lowest terms (Fraction guarantees that).

Examples

>>> mediant(Fraction(1, 2), Fraction(3, 5))
Fraction(4, 7)
noble_mediant(x: Fraction, y: Fraction) float[source]#

The φ-weighted “noble” mediant (a + cφ) / (b + dφ).

This is the point in (x, y) that is maximally far from every simple rational – the limit of the infinite Stern-Brocot path LRLRLR…. In MOS terms it is the generator whose scale never settles into an equal temperament, and so keeps producing new MOS cardinalities forever.

Examples

>>> round(noble_mediant(Fraction(1, 2), Fraction(3, 5)), 6)
0.580179
is_farey_neighbor(x: Fraction, y: Fraction) bool[source]#

True when |ad - bc| == 1, i.e. x and y bracket a tree node.

Every bracketing pair produced by sb_walk() satisfies this, and it is what makes the mediant land in lowest terms.

farey_sequence(n: int) List[Fraction][source]#

The Farey sequence F_n: every reduced p/q in [0, 1] with q <= n.

Generated by the standard next-term recurrence, so this is O(len(F_n)) rather than O(n^2). The loop stops once the recurrence steps past 1/1, which is the sequence’s last term.

Examples

>>> farey_sequence(5)  
[Fraction(0, 1), Fraction(1, 5), Fraction(1, 4), Fraction(1, 3),
 Fraction(2, 5), Fraction(1, 2), Fraction(3, 5), Fraction(2, 3),
 Fraction(3, 4), Fraction(4, 5), Fraction(1, 1)]
continued_fraction(x: float, max_terms: int = 32, tol: float = 1e-13) List[int][source]#

Simple continued-fraction expansion [a0; a1, a2, …] of x.

Terminates early once the remaining fractional part falls below tol, which keeps a rational input from producing spurious huge terms from floating-point noise.

convergents_from_cf(cf: Sequence[int]) List[Fraction][source]#

Convergents of a continued fraction, in order.

Examples

The fifth’s convergents run through the historically important divisions of the octave:

>>> convergents_from_cf(continued_fraction(math.log2(3 / 2), 6))
[Fraction(0, 1), Fraction(1, 1), Fraction(1, 2), Fraction(3, 5), Fraction(7, 12), Fraction(24, 41)]
semiconvergents(x: float, max_denominator: int = 53) List[Fraction][source]#

Every semiconvergent of x with denominator <= max_denominator.

The semiconvergents are exactly the Stern-Brocot nodes on the path toward x, so their denominators are exactly the MOS cardinalities of a generator fraction x (see mos_cardinalities()). Provided as a standalone entry point because it is the classical name for the object.

class SBNode(left: Fraction, right: Fraction, node: Fraction, depth: int, turn: str, exact: bool)[source]#

Bases: object

One node on the Stern-Brocot path toward a generator fraction.

left, right

The bracketing Farey pair, left < right, with |ad - bc| == 1.

Type:

Fraction

node#

Their mediant. Represents an equal division of the period into node.denominator steps.

Type:

Fraction

depth#

Number of tree levels descended, 0 for the first node (1/2).

Type:

int

turn#

'L' or 'R' – the branch taken to reach this node, '' for the root.

Type:

str

exact#

True when the target generator equals node exactly. The scale is then that equal temperament: its large and small steps coincide, and no further MOS exist.

Type:

bool

left: Fraction#
right: Fraction#
node: Fraction#
depth: int#
turn: str#
exact: bool#
property cardinality: int#

Number of notes in the MOS whose range this node bounds.

signature(g: float) Tuple[int, int][source]#

(n_large, n_small) of the MOS at this cardinality for generator g.

Milne et al. §3: for bracket a/b, c/d with mediant m, the sub-range (a/b, m) hosts b large and d small steps, and (m, c/d) hosts d large and b small.

sb_walk(g: float, max_cardinality: int = 53, max_depth: int = 512) Iterator[SBNode][source]#

Walk the Stern-Brocot tree from 0/11/1 toward g.

Each yielded node’s denominator is an MOS cardinality of the generator, and the node itself is the equal temperament at which that MOS’s large and small steps become equal. The walk stops when the mediant’s denominator would exceed max_cardinality, or when g is hit exactly.

Parameters:
  • g (float) – Generator fraction in (0, 1). Use generator_fraction() to obtain one from a frequency ratio.

  • max_cardinality (int, default 53) – Stop once the next node would have more notes than this.

  • max_depth (int, default 512) – Hard recursion guard; only reachable for pathological inputs.

Yields:

SBNode

Examples

The perfect fifth generates the familiar Pythagorean series:

>>> g = math.log2(3 / 2)
>>> [n.cardinality for n in sb_walk(g, max_cardinality=53)]
[2, 3, 5, 7, 12, 17, 29, 41, 53]
sb_path(g: float, max_cardinality: int = 53) str[source]#

The 'L'/'R' turn sequence of the walk toward g.

A compact fingerprint of where a generator sits in the labyrinth. One shorter than the number of nodes visited, since the root 1/2 is reached without a turn.

Examples

>>> sb_path(math.log2(3 / 2), max_cardinality=12)
'RLLR'
sb_node_at(g: float, cardinality: int) SBNode | None[source]#

The node of g’s walk with the given cardinality, or None.

None means cardinality is not an MOS cardinality of g.

sb_tree_nodes(max_cardinality: int = 12) List[SBNode][source]#

Every Stern-Brocot node in (0, 1) with denominator <= max_cardinality.

Unlike sb_walk(), which follows one generator, this enumerates the whole tree – the data behind the labyrinth drawing. Each node carries its own bracketing pair, so the arc endpoints and landmark tunings of every MOS are available directly.

Returned in breadth-first order (shallowest first), which is also the order the labyrinth’s rings fill in.

mos_cardinalities(g: float, max_cardinality: int = 53, include_trivial: bool = False) List[int][source]#

Cardinalities at which stacking g yields exactly two step sizes.

Parameters:
  • g (float) – Generator fraction in (0, 1).

  • max_cardinality (int, default 53) – Largest scale size to report.

  • include_trivial (bool, default False) – Whether to include cardinality 2 (a single generator plus the root), which is an MOS but a musically empty one.

Examples

Milne et al. §2 quote both of these series:

>>> mos_cardinalities(math.log2(3 / 2), 53, include_trivial=True)
[2, 3, 5, 7, 12, 17, 29, 41, 53]
>>> mos_cardinalities(316 / 1200, 19, include_trivial=True)
[2, 3, 4, 7, 11, 15, 19]
mos_signature(g: float, cardinality: int) Tuple[int, int][source]#

(n_large, n_small) of the cardinality-note MOS generated by g.

Raises:

ValueError – If cardinality is not an MOS cardinality of g.

Examples

>>> mos_signature(math.log2(3 / 2), 7)
(5, 2)
>>> mos_signature(math.log2(3 / 2), 12)
(5, 7)
mos_series(g: float, max_cardinality: int = 53, include_trivial: bool = False) List[Tuple[int, int, int]][source]#

(cardinality, n_large, n_small) for every MOS of g.

Examples

>>> mos_series(math.log2(3 / 2), 12)
[(3, 2, 1), (5, 2, 3), (7, 5, 2), (12, 5, 7)]
signature_brackets(n_large: int, n_small: int) Tuple[Tuple[Fraction, Fraction], Tuple[Fraction, Fraction]][source]#

The two Farey brackets that host the MOS n_large L, n_small s.

A generator g and its complement 1 - g produce the same scale (Milne et al. §4: “precisely the same scale is produced by generators of 697 cents and 1200 - 697 = 503 cents”), so every signature occupies two mirror-image ranges in the labyrinth.

Returns:

(dark_bracket, bright_bracket) – Each a (left, right) Farey pair. The bright bracket is the one whose MOS sub-range lies above 1/2; it is the one containing the conventional generator (e.g. the fifth rather than the fourth).

Examples

>>> signature_brackets(5, 2)
((Fraction(2, 5), Fraction(1, 2)), (Fraction(1, 2), Fraction(3, 5)))
>>> signature_brackets(2, 5)
((Fraction(2, 5), Fraction(1, 2)), (Fraction(1, 2), Fraction(3, 5)))
signature_ranges(n_large: int, n_small: int) Tuple[Tuple[Fraction, Fraction], Tuple[Fraction, Fraction]][source]#

Valid generator ranges of n_large L, n_small s, as (dark, bright).

Within each open interval the scale keeps its identity while its two step sizes co-vary; at the endpoints it degenerates into an equal temperament. The two ranges are reflections of each other about 1/2.

Examples

The diatonic scale, per Milne et al. §3:

>>> signature_ranges(5, 2)
((Fraction(2, 5), Fraction(3, 7)), (Fraction(4, 7), Fraction(3, 5)))

Its inverse, the anti-diatonic:

>>> signature_ranges(2, 5)
((Fraction(3, 7), Fraction(1, 2)), (Fraction(1, 2), Fraction(4, 7)))
class Landmarks(equalized: Fraction, small_vanishes: Fraction, large_vanishes: Fraction, bright: bool)[source]#

Bases: object

The three equal temperaments that bound an MOS pair.

Milne et al. §2 (“Landmark equal tunings”): as the generator moves, the two step sizes co-vary through three distinguished tunings.

equalized#

Where large and small steps become the same size and the MOS meets its inverse. Cardinality n_large + n_small.

Type:

Fraction

small_vanishes#

Where the n_small small steps shrink to zero, leaving n_large equal steps. This is the far end of the scale’s valid range.

Type:

Fraction

large_vanishes#

Where the inverse scale’s small steps (n_large of them) shrink to zero, leaving n_small equal steps. Lies beyond equalized, in the inverse scale’s territory.

Type:

Fraction

bright#

Which of the two mirror ranges these landmarks describe.

Type:

bool

equalized: Fraction#
small_vanishes: Fraction#
large_vanishes: Fraction#
bright: bool#
property equalized_edo: int#
property small_vanishes_edo: int#
property large_vanishes_edo: int#
as_cents(period_cents: float = 1200.0) dict[source]#

The three landmarks in cents against a period of period_cents.

mos_landmarks(n_large: int, n_small: int, bright: bool = True) Landmarks[source]#

The three landmark tunings of n_large L, n_small s.

Examples

The diatonic scale meets 7-EDO where its steps equalise, 5-EDO where its two small steps vanish, and 2-EDO where the anti-diatonic’s five small steps vanish – Milne et al. §2:

>>> lm = mos_landmarks(5, 2)
>>> lm.equalized, lm.small_vanishes, lm.large_vanishes
(Fraction(4, 7), Fraction(3, 5), Fraction(1, 2))
>>> lm.equalized_edo, lm.small_vanishes_edo, lm.large_vanishes_edo
(7, 5, 2)

The anti-diatonic meets 7-EDO too, vanishes into 2-EDO, and is bounded by the diatonic’s own 5-EDO:

>>> lm = mos_landmarks(2, 5)
>>> lm.equalized, lm.small_vanishes, lm.large_vanishes
(Fraction(4, 7), Fraction(1, 2), Fraction(3, 5))
embedding(n_large: int, n_small: int, bright: bool = True) Tuple[int, Fraction][source]#

Lowest-cardinality MOS that embeds n_large L, n_small s.

Milne et al. §3: the embedding scale has 2p + q tones and sits at the mediant of the embedded scale’s two boundary tunings.

Returns:

(cardinality, tuning)cardinality == 2 * n_large + n_small; tuning is the generator fraction at which that embedding scale is equally tuned.

Examples

>>> embedding(5, 2)          # the diatonic lives inside 12
(12, Fraction(7, 12))
>>> embedding(2, 5)          # the anti-diatonic lives inside 9
(9, Fraction(5, 9))
coherence_range(n_large: int, n_small: int, bright: bool = True) Tuple[Fraction, Fraction][source]#

Generator range over which the scale is coherent (proper).

A scale is coherent when generic interval size and specific interval size agree monotonically – every fifth larger than every fourth, and so on. Well-formed scales are coherent exactly while Blackwood’s \(R = L / s < 2\), which is the range between the equalized landmark and the tuning at which the lowest-cardinality embedding scale is equally tuned (Milne et al. §2–3).

Examples

>>> coherence_range(5, 2)
(Fraction(4, 7), Fraction(7, 12))
>>> coherence_range(2, 5)
(Fraction(5, 9), Fraction(4, 7))
mos_word(n_large: int, n_small: int, mode: int = 0) str[source]#

Step pattern of the mode-th rotation of n_large L, n_small s.

mode=0 is the Christoffel (lower mechanical) word; increasing mode rotates left by one scale degree. All n_large + n_small rotations are the modes of the scale.

Examples

>>> mos_word(5, 2, mode=0)
'sLLsLLL'
>>> mos_word(5, 2, mode=1)
'LLsLLLs'
christoffel_word(n_large: int, n_small: int, lower: bool = True) str[source]#

The Christoffel word on n_large L’s and n_small s’s.

Milne et al. §2 footnote 5: the two step sizes of a well-formed scale are “distributed with maximal evenness … [their] distribution forms a Christoffel word”. The lower and upper words are reverses of each other.

Examples

>>> christoffel_word(5, 2)
'sLLsLLL'
>>> christoffel_word(2, 3)
'ssLsL'
word_from_generator(g: float, cardinality: int) str[source]#

Step pattern obtained by actually stacking g cardinality times.

This is the empirical counterpart of mos_word(): it builds the scale, sorts it, and labels each step L or s. Used to cross-check the combinatorics against the geometry.

Examples

>>> word_from_generator(math.log2(3 / 2), 7)
'LLLsLLs'
generator_fraction(generator: float, period: float = 2.0) float[source]#

Convert a frequency ratio to its position g in the labyrinth.

The result is reduced into (0, 1) by the period, exactly as a generator is octave-reduced when a scale is built.

Examples

>>> round(generator_fraction(3 / 2), 6)
0.584963
>>> round(generator_fraction(3 / 2, period=3), 5)   # a pseudo-octave
0.36907
fraction_to_generator(g: float, period: float = 2.0) float[source]#

Convert a labyrinth position back to a frequency ratio.

Inverse of generator_fraction() (up to period reduction).

Examples

>>> round(fraction_to_generator(math.log2(3 / 2)), 6)
1.5
fold_generator(g: float) float[source]#

Fold g into (0, 0.5] using the labyrinth’s mirror symmetry.

A generator and its complement within the period build the same scale (Milne et al. §4), so g and 1 - g are musically identical. Folding gives a canonical representative – useful for deduplicating candidate generators, though the bright form g > 1/2 is usually the one quoted.

Examples

>>> round(fold_generator(math.log2(3 / 2)), 6)   # the fifth folds to the fourth
0.415037
common_tones(generators: Sequence[float], period: float = 2.0, max_cardinality: int = 18, tol_cents: float = 5.0) List[float][source]#

Positions where two different generators land on the same tone.

A shared tone belongs to no single scale, which is why it is easy to miss and worth locating: common tones are what let one tuning modulate into another, and on the labyrinth they are the angles at which two generators’ rings line up. Each generator contributes its largest MOS within max_cardinality.

Parameters:
  • generators (sequence of float) – Generator fractions in (0, 1). Fewer than two gives no pairs and an empty result.

  • period (float, default 2.0) – Period as a frequency ratio; only affects what tol_cents means.

  • max_cardinality (int, default 18)

  • tol_cents (float, default 5.0) – How close two tones must be to count as shared.

Returns:

list of float – Period fractions, ascending, midway between each coinciding pair.

Notes

A generator and its complement 1 - g build the same scale, but mirrored, so as pitch-class sets rooted at zero they share only the root. Sameness of scale and sameness of tones are different questions, and this function answers the second one.

Examples

Two generators of the same equal division share all of it – both 512 and 712 run through every step of 12-EDO:

>>> len(common_tones([7 / 12, 5 / 12], max_cardinality=12))
12

Pythagorean and meantone fifths drift apart by about 5.2 cents per step, so at a 5-cent tolerance only the root survives; widen it and the near ones reappear:

>>> g = math.log2(3 / 2)
>>> [round(t * 1200, 1) for t in common_tones([g, 18 / 31], max_cardinality=12)]
[0.0]
>>> len(common_tones([g, 18 / 31], max_cardinality=12, tol_cents=12))
3

Unrelated generators share only the root:

>>> [round(t, 6) for t in common_tones([g, 0.7071], max_cardinality=7)]
[0.0]

Fewer than two generators gives nothing to compare:

>>> common_tones([g])
[]
step_sizes(g: float, cardinality: int) Tuple[float, float][source]#

(large, small) step size as fractions of the period.

For a degenerate (equal) tuning both values coincide.

degrees_from_generator(g: float, cardinality: int) List[float][source]#

Scale degrees as period fractions in [0, 1), sorted ascending.

PERIOD_CENTS = 1200.0#

Cents in a 2/1 period. Used only for cents conversions, never for logic.

scale#

The MOSScale object – one well-formed scale, fully specified.

An MOS scale is pinned down by three things: how many large and small steps it has, where its generator sits inside the valid range for that signature, and what the period is. Everything else – degrees, cents, step sizes, landmarks, propriety, modes, inverse, embedding family – follows, and is exposed here as derived properties rather than as parallel lists.

The abstract structure (5L 2s) and the concrete tuning (generator = 702 cents) are deliberately kept as separate coordinates, because that is exactly the two-part selection the scale labyrinth affords: “the scale labyrinth allows a musician to choose, simultaneously, a scale structure (number of small and large steps) and its tuning (the sizes of its period and generator)” (Milne et al., 2011, §1).

class MOSScale(n_large: int, n_small: int, generator: float, period: float = 2.0, validate: bool = True)[source]#

Bases: object

A moment-of-symmetry (well-formed) scale.

Parameters:
  • n_large, n_small (int) – Counts of large and small steps per period. Always co-prime for a genuine MOS (Milne et al. §2, “Co-prime step numbers”).

  • generator (float) – The generator as a fraction of the period, in (0, 1). Use from_generator() to build one from a frequency ratio.

  • period (float, default 2.0) – The period as a frequency ratio. 2.0 is the octave; anything else is a pseudo-octave, which the labyrinth supports natively.

  • validate (bool, default True) – Check that generator really does produce n_large L, n_small s. Turn off only when constructing degenerate or deliberately mistuned scales.

Examples

The diatonic scale in Pythagorean tuning:

>>> d = MOSScale.from_generator(3 / 2, 7)
>>> d.signature
'5L2s'
>>> d.word
'LLLsLLs'
>>> [round(c, 1) for c in d.cents]
[0.0, 203.9, 407.8, 611.7, 702.0, 905.9, 1109.8]

Pythagorean tuning sits outside the diatonic’s coherent range of 4/7 .. 7/12 (685.7 .. 700 cents), so it is improper – its major third is wider than its diminished fourth:

>>> round(d.hardness, 3), d.is_proper
(2.26, False)

Flatten the fifth into meantone and coherence returns:

>>> m = MOSScale.from_signature(5, 2, tuning=31)
>>> round(m.generator_cents, 2), round(m.hardness, 3), m.is_proper
(696.77, 1.667, True)
n_large: int#
n_small: int#
generator: float#
period: float = 2.0#
validate: bool = True#
classmethod from_generator(generator: float, cardinality: int, period: float = 2.0) MOSScale[source]#

Build from a generator frequency ratio and a note count.

Parameters:
  • generator (float) – Frequency ratio, e.g. 3/2. Reduced into the period.

  • cardinality (int) – Must be one of the generator’s MOS cardinalities; a helpful error lists the valid ones if it is not.

  • period (float, default 2.0)

Examples

>>> MOSScale.from_generator(3 / 2, 12).signature
'5L7s'
classmethod from_fraction(g: float, cardinality: int, period: float = 2.0) MOSScale[source]#

Build from a generator period fraction and a note count.

classmethod from_signature(n_large: int, n_small: int, tuning: str | float | int | Fraction | None = None, period: float = 2.0, bright: bool = True) MOSScale[source]#

Build from an abstract signature, choosing a tuning inside its range.

Parameters:
  • n_large, n_small (int)

  • tuning (float, int, Fraction or str, optional) – Where to sit inside the valid generator range:

    • None / 'noble' (default) – the φ-weighted mediant, the generator furthest from every equal temperament.

    • 'central' – middle of the coherent sub-range; always proper.

    • 'middle' – middle of the full valid range.

    • 'equalized' – the equal temperament where L and s coincide (degenerate, but sometimes what you want).

    • an int – read the generator off that EDO.

    • a float – an explicit generator fraction.

    • a Fraction – an explicit rational generator.

  • period (float, default 2.0)

  • bright (bool, default True) – Take the generator above 1/2 (the fifth rather than the fourth).

Examples

Stacking always starts from the root, so the scale as built is the brightest mode – Lydian, not Ionian. Use mode() to rotate.

>>> [round(c, 3) for c in MOSScale.from_signature(5, 2, tuning=12).cents]
[0.0, 200.0, 400.0, 600.0, 700.0, 900.0, 1100.0]
>>> round(MOSScale.from_signature(5, 2, tuning=31).generator_cents, 2)
696.77
classmethod from_edo(edo: int, steps: int, cardinality: int, period: float = 2.0) MOSScale[source]#

Build from a generator of steps degrees of edo-EDO.

Examples

>>> MOSScale.from_edo(31, 18, 7).signature      # 31-EDO meantone diatonic
'5L2s'
property cardinality: int#

Total notes per period.

property signature: str#

Compact signature, e.g. '5L2s'.

property is_bright: bool#

True when the generator lies above half the period.

property is_degenerate: bool#

True when large and small steps have collapsed to the same size.

property period_cents: float#

Size of the period in cents (1200 for an octave).

property generator_ratio: float#

The generator as a frequency ratio.

property generator_cents: float#

The generator in cents.

property degrees: List[float]#

Scale degrees as period fractions in [0, 1), ascending.

property ratios: List[float]#

Scale degrees as frequency ratios in [1, period), ascending.

property cents: List[float]#

Scale degrees in cents, ascending, starting at 0.

property word: str#

Step pattern of the scale as tuned, e.g. 'LLLsLLs'.

Read off the actual stacked scale, so it is the rotation rooted on the generator chain’s origin – not necessarily the Christoffel word (see mos_word()), and not necessarily the brightest mode either. The chain origin is the brightest mode only when stacking darkens; see stacking_brightens(). For the brightest mode’s pattern, use scale.mode(0).word.

property step_cents: Tuple[float, float]#

(large, small) step sizes in cents.

property hardness: float#

Blackwood’s R = L / s – how uneven the two steps are.

1 at the equalized tuning, 2 at the embedding EDO (the edge of propriety), and unbounded as the small step vanishes.

property landmarks: Landmarks#

The three landmark equal temperaments bounding this MOS pair.

property tuning_range: Tuple[Fraction, Fraction]#

Generator fractions between which the scale keeps its identity.

property coherence_range: Tuple[Fraction, Fraction]#

Generator fractions over which the scale is coherent (R < 2).

property is_proper: bool#

every generic interval well-ordered.

Equivalent to hardness <= 2 for a well-formed scale (Milne et al. §2); biotuner.mos.metrics.is_proper() verifies it directly from the interval matrix instead, and the two agree.

Type:

True when the scale is coherent

property sb_node: SBNode | None#

This scale’s node on the Stern-Brocot path of its generator.

property edo: int | None#

The EDO this scale is, when the generator is exactly rational.

None for a generic tuning. The denominator cap matters: allowed to run to a million, limit_denominator approximates an irrational generator to within 1e-12 and every scale looks like some absurd equal division. Ten thousand keeps genuine EDOs exact while leaving an irrational generator visibly irrational.

property inverse: MOSScale#

The scale with large and small steps swapped (Milne et al. §2).

Realised at the mirror-image generator across the equalized landmark, keeping the same distance from it – so the diatonic’s inverse is an anti-diatonic just as far from 7-EDO as the original.

Examples

>>> MOSScale.from_generator(3 / 2, 7).inverse.signature
'2L5s'
property parent: MOSScale | None#

The next smaller MOS in this generator’s family, or None.

child(steps: int = 1) MOSScale | None[source]#

The steps-th larger MOS in this generator’s family.

property embedding: Tuple[int, Fraction]#

(cardinality, tuning) of the lowest-cardinality embedding scale.

Examples

>>> MOSScale.from_generator(3 / 2, 7).embedding
(12, Fraction(7, 12))
family(max_cardinality: int = 53) List[MOSScale][source]#

Every MOS this generator produces, smallest first.

Examples

>>> [s.signature for s in MOSScale.from_generator(3 / 2, 7).family(17)]
['2L1s', '2L3s', '5L2s', '5L7s', '12L5s']
retune(tuning: str | float | int | Fraction | None) MOSScale[source]#

Same structure, different generator inside the valid range.

Examples

>>> round(MOSScale.from_generator(3 / 2, 7).retune(19).generator_cents, 4)
694.7368
mode(index: int = 0) Mode[source]#

The index-th mode, ordered brightest (0) to darkest.

modes() List['Mode'][source]#

All cardinality modes, brightest first.

to_scala(name: str | None = None, write: bool = False) str[source]#

Render as a Scala .scl file body.

Uses biotuner’s own create_SCL(), so the output matches every other tuning the toolbox exports.

to_dict() Dict[str, object][source]#

Flat, JSON-friendly summary – handy for tables and provenance.

summary() str[source]#

Multi-line human-readable description.

mos(generator: float, cardinality: int, period: float = 2.0) MOSScale[source]#

Shorthand for MOSScale.from_generator().

Examples

>>> mos(3 / 2, 7).signature
'5L2s'
mos_family(generator: float, max_cardinality: int = 53, period: float = 2.0, min_cardinality: int = 3) List[MOSScale][source]#

Every MOS scale a generator produces, smallest first.

This is the corrected replacement for biotuner.scale_construction.find_MOS(): exact rather than brute-forced, honouring period throughout, and returning scale objects instead of a dict of parallel lists.

Parameters:
  • generator (float) – Generator as a frequency ratio.

  • max_cardinality (int, default 53)

  • period (float, default 2.0)

  • min_cardinality (int, default 3) – Skip the musically empty 2-note MOS by default.

Examples

>>> [s.signature for s in mos_family(3 / 2, 12)]
['2L1s', '2L3s', '5L2s', '5L7s']
>>> [s.cardinality for s in mos_family(2 ** (316 / 1200), 19)]
[3, 4, 7, 11, 15, 19]

modes#

Modes of a well-formed scale, and the ℤ² lattice they live in.

A scale presupposes periodicity, so all its rotations are the same scale. A mode is what you get by choosing a fundamental domain for that period – a finalis. Milne et al. (2011) §4 describe the modal universe of a well-formed scale as “freely generated from two basic and commuting transformations and … therefore isomorphic to the free commutative group ℤ² of rank 2”:

σ (Mode.rotate())

Common origin. Keeps the pitch collection, moves the finalis up one scale degree. C-Ionian → D-Dorian.

τ (Mode.brighten())

Common finalis. Keeps the finalis, moves the origin one generator sharpwards. C-Ionian → C-Lydian.

Adjacent modes in the brightness order are parsimonious: they differ by a single tone, displaced by the augmented prime (the chroma, L - s). That claim is checked directly in the test suite.

The lattice itself (Milne et al. Fig. 7) is exposed by Mode.lattice_coords(), which places each scale degree at integer coordinates in the (generator, period) basis. The zig-zag those points trace out is the mode’s fundamental frame; σ and τ are its vertical and horizontal shifts.

class Mode(scale: MOSScale, index: int)[source]#

Bases: object

One mode of a MOSScale.

Parameters:
  • scale (MOSScale)

  • index (int) – Brightness rank, 0 = brightest. Mode k is rooted on the k-th note of the generator chain, so each increment darkens the scale by one generator.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> d = MOSScale.from_signature(5, 2, tuning=12)
>>> [m.name for m in d.modes()]
['Lydian', 'Ionian', 'Mixolydian', 'Dorian', 'Aeolian', 'Phrygian', 'Locrian']
>>> d.mode(1).word
'LLsLLLs'
>>> [round(c) for c in d.mode(1).cents]
[0, 200, 400, 500, 700, 900, 1100]
scale: MOSScale#
index: int#
property cardinality: int#
property name: str#

Conventional name where one exists, else 'mode k of 4L3s'.

property chain_position: int#

How many generators up the chain this mode’s finalis sits.

Brightness indices run brightest-first, but the generator chain runs that way only when stacking darkens (see stacking_brightens()); otherwise the chain is walked in reverse.

property root_degree: float#

Position of this mode’s finalis within the parent scale, in [0, 1).

property degrees: List[float]#

Degrees as period fractions in [0, 1), rooted at 0.

property ratios: List[float]#

Degrees as frequency ratios in [1, period).

property cents: List[float]#
property word: str#

Step pattern of this mode, e.g. 'LLsLLLs' for Ionian.

property brightness: float#

Sum of the mode’s degrees – higher is brighter.

Strictly decreasing in index, which is what makes the index a brightness rank.

property chroma: float#

The augmented prime L - s in cents.

The interval by which a single tone moves when stepping between adjacent modes (Milne et al. §4).

brighten(k: int = 1) Mode[source]#

τ^-k: same finalis, origin k generators sharpwards.

Chromatic transposition. Brightening by one is the paper’s “transformation of a C-Ionian mode into the common finalis mode C-Lydian”.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> MOSScale.from_signature(5, 2, tuning=12).mode(1).brighten().name
'Lydian'
darken(k: int = 1) Mode[source]#

τ^k: same finalis, origin k generators flatwards.

rotate(k: int = 1) Mode[source]#

σ^k: same pitch collection, finalis k scale steps higher.

Diatonic transposition. Rotating C-Ionian by one gives D-Dorian – the same white keys, a new home.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> MOSScale.from_signature(5, 2, tuning=12).mode(1).rotate().name
'Dorian'
lattice_coords() List[Tuple[int, int]][source]#

(width, height) lattice coordinates of each degree.

Every pitch reachable by stacking is height periods plus width generators away from the finalis. Listing them in generator-chain order traces the zig-zag that Milne et al. Fig. 7 calls the mode’s fundamental frame: the width axis is spanned by the augmented prime, the height axis by the pseudo-octave.

Widths run negative for the notes below the finalis in the chain, which is exactly what distinguishes one mode’s frame from another’s – Lydian sits entirely above its finalis, Locrian entirely below.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> d = MOSScale.from_signature(5, 2, tuning=12)
>>> d.mode(0).lattice_coords()          # Lydian: all above
[(0, 0), (1, 0), (2, -1), (3, -1), (4, -2), (5, -2), (6, -3)]
>>> d.mode(6).lattice_coords()          # Locrian: all below
[(-6, 4), (-5, 3), (-4, 3), (-3, 2), (-2, 2), (-1, 1), (0, 0)]
differences(other: Mode, tol: float = 1e-06) List[Tuple[int, float, float]][source]#

Scale steps at which this mode and other disagree.

Both modes have the same number of degrees, so they are compared step by step: entry (k, mine, theirs) means the k-th degree sits at mine cents here and theirs cents in other. Pairing by step rather than by proximity matters – when a tone moves by the chroma it lands exactly halfway between its neighbours, and a nearest-value match would tie and break arbitrarily on floating-point noise.

For adjacent modes in the brightness order the result has length 1 and the two values differ by exactly the chroma: the parsimony property of Milne et al. §4.

Parameters:
  • other (Mode)

  • tol (float, default 1e-6) – Cents below which two degrees count as the same tone.

to_dict() Dict[str, object][source]#
DIATONIC_MODE_NAMES: Tuple[str, ...] = ('Lydian', 'Ionian', 'Mixolydian', 'Dorian', 'Aeolian', 'Phrygian', 'Locrian')#

The seven church modes, brightest first – the order τ walks.

mode_names(n_large: int, n_small: int) Tuple[str, ...] | None[source]#

Conventional mode names for a signature, brightest first, or None.

wf_number(g: float, cardinality: int) int[source]#

Carey’s g in WF(N, g) – generator order → scale step order.

The factor that converts generator order into scale step order, mod N (Carey 1998; quoted in Milne et al. §1). Equivalently: taking one step up the scale advances you wf_number places along the generator chain.

Examples

The diatonic scale and its inverse belong to WF(7, 2), the chromatic scale to WF(12, 7):

>>> wf_number(math.log2(3 / 2), 7)
2
>>> wf_number(math.log2(3 / 2), 12)
7
chain_order(g: float, cardinality: int) List[int][source]#

Sorted-degree index of each generator-chain position.

chain_order(g, N)[i] is where the i-th stacked generator lands once the scale is sorted. The inverse mapping – which chain position each sorted degree came from – is what wf_number() reads.

Examples

Stacking fifths gives C G D A E B F♯; sorted, those are degrees 0 4 1 5 2 6 3:

>>> chain_order(math.log2(3 / 2), 7)
[0, 4, 1, 5, 2, 6, 3]
mode_lattice(scale: MOSScale, width: int = 3, height: int = 3, base: int = 0) List[List[Mode]][source]#

A height × width patch of the modal ℤ² lattice.

Row j, column i holds σ^j τ^i applied to mode base: moving right darkens by one generator (chromatic transposition), moving down advances the finalis by one scale step (diatonic transposition). The two transformations commute, so the patch reads the same either way – which the test suite verifies.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> grid = mode_lattice(MOSScale.from_signature(5, 2, tuning=12), 3, 2)
>>> [[m.name for m in row] for row in grid]
[['Lydian', 'Ionian', 'Mixolydian'], ['Mixolydian', 'Dorian', 'Aeolian']]
christoffel_mode(scale: MOSScale) Mode[source]#

The mode whose step pattern is the signature’s Christoffel word.

Of the N modes, this is the one that matters for Fourier Scratching. Milne et al. §5 claim that a coherent well-formed scale is “played in generic scalar order by the first partial play state” – n evenly spaced fingers striking a keyboard whose keys are as wide as the steps above their tones, each key caught exactly once.

That is true, but it is a property of a mode, not of the scale: the Christoffel word is by construction the floor-quantisation of the equal division of the period, so it is precisely the rotation whose key boundaries interleave with evenly spaced fingers. In any other mode two fingers share a key and another key is missed. Coherence is what makes that mode exist; the mode is what makes the claim hold.

Raises:

ValueError – If the scale is degenerate, where every mode has the same all-L word and the Christoffel word does not single one out.

Examples

For the diatonic that mode is Locrian, not the brightest mode:

>>> from biotuner.mos.scale import MOSScale
>>> m = christoffel_mode(MOSScale.from_signature(5, 2, tuning=12))
>>> m.name, m.word
('Locrian', 'sLLsLLL')
parsimony_chain(scale: MOSScale) List[Tuple[Mode, Mode, List[Tuple[int, float, float]]]][source]#

Walk the brightness order, reporting what moves at each step.

Each entry is (brighter, darker, moved), where moved comes from Mode.differences(). For a well-formed scale every step moves exactly one tone, by exactly the chroma – and which tone moves is different every time, so the whole modal universe is reachable one note at a time.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> chain = parsimony_chain(MOSScale.from_signature(5, 2, tuning=12))
>>> all(len(moved) == 1 for _, _, moved in chain)
True
>>> [round(a - b) for _, _, moved in chain for _, a, b in moved]
[100, 100, 100, 100, 100, 100]
>>> [k for _, _, moved in chain for k, _, _ in moved]
[3, 6, 2, 5, 1, 4]

metrics#

Myhill’s property, Rothenberg propriety, Blackwood’s R, evenness and JI error, all read off one interval matrix so they cannot disagree with each other.

biotuner.mos.metrics.mos_ness() points the same machinery at a signal rather than a scale, and asks the question that survives the instability of “which MOS is this?”: how much of the structure needs a generator at all. It fits an equal division, a well-formed scale and a three-step scale at one shared cardinality — through the same scoring path and the same transposition search — and reports how much error each extra free parameter removes.

Structural and harmonic measurements on a scale.

Everything in this module is read off one object: the interval matrix. Row i, column k-1 of that matrix holds the specific size, in cents, of the generic k-step interval that starts on degree i and wraps through the period. Myhill’s property, Rothenberg propriety, Blackwood’s R and the per-degree interval signatures are all statements about how few distinct values appear in each of its columns, so computing them from a common source keeps them mutually consistent by construction.

That matters because MOSScale already answers some of the same questions from the combinatorics of the generator – propriety, for instance, is available there as hardness <= 2. The functions here take the other route: build the scale, measure every interval it actually contains, and decide from the numbers. Two independent derivations that must agree is a much stronger test than either alone, and the test suite asserts the agreement across many signatures and tunings. It also found where the agreement breaks: the hardness <= 2 shortcut misclassifies every MOS with a single small step, which is_proper() documents and the tests pin down.

Milne et al. (2011) §2 state the two structural facts a well-formed scale is expected to satisfy: “every scale span (generic interval size) occurs in exactly two interval sizes (Myhill’s property)”, and “every scale degree has a unique pattern of intervals surrounding it”. myhill_property() and has_unique_degree_signatures() check them empirically rather than assuming them.

Every function takes a scale-like argument, which may be

  • an MOSScale (or a Mode, or anything else exposing cents),

  • or a raw (cents_list, period_cents) pair.

The second form exists so that non-MOS scales – a hand-written subset of an EDO, a measured tuning, a scale from a Scala file – can be put through exactly the same measurements, which is the only way to show that these predicates discriminate. A hand-built scale that fails myhill_property() is what gives the passing MOS cases their meaning.

Measuring a signal instead of a scale#

Everything above takes a scale as given. mos_ness() takes a set of observed frequency ratios and asks the weaker, answerable question underneath “which MOS is this?”: how much of this signal’s structure needs a generator at all? It fits three families of increasing freedom at one fixed cardinality – an equal division, a well-formed scale, a three-step scale – through the same scoring code and the same transposition search, and reports how much error each extra free parameter removes. See MOSness.

References

Milne, A.J., Carlé, M., Sethares, W.A., Noll, T., Holland, S. (2011). Scratching the Scale Labyrinth. In Mathematics and Computation in Music, LNAI 6726, 180–195.

Rothenberg, D. (1978). A model for pattern perception with musical applications. Mathematical Systems Theory 11, 199–234.

interval_matrix(scale: ScaleLike) np.ndarray[source]#

Specific sizes of every generic interval, in cents.

Entry [i, k-1] is the size of the k-step interval rising from degree i, wrapping through the period when it runs off the top of the scale. Columns are therefore the generic interval classes (seconds, thirds, …) and the spread within a column is what all the structural predicates in this module look at.

Parameters:

scale (MOSScale, Mode, or (cents_list, period_cents)) – Either a scale object exposing cents (and period_cents, taken from the parent scale for a Mode), or a raw pair of a cents list and the period in cents. The raw form is how non-MOS scales get measured – see the module docstring.

Returns:

numpy.ndarray – Shape (N, N - 1) for an N-note scale. There is no column for k = N because that interval is the period for every degree.

Examples

The diatonic scale in 12-EDO. Row 0 is the Lydian scale measured from its root; column 0 is the step pattern LLLsLLs:

>>> from biotuner.mos.scale import MOSScale
>>> m = interval_matrix(MOSScale.from_signature(5, 2, tuning=12))
>>> m.shape
(7, 6)
>>> [round(float(x)) for x in m[0]]
[200, 400, 600, 700, 900, 1100]
>>> [round(float(x)) for x in m[:, 0]]
[200, 200, 200, 100, 200, 200, 100]

A raw scale needs no MOS structure at all:

>>> interval_matrix(([0.0, 100.0, 700.0], 1200.0))
array([[ 100.,  700.],
       [ 600., 1100.],
       [ 500.,  600.]])
generic_interval_sizes(scale: ScaleLike, tol: float = 1e-06) Dict[int, List[float]][source]#

Distinct specific sizes found in each generic interval class.

Parameters:
  • scale (MOSScale, Mode, or (cents_list, period_cents))

  • tol (float, default 1e-6) – Cents below which two sizes count as the same. Needed because degrees come from repeatedly folding a float generator into the period, so nominally identical intervals differ in the last few bits.

Returns:

dict{k: sorted distinct sizes in cents} for k in 1 .. N-1.

Examples

Two sizes per class – the diatonic’s major/minor seconds, thirds, and so on up to its major/minor sevenths:

>>> from biotuner.mos.scale import MOSScale
>>> sizes = generic_interval_sizes(MOSScale.from_signature(5, 2, tuning=12))
>>> {k: [round(v) for v in vs] for k, vs in sizes.items()}
{1: [100, 200], 2: [300, 400], 3: [500, 600], 4: [600, 700], 5: [800, 900], 6: [1000, 1100]}
myhill_property(scale: ScaleLike, tol: float = 1e-06) bool[source]#

True when every generic interval class has exactly two specific sizes.

Milne et al. §2 give this as the defining feature of the scales the labyrinth generates: “every scale span (generic interval size) occurs in exactly two interval sizes (Myhill’s property)”. A non-degenerate MOS satisfies it for every class 1 .. N-1, which makes this the single strongest structural check available on the output of MOSScale.

A degenerate tuning – one sitting exactly on a landmark equal temperament, where the large and small steps have collapsed onto each other – has one size per class, so it returns False here even though it is the limit of a family of MOS. Guard with is_degenerate if that distinction matters; myhill_property reports the structure of the scale as tuned, not the family it came from.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> myhill_property(MOSScale.from_signature(5, 2, tuning=12))
True

7-EDO is where the diatonic’s two step sizes meet, so its intervals collapse to one size per class:

>>> myhill_property(MOSScale.from_signature(5, 2, tuning='equalized'))
False

The harmonic minor is not well formed – it already fails at the steps:

>>> harmonic_minor = ([0, 200, 300, 500, 700, 800, 1100], 1200.0)
>>> myhill_property(harmonic_minor)
False
is_proper(scale: ScaleLike, strict: bool = False, tol: float = 1e-06) bool[source]#

Rothenberg propriety, decided from the interval matrix.

A scale is proper when the specific sizes never cross the generic ordering: no k-step interval is larger than any (k+1)-step interval. Strict propriety additionally forbids ties, so a scale with an ambiguous interval – the diatonic tritone, which is both an augmented fourth and a diminished fifth – is proper but not strictly so.

This deliberately duplicates is_proper, which reaches its verdict from Blackwood’s R <= 2 (Milne et al. §2). Measuring the intervals is the independent check on that shortcut, so do not reimplement this in terms of hardness. Sweeping every co-prime signature up to 13 notes at several tunings, the two agree everywhere except when the scale has a single small step, where the shortcut is simply wrong:

max(class k) <= min(class k+1) reduces to (1 - d)L <= (2 - d)s with d = m_{k+1} - m_k in {0, 1}, m_k being the number of large steps in the small variant of class k. A d = 1 transition costs nothing; only d = 0 transitions demand L <= 2s. Classes 1 .. N-2 contain exactly n_small - 1 of them.

So n_small == 1 leaves no propriety constraint and such a scale is (strictly) proper at any hardness: 2L1s tuned to L = 500 c, s = 200 c has R = 2.5 yet its largest second, 500 c, is well below its smallest third, 700 c. MOSScale.is_proper calls that improper. Away from n_small == 1 the two agree, up to tol at the boundary tuning R = 2 where the conventions can round opposite ways.

Parameters:
  • scale (MOSScale, Mode, or (cents_list, period_cents))

  • strict (bool, default False) – Require max(class k) < min(class k+1) rather than <=.

  • tol (float, default 1e-6) – Cents of slack, absorbing float noise in the degrees.

Examples

Pythagorean tuning stretches the diatonic past the propriety boundary – its major third (408 c) exceeds its diminished fourth (384 c):

>>> from biotuner.mos.scale import MOSScale
>>> is_proper(MOSScale.from_generator(3 / 2, 7))
False

12-EDO pulls it back in, but only just: the tritone is a tie, so it is proper without being strictly proper.

>>> is_proper(MOSScale.from_signature(5, 2, tuning=12))
True
>>> is_proper(MOSScale.from_signature(5, 2, tuning=12), strict=True)
False

31-EDO meantone breaks the tie and is strictly proper:

>>> is_proper(MOSScale.from_signature(5, 2, tuning=31), strict=True)
True

And the counterexample to the hardness shortcut:

>>> hard = MOSScale.from_signature(2, 1, tuning='middle', bright=False)
>>> [round(c) for c in hard.cents], round(hard.hardness, 3)
([0, 500, 1000], 2.5)
>>> is_proper(hard), hard.is_proper
(True, False)
blackwood_r(scale: ScaleLike) float[source]#

Blackwood’s R: the ratio of the largest step to the smallest.

Read off column 0 of the interval matrix, so it applies to any scale, not only to two-step-size ones – for a scale with three or more step sizes it reports the extremes and says nothing about what lies between. For an MOS it reproduces hardness.

1 means equal steps; 2 is the propriety boundary for a well-formed scale; inf when a step has vanished.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> round(blackwood_r(MOSScale.from_generator(3 / 2, 7)), 3)
2.26
>>> round(blackwood_r(MOSScale.from_signature(5, 2, tuning=12)), 6)
2.0
degree_signatures(scale: ScaleLike, tol: float = 1e-06) List[Tuple[int, ...]][source]#

Which variant of each generic interval sits above each degree.

Entry i is a tuple of length N-1: position k-1 holds the rank of degree i’s k-step interval among the distinct sizes of that class – 0 for the small variant, 1 for the large. (Ranks above 1 only appear for scales that fail myhill_property(), where a class has more than two sizes.)

This is the concrete form of Milne et al. §2’s second structural claim, that in these scales “every scale degree has a unique pattern of intervals surrounding it” – see has_unique_degree_signatures().

Examples

The diatonic in 12-EDO, rooted on its brightest mode (C Lydian, degrees C D E F♯ G A B). Degree 0 is the bottom of the chain of fifths, so every interval above it is the large variant; degree 3 – the F♯ that closes the chain – gets the small variant of all six:

>>> from biotuner.mos.scale import MOSScale
>>> for sig in degree_signatures(MOSScale.from_signature(5, 2, tuning=12)):
...     print(sig)
(1, 1, 1, 1, 1, 1)
(1, 1, 0, 1, 1, 0)
(1, 0, 0, 1, 0, 0)
(0, 0, 0, 0, 0, 0)
(1, 1, 0, 1, 1, 1)
(1, 0, 0, 1, 1, 0)
(0, 0, 0, 1, 0, 0)
has_unique_degree_signatures(scale: ScaleLike, tol: float = 1e-06) bool[source]#

True when no two degrees share the same interval pattern.

Milne et al. §2: in a well-formed scale “every scale degree has a unique pattern of intervals surrounding it”, which is what lets a listener locate themselves in the scale from its intervals alone. Degenerate tunings fail this – every degree of an equal division looks identical.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> has_unique_degree_signatures(MOSScale.from_signature(5, 2, tuning=12))
True
>>> has_unique_degree_signatures(MOSScale.from_signature(5, 2, tuning='equalized'))
False
evenness(scale: ScaleLike) float[source]#

Largest departure of any degree from the equal division, in period fractions.

Degree i of an N-note equal division sits at i / N of the period; this returns max |degree_i - i/N|, measured from the scale’s lowest degree. 0 is the equal division itself, and the value grows as the two step sizes separate – a scalar companion to blackwood_r() that weighs where the unevenness accumulates rather than only how extreme the steps get.

Examples

The 12-EDO diatonic’s worst-placed degree is its tritone, a full half-step (1/14 of the octave) above 7-EDO’s:

>>> from biotuner.mos.scale import MOSScale
>>> round(evenness(MOSScale.from_signature(5, 2, tuning=12)), 6)
0.071429
>>> round(evenness(MOSScale.from_signature(5, 2, tuning='equalized')), 12)
0.0
ji_error(scale: ScaleLike, targets: Sequence[float], weights: Sequence[float] | None = None, period_reduce: bool = True) Dict[str, object][source]#

How well the scale approximates a set of just intervals.

Each target is a frequency ratio; its distance to the nearest scale degree is reported in cents, signed positive when the target is sharp of the degree it lands on. This is the quantity the labyrinth trades against structure: moving the generator inside a signature’s valid range changes nothing about the scale’s identity but everything about how close it gets to the ratios you care about.

Parameters:
  • scale (MOSScale, Mode, or (cents_list, period_cents))

  • targets (sequence of float) – Frequency ratios, e.g. [3/2, 5/4, 6/5]. Must be finite and positive. nan and inf are rejected rather than tolerated: they propagate through every summary statistic and would silently turn a whole mos_report() row into nan.

  • weights (sequence of float, optional) – Relative importance of each target, normalised internally to sum to 1. Uniform by default. Must be finite, non-negative and not all zero.

  • period_reduce (bool, default True) – Fold each target into the period before matching, and let the match wrap around it – so a target just under the period matches the root. With False the target’s raw cents (which for a target above the period exceed every degree) are compared to the degrees as they stand, so the error reports the absolute distance rather than the pitch-class distance.

Returns:

dicterrors (signed, per target, in cents), plus mean_abs, max_abs, rms and weighted_mean, all computed on the absolute errors.

Examples

12-EDO’s fifth is famously 2 cents flat of just, its major third 14 cents sharp, and its minor third badly off:

>>> from biotuner.mos.scale import MOSScale
>>> e = ji_error(MOSScale.from_signature(5, 2, tuning=12), [3 / 2, 5 / 4, 6 / 5])
>>> [round(x, 2) for x in e['errors']]
[1.96, -13.69, -84.36]
>>> round(e['mean_abs'], 3), round(e['max_abs'], 3)
(33.333, 84.359)

Weighting the fifth heavily reflects how little its error matters:

>>> w = ji_error(MOSScale.from_signature(5, 2, tuning=12),
...              [3 / 2, 5 / 4, 6 / 5], weights=[10, 1, 1])
>>> round(w['weighted_mean'], 3)
9.8
harmonicity(scale: ScaleLike, maxdenom: int = 1000) Dict[str, float][source]#

Biotuner’s tuning-wide consonance metrics for this scale.

Delegates to biotuner.metrics.tuning_to_metrics(), so an MOS is scored on exactly the same footing as any other tuning the toolbox handles – that is the point of routing through it rather than reimplementing the measures. The import is deferred because biotuner.metrics pulls in the whole package, which is slow enough to notice on a module import.

The underlying metrics rationalise every ratio with limit_denominator(maxdenom), so they are sensitive to that bound: a tuning whose degrees are irrational (any non-EDO generator) gets whatever fraction the bound admits, and the p/q-based scores move with it.

Returns:

dict – Whatever tuning_to_metrics() produced, with numpy scalars converted to floats. An empty dict if that call raised – the metrics assume octave-ish, well-conditioned ratio lists and can fail on degenerate or pseudo-octave scales, and a report that loses one column is more useful than one that cannot be produced.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> h = harmonicity(MOSScale.from_signature(5, 2, tuning=12))
>>> round(h['harm_sim'], 2)
14.46
mos_report(scale: ScaleLike, targets: Sequence[float] | None = None, weights: Sequence[float] | None = None, maxdenom: int = 1000, harmonic: bool = True) Dict[str, object][source]#

Everything this module measures, in one flat dict.

Merges to_dict() with the structural predicates and, optionally, the harmonic metrics. Values are plain ints/floats/bools/strings and lists thereof, so the result drops straight into a pandas.DataFrame row or a JSON file – which is how the plotting and derivation layers consume it.

Parameters:
  • scale (MOSScale) – Anything with a to_dict method; a raw (cents, period) pair has no identity to report and is rejected.

  • targets (sequence of float, optional) – Just ratios to score against. When given, ji_error()’s summary statistics are added under ji_* keys.

  • weights (sequence of float, optional) – Passed through to ji_error().

  • maxdenom (int, default 1000) – Passed through to harmonicity().

  • harmonic (bool, default True) – Include harmonicity()’s keys. Turn off for bulk scans, where the rationalisation of every ratio dominates the runtime.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> r = mos_report(MOSScale.from_signature(5, 2, tuning=12),
...                targets=[3 / 2, 5 / 4], harmonic=False)
>>> r['signature'], r['myhill'], r['proper_from_matrix'], r['strictly_proper']
('5L2s', True, True, False)
>>> round(r['blackwood_r'], 6), r['is_proper'] == r['proper_from_matrix']
(2.0, True)
>>> [round(x, 2) for x in r['ji_errors']]
[1.96, -13.69]
MODEL_PARAMETERS: Dict[str, int] = {'edo': 1, 'mos': 2, 'ternary': 3}#

Free parameters each scale family fits to the data, transposition included.

At a fixed cardinality N the three families differ only in how much freedom they have to place their degrees:

edo

N equal steps. Nothing about the shape is free; only where the whole thing sits against the data.

mos

One generator, stacked N times. Two step sizes, distributed with maximal evenness – the shape is a one-parameter family.

ternary

Three step sizes filling the period, i.e. two free shape parameters (biotuner.mos.ternary’s simplex is two-dimensional).

Transposition is counted for all three because it really is fitted – biotuner.mos.derive._evaluate() searches rotations – even though a scale and its transpositions are the same scale. It is common to all three, so it cancels out of any difference between them; it is here so that the degrees-of-freedom correction below counts every quantity read off the data.

class MOSness(cardinality: int, cardinality_rule: str, n_targets: int, n_merged: int, period: float, edo_error_cents: float, mos_error_cents: float, ternary_error_cents: float | None, mos_ness: float, ternary_ness: float | None, two_step_sufficiency: float | None, signature: str, generator: float, generator_cents: float, hardness: float, ternary_word: str | None, ternary_signature: str | None, is_identifiable: bool, ternary_step_cents: Tuple[float, float, float] | None = None, notes: Tuple[str, ...] = (), by_cardinality: Tuple[Dict[str, object], ...] = ())[source]#

Bases: object

How much of a signal’s structure a generator accounts for.

evidence answers a weaker question than it looks like it answers. It compares a fitted scale against ratios scattered uniformly, so beating it establishes only that the signal is not noise – any N-note scale with somewhere to slide would beat it too. The alternative that matters is not noise, it is an equally spaced scale of the same size, which has all the same coverage and none of the structure. This class is that comparison, run at one fixed cardinality across three families of increasing freedom:

family

shape

free parameters

edo

N equal steps

1 (transposition)

mos

one generator, two steps

2 (+ generator)

ternary

three step sizes

3 (+ a second shape)

All three are scored by biotuner.mos.derive._evaluate() with the same weights, the same targets and the same exhaustive transposition search, so the numbers differ because the hypotheses differ and for no other reason. Fixing the cardinality is what makes them comparable at all: a five-note equal division against a nine-note MOS would be measuring the note count.

cardinality#

Notes per period, shared by all three families.

Type:

int

cardinality_rule#

How cardinality was chosen – 'edo-evidence' (the note count at which the equal division carried the most evidence, which does not select on the hypothesis under test), 'mos-evidence' (likewise for the MOS fit, which does), 'explicit' or 'max'.

Type:

str

n_targets, n_merged

Distinct pitch classes fitted, and how many input ratios were absorbed into one already present.

Type:

int

period#
Type:

float

edo_error_cents, mos_error_cents

Weighted mean absolute cents error of the best fit in each family.

Type:

float

ternary_error_cents#

None when the ternary rung was skipped – see notes.

Type:

float or None

mos_ness#

The headline. The share of the equal division’s error that the generator removes, after both errors are corrected for the parameters that produced them (_adjusted_error()):

mos_ness = max(0, 1 - adjusted_mos_error / adjusted_edo_error)

0 means the generator bought nothing: the signal’s ratios are spread as evenly as an equal division and a well-formed scale describes them no better. 1 means the MOS fits exactly. In between is the fraction of the null’s error that well-formedness explains away.

It is a ratio between two fits to the same data at the same cardinality, which is why it survives what the fitted generator does not: change the peak extraction and the generator moves hundreds of cents, but both fits move with it.

Zero is the definition of no improvement, not the empirical null. Uniformly random ratios score around 0.09 on average and can reach 0.33 – see mos_ness()’s notes for the measured band and why the parameter correction cannot shrink it further.

Two conventions, both deliberate. It is clamped at 0: the equal division is the degenerate limit of every MOS at this cardinality – and its generator is planted in the candidate list precisely so the search cannot miss it – so a negative value can only come from the parameter correction, which is what it is for. And it is 0 when the equal division already fits exactly (both errors below a nanocent), because there is then nothing left for a generator to remove; an equal-tempered input scores 0, which is the right answer for it.

Type:

float

ternary_ness#

The same quantity for the three-step family.

Type:

float or None

two_step_sufficiency#

mos_ness / ternary_ness, clamped to [0, 1]: of everything a third step size manages to explain beyond the equal division, how much two step sizes already had. 1 says the third step is redundant and the signal is well formed rather than merely non-uniform; a low value says two step sizes were the wrong description.

Clamped above because the admissible ternary words (_ternary_words_at()) are not guaranteed to contain the fitted MOS: their step patterns have three letters, and while the interior lines where two of the three sizes coincide do reduce a ternary word to a two-step scale, the particular MOS the signal wants need not be one of them. Where it is not, a genuinely well-formed signal comes out fitting the ternary family worse, which reads as full sufficiency.

Type:

float or None

ternary_step_cents#

(large, medium, small) of the winning ternary scale. Worth reading: full two-step sufficiency usually shows up here as two of the three sizes collapsing onto each other, which is the third step size visibly declining to exist. See ternary_collapsed.

Type:

tuple of float or None

signature, generator, generator_cents, hardness

The winning MOS. Report these as what was compared, not as an identification: the fitted generator is famously unstable across peak extraction settings even when mos_ness is not.

Type:

str, float, float, float

ternary_word, ternary_signature
Type:

str or None

is_identifiable#

False when cardinality is not strictly below n_targets. Same idea as biotuner.mos.derive.MOSFit.is_underdetermined, one notch stricter: that flag fires when the scale has more degrees than there were targets, and this one already fires when it has as many, because a scale with a degree per target is fitting one number per observation. The result is still computed – an N-note equal division has exactly N pitch classes, so refusing would make the most informative test case unmeasurable – but it is not evidence.

Type:

bool

notes#

Plain-language caveats raised during the computation: the identifiability warning, why the ternary rung was skipped, and so on. Empty is the clean case.

Type:

tuple of str

by_cardinality#

One row per candidate cardinality, each with cardinality, edo_error_cents, mos_error_cents, mos_ness, signature, generator_cents, mos_evidence, edo_evidence and identifiable. The selected row is one of these. Read the whole table before quoting the headline: the cardinality was chosen from it, and this is what shows whether the answer depended on that choice. It is also the honest form of the result – MOS-ness is a curve over note counts, and a single number is one point of it.

Type:

tuple of dict

Examples

See mos_ness().

cardinality: int#
cardinality_rule: str#
n_targets: int#
n_merged: int#
period: float#
edo_error_cents: float#
mos_error_cents: float#
ternary_error_cents: float | None#
mos_ness: float#
ternary_ness: float | None#
two_step_sufficiency: float | None#
signature: str#
generator: float#
generator_cents: float#
hardness: float#
ternary_word: str | None#
ternary_signature: str | None#
is_identifiable: bool#
ternary_step_cents: Tuple[float, float, float] | None = None#
notes: Tuple[str, ...] = ()#
by_cardinality: Tuple[Dict[str, object], ...] = ()#
property ternary_collapsed: bool | None#

True when two of the fitted ternary steps agree to within a cent.

The ternary simplex has interior lines on which two step sizes coincide and the scale is really binary. Landing on one is not a failure of the search: it is the three-step family saying, from inside itself, that the third size was not wanted.

None when the ternary rung was not computed.

property adjusted_edo_error_cents: float#

edo_error_cents after _adjusted_error().

property adjusted_mos_error_cents: float#

mos_error_cents after _adjusted_error().

property adjusted_ternary_error_cents: float | None#

ternary_error_cents after _adjusted_error().

property raw_mos_ness: float#

mos_ness computed on the uncorrected errors.

The same ratio without the parameter penalty, so the size of the penalty is visible rather than baked in. Always at least mos_ness.

to_dict() Dict[str, object][source]#

Flat, JSON-friendly summary – one row of a table.

by_cardinality is kept as a list of dicts rather than flattened, so the row this result was read off stays attached to it.

summary() str[source]#

Multi-line human-readable description, ASCII only.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> r = mos_ness(MOSScale.from_signature(5, 2, tuning=31).ratios,
...              ternary=False)
>>> print(r.summary())
MOS-ness 1.000   at 7 notes   (cardinality chosen by: edo-evidence)
  targets        7 pitch classes, 0 merged, period 1200.000 c
  equal div.     7-EDO                  error   18.960 c   (1 parameter)
  well formed    5L2s @ 696.774 c       error    0.000 c   (2 parameters)
  three steps    not computed
  two-step suff. n/a
  note           cardinality 7 is not below the 7 targets: the fit has a degree
                 per observation and is not evidence
  note           ternary rung skipped: ternary=False
mos_ness(ratios: Sequence[float], weights: Sequence[float] | None = None, *, period: float = 2.0, cardinality: int | str = 'edo', min_cardinality: int = 4, max_cardinality: int | None = None, tolerance_cents: float = 15.0, ternary: bool = True, ternary_max_cardinality: int = 10, grid: int = 720, include_intervals: bool = True, fold: bool = True, allow_underdetermined: bool = False) MOSness[source]#

How much of a signal’s structure needs a generator.

Not “which moment-of-symmetry scale is this signal in” – that question is not answerable from spectral peaks, because the fitted generator moves by hundreds of cents when the peak extraction changes – but “is this signal well formed at all, more so than an equally spaced scale of the same size would be”. The second question survives the instability of the first, because it is a ratio between two fits to the same data at the same cardinality and most of what moves moves in both.

Three families are fitted at one shared cardinality and scored through the same code path with the same transposition search:

  1. the equal division – maximally even, no shape parameters;

  2. the best well-formed scale – one generator;

  3. the best admissible three-step scale – two shape parameters.

MOSness.mos_ness is how much of (1)’s error the generator removes; MOSness.two_step_sufficiency is how little (3) adds on top. Both are computed on errors corrected for the number of parameters that produced them – see _adjusted_error() for the rule and its limits.

Parameters:
  • ratios (sequence of float) – Frequency ratios to explain – typically bt.peaks_ratios.

  • weights (sequence of float, optional) – Per-ratio importance, e.g. peak amplitudes. Normalised internally; uniform when omitted.

  • period (float, default 2.0) – Period as a frequency ratio. 2.0 is the octave.

  • cardinality (int or {‘edo’, ‘mos’, ‘max’}, default ‘edo’) – Where to make the comparison.

    • 'edo' (default) – the cardinality at which the equal division carries the most evidence, i.e. the note count at which the signal’s pitch classes are most evenly spread. The generator plays no part in choosing it, so the choice cannot inflate the answer. That matters: over sixty draws of twenty uniform ratios the null level of MOSness.mos_ness averages 0.09 under this rule and 0.18 under 'mos', while a real MOS reaches 1.0 under either. Recorded as 'edo-evidence'.

    • 'mos' – the cardinality at which the MOS fit carries the most evidence, i.e. the note count that best supports the well-formed reading. A selection made on the hypothesis under test, so it biases MOSness.mos_ness upward; use it to ask “at how many notes is this signal well formed”, not to compare conditions. Recorded as 'mos-evidence'.

    • an int – use it. Recorded as 'explicit'.

    • 'max' – the largest cardinality searched.

    MOSness.by_cardinality carries every candidate either way, so the effect of the rule is always visible after the fact.

  • min_cardinality (int, default 4) – Smallest note count to consider. Below 4 the equal division has so few degrees that everything fits it badly and the ratio is noisy.

  • max_cardinality (int, optional) – Largest note count to consider. Defaults to the number of distinct pitch classes, which is the largest cardinality at which the scale has no spare degrees. Larger values need allow_underdetermined.

  • tolerance_cents (float, default 15.0) – What counts as a hit, passed straight through to the scoring function for parity with fit_mos(). It feeds only that function’s coverage figure, which nothing on MOSness reads, so changing it cannot change any number reported here.

  • ternary (bool, default True) – Fit the three-step rung. Turn off to halve the runtime when only MOSness.mos_ness is wanted.

  • ternary_max_cardinality (int, default 10) – Skip the ternary rung above this note count, recording why in MOSness.notes. The word enumeration behind _ternary_words_at() grows like 3**N / N: about 0.03 s at 7 notes, 0.7 s at 10, 7 s at 12 and 25 s at 13. Raise it if you need it and can wait; the result is cached per cardinality.

  • grid (int, default 720) – Background generator-grid resolution for the MOS search. 0 uses only signal-derived candidates.

  • include_intervals (bool, default True) – Also try every interval between observed ratios as a generator.

  • fold (bool, default True) – Reduce the ratios to distinct pitch classes first, as fit_mos() does.

  • allow_underdetermined (bool, default False) – Permit a cardinality above the number of targets. Refused by default: a scale with spare degrees can be rotated until everything lands somewhere, and its error stops being a measurement.

Returns:

MOSness

Raises:

ValueError – If there are fewer than four distinct pitch classes (three fitted parameters need more than three observations to mean anything); if an explicit cardinality sits below min_cardinality; or if a cardinality above the number of targets is requested without allow_underdetermined.

Notes

The equal division’s own generator – (N-1)/N of the period, whose degenerate MOS is exactly N-EDO – is planted in the candidate list at every cardinality searched. The MOS family therefore contains the null, so the best MOS can never do worse than the equal division except by the parameter correction, and MOSness.mos_ness is a genuine improvement rather than an artefact of two searches missing each other.

The null is not zero, the parameter correction does not make it zero, and the null band below does not transfer to your data. Sixty draws of twenty uniformly random ratios, searched over four to nine notes, give a mean MOS-ness of 0.088, a median of 0.082, a 90th percentile of 0.177 and a maximum of 0.330 (0.183 / 0.173 / 0.332 / 0.565 under cardinality='mos'). test_random_uniform_ratios_score_near_zero re-measures that band rather than taking it on trust.

Those numbers are for twenty targets and are much too low for a typical spectral-peak set. The null rises steeply as targets get scarcer: at the seven or eight targets a peak finder actually returns, matched noise scores a median of 0.25 to 0.42, so more than half of pure noise clears the 0.330 quoted above as a maximum. Reading a real measurement against the wrong band is how you conclude a biosignal is well formed when it is not. Generate your own null, matched to your own target count.

Matched on what, specifically, matters as much as matching at all. For spectral peaks the surrogate must copy the real set’s smallest separation between peaks, not just its count and frequency span. A peak finder cannot return two peaks on top of each other, so a real set carries a minimum spacing that a log-uniform draw does not; two surrogate peaks landing close together fold to adjacent pitch classes and force a hole elsewhere, and the equal division in the denominator of this measure is precisely an evenness statistic. The consequence is not subtle – on one EEG dataset the same epochs at the same cardinality gave Cliff’s delta -0.12 against a log-uniform surrogate and +0.04 against a spacing-matched one. The sign of the answer was decided by the null, not by the data.

The reason is worth stating, because it bounds what _adjusted_error() can do. A generator is one number, but it is a number with enormous leverage: moving it slides every degree of the scale at once, in a correlated way. Counting it as one parameter out of n residuals – which is what a degrees-of-freedom correction does – charges it far less than it is worth on random data, where it removes about a tenth of the error rather than the twentieth the count would predict. So read MOSness.mos_ness against the band above, not against zero: below roughly 0.2 is “no more well formed than noise”, and the separation from a genuine MOS (1.0) is the whole usable range.

Examples

A scale that really is a moment of symmetry – 31-EDO meantone – is explained by its generator and not at all by an equal division of the same size:

>>> from biotuner.mos.scale import MOSScale
>>> meantone = MOSScale.from_signature(5, 2, tuning=31)
>>> r = mos_ness(meantone.ratios, ternary=False)
>>> r.cardinality, r.signature, round(r.mos_ness, 6)
(7, '5L2s', 1.0)
>>> round(r.edo_error_cents, 3), round(r.mos_error_cents, 9)
(18.96, 0.0)

Seven notes from seven pitch classes is a degree per observation, and the result says so rather than quietly pretending otherwise:

>>> r.is_identifiable
False
>>> r.notes[0].startswith('cardinality 7 is not below the 7 targets')
True

An equal division is the case where the generator buys nothing. 7-EDO scores exactly zero – both families fit it perfectly, so there is no error left for the extra parameter to remove:

>>> edo7 = [2 ** (k / 7) for k in range(7)]
>>> r = mos_ness(edo7, cardinality=7, ternary=False)
>>> round(r.edo_error_cents, 9), r.mos_ness
(0.0, 0.0)

A genuinely three-step scale is partly well formed – a MOS gets most of the way there, because three step sizes are a perturbation of two – and the third step is what finishes the job. two_step_sufficiency is what says so:

>>> from biotuner.mos.ternary import TernaryScale
>>> t = TernaryScale.from_barycentric('LMLsLMs', 0.52, 0.30, 0.18)
>>> r = mos_ness(t.ratios, cardinality=7)
>>> round(r.mos_error_cents, 3), round(r.ternary_error_cents, 9)
(5.6, 0.0)
>>> round(r.two_step_sufficiency, 2)
0.68

On the well-formed scale above the same rung finds nothing to add: it lands on an interior line of the ternary simplex where two of the three step sizes coincide, which is the three-step family declining the third step.

>>> r = mos_ness(meantone.ratios, cardinality=7)
>>> r.two_step_sufficiency, r.ternary_collapsed
(1.0, True)
>>> [round(c, 3) for c in r.ternary_step_cents]
[193.548, 116.129, 193.548]

temperaments#

Named rank-2 temperaments, with every generator derived from its comma.

Milne et al. §4 overlay the interactive scale labyrinth with named regular temperaments – meantone, srutal, magic, hanson, … – drawn as radial lines whose angle is that temperament’s optimal generator/period ratio. This module supplies those angles.

Nothing here is a remembered table of cents. A temperament is entered as the comma it tempers out (81/80 for meantone) and the rest is computed:

  • the mapping – how many periods and generators each prime is worth – is a saturated integer basis of the comma’s annihilator lattice, put into Hermite normal form so that it is canonical and not merely correct;

  • the generator is the Tenney-weighted least-squares fit to the just primes, with the period held exactly pure.

Because the numbers are derived, the module checks itself: mapping @ monzo is exactly (0, 0) in integer arithmetic for every named comma, and periods_per_octave is read off the mapping rather than declared.

Saturation matters#

The annihilator of a comma is obtained here by an iterative extended-Euclid on the monzo’s entries, each step a unimodular 2 x 2 row operation on an accumulating matrix U. When U @ m == (gcd, 0, ..., 0), rows 1.. of U span exactly {v : v . m == 0} – every integer val that tempers the comma out, not just a finite-index sublattice of them. A rational nullspace with denominators cleared afterwards can silently land on such a sublattice (e.g. give 2 periods per octave where the temperament really has 1), and then every downstream cent value is wrong in a way that still looks plausible.

Tuning convention#

Three choices are free in a rank-2 mapping and are pinned down as follows.

  1. Hermite normal form. Pivots are positive, the second row is cleared out of the octave column (mapping[1][0] == 0, so the period is a pure fraction of the octave), and the first row is reduced modulo the second’s pivot. For meantone this yields the familiar [[1, 0, -4], [0, 1, 4]] – prime 5 is four generators up – available as Rank2Temperament.hermite_mapping.

  2. Sign. mapping[1] and its negation describe the same temperament with generators running in opposite directions. The sign is fixed by requiring the unreduced generator to be a positive interval. This is what makes porcupine come out as ~163 c rather than ~-163 c.

  3. Octave reduction. The generator is then folded into [0, period) by adding multiples of mapping[1] to mapping[0], which leaves periods_per_octave untouched. Meantone’s twelfth becomes its fifth and the stored mapping becomes [[1, 1, 0], [0, 1, 4]], so that prime_errors is consistent with the generator actually reported.

Octave-locked#

Every quantity here is measured in octaves and then scaled by theory.PERIOD_CENTS: the period is 1 / periods_per_octave octaves, the period ratio is 2 ** (1 / periods_per_octave), and each prime’s error is measured against log2(p). That is only coherent when the first prime is 2, so Rank2Temperament refuses any other basis. A no-twos subgroup such as (3, 5, 7) would otherwise report a 1200-cent period for an equave of 3, a 702-cent error on its own equave, and a generator with no meaning – all without raising. Supporting one would mean replacing those octave constants with the equave, not relabelling them.

A generator g and its complement period - g build precisely the same scales (Milne et al. §4), so steps 2–3 are cosmetic: they choose which of two mirror-image radial lines gets drawn. Published tables sometimes choose the other one: father and bug, among others, are normally quoted as this convention’s complement. Hence Rank2Temperament.complement_cents, and hence nearest_temperaments() matching against both.

Which optimum#

The period is held exactly pure at 1 / periods_per_octave octaves and the generator minimises the Tenney-weighted squared error of the remaining primes. That is the constrained Tenney-Euclidean tuning (CTE). It is not the same as the POTE tuning quoted on the Xenharmonic wiki, which optimises period and generator together and then rescales the result to a pure octave; the two agree to a fraction of a cent for accurate temperaments and diverge by several cents for wildly inaccurate ones. Both are available – Rank2Temperament.generator_cents is CTE, Rank2Temperament.pote_generator_cents is POTE.

References

Milne, A.J., Carlé, M., Sethares, W.A., Noll, T., Holland, S. (2011). Scratching the Scale Labyrinth. In Mathematics and Computation in Music, LNAI 6726, 180–195.

Smith, G.W. / Breed, G. Regular temperament theory: vals, monzos, Hermite normal form and Tenney-Euclidean tunings.

PRIMES_5: Tuple[int, ...] = (2, 3, 5)#

The 5-limit primes – the plane the classical comma names live in.

PRIMES_7: Tuple[int, ...] = (2, 3, 5, 7)#

The 7-limit primes. A rank-2 temperament here needs two commas; pass the second through rank2_from_comma(..., extra_commas=[...]).

monzo(ratio: int | Fraction | str, primes: Sequence[int] = (2, 3, 5)) Tuple[int, ...][source]#

Prime-exponent vector of a rational interval.

Parameters:
  • ratio (int, Fraction or str) – The interval, exactly. Floats are refused: 81 / 80 as a float is not Fraction(81, 80), and the resulting monzo would be nonsense rather than an error.

  • primes (sequence of int, default PRIMES_5) – The prime basis: strictly increasing, and every entry genuinely prime. Every prime factor of ratio must appear in it. Both conditions are checked, because a monzo over a repeated or composite basis is not unique – (2, 3, 3) would silently send all of prime 3 into the first of the two slots and report the second as untouched.

Returns:

tuple of intm such that ratio == prod(p ** e for p, e in zip(primes, m)).

Raises:

ValueError – If ratio has a prime factor outside primes, or if primes is not a strictly increasing sequence of primes.

Examples

>>> monzo(Fraction(81, 80))
(-4, 4, -1)
>>> monzo(Fraction(3, 2))
(-1, 1, 0)
>>> monzo(Fraction(64, 63), PRIMES_7)
(6, -2, 0, -1)
>>> monzo(Fraction(7, 5))
Traceback (most recent call last):
    ...
ValueError: 7/5 is not 5-limit over primes (2, 3, 5): 7/1 is left over

A basis that is not strictly increasing primes is refused rather than silently producing a non-unique monzo:

>>> monzo(Fraction(9, 8), (2, 3, 3))
Traceback (most recent call last):
    ...
ValueError: the prime basis (2, 3, 3) must be strictly increasing; 3 does not exceed 3
>>> monzo(Fraction(4, 1), (2, 4))
Traceback (most recent call last):
    ...
ValueError: the prime basis (2, 4) contains 4, which is not prime; a monzo over a composite basis is not unique
saturated_annihilator(monzos: Sequence[Sequence[int]]) List[List[int]][source]#

A saturated integer basis of {v : v . m == 0 for every m}.

“Saturated” means the returned rows generate every integer vector orthogonal to the given monzos, not a finite-index sublattice of them. That is the whole point: a sublattice basis still satisfies v . m == 0 and still looks like a mapping, but it multiplies the apparent number of periods per octave and silently corrupts every tuning derived from it.

Parameters:

monzos (sequence of int vectors) – All of the same length, and linearly independent.

Returns:

list of list of intlen(monzos[0]) - len(monzos) rows.

Examples

The rows annihilate the comma exactly, in integer arithmetic:

>>> basis = saturated_annihilator([monzo(Fraction(81, 80))])
>>> len(basis)
2
>>> [sum(x * y for x, y in zip(row, monzo(Fraction(81, 80)))) for row in basis]
[0, 0]

And the lattice they span is the canonical meantone mapping:

>>> hermite_normal_form(basis)
[[1, 0, -4], [0, 1, 4]]
hermite_normal_form(rows: Sequence[Sequence[int]]) List[List[int]][source]#

Row-style Hermite normal form over the integers.

Echelon shape, every pivot positive, every entry above a pivot reduced into [0, pivot), zero rows dropped. Two bases of the same lattice have the same Hermite normal form, which is what makes a mapping comparable with a published one.

Examples

>>> hermite_normal_form([[-1, -1, 0], [0, 1, 4]])
[[1, 0, -4], [0, 1, 4]]

Rank deficiency shows up as a shorter result rather than as an error:

>>> hermite_normal_form([[2, 4], [3, 6]])
[[1, 2]]
class Rank2Temperament(name: str, comma: Fraction, primes: Tuple[int, ...], mapping: Tuple[Tuple[int, ...], Tuple[int, ...]], extra_commas: Tuple[Fraction, ...] = ())[source]#

Bases: object

A rank-2 regular temperament: a period, a generator, and a prime mapping.

Constructing one canonicalises the mapping (Hermite normal form, then the sign and octave-reduction conventions described in the module docstring), so any basis of the right lattice gives the same object. The commas are re-checked against the canonical rows in exact integer arithmetic.

Parameters:
  • name (str)

  • comma (Fraction) – The comma this temperament tempers out.

  • primes (tuple of int) – Prime basis, e.g. PRIMES_5. Strictly increasing, and it must start at 2 – see the Octave-locked note in the module docstring.

  • mapping (2 x len(primes) integers) – Row 0 counts periods per prime, row 1 counts generators per prime. Any basis of the annihilator lattice will do; it is canonicalised.

  • extra_commas (tuple of Fraction, optional) – Further commas, needed once the prime limit exceeds 5 (a rank-2 temperament in an n-prime basis tempers out n - 2 of them).

Examples

>>> mt = temperament("meantone")
>>> mt.mapping
((1, 1, 0), (0, 1, 4))
>>> mt.periods_per_octave, round(mt.period_cents, 1)
(1, 1200.0)
>>> round(mt.generator_cents, 3)
697.214

Prime 3 is one generator up, prime 5 is four – the syntonic comma is gone:

>>> mt.hermite_mapping
((1, 0, -4), (0, 1, 4))
>>> [round(e, 3) for e in mt.prime_errors]
[0.0, -4.741, 2.544]

The classical POTE figure, for comparison with published tables:

>>> round(mt.pote_generator_cents, 3)
696.239
name: str#
comma: Fraction#
primes: Tuple[int, ...]#
mapping: Tuple[Tuple[int, ...], Tuple[int, ...]]#
extra_commas: Tuple[Fraction, ...] = ()#
property commas: Tuple[Fraction, ...]#

Every comma tempered out, primary first.

property periods_per_octave: int#

How many equal periods the octave is cut into.

Read off mapping[0][0]: prime 2 is worth exactly that many periods and no generators, which is what makes the period a pure fraction of a pure octave.

property hermite_mapping: Tuple[Tuple[int, ...], Tuple[int, ...]]#

The mapping before the sign and octave-reduction conventions.

Same lattice, canonical Hermite form – this is the shape published tables usually print.

property period_cents: float#

Period size in cents; the octave is exactly pure by construction.

property period_ratio: float#

Period as a frequency ratio, 2 ** (1 / periods_per_octave).

property generator_octaves: float#

Generator in octaves – the raw quantity everything else scales.

property generator_cents: float#

Generator in cents, pure-period Tenney-weighted least squares (CTE).

Lies in [0, period_cents). See the module docstring on how this differs from the POTE figures in published tables.

property generator_ratio: float#

Generator as a frequency ratio.

property complement_cents: float#

period_cents - generator_cents.

Builds exactly the same scales as generator_cents (Milne et al. §4), and is the figure some tables quote instead.

generator_fraction() float[source]#

Generator as a fraction of the period, in (0, 1).

This is the labyrinth coordinate: feed it to biotuner.mos.theory.sb_walk() or biotuner.mos.scale.MOSScale.from_fraction() directly.

Examples

>>> round(temperament("meantone").generator_fraction(), 6)
0.581012
>>> round(temperament("srutal").generator_fraction(), 6)
0.175227
property pote_generator_cents: float#

Generator under the classical POTE tuning, for comparison.

Period and generator are fitted together (so the octave comes out tempered), then everything is rescaled until the octave is pure. This is the number the Xenharmonic wiki lists; generator_cents instead holds the octave pure during the fit.

property prime_errors: Tuple[float, ...]#

Cents by which each prime is mistuned, in primes order.

Positive means sharp. The first entry is exactly 0.0: the octave is pure by construction, which is the “PO” in POTE.

Examples

>>> [round(e, 3) for e in temperament("schismatic").prime_errors]
[0.0, -0.236, -0.063]
property max_error: float#

Largest absolute prime error, in cents.

property rms_error: float#

Root-mean-square prime error in cents, over all primes.

Prime 2 is included and contributes exactly zero, so this is a little lower than an RMS over the tempered primes alone; it is comparable across temperaments of the same prime limit, which is what it is for.

mos_family(max_cardinality: int = 32) List['MOSScale'][source]#

Every MOS this temperament’s optimal generator produces, per period.

Cardinalities count notes per period, so a temperament with several periods per octave produces that many times as many notes per octave.

Returns:

list of MOSScale

Examples

Meantone walks up to the diatonic and on into chromatic territory:

>>> [s.signature for s in temperament("meantone").mos_family(12)]
['2L1s', '2L3s', '5L2s', '7L5s']

Hanson’s minor-third generator gives the Kleismic series instead:

>>> [s.cardinality for s in temperament("hanson").mos_family(19)]
[3, 4, 7, 11, 15, 19]
supports(scale: MOSScale, tol_cents: float = 5.0) bool[source]#

Whether scale is this temperament at (near enough) its optimum.

Both the period and the generator must match within tol_cents. The generator is compared against complement_cents too, since a generator and its complement build the same scale.

Parameters:
  • scale (MOSScale)

  • tol_cents (float, default 5.0)

Examples

31-EDO’s diatonic is meantone; 12-EDO’s is close enough; Pythagorean tuning is not:

>>> from biotuner.mos.scale import MOSScale
>>> mt = temperament("meantone")
>>> mt.supports(MOSScale.from_signature(5, 2, tuning=31))
True
>>> mt.supports(MOSScale.from_signature(5, 2, tuning=12))
True

Pythagorean tuning is 4.7 cents sharp of the meantone optimum, so it squeaks inside the default tolerance and fails a tighter one:

>>> mt.supports(MOSScale.from_generator(3 / 2, 7))
True
>>> mt.supports(MOSScale.from_generator(3 / 2, 7), tol_cents=2.0)
False
to_dict() Dict[str, object][source]#

Flat, JSON-friendly summary – one row of a temperament table.

summary() str[source]#

Multi-line human-readable description.

Examples

>>> print(temperament("porcupine").summary())
porcupine   tempers out 250/243
  mapping        <1 2 3|   <0 -3 -5|   (primes 2.3.5)
  period           1200.000 c  x1 per octave
  generator         164.166 c  (0.136805 of the period)
  also             1035.834 c complement,  163.950 c POTE
  prime errors   2: +0.000,  3: +5.547,  5: -7.143 c
  max 7.143 c,  rms 5.222 c
rank2_from_comma(comma: int | Fraction | str, primes: Sequence[int] = (2, 3, 5), name: str | None = None, extra_commas: Sequence[int | Fraction | str] = ()) Rank2Temperament[source]#

Build the rank-2 temperament that tempers out comma.

Parameters:
  • comma (Fraction) – Must be primitive: the entries of its monzo share no common factor. (81/80) ** 2 defines the same temperament as 81/80 but is not a comma, and accepting it would quietly hide the squaring.

  • primes (sequence of int, default PRIMES_5) – Strictly increasing primes starting at 2; see the Octave-locked note in the module docstring for why the equave is not free.

  • name (str, optional) – Defaults to the comma itself.

  • extra_commas (sequence of Fraction, optional) – Required above the 5-limit: len(primes) - 2 commas in total pin a rank-2 temperament down.

Returns:

Rank2Temperament

Examples

>>> t = rank2_from_comma(Fraction(81, 80))
>>> t.name, t.mapping, round(t.generator_cents, 3)
('81/80', ((1, 1, 0), (0, 1, 4)), 697.214)

Septimal meantone needs a second comma, and then prime 7 joins in:

>>> sm = rank2_from_comma(Fraction(81, 80), PRIMES_7, "septimal meantone",
...                       extra_commas=[Fraction(126, 125)])
>>> sm.mapping
((1, 1, 0, -3), (0, 1, 4, 10))
>>> round(sm.generator_cents, 3)
696.952

A non-primitive comma is refused rather than silently accepted:

>>> rank2_from_comma(Fraction(6561, 6400))
Traceback (most recent call last):
    ...
ValueError: comma 6561/6400 has monzo (-8, 8, -2), whose entries share the factor 2; use its primitive root 81/80 instead

So is a no-twos subgroup, whose equave this octave-locked module cannot represent:

>>> rank2_from_comma(Fraction(245, 243), (3, 5, 7))
Traceback (most recent call last):
    ...
ValueError: the prime basis must start at 2, got (3, 5, 7): every period and generator in this class is measured in octaves (PERIOD_CENTS / periods_per_octave, 2 ** (1 / periods_per_octave), log2 of each prime), so a basis whose equave is not 2 would report a 1200-cent period, a nonzero error on prime 3 and a meaningless generator rather than raising
TEMPERAMENTS: Dict[str, Fraction] = {'amity': Fraction(1600000, 1594323), 'augmented': Fraction(128, 125), 'blackwood': Fraction(256, 243), 'bug': Fraction(27, 25), 'compton': Fraction(531441, 524288), 'dicot': Fraction(25, 24), 'diminished': Fraction(648, 625), 'father': Fraction(16, 15), 'hanson': Fraction(15625, 15552), 'magic': Fraction(3125, 3072), 'mavila': Fraction(135, 128), 'meantone': Fraction(81, 80), 'negri': Fraction(16875, 16384), 'porcupine': Fraction(250, 243), 'ripple': Fraction(6561, 6250), 'schismatic': Fraction(32805, 32768), 'sensipent': Fraction(78732, 78125), 'srutal': Fraction(2048, 2025), 'tetracot': Fraction(20000, 19683), 'wuerschmidt': Fraction(393216, 390625)}#

5-limit rank-2 temperaments by name, each stored as the comma it tempers out. The mapping, period and generator of each are computed from the comma – see temperament().

temperament(name: str) Rank2Temperament[source]#

The named 5-limit temperament, built from its comma and cached.

Examples

>>> temperament("srutal").periods_per_octave
2
>>> round(temperament("srutal").period_cents, 1)
600.0
>>> temperament("magic").mapping
((1, 0, 2), (0, 5, 1))

The name is normalised before the cache is consulted, so spelling variants share one object:

>>> temperament("Meantone") is temperament("meantone")
True

Unknown names list what is available rather than raising a bare KeyError:

>>> temperament("meantime")
Traceback (most recent call last):
    ...
ValueError: unknown temperament 'meantime'; did you mean 'meantone'?
all_temperaments() Dict[str, Rank2Temperament][source]#

Every named temperament, built and cached.

Examples

>>> ts = all_temperaments()
>>> len(ts)
20
>>> sorted(n for n, t in ts.items() if t.periods_per_octave > 1)
['augmented', 'blackwood', 'compton', 'diminished', 'srutal']
nearest_temperaments(generator_cents: float, period_cents: float = 1200.0, n: int = 3, max_distance_cents: float = 25.0) List[Tuple[str, Rank2Temperament, float]][source]#

Named temperaments whose optimal generator sits near a given one.

This is the labyrinth’s reverse lookup: the user lands somewhere on the disc, and this reports which radial lines (Milne et al. §4) they are close to. Only temperaments with a matching period are considered – a 600-cent period is a different disc from a 1200-cent one. Distances are measured to the generator and to its complement, because those draw mirror-image lines that build identical scales.

Parameters:
  • generator_cents (float) – Reduced into [0, period_cents) before comparing.

  • period_cents (float, default 1200.0)

  • n (int, default 3) – Most that will be returned.

  • max_distance_cents (float, default 25.0)

Returns:

list of (name, Rank2Temperament, distance_cents) – Nearest first; distance_cents is unsigned.

Examples

>>> [(n, round(d, 3)) for n, _, d in nearest_temperaments(696.6)]
[('meantone', 0.614), ('schismatic', 5.119), ('mavila', 19.455)]

Nothing named lives near 640 cents:

>>> nearest_temperaments(640.0)
[]

Half-octave periods are searched separately:

>>> [n for n, _, _ in nearest_temperaments(105.0, period_cents=600.0)]
['srutal']

derive#

Biosignal → MOS: candidate generators, the fit, time-resolved trajectories, and the comparison across every way a compute_biotuner has of deriving ratios.

Two directions. biotuner.mos.derive.fit_mos() is the inverse direction and the default: the generator is latent, searched for jointly with the cardinality and the rotation, and it need not be an interval anything in the signal states. biotuner.mos.derive.forward_scales() is the forward direction: it takes an interval the signal does state — the quotient of two peaks, or a peak ratio itself — declares it the generator, and reports the ForwardScale that stacking it produces. Nothing there is optimised, so the result is a consequence rather than a fit; it is scored against the same targets with the same objective, so its error_cents and coverage can be set beside a MOSFit. biotuner.mos.derive.mos_from_biotuner() switches between them with mode='inverse' / mode='forward'. Both fold their generators into the bright half of the period, since a generator and its complement build the same scale — see docs/mos_architecture.md for why an apparently half-empty labyrinth is that convention rather than a finding.

Any get_tuning() source can feed a fit — peak ratios, consonant ratios, extended-peak ratios, dissonance-curve minima, harmonic-entropy minima, an Euler-Fokker genus, a common-harmonic or harmonic-fit tuning — via source= on biotuner.mos.derive.mos_from_biotuner(), biotuner.mos.derive.mos_trajectory() and bt.fit_mos. biotuner.mos.derive.compare_sources() fits all of them at once and ranks the results by MOSFit.evidence, the error expressed in standard errors below chance, so that a two-point fit reporting 0.00 cents cannot outrank a seven-point one. A source that raises still gets a row, with the exception in reason. 'mos' is refused outright: it would fit a moment-of-symmetry scale to a moment-of-symmetry scale.

Ratios are folded into the period before fitting and merged within biotuner.mos.derive.FOLD_TOLERANCE_CENTS, so 1/1 and 2/1 count as the one pitch class they are; pass fold=False to keep every ratio a separate target. A fit with more degrees than targets is returned unchanged but marked MOSFit.is_underdetermined, because a scale with spare notes can be rotated onto any data.

Deriving moment-of-symmetry scales from biosignals.

The scale labyrinth is a space: every point is a (period, generator) pair, and every ring a cardinality. Milne et al. (2011) treat it as an instrument – a surface a performer navigates by hand. This module treats it as a search space instead, and asks the question the paper does not: given a signal’s spectral peaks, which well-formed scale best explains them?

The fit has three coordinates, matching the three choices the labyrinth affords:

generator

Where around the circle. Candidates come from the signal itself – every observed ratio, and every ratio between ratios, is a generator worth trying – plus a background grid so nothing is missed.

cardinality

Which ring. Only the generator’s own MOS cardinalities are considered, since those are the only note-counts at which a generated scale is well-formed at all.

period

How big the circle is. Usually the octave, but the paper is explicit that the period is a free parameter (a “pseudo-octave”), and a biosignal has no particular reason to prefer 2/1. Set optimize_period=True to fit it.

Plus one nuisance coordinate the labyrinth does not show, because it is not a property of the scale at all:

transposition

Where the scale sits relative to the signal. A scale and its transpositions are the same scale, so each candidate is free to slide onto the data. Without that, a stack of fifths does not read as the pentatonic – the pentatonic matches only in one of its five modes, and rooting every candidate at 1/1 picks the wrong one. MOSFit.offset reports the fitted transposition and MOSFit.mode says which mode it lands in.

The objective is amplitude-weighted mean absolute cents error from each peak ratio to its nearest scale degree, plus a penalty for surplus notes – without which a 53-note MOS would always “win” by brute coverage.

Two directions#

That search is the inverse direction, and it is the default: the generator is a latent parameter, recovered from the peaks without ever having to appear between two of them. Delete 3/2 from a stack of fifths and fit_mos() still reports 701.96 cents, because 27/16 over 9/8 is a fifth.

forward_scales() asks the other question. It takes an interval the signal actually states – the quotient of two peaks, or a peak ratio itself – declares it the generator, stacks it, and reads off the MOS that comes out. Not “which latent generator explains these peaks” but “if this observed interval were the generator, what scale would the signal be playing”. Nothing is optimised there, so the result is a consequence rather than a fit.

The two are made comparable by scoring them the same way: every forward reading is measured against the whole target set with the same objective and the same transposition freedom the inverse search uses, so its error_cents, coverage and evidence mean what they mean on a MOSFit. Both also fold their generators into the bright half of the period, so the two land on the same axis of the same plot.

FOLD_TOLERANCE_CENTS = 1.0#

Cents within which two ratios count as the same pitch class when fit_mos() folds its targets into the period.

One cent is the same threshold generator_candidates() already uses to thin its generator list, and the choice is bounded from both sides. It has to be large enough to absorb the near-duplicates a real derivation emits – two peak pairs giving 1.1250 and 1.1249 are one pitch class measured twice, 0.15 cents apart – and small enough that it can never merge two degrees the fit would otherwise distinguish, which at a 15-cent default hit tolerance leaves more than an order of magnitude of headroom.

GENERATOR_EPSILON = 1e-09#

Period fractions closer together than this are the same number, not two candidate generators – and a generator within this of a boundary of the bright half is on that boundary.

The unit is a fraction of the period, so the constant is period-independent; at the octave it is 1.2e-6 cents. It is bounded from both sides, and the gap between the bounds is enormous, which is why a round number in the middle is safe rather than tuned:

From below. Every generator here arrives through log(a) / log(b), and the identities that ought to hold exactly do not. log(2 ** 0.5) / log(2) is 0.5000000000000001, not 0.5; a difference of positions accumulates a few more ulps. Empirically that noise stays under 1e-14, and 1e-9 is five orders of magnitude above it.

From above. The narrowest thing this must not swallow is a genuine generator a few cents from half the period – 3 cents is 2.5e-3 of an octave – and 1e-9 is six orders of magnitude below that. Even the sub-cent distinctions _refine_generator() exists to preserve are 1e-6 of a period, a thousand times coarser.

MIN_REFINED_STEP = 1e-05#

Smallest step, as a fraction of the period, a refined scale may have.

GENERATOR_EPSILON asks whether two degrees are the same number. This asks the different and larger question of whether they are the same note, and it exists because _refine_generator() will happily answer the first one correctly and the second one wrongly: sliding the generator to the edge of its tuning range collapses the scale onto a smaller one, which fits any data at least as well as the scale it claims to be, so the optimiser goes there whenever the data lets it. On five-tone equal input that returned a 5L6s whose eleven degrees were five pitch classes 0.0002 cents apart – arithmetically distinct, musically one note each.

1e-5 of the period is 0.012 cents at the octave, and the choice is bounded from both sides. From below: four orders of magnitude above GENERATOR_EPSILON, so it can never fire on arithmetic noise, and an order above the 1e-6 of a period that is the finest generator distinction this module claims to resolve at all – a step below the resolution of the generator that produced it is not a step. From above: across the fitted corpus in tests/mos the narrowest step any scale legitimately wanted was 0.81 cents, seventy times wider, and the collapsed ones sat at 0.0002 cents, sixty times narrower. The gap between the two populations is four orders of magnitude wide, which is why a round number in the middle of it is safe rather than tuned.

MIN_AUDIBLE_STEP_CENTS = 1.0#

Smallest step, in cents, that makes two degrees separate notes.

GENERATOR_EPSILON asks whether two degrees are the same number and MIN_REFINED_STEP bounds what the refiner may do; this asks the musical question, and it is the one that decides whether a scale gets returned at all.

The two populations it separates were measured, not guessed. Over a corpus of forward and inverse fits the scales that had collapsed onto a smaller one carried a smallest step of 0.012 to 0.030 cents and a hardness (L / s) between 10158 and 24810 – a “two step size” scale whose second step size is four orders too small to be one. Every scale that had not collapsed had steps tens to hundreds of cents wide and a hardness of 1 to 6. Nothing lands in between.

One cent sits 33x above the worst collapsed case, an order of magnitude below the smallest step a well-formed scale in this cardinality range legitimately has – 53-EDO, far beyond the default ceiling of 24, still has 22-cent steps – and five times below the melodic just-noticeable difference, so nothing it rejects could have been heard as two notes anyway.

class MOSFit(scale: MOSScale, error_cents: float, max_error_cents: float, rms_error_cents: float, coverage: float, score: float, assignments: Tuple[int, ...], residuals: Tuple[float, ...], n_targets: int, offset: float = 0.0, n_merged: int = 0, targets: Tuple[float, ...] = ())[source]#

Bases: object

One candidate explanation of a signal as a well-formed scale.

scale#
Type:

MOSScale

error_cents#

Amplitude-weighted mean absolute distance from each target ratio to its nearest scale degree. The headline number.

Type:

float

max_error_cents#
Type:

float

rms_error_cents#
Type:

float

coverage#

Weighted fraction of targets landing within tolerance_cents.

Type:

float

score#

error_cents plus the surplus-note penalty. Fits are ranked by this, not by raw error, so a big scale cannot win by covering everything.

Type:

float

assignments#

Which scale degree each target matched, parallel to targets.

Type:

tuple of int

residuals#

Signed cents error per target (target minus degree, wrapped), parallel to targets.

Type:

tuple of float

n_targets#

How many targets were actually fitted. With folding on – the default – that is the number of distinct pitch classes in the input, not the number of ratios handed in; see n_merged.

Type:

int

offset#

The transposition, as a period fraction, that put the scale where the data is. A scale and its transpositions are the same scale, so this is a fitted nuisance parameter, not a property of the structure – but it says which mode the signal is sitting in, which is musical information. See aligned_ratios.

Type:

float

n_merged#

How many input ratios were absorbed into a pitch class already present. Four of biotuner’s eight working tuning derivations emit a ratio at exactly 1/1 or 2/1, so this is routinely non-zero.

Type:

int

targets#

The ratios actually fitted, folded into [1, period). This – not the caller’s original list – is what assignments and residuals run parallel to once anything has been merged.

The order is the caller’s, not the search’s. Everything inside the fit runs on targets sorted ascending, because a set of ratios has to be fitted as a set; these three vectors are permuted back on the way out, so target i is the i-th ratio the caller passed, minus any that were unusable and with each merged group standing in the place of its earliest member.

Type:

tuple of float

scale: MOSScale#
error_cents: float#
max_error_cents: float#
rms_error_cents: float#
coverage: float#
score: float#
assignments: Tuple[int, ...]#
residuals: Tuple[float, ...]#
n_targets: int#
offset: float = 0.0#
n_merged: int = 0#
targets: Tuple[float, ...] = ()#
property signature: str#
property n_unmatched_degrees: int#

Scale degrees that no target landed on – notes the signal never used.

property aligned_degrees: List[float]#

The scale’s degrees where the fit actually put them, rooted at the data.

scale is rooted on its generator chain’s origin, which is an arbitrary choice; the fit is free to transpose it. These are the fitted degrees rotated back so the tone the signal’s own reference landed on comes first – i.e. the mode the signal occupies, as a tuning.

property aligned_ratios: List[float]#

aligned_degrees as frequency ratios, starting at 1/1.

property aligned_cents: List[float]#
property mode#

Which mode of scale the signal occupies, or None.

None only when the alignment does not land on a scale tone, which a degenerate tuning can produce.

property chance_error_cents: float#

Error a random set of ratios would get against a scale this size.

Points scattered uniformly around the period sit, on average, a quarter of a step away from the nearest of N equally spaced degrees. This is the baseline any fit has to beat, and it shrinks as the scale grows – which is why a large MOS covering everything is not, by itself, evidence of anything.

property improvement: float#

How many times better than chance the fit is.

1.0 means no better than a scale of this size would do on random input; large values mean the signal really does sit on these degrees. Infinite for an exact fit – where “exact” means below a nanocent, since an exactly-recovered scale lands at 1e-13 rather than 0 and a ratio like 9e13 is arithmetic noise reported as if it were a finding.

property is_underdetermined: bool#

True when the scale has more degrees than the data had targets.

A scale with spare notes can be rotated so that every target lands on some degree, so its error is not a measurement of anything: best_mos([1.5]) reports a four-note scale at 0.000 cents from a single data point. Such a fit is not returned any differently – it may still be the right structure – but it must not be read as evidence, and explain_fit() says so out loud.

property evidence: float#

How many standard errors below chance the fit’s mean error sits.

error_cents on its own cannot be compared across fits, because a small target set can reach zero by luck and a large scale can reach it by having somewhere for everything to go. This folds in how much data there was.

Targets scattered uniformly around the period land uniformly in [0, 2 * chance] from the nearest of N evenly spaced degrees, a distribution with mean chance_error_cents and standard deviation chance / sqrt(3). The mean of n such draws therefore has standard error chance / sqrt(3n), and

evidence = sqrt(3 * n_targets) * (1 - error_cents / chance)

is how far below chance the observed mean falls in those units. Zero is chance; larger is better supported. It is a rule of thumb, not a test statistic – the degrees are not exactly evenly spaced, the weights are not uniform, and the generator was fitted to the same data – but it ranks fits in the order a reader would defend.

Examples

Two ratios and seven ratios can both be fitted exactly. Only one of them is a finding:

>>> two = best_mos([1.0, 1.5], max_cardinality=8)
>>> seven = best_mos(MOSScale.from_signature(5, 2, tuning=31).ratios,
...                  max_cardinality=12)
>>> round(two.error_cents, 4), round(seven.error_cents, 4)
(0.0, 0.0)
>>> round(two.evidence, 2), round(seven.evidence, 2)
(2.45, 4.58)
to_dict() Dict[str, object][source]#
class FitField(errors: ndarray, generators: ndarray, cardinalities: ndarray, period: float, n_targets: int, ratios: Tuple[float, ...] = ())[source]#

Bases: object

Every point of the labyrinth scored against one set of ratios.

fit_mos() answers “which scale is this?” with a ranked shortlist, which hides how the answer sits among its neighbours. A signal is often compatible with several disconnected regions of the labyrinth, and a winner reported without that context reads as more decisive than it is. This is the same objective evaluated everywhere instead.

errors#

Weighted mean cents error per (cardinality, generator) cell. NaN where the generator admits no MOS at that cardinality – most of the plane, since well-formedness is rare.

Type:

np.ndarray, shape (max_cardinality + 1, len(generators))

generators#

Generator fractions, one per column.

Type:

np.ndarray

cardinalities#

Row index, 0 .. max_cardinality. Rows 0 and 1 are always empty.

Type:

np.ndarray

period#
Type:

float

n_targets#
Type:

int

ratios#

The ratios this field was scored against. Carried along so a figure drawn from a precomputed field can still mark them; without it the overlay silently disappears exactly when you reuse a field, which is the case it exists for.

Type:

tuple of float

errors: ndarray#
generators: ndarray#
cardinalities: ndarray#
period: float#
n_targets: int#
ratios: Tuple[float, ...] = ()#
property period_cents: float#
property coverage: float#

Fraction of cells that contain a well-formed scale at all.

chance_error(cardinality: int) float[source]#

Error a random set of ratios would get against a scale this size.

best() Dict[str, float][source]#

The single lowest-error cell, with its coordinates.

No parsimony penalty is applied, so this is the raw best fit and will usually name a larger scale than fit_mos() does. That difference is the penalty doing its job, not a disagreement.

islands(threshold_cents: float = 3.0) int[source]#

How many separate low-error regions there are.

Counts connected components of cells under threshold_cents, with the generator axis treated as circular because the labyrinth is. More than one means the signal genuinely fits in several unrelated places, which a single best-fit answer cannot tell you.

class ForwardScale(fit: MOSFit, sources: Tuple[Tuple[float, float], ...])[source]#

Bases: object

A scale read forward from an interval the signal actually states.

fit_mos() treats the generator as latent: it searches for whichever value best explains the peaks, and that value need not sit between any two of them. This is the opposite reading. An interval that is present in the data – the quotient of two peaks, or a peak ratio itself – is declared the generator, stacked, and folded into the period. Nothing is optimised, so this is not a best fit to anything; it is the consequence of taking one audible interval seriously.

What makes the two directions comparable is fit. The scale that comes out is scored against the whole target set by the same machinery the inverse search uses, with the same objective and the same freedom to transpose onto the data, so error_cents, coverage and evidence mean exactly what they mean on a MOSFit. A forward reading that beats the inverse fit is a genuine finding – the latent generator turned out to be audible after all. One that loses is the ordinary case, and the gap is the price of insisting the generator be an interval you can point at.

fit#

The scale scored against every target. fit.scale.generator is exactly the observed interval folded into the bright half, never a refined value: refining it would destroy the one property that defines a forward reading.

Type:

MOSFit

sources#

Every observed (numerator, denominator) pair that proposed a generator inside this reading’s de-duplication window. More than one means several independent peak pairs state the same interval, which is stronger evidence for it than a single coincidence – see n_sources. A raw ratio taken as a generator in its own right is recorded as (ratio, 1.0), since that is the interval it forms with the reference.

The first entry is the representative: the pair whose folded quotient is the generator actually used. The rest follow in ascending order. Neither the choice nor the order depends on how the caller happened to order the input – see forward_scales().

Type:

tuple of (float, float)

Notes

Every public readout of fit is re-exported here under the same name, so a caller holding a ForwardScale never has to reach through to the fit to get at residuals, offset or improvement. The facade is complete on purpose: a partial one would imply the missing quantities mean something different in this direction, and they do not.

fit: MOSFit#
sources: Tuple[Tuple[float, float], ...]#
property interval_pair: Tuple[float, float]#

The (numerator, denominator) this reading is built on.

One of the observed pairs, not an average of the group: the whole point is that the generator is an interval the signal states, and the mean of several near-identical quotients is not one of them. It is the pair forward_scales() elected to represent the window – the one whose scale scores best – so generator is this quotient folded, to the last bit.

property interval: float#

The observed quotient, as a frequency ratio, before folding.

Reported unfolded, so a peak pair spanning more than a period still reads as what it is: 22.91 / 10.07 is 2.275, which the generator (976.9 cents) no longer shows.

property n_sources: int#

How many observed intervals proposed this generator.

property scale: MOSScale#
property signature: str#
property generator: float#

The generator as a period fraction, always in (0.5, 1).

property generator_cents: float#
property generator_ratio: float#

The generator as a frequency ratio, always above sqrt(period).

Not simply interval reduced into the period. Folding keeps the bright spelling, so an observed quotient landing in the dark half comes back inverted: 19.31/15.64 is 1.2347, which reduces to 1.2347 at 365 cents, and the number reported here is its complement 2 / 1.2347 = 1.6199 at 835 cents. The two build the same scale (Milne et al. §4), which is why only one is quoted – but the one quoted is then an interval the signal states upside down, and reading it as the audible interval would be wrong. For that, use interval.

Examples

>>> reading = next(
...     r for r in forward_scales([10.07, 15.64, 19.31, 22.91],
...                               include_ratios=False,
...                               min_cardinality=7, max_cardinality=7)
...     if r.interval_pair == (19.31, 15.64)
... )
>>> round(reading.interval, 4), round(reading.generator_ratio, 4)
(1.2347, 1.6199)
property error_cents: float#
property max_error_cents: float#
property rms_error_cents: float#
property coverage: float#
property score: float#
property assignments: Tuple[int, ...]#
property residuals: Tuple[float, ...]#
property n_targets: int#
property offset: float#
property n_merged: int#
property targets: Tuple[float, ...]#
property n_unmatched_degrees: int#
property aligned_degrees: List[float]#
property aligned_ratios: List[float]#
property aligned_cents: List[float]#
property mode#
property chance_error_cents: float#
property improvement: float#
property evidence: float#
property is_underdetermined: bool#
to_dict() Dict[str, object][source]#

Flat summary – the fit’s own fields plus where the generator came from.

fit_field(ratios: Sequence[float], weights: Sequence[float] | None = None, *, period: float = 2.0, max_cardinality: int = 24, resolution: int = 720, min_cardinality: int = 3, align: bool = True, n_anchors: int = 3) FitField[source]#

Score every (generator, cardinality) in the labyrinth against some ratios.

Parameters:
  • ratios (sequence of float) – Frequency ratios to explain, e.g. bt.peaks_ratios.

  • weights (sequence of float, optional) – Per-ratio importance, normalised internally.

  • period (float, default 2.0)

  • max_cardinality (int, default 24) – Outermost ring to score.

  • resolution (int, default 720) – Generator samples across the full period. Cost is roughly linear in this and in the number of MOS cardinalities each generator admits.

  • min_cardinality (int, default 3)

  • align (bool, default True) – Let each candidate transpose onto the data, as fit_mos() does. A scale and its transpositions are the same scale, so leaving this off answers a different and less useful question.

  • n_anchors (int, default 3) – Transpositions are seeded from this many of the heaviest targets, and from all of them where the weights tie – which, unweighted, is always. See _offset_candidates().

Returns:

FitField

Notes

The generator axis is sampled, not optimised. A rational generator such as 9/19 will not sit exactly on a uniform grid, so even a scale scored against itself lands slightly off zero here. fit_mos() refines within the valid range and does reach zero; this function draws the landscape that refinement happens inside, and the two should not be expected to agree to the last cent.

Examples

>>> ref = MOSScale.from_signature(4, 3, tuning=19)
>>> field = fit_field(ref.ratios, max_cardinality=12, resolution=360)
>>> field.errors.shape
(13, 359)

Most of the plane holds no well-formed scale at all:

>>> bool(field.coverage < 0.6)
True

The best cell is close, but not exact – see the note above:

>>> bool(0.0 < field.best()["error_cents"] < 3.0)
True
generator_candidates(ratios: Sequence[float], period: float = 2.0, include_intervals: bool = True, grid: int = 720, dedupe_cents: float = 1.0) List[float][source]#

Generator fractions worth trying for a set of observed ratios.

Three sources, merged and de-duplicated:

  1. Each observed ratio, read as a generator in its own right. If a signal’s peaks really are a stack of some interval, that interval is among them.

  2. Every ratio between two observed ratios, when include_intervals. A generator need not appear as a peak – the diatonic scale’s fifth is a relation among its notes, not one of them.

  3. A uniform background grid, so a generator the signal only implies is still reachable.

Everything is folded into the bright half (0.5, 1): a generator and its complement within the period build the same scale (Milne et al. §4), so searching one half covers the whole labyrinth.

Parameters:
  • ratios (sequence of float) – Frequency ratios, e.g. bt.peaks_ratios.

  • period (float, default 2.0)

  • include_intervals (bool, default True)

  • grid (int, default 720) – Background grid resolution across the full period. 0 disables it.

  • dedupe_cents (float, default 1.0) – How close a grid point may come to a candidate already kept before it is dropped, in cents of the period. It does not apply between two signal-derived candidates – see the notes.

Returns:

list of float – Sorted generator fractions, all in (0.5, 1).

Notes

De-duplication is priority-aware, and the priority runs one way only: the background grid is thinned against the signal, never the signal against itself. A grid point half a cent from the exact generator would otherwise shadow it and the search would recover it only approximately – but the same rule turned on two signal-derived candidates is strictly worse, since it discards a real proposal in favour of another real proposal chosen by nothing but sorted order. On one recording (S004, eyes closed) that lost the generator at 810.302 cents to a neighbour 0.909 cents away that fits the peaks measurably worse, and no information available at this stage could have told them apart: which of two candidates is better is a question about the fit, and this function does not score anything. Sub-cent precision is what _refine_generator() exists to protect, so it is not thrown away here.

Signal-derived candidates are still collapsed at GENERATOR_EPSILON, which removes arithmetic duplicates – a stack of fifths proposes 3/2 three times over – without ever removing a distinct proposal. There are at most n (n + 1) / 2 of them against a grid of hundreds, so keeping them all costs almost nothing.

Examples

A stack of fifths proposes the fifth itself, and keeps proposing it exactly even with a dense grid running alongside:

>>> stack = [1.0, 1.125, 1.265625, 1.5]
>>> fifth = math.log2(3 / 2)
>>> any(abs(c - fifth) < 1e-12 for c in generator_candidates(stack, grid=0))
True
>>> any(abs(c - fifth) < 1e-12 for c in generator_candidates(stack, grid=720))
True

Two candidates the signal genuinely states less than a cent apart both survive, and the grid still does not crowd in between them:

>>> peaks = [10.71, 10.47, 21.17, 13.12, 8.07, 17.18, 25.55]
>>> cands = generator_candidates(peaks, grid=720)
>>> [round(c * 1200, 5) for c in cands
...  if 809.0 < c * 1200 < 811.0]
[809.39247, 810.30166]
labyrinth_positions(ratios: Sequence[float], period: float = 2.0, fold: bool = False) List[float][source]#

Where a set of ratios sits on the labyrinth’s circumference.

The angle of each ratio, as a fraction of the period – what plot_labyrinth() needs to draw a signal’s peaks on top of the scale universe.

Examples

>>> [round(p, 4) for p in labyrinth_positions([1.0, 1.5, 2.0])]
[0.0, 0.585, 0.0]
fit_mos(ratios: Sequence[float], weights: Sequence[float] | None = None, period: float = 2.0, min_cardinality: int = 4, max_cardinality: int = 24, tolerance_cents: float = 15.0, complexity_penalty: float = 1.0, grid: int = 720, include_intervals: bool = True, refine: bool = True, n_refine: int = 12, align: bool = True, n_anchors: int = 3, top_n: int = 5, candidates: Sequence[float] | None = None, optimize_period: bool = False, period_bounds: Tuple[float, float] = (1.8, 2.2), period_steps: int = 21, fold: bool = True) List[MOSFit][source]#

Rank the well-formed scales that best explain a set of ratios.

Parameters:
  • ratios (sequence of float) – Frequency ratios to explain – typically bt.peaks_ratios.

  • weights (sequence of float, optional) – Per-ratio importance, e.g. peak amplitudes. Normalised internally. Uniform when omitted.

  • period (float, default 2.0) – Period as a frequency ratio. Ignored when optimize_period.

  • min_cardinality, max_cardinality (int) – Ring range to search. The default upper bound of 24 keeps scales in playable territory; raise it to explore microtonal ones.

  • tolerance_cents (float, default 15.0) – What counts as a hit, for MOSFit.coverage. Does not affect the error or the ranking.

  • complexity_penalty (float, default 1.0) – Cents of penalty per scale degree beyond the number of targets. A larger scale can always cover more ratios; without this, the search just returns the biggest one allowed. Set to 0 for pure error ranking.

    The default is calibrated, not guessed. Fitting fourteen known MOS scales – first exactly, then from five jittered peaks at 8 cents SD – recovers the true signature in 14/14 and 12/14 cases at 1.0, versus 8/14 and 2/14 at 0.0 (where the search overfits to a median of 21 notes). Raising it to 3.0 costs recovery (10/14) but returns smaller scales on unstructured input, which is the trade to make if you care more about parsimony than about identification.

  • grid (int, default 720) – Background generator-grid resolution. 0 uses only signal-derived candidates.

  • include_intervals (bool, default True) – Also try every interval between observed ratios as a generator.

  • refine (bool, default True) – Slide the top n_refine fits’ generators inside their valid ranges to minimise the error. The signature never changes, only the tuning.

  • n_refine, top_n (int)

  • align (bool, default True) – Let each candidate scale transpose itself onto the data. A scale and its transpositions are the same scale, so this is the right comparison: without it a stack of fifths does not read as the pentatonic, because the pentatonic is only rooted correctly in one of its five modes. Set False to pin every candidate to a root of 1/1.

  • n_anchors (int, default 3) – How many of the heaviest targets seed candidate transpositions during the coarse scan. The exact set uses every target, which is len(targets) x cardinality rotations per candidate and gets slow for large scales; the shortlist is always re-scored exactly during refinement.

    A cut landing inside a run of equally heavy targets takes the whole run, so with weights=None – where every target weighs the same – the coarse scan is exact whatever this is set to. A tie between equal weights says nothing about which target matters more, and any rule that broke it would be reading the input’s order or its transposition rather than the input; see _offset_candidates().

  • candidates (sequence of float, optional) – Explicit generator fractions to try instead of deriving them.

  • optimize_period (bool, default False) – Also fit the pseudo-octave, over period_steps values spanning period_bounds. Slower by that factor.

  • period_bounds, period_steps – Only used when optimize_period.

  • fold (bool, default True) – Reduce the ratios to distinct pitch classes before fitting: fold each into [1, period) and merge anything landing within FOLD_TOLERANCE_CENTS of a pitch class already seen, summing the weights rather than dropping them.

    A scale cannot tell 1/1 from 2/1, so counting both as targets double-counts the unison, inflates n_targets, and drags the error toward whatever the unison happens to do. Most of biotuner’s tuning derivations emit a ratio at exactly 1/1 or 2/1, so this is the common case rather than an edge case. Set False to score every ratio as handed in.

Returns:

list of MOSFit – Best first, by MOSFit.score. At most one fit per (signature, period) so the list is not filled with near-duplicates.

Notes

ratios is read as a multiset. Permuting it – carrying weights along – returns the same ranked list, in the same order, with bit-identical signatures, generators, scores, errors, coverages and offsets. The only thing that moves is the three per-target vectors (targets, assignments, residuals), which are defined to run parallel to the input and are permuted with it. See _prepare_targets() for why that took canonicalising the targets rather than fixing the three places that read them in order.

Examples

A scale that is an MOS is recovered exactly – generator, signature and all:

>>> d = MOSScale.from_signature(5, 2, tuning=31)
>>> fit = fit_mos(d.ratios, max_cardinality=12)[0]
>>> fit.signature
'5L2s'
>>> round(fit.error_cents, 6)
0.0
>>> round(fit.scale.generator_cents, 2)
696.77

Twelve-tone equal temperament is recognised as the chromatic MOS at a 700-cent generator. It is a degenerate well-formed scale – its two step sizes are identical (Milne et al. §2, footnote 6) – so 7L5s and its inverse 5L7s describe it equally well, and the tie-break picks one:

>>> edo12 = [2 ** (k / 12) for k in range(12)]
>>> fit = fit_mos(edo12, max_cardinality=12)[0]
>>> fit.signature, round(fit.scale.generator_cents, 3)
('7L5s', 700.0)
>>> fit.scale.is_degenerate
True

A tuning that runs from the unison to the octave states the same pitch class at both ends. Folding counts it once:

>>> ladder = [1.0, 1.125, 1.25, 1.5, 2.0]
>>> fit = fit_mos(ladder, max_cardinality=12)[0]
>>> fit.n_targets, fit.n_merged
(4, 1)
>>> fit_mos(ladder, fold=False, max_cardinality=12)[0].n_targets
5
forward_scales(ratios: Sequence[float], weights: Sequence[float] | None = None, period: float = 2.0, min_cardinality: int = 4, max_cardinality: int = 24, tolerance_cents: float = 15.0, complexity_penalty: float = 1.0, include_ratios: bool = True, include_intervals: bool = True, dedupe_cents: float = 1.0, align: bool = True, n_anchors: int | None = None, fold: bool = True, top_n: int | None = None) List[ForwardScale][source]#

Stack each interval the signal states, and see what scale it builds.

The forward direction. Where fit_mos() searches for a latent generator, this one refuses to invent anything: every generator it tries is an interval already present in ratios. For each it enumerates the cardinalities at which stacking is well-formed at all, builds the scale there, and scores it against the whole input with the same objective the inverse search uses – so the two answers can be laid side by side.

Parameters:
  • ratios (sequence of float) – The observed values. Frequency ratios such as bt.peaks_ratios, or raw peak frequencies – quotients of frequencies are ratios, so the pairwise part works either way. With raw frequencies set include_ratios=False; 19.31 is a frequency, not an interval, and reading it as a generator means nothing.

  • weights (sequence of float, optional) – Per-ratio importance for the scoring, e.g. peak amplitudes. It does not influence which generators are proposed: an interval is either stated by the signal or it is not.

  • period (float, default 2.0)

  • min_cardinality, max_cardinality (int) – Ring range to enumerate, as in fit_mos(). A generator supports only its own MOS cardinalities, so most rings produce nothing.

  • tolerance_cents (float, default 15.0) – What counts as a hit, for ForwardScale.coverage.

  • complexity_penalty (float, default 1.0) – Cents charged per degree beyond the number of targets. Needed for the same reason as in fit_mos(), and needed identically if the two directions’ scores are to be compared – stacking an observed interval far enough will eventually cover everything by brute force.

  • include_ratios (bool, default True) – Also read each ratio itself as a generator, not only the quotients between pairs.

  • include_intervals (bool, default True) – Use the quotient of every pair of ratios. Turning this off leaves only the ratios themselves.

  • dedupe_cents (float, default 1.0) – Width of the window inside which several proposed generators are treated as one reading. The window is represented by whichever of its proposals scores best, and every pair that proposed into it survives in ForwardScale.sources, so the corroboration count ForwardScale.n_sources is unaffected by the choice. Matches generator_candidates()’s own threshold, so the two directions resolve the generator axis equally finely.

  • align (bool, default True) – Let each scale transpose onto the data before scoring, exactly as fit_mos() does. A scale and its transpositions are one scale.

  • n_anchors (int, optional) – Targets seeding candidate transpositions. None – the default – uses every target, which is the exact optimum. fit_mos() scans with a shortlist because it evaluates thousands of candidates and re-scores the survivors exactly; there are far fewer readings here, so the exact rotation is affordable from the start and the numbers need no caveat when set beside a refined inverse fit.

  • fold (bool, default True) – Reduce the input to distinct pitch classes before scoring, as fit_mos() does. Applies to the targets only; the proposed intervals are read off the ratios as given, so a peak pair spanning two octaves still proposes the interval it spans.

  • top_n (int, optional) – Truncate the ranking. None returns every reading.

Returns:

list of ForwardScale – Best first. Empty when the signal states no interval capable of generating a scale – a list of pure octaves, for instance, whose only quotients fold to nothing. That is an answer, not a failure, so it is returned rather than raised.

Notes

Ranked by ForwardScale.score – the same weighted error plus surplus-note penalty that ranks fit_mos() – because the question a reader asks of this list is “which observed interval, used as a generator, accounts for the signal best?”, and only a quantity the inverse fit also reports lets that be answered against the alternative. Ties break on ForwardScale.n_sources (more corroboration first), then on the structural order MOSFit._rank_key uses.

The generator is never refined. fit_mos() slides its winners inside their valid tuning ranges to shave off cents; doing that here would replace the observed interval with a nearby unobserved one and quietly turn a forward reading back into an inverse fit.

n_sources counts proposals, not distinct pitch classes. Two input ratios an octave apart name one pitch class but are two proposals, so they corroborate a generator twice. With fold=True the targets have already been merged, so this affects the tie-break only, never the score.

The answer depends on the input only as a multiset: permuting ratios (carrying weights along with it) returns the same readings, in the same order, with bit-identical generators, signatures, scores, errors and sources. Only the per-target vectors follow the caller’s list, because they are defined to – targets, assignments and residuals run parallel to the input and are permuted with it.

That invariance is not free, and it is not local. Two separate order dependencies had to go. Proposals arrive in whatever order the caller’s list dictates, and grouping them greedily in that order lets the first arrival define its window and speak for it, so reversing four ratios could swap a 7L5s at 699.75 cents for a 5L7s at 700.25 – a different generator, a different error, and a signature flipped to its inverse, from the same numbers. Proposals are therefore sorted before the windows are cut, and each window is represented by its best-scoring member rather than by its first. Targets had the same problem one level down, in machinery both directions share: which pitch class survived a merge, which targets seeded the rotation search, and the order the weighted errors were summed in all read the array as the caller filled it. _prepare_targets() sorts once, so all three are answered by the data.

Examples

A stack of fifths states the fifth outright, so the forward reading finds it without searching, and the pentatonic falls out exactly:

>>> stack = [1.0, 1.125, 1.265625, 1.5]
>>> top = forward_scales(stack, max_cardinality=12)[0]
>>> top.signature, round(top.generator_cents, 3)
('2L3s', 701.955)
>>> round(top.error_cents, 9), top.interval_pair
(0.0, (1.5, 1.0))

Two intervals proposed that generator – 3/2 against the root, and 3/2 against 9/8, which is 4/3 and folds to the same bright half:

>>> top.n_sources
2

Real EEG (S001, eyes closed), four alpha-band peaks in Hz. Each pair is taken as a generator in turn; printed here at the smallest scale each one supports, which is not the ranking but is the readable way to see six different answers at once:

>>> peaks = [10.07, 15.64, 19.31, 22.91]
>>> readings = forward_scales(peaks, include_ratios=False,
...                           min_cardinality=5, max_cardinality=7)
>>> smallest = {}
>>> for r in readings:
...     key = r.interval_pair
...     if (key not in smallest
...             or r.scale.cardinality < smallest[key].scale.cardinality):
...         smallest[key] = r
>>> for r in sorted(smallest.values(), key=lambda r: r.generator_cents):
...     print(f"{r.interval_pair[0]:6.2f}/{r.interval_pair[1]:5.2f} = "
...           f"{r.interval:.3f} -> generator {r.generator_cents:6.1f} c"
...           f" -> {r.signature} ({r.scale.cardinality} notes)")
 22.91/15.64 = 1.465 -> generator  660.9 c -> 2L3s (5 notes)
 15.64/10.07 = 1.553 -> generator  762.2 c -> 3L2s (5 notes)
 19.31/15.64 = 1.235 -> generator  835.1 c -> 3L4s (7 notes)
 22.91/19.31 = 1.186 -> generator  904.0 c -> 4L1s (5 notes)
 22.91/10.07 = 2.275 -> generator  976.9 c -> 1L4s (5 notes)
 19.31/10.07 = 1.918 -> generator 1127.1 c -> 1L4s (5 notes)

None of those is the generator the inverse search settles on, which is the finding: this signal’s best latent explanation is an interval no pair of its peaks states.

>>> inverse = fit_mos(peaks, max_cardinality=6)[0]
>>> inverse.signature, round(inverse.scale.generator_cents, 2)
('1L3s', 930.44)
best_mos(ratios: Sequence[float], **kwargs) MOSFit[source]#

The single best-fitting well-formed scale.

Raises:

ValueError – If no MOS could be fitted at all – which happens only when the search range excludes every cardinality.

Examples

>>> best_mos(MOSScale.from_signature(4, 3, tuning=19).ratios).signature
'4L3s'
mos_tuning(ratios: Sequence[float], **kwargs) List[float][source]#

The best-fitting MOS as a plain list of frequency ratios.

Drop-in for anywhere biotuner expects a tuning.

Examples

>>> ref = MOSScale.from_signature(4, 3, tuning=19)
>>> tuning = mos_tuning(ref.ratios, max_cardinality=12)
>>> len(tuning)
7
>>> max(abs(a - b) for a, b in zip(tuning, ref.ratios)) < 1e-9
True
mos_from_biotuner(bt, source: str = 'peaks_ratios', use_amplitudes: bool = True, mode: str = 'inverse', **kwargs) List[MOSFit] | List[ForwardScale][source]#

Read MOS scales off a compute_biotuner.

Every way the object has of deriving ratios can feed the fit, so the question “which well-formed scale is this signal in?” can be asked of the peak ratios, the dissonance-curve minima, the harmonic-entropy minima, an Euler-Fokker genus, or the common-harmonic tuning, and the answers compared – see compare_sources().

Parameters:
  • bt (compute_biotuner) – Must already have run peaks_extraction; sources beyond the peak ratios may need their own precursor (peaks_extension for 'extended_ratios', for instance).

  • source (str, default ‘peaks_ratios’) – Any name compute_biotuner.get_tuning() accepts, except 'mos'.

  • use_amplitudes (bool, default True) – Weight each ratio by its peak amplitude where an amplitude vector genuinely lines up with the source. A strong peak should pull the fit harder than a weak one.

  • mode ({‘inverse’, ‘forward’}, default ‘inverse’) – Which question to ask.

    'inverse' runs fit_mos(): the generator is latent, searched for, and need not be an interval the signal contains.

    'forward' runs forward_scales(): every generator tried is an interval the signal states, and the result says what scale that interval builds. The two are scored identically, so their error_cents and coverage can be compared directly.

  • **kwargs – Passed to fit_mos() or forward_scales(), per mode.

Returns:

list of MOSFit or list of ForwardScale – Depending on mode.

Raises:

ValueError – If source='mos', which would fit a moment-of-symmetry scale to a moment-of-symmetry scale; or if mode is neither 'inverse' nor 'forward'.

Notes

Only 'peaks_ratios' and 'extended_ratios' have a candidate weight vector at all (bt.amps and bt.extended_amps), and each is used only when its length matches the derived ratios exactly – see _SOURCE_WEIGHTS. Everything else is fitted unweighted, which is the honest default: an invented weighting would move the answer without being derived from anything.

Examples

>>> mos_from_biotuner(bt, mode='sideways')      
Traceback (most recent call last):
ValueError: mode must be 'inverse' or 'forward', got 'sideways'
compare_sources(bt, sources: Sequence[str] | None = None, use_amplitudes: bool = True, **kwargs) pd.DataFrame[source]#

Fit an MOS from every tuning derivation, and rank them.

Biotuner derives a scale from a signal in eight different ways, and they do not agree. This runs the same fit through all of them and puts the results side by side, so the question stops being “which scale is this?” and becomes “which way of asking produces a well-formed answer at all?”.

Parameters:
  • bt (compute_biotuner) – Must already have run peaks_extraction.

  • sources (sequence of str, optional) – Which derivations to try. Defaults to every name in TUNING_SOURCES except 'mos', which would be circular.

  • use_amplitudes (bool, default True) – As mos_from_biotuner().

  • **kwargs – Passed to fit_mos().

Returns:

pandas.DataFrame – One row per source, best first. Columns: source, n_ratios (as derived), n_targets and n_merged (as fitted, after folding), signature, cardinality, generator_cents, error_cents, chance_error_cents, improvement, evidence, coverage, score, underdetermined, reason.

A source that raises still gets a row, with reason holding the exception and everything else NaN. Silently dropping it would hide a real breakage: harm_tuning currently fails outright for every peaks_function other than 'harmonic_recurrence', and a shorter table is not a report of that.

Notes

Rows are ordered by MOSFit.evidence, descending, failures last.

“Most convincing” cannot mean lowest error_cents. A fit with two targets reports 0.00 cents against a four-note scale, because four degrees can be rotated onto any two points; ranking by error puts the derivation that produced the least data on top. evidence measures the same error in units of the standard error a chance fit would have, so it grows with both the margin below chance and the number of targets that margin was measured over. underdetermined marks the rows where the scale had more degrees than the data had points, which is the extreme case of the same problem.

Examples

>>> df = compare_sources(bt)                            
>>> df[["source", "signature", "error_cents", "evidence"]]  
trajectory_from_windows(windows: Sequence[Sequence[float]], weights: Sequence[Sequence[float] | None] | None = None, **kwargs) List[MOSFit | None][source]#

Best-fitting MOS for each window of ratios.

Windows that yield no usable ratios become None rather than raising, so one bad epoch does not sink a whole recording.

Examples

>>> a = MOSScale.from_signature(5, 2, tuning=12).ratios
>>> b = MOSScale.from_signature(4, 3, tuning=19).ratios
>>> [f.signature for f in trajectory_from_windows([a, b], max_cardinality=12)]
['5L2s', '4L3s']
mos_trajectory(data: Sequence[float], sf: float, window_sec: float = 4.0, step_sec: float | None = None, peaks_function: str = 'EMD', n_peaks: int = 5, precision: float = 0.5, bt_kwargs: Dict[str, object] | None = None, source: str = 'peaks_ratios', use_amplitudes: bool = True, **kwargs) List[MOSFit | None][source]#

Track which well-formed scale a signal occupies, window by window.

A path through the labyrinth. Each window’s peaks are extracted with compute_biotuner and fitted independently, so the returned sequence shows the scale structure drifting (or holding) as the signal evolves – plot_mos_trajectory() draws it.

Parameters:
  • data (sequence of float) – A single-channel time series.

  • sf (float) – Sampling frequency, Hz.

  • window_sec (float, default 4.0)

  • step_sec (float, optional) – Hop between windows; defaults to window_sec / 2 (50 % overlap).

  • peaks_function, n_peaks, precision – Passed to peak extraction.

  • bt_kwargs (dict, optional) – Extra keyword arguments for the compute_biotuner constructor.

  • source (str, default ‘peaks_ratios’) – Which derivation to fit in each window – any name compute_biotuner.get_tuning() accepts, except 'mos'. A trajectory over 'diss_curve' and one over 'peaks_ratios' are different measurements of the same recording, and there is no reason to be able to make only the second.

  • use_amplitudes (bool, default True) – Weight each window’s ratios by peak amplitude where the source has a matching amplitude vector; see mos_from_biotuner().

  • **kwargs – Passed to fit_mos().

Returns:

list of MOSFit or None – One entry per window; None where peak extraction found nothing.

Raises:

ValueError – If source='mos', which would fit an MOS to an MOS in every window; or if source is a name no derivation answers to. A misspelt source fails in every window, and the per-window gap rule would otherwise turn it into an all-None path indistinguishable from a structureless recording, so the name is validated before any window is analysed.

Notes

This runs a full peak extraction per window, so it is the slow entry point in this module. For a signal you have already windowed and analysed, call trajectory_from_windows() with the ratios directly.

A source that cannot be derived in a given window yields None for that window rather than raising, on the same principle as an empty window: one bad epoch is a gap in the path, not a failed recording. Sources needing a precursor the per-window object never runs – 'extended_ratios' wants a peaks_extension – therefore come back as an all-None trajectory, which is a truthful answer rather than a crash.

trajectory_dataframe(trajectory: Sequence[MOSFit | None], times: Sequence[float] | None = None) pd.DataFrame[source]#

Tabulate a trajectory: one row per window, NaN where the fit failed.

Examples

>>> a = MOSScale.from_signature(5, 2, tuning=12).ratios
>>> traj = trajectory_from_windows([a], max_cardinality=12)
>>> df = trajectory_dataframe(traj)
>>> list(df["signature"])
['5L2s']
explain_fit(fit: MOSFit, ratios: Sequence[float] | None = None) str[source]#

A readable account of what a fit claims, and how well it holds up.

Alongside the error it prints the error a random set of ratios would get against a scale this size, because the raw number is unreadable without it: 0.00 cents from two data points is not better than 5.19 cents from eight, it is a smaller scale having somewhere to put everything. A fit with more degrees than targets is labelled UNDERDETERMINED outright.

Examples

>>> print(explain_fit(best_mos(MOSScale.from_signature(5, 2, tuning=12).ratios)))
... 
5L2s  (7 notes)   LLLsLLs
...
  fit            error 0.000 c (weighted mean), max 0.000 c, rms 0.000 c
...

One ratio is not evidence for a four-note scale, however well it fits:

>>> print(explain_fit(best_mos([1.5])))          
1L3s...
  UNDERDETERMINED  1 target for 4 degrees...

fourier#

Fourier scratching – playing a scale by manipulating a DFT.

Milne et al. (2011) §5 close the scale labyrinth with a performance technique. A virtual robot with n fingers strikes a continuous circular keyboard at a fixed pulse. Finger k is described by one complex number

\[f_k = r_k \, e^{i t_k}\]

whose magnitude r_k is how hard it strikes (loudness) and whose phase t_k is where on the circle it strikes (pitch). The whole performance state is therefore a single vector \(f \in \mathbb{C}^n\).

The point of the technique is that the performer never edits f. They edit its discrete Fourier transform and let the inverse transform put the fingers back on the keyboard – “scratching” one Fourier coefficient nudges every finger at once, in a way that is coherent rather than arbitrary. A single coefficient’s phase is a rigid rotation of a whole interval cycle; its magnitude is that cycle’s depth. That is what makes the gesture playable: one continuous control, n coordinated voices.

Two consequences the paper leans on, both checked in tests/mos/test_fourier.py:

  • The elementary play states – the paper’s “pure partials”, here partial() – are the states whose spectrum is a single unit impulse. Partial k spreads the fingers evenly around the circle in k turns.

  • Changing the number of fingers is a spectral edit too: grow by PlayState.zero_pad(), shrink by PlayState.prune(), which deletes “the Fourier coefficients with minimal energy” exactly as §5 prescribes.

The keyboard is continuous, so a phase only becomes a note once it is quantised against a scale. keyboard_sectors() does that, following Fig. 8: key widths are “proportional to the sizes of the step intervals above each tone”, so a scale tone sits at the lower edge of its own key and the key extends up to the next tone. Quantising is therefore rounding down to the nearest scale tone, not rounding to nearest – which has a real musical consequence, documented on keyboard_sectors() and measured in the tests.

References

Milne, A.J., Carlé, M., Sethares, W.A., Noll, T., Holland, S. (2011). Scratching the Scale Labyrinth. In Mathematics and Computation in Music, LNAI 6726, 180–195. https://doi.org/10.1007/978-3-642-21590-2_14

class PlayState(f: ndarray)[source]#

Bases: object

The n fingers of the robot, as one complex vector.

Parameters:

f (array_like) – 1-D, coerced to complex128 and copied read-only, so a PlayState really is immutable rather than merely frozen at the attribute level. f[k] is finger k: abs(f[k]) its loudness, angle(f[k]) its position on the circular keyboard.

Notes

The spectrum convention here is fft(f) / n, so coefficient magnitudes are on the same scale as finger magnitudes: partial() has a unit coefficient, not a coefficient of n. from_spectrum() undoes it.

Examples

>>> s = PlayState.from_polar([1.0, 0.5], [0.0, math.pi])
>>> s.n
2
>>> [round(float(m), 6) for m in s.magnitudes]
[1.0, 0.5]
>>> [round(float(p), 6) for p in s.phases]
[0.0, 3.141593]
f: ndarray#
classmethod from_polar(magnitudes: Sequence[float], phases: Sequence[float]) PlayState[source]#

Build from loudnesses and keyboard positions.

Parameters:

magnitudes, phases (array_like) – Same length. phases are radians and need not be reduced.

Examples

>>> PlayState.from_polar([1, 1, 1], [0, TWO_PI / 3, 2 * TWO_PI / 3]).n
3
classmethod from_spectrum(a: Sequence[complex]) PlayState[source]#

Resynthesise a play state from its Fourier coefficients.

Exact inverse of spectrum: since that is fft(f) / n, this is n * ifft(a). The number of fingers is len(a), which is what makes zero_pad() and prune() able to change it.

Examples

>>> s = PlayState([1 + 2j, -3j, 0.5])
>>> bool(np.allclose(PlayState.from_spectrum(s.spectrum).f, s.f))
True
property n: int#

Number of fingers.

property magnitudes: ndarray#

|f_k| – how hard each finger strikes.

property phases: ndarray#

arg(f_k) reduced to [0, 2*pi) – where each finger strikes.

numpy returns angles in (-pi, pi]; reducing modulo 2*pi pushes a hair-negative angle up against 2*pi, which is the far side of the keyboard from where it belongs. Anything within _PHASE_EPS of a full turn is therefore snapped back to 0.

property spectrum: ndarray#

Fourier coefficients, fft(f) / n – what the performer edits.

property energy: float#

sum |f_k|^2.

Parseval ties this to the spectrum: energy == n * sum |a_p|^2, which is why “delete the minimal-energy coefficients” (prune()) is the least destructive way to drop a finger.

Examples

>>> s = PlayState([3, 4j])
>>> round(s.energy, 9)
25.0
allclose(other: PlayState, rtol: float = 1e-09, atol: float = 1e-12) bool[source]#

Numerical equality – what round-trip checks actually want.

Examples

>>> s = PlayState([1, 1j, -1])
>>> PlayState.from_spectrum(s.spectrum).allclose(s)
True
scratch(k: int, magnitude: float | None = None, phase: float | None = None, scale: float | None = None, rotate: float | None = None) PlayState[source]#

Edit Fourier coefficient k and resynthesise.

The performer’s single gesture. Every finger moves, but coherently: rotating coefficient k by d advances the k-th interval cycle by d without disturbing any other cycle.

Parameters:
  • k (int) – Coefficient index. Negative indices count from the end, as for a list, so -1 is the highest coefficient.

  • magnitude (float, optional) – Set |a_k| to this. Must be non-negative.

  • scale (float, optional) – Multiply |a_k| by this. A negative factor is allowed and flips the coefficient’s phase by pi.

  • phase (float, optional) – Set arg(a_k) to this, in radians.

  • rotate (float, optional) – Add this to arg(a_k), in radians.

Raises:

ValueError – If the absolute and the relative form of the same attribute are combined (magnitude with scale, or phase with rotate): there is no sensible order in which to apply both.

Examples

Rotating a pure partial’s own coefficient just slides every finger round the keyboard by the same angle:

>>> p = partial(4, 1)
>>> q = p.scratch(1, rotate=math.pi / 2)
>>> [round(float(x), 6) for x in (q.phases - p.phases) % TWO_PI]
[1.570796, 1.570796, 1.570796, 1.570796]

Damping a coefficient to nothing removes that cycle entirely:

>>> [round(float(abs(z)), 9) for z in p.scratch(1, magnitude=0.0).f]
[0.0, 0.0, 0.0, 0.0]
zero_pad(m: int) PlayState[source]#

Grow to m fingers by appending zero Fourier coefficients.

Milne et al. §5 change dimension “by zero-padding the DFT of the current play state”. Padding at the top of the coefficient list keeps every existing coefficient at its own index, so a partial stays the same partial and any control the performer had mapped to coefficient k still points at it: partial(n, k).zero_pad(m) is partial(m, k) up to floating-point round-off (hence allclose(), not ==, in the check below – the two go through different FFT lengths).

Examples

>>> partial(4, 1).zero_pad(8).allclose(partial(8, 1))
True
>>> [round(float(abs(z)), 9) for z in PlayState([1, 0, 0]).zero_pad(5).spectrum]
[0.333333333, 0.333333333, 0.333333333, 0.0, 0.0]
prune(m: int) PlayState[source]#

Drop the m lowest-energy Fourier coefficients, leaving n - m.

The paper’s way of shrinking the robot’s hand: “deleting the Fourier coefficients with minimal energy” throws away the least of the sound. Survivors keep their relative order but are re-indexed, so coefficient identity is not preserved – unlike zero_pad(). Equal-energy coefficients are broken toward the lower index: the higher one is dropped first.

Examples

>>> s = PlayState.from_spectrum([3, 0.1, 2, 0.2])
>>> q = s.prune(2)
>>> q.n, [round(float(abs(z)), 9) for z in q.spectrum]
(2, [3.0, 2.0])

The tie-break, made visible by two coefficients of equal magnitude but different phase – the 1 survives, not the 1j:

>>> [complex(z) for z in PlayState.from_spectrum([1, 1j, 0, 0]).truncate(1).spectrum]
[(1+0j)]

A partial’s one loud coefficient always survives, so pruning a partial just shortens the hand:

>>> partial(6, 2).prune(2).n
4
truncate(m: int) PlayState[source]#

Keep only the m highest-energy Fourier coefficients.

The complement of prune(): truncate(m) and prune(n - m) are the same edit, and share the same tie-breaking.

Examples

>>> s = PlayState([1, 2, 3, 4])
>>> s.truncate(2).allclose(s.prune(2))
True
rotate_all(delta: float) PlayState[source]#

Slide every finger delta radians round the keyboard.

Transposition on a continuous keyboard. In the spectrum this is a single global factor exp(i*delta) on all coefficients, so it is the one gesture that is as simple in either domain.

Examples

>>> p = partial(5, 1).rotate_all(math.pi)
>>> [round(float(x), 5) for x in p.phases]
[3.14159, 4.39823, 5.65487, 0.62832, 1.88496]
interpolate(other: PlayState, t: float) PlayState[source]#

Blend toward other, linearly in the Fourier domain.

t = 0 is self, t = 1 is other; values outside [0, 1] extrapolate. Because the DFT is linear this coincides exactly with interpolating the finger vectors – the point of phrasing it spectrally is that the Fourier coefficients are the performer’s coordinates, so a morph specified there is a morph they can hear themselves making. For a non-linear morph (constant-loudness rotation of a cycle, say) use scratch_sequence() on one coefficient instead.

Examples

>>> a, b = partial(4, 1), partial(4, 2)
>>> mid = a.interpolate(b, 0.5)
>>> [round(float(abs(z)), 9) for z in mid.spectrum]
[0.0, 0.5, 0.5, 0.0]
class NoteEvent(index: int, degree: int, ratio: float, cents: float, loudness: float, phase: float)[source]#

Bases: object

One finger’s strike, resolved against a scale.

index#

Which finger. Fingers strike in index order, so this is also the event’s position in the pulse.

Type:

int

degree#

Scale degree the finger’s phase quantised to.

Type:

int

ratio#

That degree as a frequency ratio against the root.

Type:

float

cents#

That degree in cents above the root.

Type:

float

loudness#

|f_index|.

Type:

float

phase#

The finger’s raw position in [0, 2*pi), before quantisation – kept because the keyboard is continuous and the residual is audible information the degree alone throws away.

Type:

float

index: int#
degree: int#
ratio: float#
cents: float#
loudness: float#
phase: float#
partial(n: int, k: int) PlayState[source]#

The k-th elementary play state on n fingers – a “pure partial”.

f_j = exp(2*pi*i*j*k/n): all fingers strike equally hard, and their positions wind k times round the keyboard. Its spectrum is a unit impulse at k, so these are the basis Milne et al. Fig. 8 draws and the thing every other play state is a superposition of.

Note that k and k + n give the same state, and that the set of finger positions is the same for every k – only the order in which the fingers visit them changes, which is why to_events() is where k starts to matter musically.

Parameters:
  • n (int) – Number of fingers, >= 1.

  • k (int) – Partial index, taken modulo n.

Returns:

PlayState

Examples

>>> [round(float(abs(z)), 9) for z in partial(4, 1).spectrum]
[0.0, 1.0, 0.0, 0.0]
>>> [round(float(p), 6) for p in partial(4, 3).phases]
[0.0, 4.712389, 3.141593, 1.570796]
>>> partial(4, 5) == partial(4, 1)
True

Fingers that land back on the root land there exactly, because the j*k mod n below is integer arithmetic. Doing the reduction in floats instead leaves exp(2*pi*i*j*k/n) a hair off 1, and a hair-negative angle reads as the top key rather than the root – a whole scale step of error:

>>> sorted(set(round(float(p), 9) for p in partial(12, 3).phases))
[0.0, 1.570796327, 3.141592654, 4.71238898]
keyboard_sectors(mode_or_scale: Any) List[Tuple[float, float]][source]#

The angular key each scale degree owns, as [start, end) in radians.

Milne et al. Fig. 8 gives the circular keyboard “key widths which are proportional to the sizes of the step intervals above each tone”. So a tone sits at the bottom edge of its own key and the key runs up to the next tone: degree i owns [2*pi*d_i, 2*pi*d_{i+1}), and the last degree’s key closes the circle at 2*pi. Keys are therefore unequal – wide above a large step, narrow above a small one – and they tile [0, 2*pi) exactly.

The asymmetry is load-bearing. Because a tone sits on its key’s lower edge, phase_to_degree() rounds a phase down to the nearest scale tone rather than to the nearest tone in either direction. Whether a set of evenly spaced fingers then lands one-per-key depends on the mode, not just on the tuning: it happens exactly in the mode whose step pattern is the Christoffel word theory.christoffel_word(n_large, n_small), since that word is by construction the floor-quantisation of the equal division. See to_events() for what that means in practice.

Parameters:

mode_or_scale (MOSScale or Mode) – Anything exposing .degrees as ascending period fractions in [0, 1) starting at 0.

Returns:

list of (float, float) – One (start, end) pair per degree, in ascending pitch order.

Examples

Diatonic in 12-EDO: five wide keys of a whole tone, two narrow of a semitone, in the order of the scale’s own word LLLsLLs.

>>> from biotuner.mos.scale import MOSScale
>>> secs = keyboard_sectors(MOSScale.from_signature(5, 2, tuning=12))
>>> [round(hi - lo, 4) for lo, hi in secs]
[1.0472, 1.0472, 1.0472, 0.5236, 1.0472, 1.0472, 0.5236]
>>> round(secs[0][0], 9), abs(secs[-1][1] - TWO_PI) < 1e-12
(0.0, True)
phase_to_degree(phase: float, mode_or_scale: Any) int[source]#

Which key a finger’s phase lands on.

Consistent with keyboard_sectors() by construction: the phase is reduced into [0, 2*pi) and the key whose half-open sector contains it is returned, so a phase sitting exactly on a boundary belongs to the key above it.

Boundaries are matched to within _PHASE_EPS rather than exactly. Rounding down turns “one ulp short of a boundary” into a whole scale step of error, and a finger is one ulp short of its tone whenever the scale is an equal division – exp/angle and 2*pi*d are different computations of the same angle. Without the tolerance, to_events(partial(3, 1), MOSScale.from_signature(1, 2, tuning=12)) returns [0, 1, 1].

Parameters:
  • phase (float) – Radians; any value, reduced modulo 2*pi.

  • mode_or_scale (MOSScale or Mode)

Returns:

int – Degree index into mode_or_scale.degrees.

Examples

Probing the middle of each 12-EDO semitone shows the rounding-down: the semitone above a scale tone still reads as that tone.

>>> from biotuner.mos.scale import MOSScale
>>> d = MOSScale.from_signature(5, 2, tuning=12)
>>> [phase_to_degree(TWO_PI * (x + 0.5) / 12, d) for x in range(12)]
[0, 0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6]

A hair below the root wraps into the top key, not the root’s own – but “a hair” means audibly so, not one ulp:

>>> phase_to_degree(0.0, d), phase_to_degree(-1e-6, d)
(0, 6)
>>> phase_to_degree(-1e-15, d)
0

An exact equal division quantises to the identity, ulps notwithstanding:

>>> edo = MOSScale.from_signature(1, 2, tuning=12)   # 3-EDO, L == s
>>> [phase_to_degree(p, edo) for p in partial(3, 1).phases]
[0, 1, 2]
to_events(state: PlayState, mode_or_scale: Any) List[NoteEvent][source]#

Resolve a play state into one note per finger, in striking order.

The finger count and the scale cardinality are independent – a 5-finger robot can play a 12-note scale – but they coincide in the case Milne et al. Fig. 8 illustrates, and that is where the paper’s claim about partials lives.

The claim, and what is actually true#

Fig. 8 shows the first partial sweeping every tone of the scale exactly once, in ascending scalar order, and higher partials with k coprime to n generating complete generic interval cycles. Under the Fig. 8 key layout (keyboard_sectors()) that is a statement about a mode, not about a scale:

  • It holds exactly when the mode’s step pattern is the Christoffel word theory.christoffel_word(n_large, n_small) – for 5L2s that is sLLsLLL (Locrian), for 2L3s it is ssLsL. Propriety is sufficient but far from necessary: sweeping the whole coherent generator range – endpoints R = 1 and R = 2 included – of every signature up to 12 notes, the Christoffel mode never fails, and 5L2s keeps working out past R = 6. There is no sharper threshold to quote, because the cut-off depends on the signature and not on R alone – 5L2s still works above R = 6 where 4L5s has already broken at R = 3.25.

  • In any other mode it fails, and the failure is not subtle: in the brightest mode of 12-EDO 5L2s the first partial yields degrees [0, 0, 1, 2, 3, 4, 5] – the root is struck twice and the leading tone never. Finger 0 sits at phase 0, which is the bottom edge of key 0, and key 0 is a whole tone wide while the fingers are only 1/7 of a turn apart, so key 0 catches two of them.

Both branches are asserted in tests/mos/test_fourier.py.

Parameters:
  • state (PlayState)

  • mode_or_scale (MOSScale or Mode) – Uses .ratios and .cents when present. If only one is present the other is derived from it (cents = 1200*log2(ratio)), so the two fields of a NoteEvent always agree. If neither is, both come from .degrees against .period if the object has one and a 2/1 period otherwise.

returns:

list of NoteEvent

Examples

>>> from biotuner.mos.scale import MOSScale
>>> locrian = MOSScale.from_signature(5, 2, tuning=12).mode(6)
>>> locrian.word
'sLLsLLL'
>>> ev = to_events(partial(7, 1), locrian)
>>> [e.degree for e in ev]
[0, 1, 2, 3, 4, 5, 6]
>>> [round(e.cents) for e in ev]
[0, 100, 300, 500, 600, 800, 1000]

The third partial reorders the same seven tones into a cycle of thirds:

>>> [e.degree for e in to_events(partial(7, 3), locrian)]
[0, 3, 6, 2, 5, 1, 4]

A non-octave period is carried through, because .ratios/.cents come from the scale rather than from a hardcoded 2/1:

>>> tritave = MOSScale.from_signature(5, 2, tuning=12, period=3.0).mode(6)
>>> [round(e.cents, 2) for e in to_events(partial(7, 1), tritave)]
[0.0, 158.5, 475.49, 792.48, 950.98, 1267.97, 1584.96]
to_frequencies(events: Iterable[NoteEvent], fund: float = 250.0) List[float][source]#

Sound the events against a fundamental, in hertz.

Parameters:
  • events (iterable of NoteEvent)

  • fund (float, default 250.0) – Frequency of the scale root.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> ev = to_events(partial(5, 1), MOSScale.from_signature(2, 3, tuning=12))
>>> [round(f, 3) for f in to_frequencies(ev, fund=200.0)]
[200.0, 224.492, 251.984, 299.661, 336.359]
scratch_sequence(state: PlayState, k: int, values: Iterable[float], attr: str = 'phase') List[PlayState][source]#

A trajectory of play states: scratch(k, **{attr: v}) for each v.

Every state is scratched from the same base state, not from its predecessor, so values is a path through the control’s absolute range and the trajectory is reproducible from any frame – what an animation or a scrubbable UI needs. With attr='rotate' that means passing already cumulative offsets (np.linspace(0, 2*pi, 60)), not per-frame deltas.

Parameters:
  • state (PlayState)

  • k (int) – Coefficient to scratch.

  • values (iterable of float)

  • attr ({‘phase’, ‘magnitude’, ‘scale’, ‘rotate’}, default ‘phase’)

Returns:

list of PlayState

Examples

Sweeping one coefficient’s phase through a full turn returns to the start:

>>> p = partial(4, 1)
>>> traj = scratch_sequence(p, 1, [0.0, math.pi, TWO_PI])
>>> len(traj), traj[0].allclose(traj[-1])
(3, True)
>>> [round(float(x), 6) for x in traj[1].phases]
[3.141593, 4.712389, 0.0, 1.570796]

timbre#

Dynamic Tonality: spectra bent to fit a well-formed scale.

Everything else in biotuner.mos moves the scale. This module moves the timbre, and it is the move that makes the rest of the labyrinth playable.

The problem it solves is the one that has always limited microtonality. Slide the generator of a well-formed scale away from the familiar equal temperaments and the scale’s intervals stop landing near small-integer frequency ratios. A harmonic tone – partials at 1, 2, 3, 4, … times the fundamental – then has nothing to lock onto: partial 3 of one note sits a few dozen cents from partial 2 of the note a fifth above, which is exactly the spacing that maximises Plomp-Levelt roughness. The scale sounds out of tune not because it is mistuned but because the timbre is tuned to a different lattice.

Milne et al. (2011) §6 close the paper by inverting the fix: keep the scale wherever the musician put it and retune the partials instead, so that “the pitch (relative to the fundamental) of each partial is mapped to a linear combination of the pitch heights of the period and generator of the underlying scale”. Concretely, harmonic h moves from h to

\[P^{\alpha} \, G^{\beta}\]

with P the period, G the generator, and (\alpha, \beta) the integer pair that best approximates it. Because every scale degree is also a point on that (P, G) lattice, partials of different tones now coincide exactly wherever scale intervals do – and they keep coinciding as the generator slides, since both move together. Sensory dissonance (Sethares, 2005) is minimised continuously across the whole tuning range rather than at a handful of privileged temperaments.

A caution about max_beta and beta_penalty#

The mapping is only musically useful when |beta| is small. A generator chain of length 4 is something a listener tracks (four fifths is the major third); a chain of length 21 is an accident of number theory. Worse, a large beta budget defeats the whole exercise: given ~24 generators to spend, the optimiser can approximate any just harmonic to within a cent or so, at which point the “matched” spectrum is the harmonic series again in all but name, and no partials coincide with anything.

Measured here on 26 MOS signatures at their noble tunings, sounding every degree with 12 partials, the dissonance reduction over a plain harmonic timbre of the same size and loudness is:

setting

reduction (median)

signatures worse

max_beta=5

13.9 % (6.2–30.2)

0 / 26

max_beta=8

9.3 % (3.2–17.1)

0 / 26

beta_penalty=3.0

8.4 %

1 / 26

max_beta=24 (default)

0.7 %

6 / 26

So the defaults – which are the permissive ones the API contract specifies – are the setting at which Dynamic Tonality barely works, and for six signatures (1L3s, 1L4s, 4L1s, 2L5s, 2L7s, 4L7s) it comes out a fraction of a percent worse than a harmonic timbre. That is not a defect in the theory, it is the beta budget swamping it. Bounding the chain rescues every one of them: the same six gain 6–24 % at max_beta=5. Bounding is also more reliable than penalising – beta_penalty=3.0 still leaves 3L2s 1.0 % worse. dissonance_advantage() reports which side of this you landed on for any given scale.

References

Milne, A.J., Carlé, M., Sethares, W.A., Noll, T., Holland, S. (2011). Scratching the Scale Labyrinth. In Mathematics and Computation in Music, LNAI 6726, 180–195. §6 “Dynamic Tonality”.

Sethares, W.A. (2005). Tuning, Timbre, Spectrum, Scale (2nd ed.). Springer.

class PartialMap(harmonic: int, alpha: int, beta: int, ratio: float, just_ratio: float, error_cents: float)[source]#

Bases: object

Where one harmonic lands once it is pulled onto the scale’s lattice.

harmonic#

The harmonic number h that was mapped, 1 being the fundamental.

Type:

int

alpha#

Periods in the retuned partial. May be negative: a partial can need pulling down by an octave once a long generator chain has overshot.

Type:

int

beta#

Generators in the retuned partial. Its magnitude is the interesting number – see the module docstring.

Type:

int

ratio#

The retuned partial, period ** alpha * generator_ratio ** beta. This is what actually sounds.

Type:

float

just_ratio#

The plain harmonic float(h), i.e. where the partial would sit in an ordinary harmonic tone.

Type:

float

error_cents#

ratio minus just_ratio in cents, signed. Zero means the scale’s lattice happens to contain that harmonic exactly, which for an octave period is true of every power of two.

Type:

float

harmonic: int#
alpha: int#
beta: int#
ratio: float#
just_ratio: float#
error_cents: float#
property cents: float#

The retuned partial in cents above the fundamental.

class SimpleTimbre(partials_hz: ~numpy.ndarray, amplitudes: ~numpy.ndarray, base_freq: float = 1.0, matched_tuning: list | None = None, matching_method: str = '', metadata: dict = <factory>)[source]#

Bases: object

Fallback spectrum container for when harmonic_timbre is unavailable.

dynamic_timbre() prefers biotuner.harmonic_timbre.Timbre, which carries phases, decay times, modulators and exporters. This stands in when that subpackage cannot be imported, and mirrors the field names of the fields it does carry so that downstream code can duck-type across the two.

partials_hz: ndarray#
amplitudes: ndarray#
base_freq: float = 1.0#
matched_tuning: list | None = None#
matching_method: str = ''#
metadata: dict#
n_partials() int[source]#
map_harmonic(h: int, scale: MOSScale, max_beta: int = 24, beta_penalty: float = 0.0) PartialMap[source]#

Pull harmonic h onto the (period, generator) lattice of scale.

Finds the integer pair minimising

|alpha * log(period) + beta * log(generator) - log(h)|

over |beta| <= max_beta. Only beta is searched: once it is fixed the best alpha is forced, because moving by whole periods is the coarsest possible adjustment and rounding to the nearest one is optimal by construction. That turns a two-dimensional lattice search into a scan of 2 * max_beta + 1 candidates.

Parameters:
  • h (int) – Harmonic number, >= 1.

  • scale (MOSScale) – Supplies period and generator_ratio. Nothing else about the scale enters – two scales sharing a generator share a timbre, which is why a whole MOS family can be played with one spectrum.

  • max_beta (int, default 24) – Longest generator chain allowed. See the module docstring: this default is permissive enough to hide the effect it is meant to produce.

  • beta_penalty (float, default 0.0) – Cents of penalty per generator in the chain. Non-zero trades tuning accuracy for a shorter, more audible chain; 3.0 is a good starting point for an octave period.

Returns:

PartialMap

Examples

The fundamental is always the origin of the lattice, at no error:

>>> from biotuner.mos.scale import MOSScale
>>> m = MOSScale.from_signature(5, 2, tuning=31)
>>> p = map_harmonic(1, m)
>>> p.alpha, p.beta, p.error_cents
(0, 0, 0.0)

With an octave period the octave is the period, so harmonic 2 needs no generators at all:

>>> p = map_harmonic(2, m)
>>> p.alpha, p.beta, p.error_cents
(1, 0, 0.0)

Harmonic 3 is one period plus one generator – a twelfth is an octave plus a fifth – and in 31-EDO meantone that fifth is 5.18 cents flat:

>>> p = map_harmonic(3, m)
>>> p.alpha, p.beta, round(p.error_cents, 3)
(1, 1, -5.181)

Harmonic 5 is four generators and no periods, which is the definition of meantone: four fifths, octave-reduced, are the major third:

>>> p = map_harmonic(5, m)
>>> p.alpha, p.beta, round(p.error_cents, 3)
(0, 4, 0.783)

A penalty buys a shorter chain at the cost of accuracy. At the noble tuning harmonic 5 defaults to a 21-generator chain that is essentially just the plain harmonic; three cents per generator collapses it to meantone’s four, 30 cents sharp:

At a rational generator the lattice closes on itself, so beta and beta ± denominator name the very same pitch. The shorter chain is the one that comes back – in 7-EDO the third harmonic is one fifth, not the bit-identical twenty-generator spelling:

>>> seven = MOSScale.from_signature(5, 2, tuning='equalized')
>>> map_harmonic(3, seven).alpha, map_harmonic(3, seven).beta
(1, 1)
>>> max(abs(map_harmonic(h, seven).beta) for h in range(1, 13))
3
>>> n = MOSScale.from_signature(5, 2)
>>> map_harmonic(5, n).beta
21
>>> p = map_harmonic(5, n, beta_penalty=3.0)
>>> p.beta, round(p.error_cents, 2)
(4, 30.07)
matched_partials(scale: MOSScale, n_partials: int = 12, max_beta: int = 24, beta_penalty: float = 0.0) List[PartialMap][source]#

Map harmonics 1 .. n_partials onto scale’s lattice.

Parameters:
  • scale (MOSScale)

  • n_partials (int, default 12) – How far up the harmonic series to go. Twelve reaches the point where Plomp-Levelt roughness between neighbouring partials of a single tone starts to dominate, which is where a real instrument’s spectrum matters.

  • max_beta, beta_penalty – Passed to map_harmonic().

Returns:

list of PartialMap – Always begins with the fundamental at (0, 0).

Examples

>>> from biotuner.mos.scale import MOSScale
>>> m = MOSScale.from_signature(5, 2, tuning=31)
>>> [(p.alpha, p.beta) for p in matched_partials(m, n_partials=8)]
[(0, 0), (1, 0), (1, 1), (2, 0), (0, 4), (2, 1), (-3, 10), (3, 0)]

Bound the chain and the seventh partial gives up on 7/4, settling for the two-generator approximation instead:

>>> [(p.alpha, p.beta) for p in matched_partials(m, 8, max_beta=5)]
[(0, 0), (1, 0), (1, 1), (2, 0), (0, 4), (2, 1), (4, -2), (3, 0)]
matched_ratios(scale: MOSScale, n_partials: int = 12, **kwargs: Any) List[float][source]#

The retuned partials of matched_partials() as bare frequency ratios.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> m = MOSScale.from_signature(5, 2, tuning=31)
>>> [round(r, 4) for r in matched_ratios(m, n_partials=6)]
[1.0, 2.0, 2.991, 4.0, 5.0023, 5.9821]
matched_spectrum(scale: MOSScale, fundamental: float = 250.0, n_partials: int = 12, amplitudes: Sequence[float] | None = None, **kwargs: Any) Tuple[ndarray, ndarray][source]#

A sounding Dynamic Tonality spectrum: frequencies in Hz and amplitudes.

Parameters:
  • scale (MOSScale)

  • fundamental (float, default 250.0) – Hz. Roughness is not scale-invariant – the Plomp-Levelt critical band widens with frequency – so the choice of fundamental changes every dissonance number downstream. 250 Hz sits in the middle of the range where the curve is best characterised.

  • n_partials (int, default 12)

  • amplitudes (sequence of float, optional) – One value per partial. None gives the 1/h roll-off of an idealised sawtooth. Whatever is passed is rescaled to peak at 1.

  • **kwargsmax_beta and beta_penalty, forwarded to map_harmonic().

Returns:

(frequencies, amplitudes) (tuple of ndarray)

Examples

>>> from biotuner.mos.scale import MOSScale
>>> m = MOSScale.from_signature(5, 2, tuning=31)
>>> f, a = matched_spectrum(m, fundamental=100.0, n_partials=5)
>>> [round(float(x), 3) for x in f]
[100.0, 200.0, 299.104, 400.0, 500.226]
>>> [round(float(x), 4) for x in a]
[1.0, 0.5, 0.3333, 0.25, 0.2]
dynamic_timbre(scale: MOSScale, n_partials: int = 12, fundamental: float = 250.0, amplitudes: Sequence[float] | None = None, **kwargs: Any)[source]#

Package matched_spectrum() as a timbre object ready for synthesis.

Returns a biotuner.harmonic_timbre.Timbre when that subpackage imports, so the result drops straight into render_additive, the exporters and the cross-modal sidecar. If the import fails – the subpackage is not a hard requirement of biotuner.mos – a SimpleTimbre with the same core fields is returned instead, and the substitution is recorded in metadata['timbre_class'].

Parameters:

scale, n_partials, fundamental, amplitudes, **kwargs – As matched_spectrum().

Returns:

Timbre or SimpleTimbre – Carrying partials_hz, amplitudes, base_freq, matched_tuning (the scale’s ratios), matching_method ('dynamic_tonality') and a metadata dict recording the signature, generator and per-partial (alpha, beta) mapping.

Examples

>>> from biotuner.mos.scale import MOSScale
>>> t = dynamic_timbre(MOSScale.from_signature(5, 2, tuning=31),
...                    n_partials=4, fundamental=100.0)
>>> [round(float(f), 3) for f in t.partials_hz]
[100.0, 200.0, 299.104, 400.0]
>>> t.matching_method
'dynamic_tonality'
>>> t.metadata['signature'], t.metadata['lattice'][2]
('5L2s', (1, 1))
spectral_dissonance(freqs: Sequence[float], amps: Sequence[float]) float[source]#

Total pairwise Plomp-Levelt roughness of a spectrum.

Thin wrapper over biotuner.scale_construction.dissmeasure(), which sums the roughness of every pair of sinusoids using the minimum of the two amplitudes (the beat amplitude) rather than their product. Kept here so that the whole Dynamic Tonality argument can be checked without leaving this module, and so the lazy import stays in one place.

Parameters:
  • freqs (sequence of float) – Frequencies in Hz. Order does not matter; dissmeasure sorts.

  • amps (sequence of float) – One amplitude per frequency.

Returns:

float – Unnormalised roughness. Only differences between spectra of the same size and loudness are meaningful; the absolute value is not a scale.

Examples

An octave is nearly smooth, a semitone is not:

>>> round(spectral_dissonance([250.0, 500.0], [1.0, 1.0]), 6)
0.000809
>>> round(spectral_dissonance([250.0, 265.0], [1.0, 1.0]), 4)
0.8413
scale_dissonance(scale: MOSScale, n_partials: int = 12, matched: bool = True, fundamental: float = 250.0, amplitudes: Sequence[float] | None = None, **kwargs: Any) float[source]#

Roughness of the whole scale sounded at once, every degree, every partial.

This is the quantity Dynamic Tonality is trying to minimise. Sounding the scale as a single simultaneity is a blunt instrument – no real music plays all seven notes together – but it is the right blunt instrument here, because it counts every partial-against-partial collision the tuning affords, with none of the arbitrariness of picking a chord progression.

Parameters:
  • scale (MOSScale)

  • n_partials (int, default 12)

  • matched (bool, default True) – True sounds each degree with the lattice-matched partials of matched_partials(); False with a plain harmonic series. The two spectra have the same number of partials and the same amplitude envelope, so the comparison isolates partial placement.

  • fundamental (float, default 250.0) – Hz of the scale’s root.

  • amplitudes (sequence of float, optional)

  • **kwargsmax_beta and beta_penalty; ignored when matched=False.

Returns:

float

Examples

>>> from biotuner.mos.scale import MOSScale
>>> m = MOSScale.from_signature(5, 2, tuning=31)
>>> round(scale_dissonance(m, matched=False), 4)
29.8699
>>> round(scale_dissonance(m, matched=True), 4)
28.327
dissonance_advantage(scale: MOSScale, **kwargs: Any) Dict[str, float][source]#

How much roughness the matched timbre saves over a harmonic one.

Parameters:
  • scale (MOSScale)

  • **kwargs – Forwarded to scale_dissonance() (n_partials, fundamental, amplitudes, max_beta, beta_penalty). matched is not accepted – both settings are computed, that is the point.

Returns:

dict'harmonic' and 'matched' total dissonances, their difference 'reduction' (positive means the matched timbre won), and 'reduction_pct' as a percentage of the harmonic total.

Examples

31-EDO meantone is close enough to 12-EDO that a harmonic timbre already half works, and the matched one still takes 5 % off:

>>> from biotuner.mos.scale import MOSScale
>>> adv = dissonance_advantage(MOSScale.from_signature(5, 2, tuning=31))
>>> round(adv['reduction'], 4), round(adv['reduction_pct'], 2)
(1.5429, 5.17)

Push the generator out to 714 cents, far from anything 12-EDO can spell, and bound the generator chain to lengths a listener can follow:

>>> far = MOSScale.from_signature(5, 2, tuning=0.5952)
>>> adv = dissonance_advantage(far, max_beta=5)
>>> round(adv['reduction_pct'], 2)
19.72

plotting#

Matplotlib figures: the labyrinth, the Stern–Brocot tree, scale wheels, tuning ranges, modes, and fits. biotuner.mos.plotting.plot_forward_vs_inverse() puts both directions of the derivation in one frame — every forward reading at its (generator, cardinality), sized by how many observed intervals proposed that generator and shaded by how well the resulting scale explains the signal, against the latent generator fit_mos() settled on. It draws the bright half only, because both directions fold there and a permanently empty semicircle would read as absence of evidence.

Static visualisations of the scale labyrinth and its inhabitants.

The centrepiece is plot_labyrinth(), a faithful rendering of Milne et al. (2011) Figures 1–2. Reading it, per their §4:

  • angle is the generator/period ratio – top of the circle is 0, the bottom is 1/2, so 700 cents against a 1200-cent period sits at 7/12 of the way round. The picture is left–right symmetric because a generator and its complement within the period build the same scale.

  • ring is cardinality. Ring N carries every N-note MOS.

  • spokes are equal temperaments. Each spoke runs inward from the rim and touches without crossing the ring giving its number of notes.

  • arcs are valid tuning ranges. An arc on ring N spans the generators over which some N-note MOS keeps its identity; the darker inner band is where it is also coherent.

Every function takes ax=None and returns (fig, ax), so figures compose.

plot_labyrinth(max_cardinality: int = 18, *, period: float = 2.0, periods_per_octave: int = 1, highlight: None | float | MOSScale | Sequence = None, peaks: Sequence[float] | None = None, peak_weights: Sequence[float] | None = None, temperaments: bool = False, temperament_tuning: str = 'pote', label: str = 'cents', n_labels: int = 12, show_arcs: bool = True, show_spokes: bool = True, show_coherence: bool = True, annotate: int | None = None, generator_range: Tuple[float, float] | None = None, ax=None, figsize: Tuple[float, float] = (9.0, 9.0))[source]#

The scale labyrinth: every well-formed scale up to max_cardinality.

Parameters:
  • max_cardinality (int, default 18) – Outermost ring. Milne et al. Fig. 1 shows 18.

  • period (float, default 2.0) – The interval the whole circle spans, as a frequency ratio.

  • periods_per_octave (int, default 1) – Draw a scale whose period is period ** (1 / n) – a fractional period, as srutal (2), augmented (3), blackwood (5) and compton (12) have. The scale then repeats n times around the circle, giving the labyrinth n-fold rotational symmetry, and rings count notes per octave rather than per period, so only multiples of n appear. 1 leaves the ordinary labyrinth untouched.

  • highlight (float, MOSScale, or sequence, optional) – A generator fraction, a scale, or several of either. Each gets a bold radial line, and every MOS in its family gets a marker on its ring – so you can read a generator’s whole scale series off the picture.

  • peaks (sequence of float, optional) – Biosignal peak ratios to overlay, drawn just outside the rim at the angle each one occupies in the labyrinth.

  • peak_weights (sequence of float, optional) – Marker areas, e.g. peak amplitudes.

  • temperaments (bool, default False) – Overlay named rank-2 temperaments as radial lines at their optimal generators – the red lines of Milne et al. Fig. 1. Only those whose period is the whole octave are drawn.

  • temperament_tuning ({‘pote’, ‘cte’}, default ‘pote’) – Which optimum to place the lines at. POTE is what published temperament tables quote, so it is the default and the one to use when cross-checking against them; CTE constrains the period pure from the outset instead. See biotuner.mos.temperaments.

  • label ({‘cents’, ‘fraction’, ‘none’}, default ‘cents’)

  • n_labels (int, default 12)

  • show_arcs, show_spokes, show_coherence (bool)

  • annotate (int, optional) – Write the nLms signature beside every arc up to this cardinality. Unreadable above about 9 rings.

  • generator_range ((float, float), optional) – Restrict the angular window to these generator fractions – the zoom of Milne et al. Fig. 6. (0.5, 0.62) frames the diatonic region.

  • ax (matplotlib polar axes, optional)

  • figsize (tuple)

Returns:

(fig, ax)

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_labyrinth(12)
>>> bool(ax.get_ylim()[1] > 12)
True
>>> plt.close(fig)
plot_stern_brocot(max_cardinality: int = 12, *, highlight_generator: float | None = None, annotate_signatures: bool = True, ax=None, figsize: Tuple[float, float] = (12.0, 6.0))[source]#

The Stern-Brocot tree, with each node’s MOS signatures.

The labyrinth is this tree bent into a circle (Milne et al. §1). Drawn flat, the parent/child structure is explicit: each node is the mediant of the pair bracketing it, and its denominator is the cardinality of the MOS whose two step sizes equalise there.

Parameters:
  • max_cardinality (int, default 12)

  • highlight_generator (float, optional) – Generator fraction whose path down the tree to trace.

  • annotate_signatures (bool, default True) – Label each node with the bLds / dLbs pair its two sub-ranges host.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_stern_brocot(7, highlight_generator=math.log2(3 / 2))
>>> plt.close(fig)
plot_scale_wheel(scale, *, show_labels: bool = True, ax=None, figsize: Tuple[float, float] = (7.0, 7.0))[source]#

A scale as a circular keyboard, keys as wide as the steps above them.

Milne et al. §5 and Fig. 8: “the keyboard layout for a finite scale can have specific key widths which are proportional to the sizes of the step intervals above each tone”. Large and small steps are coloured apart, so the maximally even distribution of the MOS word is visible at a glance.

Accepts an MOSScale or a Mode.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_scale_wheel(MOSScale.from_signature(5, 2, tuning=12))
>>> plt.close(fig)
plot_step_covariation(n_large: int, n_small: int, *, bright: bool = True, period: float = 2.0, mark: float | None = None, ax=None, figsize: Tuple[float, float] = (10.0, 6.5))[source]#

How the two step sizes trade off as the generator moves.

This draws the paragraph Milne et al. §2 calls “Landmark equal tunings”: across the valid range the large and small steps co-vary, always summing to the period, and they pass through three distinguished tunings – one where they become equal (and the scale meets its inverse), and one on each side where a step size reaches zero.

The span shown covers the MOS and its inverse, meeting at the equalized landmark, so the whole story is in one frame. The lower panel tracks Blackwood’s R; the scale is coherent below R = 2.

Parameters:
  • n_large, n_small (int)

  • bright (bool, default True) – Which of the two mirror ranges to show.

  • period (float, default 2.0)

  • mark (float, optional) – A generator fraction to mark with a vertical line, e.g. the tuning you actually fitted.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, axes = plot_step_covariation(5, 2)
>>> plt.close(fig)
plot_modes(scale: MOSScale, *, ax=None, figsize: Tuple[float, float] = (11.0, 6.0))[source]#

Every mode of a scale, stacked brightest to darkest.

Each row is one mode’s step pattern drawn to scale. Read down the stack and the parsimony of Milne et al. §4 is visible directly: between neighbouring rows exactly one boundary moves, by the chroma L - s.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_modes(MOSScale.from_signature(5, 2, tuning=12))
>>> plt.close(fig)
plot_mode_lattice(scale: MOSScale, *, width: int = 4, height: int = 3, base: int = 0, ax=None, figsize: Tuple[float, float] = (12.0, 5.5))[source]#

The modal ℤ² lattice and one mode’s fundamental frame (Fig. 7).

Left panel: a patch of the free commutative group generated by the two commuting transformations – chromatic transposition τ rightwards (same finalis, origin a generator sharper) and diatonic transposition σ downwards (same collection, finalis a step higher).

Right panel: the base mode’s own frame, each degree placed at its (generator, period) lattice coordinates. The zig-zag is the step pattern.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, axes = plot_mode_lattice(MOSScale.from_signature(5, 2, tuning=12))
>>> plt.close(fig)
plot_mos_family(generator: float, *, max_cardinality: int = 24, period: float = 2.0, ax=None, figsize: Tuple[float, float] = (11.0, 6.0))[source]#

Every MOS a generator produces, stacked by cardinality.

The nesting Milne et al. §2 call embedding: each scale’s tones are a subset of the next one down, because they are all the same generator chain cut at different lengths.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_mos_family(3 / 2, max_cardinality=17)
>>> plt.close(fig)
plot_mos_fit(fit, ratios: Sequence[float] | None = None, *, weights: Sequence[float] | None = None, ax=None, figsize: Tuple[float, float] = (11.0, 6.0))[source]#

A fitted MOS against the ratios it was fitted to.

Top: the scale’s degrees as vertical lines, with each target ratio placed where it actually falls. Bottom: the signed residual per target, against the error a scale of this size would get on random input – the band inside which a fit is not evidence of anything.

Parameters:
  • fit (MOSFit)

  • ratios (sequence of float, optional) – The targets. Taken from fit alone if omitted, in which case only the residuals are drawn.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> from biotuner.mos.derive import best_mos
>>> r = MOSScale.from_signature(4, 3, tuning=19).ratios
>>> fig, axes = plot_mos_fit(best_mos(r, max_cardinality=12), r)
>>> plt.close(fig)
plot_mos_trajectory(trajectory: Sequence, *, times: Sequence[float] | None = None, max_cardinality: int = 18, figsize: Tuple[float, float] = (15.0, 6.5))[source]#

A signal’s path through the labyrinth over time.

Left: the labyrinth with the trajectory drawn through it, each window a point at (its generator, its cardinality) and consecutive windows joined, coloured by time. Right: the same three coordinates as time series – generator, cardinality, and fit error.

Windows where no scale could be fitted (None entries) break the path rather than being interpolated across.

Parameters:
  • trajectory (sequence of MOSFit or None) – As returned by mos_trajectory().

  • times (sequence of float, optional) – Window times; window index is used if omitted.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> from biotuner.mos.derive import trajectory_from_windows
>>> a = MOSScale.from_signature(5, 2, tuning=12).ratios
>>> b = MOSScale.from_signature(4, 3, tuning=19).ratios
>>> traj = trajectory_from_windows([a, b, a], max_cardinality=12)
>>> fig, axes = plot_mos_trajectory(traj)
>>> plt.close(fig)
plot_forward_vs_inverse(forward, inverse=None, *, max_cardinality: int | None = None, top_n: int | None = None, max_error_cents: float | None = None, annotate: bool = True, ax=None, figsize: Tuple[float, float] = (13.5, 8.5))[source]#

Both readings of a signal on one labyrinth: observed vs latent generator.

fit_mos() searches for the generator that best explains the peaks, wherever it lies – a latent parameter, which need not be an interval anything in the signal states. forward_scales() runs the other way: it takes an interval the signal does state, declares it the generator, and reads off the scale that interval builds. The two answers are usually different, and the difference is the finding, so they belong in one frame.

The labyrinth is faded to scenery. On top of it, each forward reading is a point at its (generator, cardinality) – s grows with how many observed intervals proposed that generator, colour darkens as the resulting scale explains the whole target set better – and every reading built on the same interval hangs off one radial guide, numbered at the rim and keyed in the legend. The inverse fit is a star on its own dashed ray, in a colour absent from the forward ramp.

Only the bright half is drawn. A generator and its complement build the same scale – mos_series gives the pair the same signature at the same cardinality, and their degrees are reflections of one another, so the fit cannot tell them apart – and both directions therefore fold into (0.5, 1). The dark half would be permanently empty, and an empty half-circle reads as absence of evidence rather than as a convention. Every generator quoted in the title and the legend is the folded one, so a fit that happens to name the dark spelling of its own scale is captioned with the value under the marker rather than its complement.

Parameters:
  • forward (ForwardScale or sequence of ForwardScale) – As returned by forward_scales(). Re-sorted into rank order here, so a caller-sliced or hand-assembled list is fine.

  • inverse (MOSFit or sequence of MOSFit, optional) – The competing latent-generator fit; the first is drawn when a ranked list is passed. Omitted, it is computed with best_mos() from the very targets the forward readings were scored against (fit.targets), over the same ring range – which is the only way the comparison is honest, and cheaper to get right here than to remember at every call site.

  • max_cardinality (int, optional) – Outermost ring. Defaults to the largest cardinality drawn, so the picture is as small as the data allows.

  • top_n (int, optional) – Draw only the best n forward readings. Worth setting: a single interval can generate a well-formed scale at a dozen cardinalities, and forward_scales() returns all of them.

  • max_error_cents (float, optional) – Ceiling of the colour ramp. Defaults to the worst reading drawn, capped at 60 cents – past half a semitone, “explains the signal” has no content and letting one hopeless reading set the scale flattens the rest.

  • annotate (bool, default True) – Number the generators at the rim and key the numbers in the legend.

  • ax (matplotlib polar axes, optional)

  • figsize (tuple)

Returns:

(fig, ax)

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> from biotuner.mos.derive import forward_scales
>>> peaks = [10.07, 15.64, 19.31, 22.91]          # S001, eyes closed (Hz)
>>> readings = forward_scales(peaks, include_ratios=False,
...                           min_cardinality=5, max_cardinality=7)
>>> fig, ax = plot_forward_vs_inverse(readings, top_n=8)
>>> float(ax.get_thetamin()), float(ax.get_thetamax())    # the bright half
(180.0, 360.0)
>>> sum(line.get_marker() == "*" for line in ax.get_lines())   # the inverse
1
>>> plt.close(fig)
plot_ji_landscape(*, max_cardinality: int = 24, targets: Sequence[float] = (1.0666666666666667, 1.125, 1.2, 1.25, 1.3333333333333333, 1.5, 1.6, 1.6666666666666667, 1.8, 1.875), period: float = 2.0, resolution: int = 1200, generator_range: Tuple[float, float] = (0.5, 1.0), max_error_cents: float = 50.0, ax=None, figsize: Tuple[float, float] = (13.0, 6.5))[source]#

Where in the labyrinth the well-formed scales approximate just intonation.

Milne et al. §1 point at exactly this use: “a scale labyrinth is used to indicate MOS scale tunings that provide good approximations of just intonation”. Each cell is the mean cents error from a just interval to the nearest degree of the MOS at that (generator, cardinality); blank cells are generators with no MOS at that cardinality at all, which is most of them.

Parameters:
  • max_cardinality (int, default 24)

  • targets (sequence of float, default 5-limit consonances)

  • resolution (int, default 1200) – Generator samples across generator_range.

  • generator_range ((float, float), default (0.5, 1.0)) – The bright half is enough – the other half mirrors it.

  • max_error_cents (float, default 50.0) – Colour-scale ceiling. Errors are unbounded as the generator approaches the period (where every degree piles up at the root), and letting that set the scale flattens everything worth looking at. Fifty cents is already half a semitone; past it “approximation” has no content.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_ji_landscape(max_cardinality=12, resolution=200)
>>> plt.close(fig)
plot_fit_field(field_or_ratios, *, weights: Sequence[float] | None = None, period: float = 2.0, max_cardinality: int = 22, resolution: int = 720, polar: bool = True, max_error_cents: float = 30.0, show_peaks: bool = True, peaks: Sequence[float] | None = None, mark: None | float | MOSScale | Sequence = None, ax=None, figsize: Tuple[float, float] = (8.6, 8.6))[source]#

Where in the labyrinth a signal lives – the whole plane, scored.

plot_mos_fit() shows one answer; this shows the landscape that answer was chosen from. Each cell is the weighted cents error from the signal’s ratios to the nearest degree of the scale at that (generator, cardinality). Grey means no well-formed scale exists there at all, which is most of the plane.

Reading it matters more than admiring it. A signal usually sits in several disconnected dark patches rather than one, and a single best-fit answer cannot say that. islands() counts them.

Parameters:
  • field_or_ratios (FitField or sequence of float) – A precomputed field, or the ratios to compute one from. Passing the field is the way to draw several views without recomputing.

  • weights (sequence of float, optional) – Ignored when a field is passed.

  • period, max_cardinality, resolution – Ignored when a field is passed.

  • polar (bool, default True) – Polar keeps the labyrinth’s own geometry – angle is the generator, radius the cardinality – so this figure can be read against plot_labyrinth(). Cartesian is easier to read values off.

  • max_error_cents (float, default 30.0) – Colour ceiling. Error is unbounded as the generator approaches the period, and letting that set the scale flattens everything else.

  • show_peaks (bool, default True) – Mark the signal’s own ratio positions. A FitField remembers the ratios it was built from, so this works whether you pass ratios or a precomputed field.

  • peaks (sequence of float, optional) – Ratios to mark instead of the field’s own.

  • mark (float, MOSScale or sequence, optional) – Generator(s) to trace, e.g. the scale fit_mos settled on.

  • ax (matplotlib axes, optional) – Must be polar when polar is True.

  • figsize (tuple)

Returns:

(fig, ax)

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> fig, ax = plot_fit_field([1.0, 1.125, 1.5], max_cardinality=10,
...                          resolution=120)
>>> ax.name
'polar'
>>> plt.close(fig)
plot_play_state(state, scale=None, *, ax=None, figsize: Tuple[float, float] = (12.0, 5.5))[source]#

A Fourier Scratching play state and its spectrum (Milne et al. Fig. 9).

Left: the n fingers on the circular keyboard, magnitude as radius and phase as position, joined into the polygon that is struck in order. When a scale is supplied its keys are drawn underneath, widths proportional to the step above each tone. Right: the DFT the performer actually manipulates.

Milne et al. render this on a pair of Riemann spheres; the plane is enough to read the same information.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> from biotuner.mos.fourier import partial      
>>> fig, axes = plot_play_state(partial(7, 1))    
INVERSE_COLOR = '#1B6CA8'#

The inverse fit’s colour. Blue appears nowhere on FORWARD_CMAP, so the one latent generator can never be mistaken for one more observed one – and it carries a star rather than a disc, so the distinction survives printing in grey.

FORWARD_CMAP = 'magma'#

dark is accurate, as in plot_ji_landscape(). The ramp is truncated before its pale end by _forward_colormap(), because a near-white marker on a white page is a marker you cannot see.

Type:

Forward readings are shaded by how well they explain the signal

morph#

Moving between two well-formed scales, three ways: hold the structure and slide the generator along one arc (tuning), hop between rings along the signature graph (tree), or glide every tone to its counterpart and leave the space of well-formed scales while doing it (voice). Also the signature graph itself, the figures, and audio.

Morphing between well-formed scales – three ways of navigating the labyrinth.

Milne et al. (2011) built the labyrinth as a surface to move across: their §1 describes choosing a structure and a tuning at once, and their §6 keeps the timbre consonant while you do. This module makes the movement itself the object, and offers three strategies that are genuinely different journeys rather than three spellings of one.

tuning

Hold the structure, slide the generator. The path runs along a single arc of the labyrinth. This is the Dynamic Tonality knob: the scale keeps its identity while its two step sizes co-vary, and where the path crosses an equalized landmark the large and small steps trade places and the scale becomes its own inverse. See tuning_morph().

tree

Change the structure, discretely. The path hops between rings, walking the labyrinth’s own connectivity: a signature’s children are (nL, nL+ns) and (nL+ns, ns) – the Stern-Brocot mediant, which is why 5L2s’s child 5L7s has exactly the twelve notes embedding() predicts – its parent is the subtractive Euclidean step, and one further edge swaps (nL, ns) for (ns, nL) by crossing a landmark. Pentatonic to chromatic is a walk up this tree. See tree_morph().

voice

Move the notes and ignore the structure. Each tone glides to its nearest counterpart in the target; where the two scales have different note counts, tones split or merge. See voice_morph().

The contrast is the point. The first two paths never leave the set of well-formed scales, because every frame is one by construction. The third does: its intermediate pitch sets are generally not well-formed, and MorphStep.wellformedness measures how far outside it strays. Sliding a generator and gliding the notes are different journeys between the same two places, and they sound different.

STRATEGIES: Tuple[str, ...] = ('tuning', 'tree', 'voice')#

The three journeys.

class MorphStep(t: float, degrees: Tuple[float, ...], scale: MOSScale | None = None, period: float = 2.0, label: str = '', event: str | None = None, wellformedness: float = 0.0)[source]#

Bases: object

One frame: a set of pitches, and whatever structure it happens to have.

t#

Position along the morph, 0 at the start scale and 1 at the end.

Type:

float

degrees#

Pitch classes as period fractions in [0, 1), ascending. Always present – this is the only thing every strategy guarantees.

Type:

tuple of float

scale#

The well-formed scale this frame is, when it is one. None for a voice-leading frame that has left the space.

Type:

MOSScale or None

period#
Type:

float

label#

Short human-readable name for the frame.

Type:

str

event#

Something worth hearing happened here: a landmark crossed, a step count changed, tones split or merged.

Type:

str or None

wellformedness#

Cents by which this frame misses being a well-formed scale – 0.0 when it is one. Computed for voice frames, where it is the interesting quantity; the other strategies are exact by construction.

Type:

float

t: float#
degrees: Tuple[float, ...]#
scale: MOSScale | None = None#
period: float = 2.0#
label: str = ''#
event: str | None = None#
wellformedness: float = 0.0#
property cardinality: int#
property cents: List[float]#
property ratios: List[float]#
property is_well_formed: bool#
class Morph(steps: Tuple[MorphStep, ...], strategy: str, start: MOSScale, end: MOSScale, voices: Tuple[Tuple[int, ...], ...] = ())[source]#

Bases: object

A journey between two well-formed scales.

steps#
Type:

tuple of MorphStep

strategy#
Type:

str

start, end
Type:

MOSScale

voices#

Per frame, which voice each degree belongs to, so a trajectory can be drawn as continuous lines rather than a scatter. Empty for strategies where the note count changes and voices are not tracked.

Type:

tuple of tuple of int

steps: Tuple[MorphStep, ...]#
strategy: str#
start: MOSScale#
end: MOSScale#
voices: Tuple[Tuple[int, ...], ...] = ()#
property period_cents: float#
events() List[Tuple[float, str]][source]#

Every (t, description) where something audible happened.

signatures() List[str][source]#

The distinct signatures passed through, in order.

labyrinth_path() List[Tuple[float, int] | None][source]#

(generator, cardinality) per frame; None where off the map.

trajectory() ndarray[source]#

Degrees as a (frames, max_cardinality) array, NaN-padded.

Rows are frames. Columns are voices where the strategy tracked them, and sorted degrees where it did not.

The difference matters wherever two tones cross. Read in ascending order, a crossing hands each tone to the other’s column, which looks like two large jumps instead of two lines passing; summed up it charges motion no voice performs. voices records which tone is which, so a voice morph is unscrambled here and every consumer – the trajectory plot, morph_audio(), voice_leading_distance() – gets continuous lines for free.

voice_leading_distance() float[source]#

Total pitch motion over the whole journey, in cents.

Summed frame to frame over trajectory()’s columns – matched voices where the strategy tracked them – so it measures how far the tones actually travel rather than how far apart the endpoints are. Each hop takes the shorter way round the period, and a column that is absent from either frame contributes nothing, which is how a change of note count is handled without inventing motion for the tones that are not there yet.

The three strategies give genuinely different totals for the same pair; that difference is what makes them different journeys rather than different spellings of one.

summary() str[source]#

Multi-line description of the journey.

morph(start: MOSScale, end: MOSScale, strategy: str = 'tuning', **kwargs) Morph[source]#

Journey from one well-formed scale to another.

Parameters:
  • start, end (MOSScale)

  • strategy ({‘tuning’, ‘tree’, ‘voice’}, default ‘tuning’) – See the module docstring; they are different journeys, not different spellings of one.

  • **kwargs – Passed to the chosen strategy.

Examples

>>> a = MOSScale.from_signature(2, 3, tuning=12)
>>> b = MOSScale.from_signature(5, 7, tuning=12)
>>> morph(a, b, "tree").signatures()
['2L3s', '3L2s', '5L2s', '5L7s']
tuning_morph(start: MOSScale, end: MOSScale, steps: int = 64) Morph[source]#

Hold the structure, slide the generator: a path along one arc.

The scale keeps its note count throughout and its two step sizes co-vary, exactly as Milne et al. §2 describe. If the path crosses the equalized landmark the signature flips – the scale meets and becomes its own inverse – which is reported as an event rather than hidden.

Parameters:
  • start, end (MOSScale) – Must have the same cardinality. Different signatures are fine and interesting: 5L2s to 2L5s crosses 7-EDO on the way.

  • steps (int, default 64) – Frames, endpoints included.

Returns:

Morph

Raises:

ValueError – If the cardinalities differ – there is no way to hold a structure fixed between scales that do not have one in common. Use tree_morph() for that.

Examples

Meantone to Pythagorean, seven notes throughout:

>>> a = MOSScale.from_signature(5, 2, tuning=31)
>>> b = MOSScale.from_generator(3 / 2, 7)
>>> m = tuning_morph(a, b, steps=9)
>>> len(m), m.signatures()
(9, ['5L2s'])
>>> all(s.is_well_formed for s in m)
True

Crossing a landmark flips the signature:

>>> m = tuning_morph(a, a.inverse, steps=33)
>>> m.signatures()
['5L2s', '2L5s']
tree_morph(start: MOSScale, end: MOSScale, max_cardinality: int = 40, steps_per_edge: int = 1, allow_inverse: bool = True) Morph[source]#

Change the structure, one legal move at a time: a path between rings.

Walks the shortest route through the signature graph – see signature_route() – and gives every signature on the way a tuning, chosen as close to the straight line between the two generators as its own valid range allows. Pentatonic to chromatic is a walk up this tree.

Parameters:
  • start, end (MOSScale)

  • max_cardinality (int, default 40) – Ceiling on note count anywhere along the route.

  • steps_per_edge (int, default 1) – Extra frames interpolated within each signature, which glides the tuning between hops instead of jumping. The note count still changes abruptly at each hop, because it must. A route of n signatures yields (n - 1) * steps_per_edge + 1 frames: the destination is a single frame, having no edge to glide along. Two tunings of the same signature count as one edge, not zero.

  • allow_inverse (bool, default True) – Permit the landmark-crossing move between a signature and its inverse.

Returns:

Morph

Examples

>>> a = MOSScale.from_signature(2, 3, tuning=12)
>>> b = MOSScale.from_signature(5, 7, tuning=12)
>>> m = tree_morph(a, b)
>>> m.signatures()
['2L3s', '3L2s', '5L2s', '5L7s']
>>> [s.cardinality for s in m]
[5, 5, 7, 12]
>>> [e for _, e in m.events()]
['start at 2L3s', '5 notes -> 7: 5L2s', '7 notes -> 12: 5L7s']
>>> all(s.is_well_formed for s in m)
True
voice_morph(start: MOSScale, end: MOSScale, steps: int = 64, locate: bool = True) Morph[source]#

Move the notes, not the structure: the path you would actually hear.

Each tone glides along the shorter way round the circle to its counterpart. When the two scales have equal note counts the correspondence is a bijection under the best rotation, which is optimal on a circle. When they differ, tones of the larger set share a source, so notes split apart on the way out or merge on the way in – the same thing that happens at a landmark when a step size shrinks to nothing.

Unlike the other two strategies this path leaves the space of well-formed scales: its intermediate pitch sets are generally not well-formed at all. That is the interesting part, and locate measures it.

Parameters:
  • start, end (MOSScale)

  • steps (int, default 64)

  • locate (bool, default True) – Fit each frame back onto the labyrinth to record how far outside it strays, in MorphStep.wellformedness. Costs a scale fit per frame; turn it off for long morphs.

Returns:

Morph – With Morph.voices populated, so a trajectory draws as continuous lines. The voice count is constant across the whole morph, even when the two scales have different note counts: the extra voices start (or finish) coincident with the tone they split from, which is both what splitting sounds like and what keeps the trajectory rectangular.

Examples

>>> a = MOSScale.from_signature(5, 2, tuning=12)
>>> b = MOSScale.from_signature(4, 3, tuning=19)
>>> m = voice_morph(a, b, steps=9, locate=False)
>>> len(m), m[0].cardinality, m[-1].cardinality
(9, 7, 7)

The endpoints are the scales themselves:

>>> np.allclose(m[0].degrees, a.degrees) and np.allclose(m[-1].degrees, b.degrees)
True
signature_children(n_large: int, n_small: int) List[Tuple[int, int]][source]#

The two signatures a scale is directly embedded in.

Taking the Stern-Brocot mediant of the step counts, which is why the diatonic’s child has exactly the twelve notes embedding() predicts.

Examples

>>> signature_children(5, 2)
[(5, 7), (7, 2)]
signature_parent(n_large: int, n_small: int) Tuple[int, int] | None[source]#

The signature this one is embedded in, or None at the root 1L1s.

The subtractive Euclidean step, run backwards up the tree.

Examples

>>> signature_parent(5, 2)
(3, 2)
>>> signature_parent(1, 1) is None
True
signature_route(start: Tuple[int, int], end: Tuple[int, int], max_cardinality: int = 40, allow_inverse: bool = True) List[Tuple[int, int]][source]#

Shortest route between two signatures through the labyrinth.

Three legal moves: down to a child, up to the parent, and – when allow_inverse – across to (ns, nL), which is a single continuous move because the two meet at their shared equalized landmark.

Shortest routes are usually not unique, and the tie-break is musical rather than arbitrary: among equally short routes, take the one whose sequence of note counts is lexicographically smallest, which is to say the one that stays small longest and adds notes only when it must. Pentatonic to chromatic then reads 2L3s 3L2s 5L2s 5L7s – five, five, seven, twelve – rather than an equally short detour that reaches twelve notes a step early and doubles back.

Parameters:
  • start, end ((int, int)) – Co-prime (n_large, n_small) pairs.

  • max_cardinality (int, default 40) – Ceiling on nL + ns anywhere along the route. Too low and there may be no route at all.

  • allow_inverse (bool, default True)

Returns:

list of (int, int) – Including both endpoints. A single-element list when they coincide.

Raises:

ValueError – If either signature is not co-prime, or no route exists under the cardinality ceiling.

Examples

Pentatonic to diatonic is one step – the diatonic is the pentatonic’s child:

>>> signature_route((2, 3), (5, 2))
[(2, 3), (3, 2), (5, 2)]
>>> signature_route((5, 2), (4, 3))
[(5, 2), (3, 2), (1, 2), (1, 3), (4, 3)]
plot_morph_path(m: Morph, *, max_cardinality: int | None = None, palette: str = 'light', show_events: bool = True, column: bool = True, colorbar: bool = True, ax=None, figsize: Tuple[float, float] = (8.6, 8.6))[source]#

The journey drawn on the labyrinth itself.

Angle is the generator and radius the note count, so the shape of the path says which kind of journey it was at a glance: a tuning morph slides along one ring, a tree morph climbs between rings, and a voice morph breaks wherever it has left the space of well-formed scales altogether.

A morph’s own scale sits on a single ring, which on the full labyrinth is a short arc and easy to miss. With column the figure also draws the other cardinalities the generator is well-formed at – the radial column of mos_cardinalities() – so the journey reads as a wedge of the labyrinth rather than a speck on it. The wedge is not decoration: its strands start and stop at the landmarks, so you can watch the column branch. Sliding a fifth flat past 7-EDO, the outer strand leaves ring 12 and picks up rings 9 and 16 – the same event that flips 5L2s into 2L5s, seen from outside the scale.

Parameters:
  • m (Morph)

  • max_cardinality (int, optional) – Outermost ring. Defaults to 18, or further out if the path needs it.

  • palette ({‘light’, ‘noir’}, default ‘light’)

  • show_events (bool, default True) – Label the structural events – a signature flipping, the note count changing. Landmark crossings get a tick on the path but no text: on a tuning morph three or four of them land within a couple of frames of each other and the labels would sit on top of one another. Morph.summary() lists all of them with their exact t.

  • column (bool, default True) – Draw the generator’s whole column of well-formed cardinalities behind the path.

  • colorbar (bool, default True) – Show the t scale. Turn it off when the panel sits next to a trajectory plot that already has t on its x-axis.

  • ax (matplotlib polar axes, optional)

  • figsize (tuple)

Returns:

(fig, ax)

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> a = MOSScale.from_signature(5, 2, tuning=31)
>>> fig, ax = plot_morph_path(tuning_morph(a, a.inverse, steps=17))
>>> ax.name
'polar'
>>> plt.close(fig)
plot_morph_trajectory(m: Morph, *, palette: str = 'light', show_events: bool = True, show_wellformedness: bool = True, ax=None, figsize: Tuple[float, float] = (11.0, 6.4))[source]#

Every tone’s pitch across the journey – the voice-leading picture.

One line per voice, cents against t. This is where the three strategies look most different: a tuning morph fans its lines smoothly, a tree morph shows lines appearing and vanishing as the note count changes, and a voice morph runs each line straight to its target.

When the morph ever leaves the space of well-formed scales, a lower panel tracks how far outside it is.

Parameters:
  • m (Morph)

  • palette ({‘light’, ‘noir’}, default ‘light’)

  • show_events (bool, default True)

  • show_wellformedness (bool, default True) – Add the lower panel when there is anything to show in it.

  • ax (matplotlib axes, optional) – Supplying one suppresses the lower panel.

  • figsize (tuple)

Returns:

(fig, axes)

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> a = MOSScale.from_signature(5, 2, tuning=12)
>>> b = MOSScale.from_signature(4, 3, tuning=19)
>>> fig, axes = plot_morph_trajectory(voice_morph(a, b, steps=17, locate=False))
>>> plt.close(fig)
plot_morph_filmstrip(m: Morph, *, n_frames: int = 9, style: str = 'ring', palette: str = 'noir', figsize: Tuple[float, float] | None = None)[source]#

The scale itself, sampled along the journey.

Uses biotuner.mos.design, so each frame carries its structure rather than only its pitches. Frames that have left the space of well-formed scales are drawn as bare polygons, since they have no signature to encode.

Parameters:
  • m (Morph)

  • n_frames (int, default 9)

  • style (str, default ‘ring’) – Any biotuner.mos.design.STYLES value. 'ring' shows the step pattern; 'chain' shows the generator structure, but only exists for frames that are genuinely well-formed.

  • palette ({‘light’, ‘noir’}, default ‘noir’)

  • figsize (tuple, optional)

Returns:

(fig, axes)

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> a = MOSScale.from_signature(5, 2, tuning=31)
>>> fig, axes = plot_morph_filmstrip(tuning_morph(a, a.inverse, steps=33),
...                                  n_frames=5)
>>> len(axes)
5
>>> plt.close(fig)
plot_morph_comparison(start: MOSScale, end: MOSScale, *, steps: int = 64, palette: str = 'light', figsize: Tuple[float, float] = (16.0, 9.5), **kwargs)[source]#

All three journeys between the same pair, side by side.

The top row is each path on the labyrinth, the bottom row the same journey as voice leading. Read down a column to see one strategy; read across to see how differently the same two scales can be connected.

Parameters:
  • start, end (MOSScale)

  • steps (int, default 64) – Frames for the continuous strategies. Ignored by tree, whose length is set by the route.

  • palette ({‘light’, ‘noir’}, default ‘light’)

  • figsize (tuple)

  • **kwargs – Passed to every strategy that accepts them.

Returns:

(fig, dict) – The figure, and the three Morph objects by strategy name, so their numbers can be reported alongside.

Notes

tuning needs both scales to have the same note count. When they do not, its column is left empty with a note rather than the whole figure failing – that limitation is a fact about the strategy and worth seeing.

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> a = MOSScale.from_signature(5, 2, tuning=31)
>>> fig, morphs = plot_morph_comparison(a, a.inverse, steps=25)
>>> sorted(morphs)
['tree', 'tuning', 'voice']
>>> plt.close(fig)
animate_morph(m: Morph, *, style: str = 'ring', palette: str = 'noir', interval: int = 80, figsize: Tuple[float, float] = (6.0, 6.4))[source]#

The journey as an animation.

Returns a matplotlib.animation.FuncAnimation. Save it with anim.save('morph.gif', writer='pillow'), or display it in a notebook with HTML(anim.to_jshtml()).

Parameters:
  • m (Morph)

  • style (str, default ‘ring’)

  • palette ({‘light’, ‘noir’}, default ‘noir’)

  • interval (int, default 80) – Milliseconds per frame.

  • figsize (tuple)

Returns:

matplotlib.animation.FuncAnimation

Examples

>>> import matplotlib
>>> matplotlib.use("Agg")
>>> a = MOSScale.from_signature(5, 2, tuning=31)
>>> anim = animate_morph(tuning_morph(a, a.inverse, steps=9))
>>> anim.save.__name__
'save'
>>> plt.close("all")
morph_audio(m: Morph, fundamental: float = 220.0, seconds: float = 10.0, sample_rate: int = 44100, matched_timbre: bool = False, n_partials: int = 6) ndarray[source]#

Render a morph as audio: every tone of the scale, sounding throughout.

Each voice is one continuous sine (or matched-timbre stack) whose frequency glides with the morph, so what you hear is the movement itself rather than a sequence of chords. Frames are interpolated, so a 64-frame morph does not step audibly.

Parameters:
  • m (Morph)

  • fundamental (float, default 220.0)

  • seconds (float, default 10.0)

  • sample_rate (int, default 44100)

  • matched_timbre (bool, default False) – Give each tone Dynamic Tonality partials matched to the frame’s scale, so the timbre tracks the tuning (Milne et al. §6). Costs a partial map per frame; audibly smoother on wildly detuned frames.

  • n_partials (int, default 6) – Only used when matched_timbre.

Returns:

np.ndarray – Mono float32 in [-1, 1].

Examples

>>> a = MOSScale.from_signature(5, 2, tuning=12)
>>> audio = morph_audio(tuning_morph(a, a.inverse, steps=8), seconds=0.5)
>>> audio.dtype, bool(abs(audio).max() <= 1.0)
(dtype('float32'), True)

interactive#

Plotly and ipywidgets explorers, morph_explorer among them — pick two scales, switch strategy, and hear the journey change. Both dependencies are optional — pip install biotuner[interactive].

Interactive labyrinths: a hover-rich Plotly figure and ipywidgets explorers.

Milne et al. (2011) built the labyrinth as a GUI object – §1: “the scale labyrinth allows a musician to choose, simultaneously, a scale structure (number of small and large steps) and its tuning (the sizes of its period and generator)”. A static image cannot do that. Two complementary surfaces here:

labyrinth_plotly()

Every arc carries its own hover card – signature, valid range, landmark EDOs, coherence, embedding. Zoomable, exportable to a standalone HTML file, and needs nothing running.

mos_explorer()

The real instrument. Drag the generator and the whole scale universe responds: the family recomputes, the wheel redraws, the summary updates, and the scale can be played. Bind it to a compute_biotuner and the signal’s own peaks ride along on the rim.

Both dependencies are optional – pip install biotuner[interactive].

morph_explorer(start: Tuple[int, int] = (5, 2), end: Tuple[int, int] = (4, 3), *, tuning: int = 19, max_cardinality: int = 18)[source]#

Drive a journey between two scales and watch the labyrinth from inside.

The three strategies in morph answer the same question – how do you get from this scale to that one – and disagree completely about the answer. Reading three static figures side by side shows that they disagree; changing the destination with the strategy held fixed shows why, because you can watch one route lengthen while another stays put.

The audio button is the point of the widget rather than an extra. A tuning morph and a voice morph between the same pair can differ by an order of magnitude in total voice motion (8687 c against 668 c for 5L2s to 4L3s), and that difference is far more obvious in the ear than in the plot: one glides through a wrap-around, the other barely moves.

Not every pair admits every strategy. A tuning morph needs both scales to have the same note count, and a tree route can exceed max_cardinality. Rather than hiding those buttons, the widget lets you press them and prints what went wrong, since “you cannot slide a 7-note scale into a 5-note one” is the lesson, not an error to be papered over.

Parameters:
  • start, end ((int, int)) – Signatures (n_large, n_small).

  • tuning (int, default 19) – EDO both endpoints are tuned in. Endpoints in the same EDO make the tree routes exact. 19 rather than 12 because 12-EDO has no 4L3s at all – its only candidate is the landmark where the small step vanishes – and an explorer whose opening view is an error message is a poor explorer. Signatures with no generator in the chosen EDO are reported in the text panel, not raised.

  • max_cardinality (int, default 18) – Outermost labyrinth ring, and the ceiling on a tree route.

Returns:

ipywidgets.Widget

Examples

>>> morph_explorer()                      
>>> morph_explorer((2, 3), (5, 7))        
labyrinth_plotly(max_cardinality: int = 18, *, period: float = 2.0, peaks: Sequence[float] | None = None, peak_weights: Sequence[float] | None = None, highlight: None | float | MOSScale | Sequence = None, temperaments: bool = False, show_spokes: bool = True, generator_slider: bool = False, slider_steps: int = 121, width: int = 850, height: int = 850)[source]#

The labyrinth as a Plotly figure, every arc self-describing on hover.

Parameters:
  • max_cardinality (int, default 18)

  • period (float, default 2.0)

  • peaks (sequence of float, optional) – Biosignal peak ratios, drawn on the rim.

  • peak_weights (sequence of float, optional) – Marker sizes, e.g. amplitudes.

  • highlight (float, MOSScale, or sequence, optional) – Generator fraction(s) or scale(s) to trace with a radial line.

  • temperaments (bool, default False) – Overlay named rank-2 temperaments at their optimal generators.

  • show_spokes (bool, default True)

  • generator_slider (bool, default False) – Add a slider that sweeps a highlighted generator around the labyrinth, marking its whole MOS family as it goes. This is what makes the exported HTML an instrument rather than a picture: unlike mos_explorer() it needs no Python running behind it, because the frames are precomputed, so the file works anywhere a browser does.

  • slider_steps (int, default 121) – Generator positions on the slider. Every step is a precomputed frame, so this is the main lever on file size.

  • width, height (int)

Returns:

plotly.graph_objects.Figurefig.write_html('labyrinth.html') gives a self-contained page.

Examples

>>> fig = labyrinth_plotly(9)                     
>>> fig.write_html('labyrinth.html')              
mos_explorer(bt=None, ratios: Sequence[float] | None = None, *, weights: Sequence[float] | None = None, max_cardinality: int = 18, source: str = 'peaks_ratios', n_generators: int = 5, generators: Sequence[float] | None = None)[source]#

Drag the generators; watch the whole scale universe respond.

The interface Milne et al. §1 describe: pick a structure and a tuning at once. A generator slider moves around the labyrinth, the cardinality dropdown offers only the note-counts that generator actually admits, and everything downstream – summary, wheel, family, fit against your signal – recomputes live.

Several generators can be active at once, each with its own toggle, and where two of them land on a coinciding tone the explorer marks it. Both affordances come from the widget this replaces (vizs.MOS_interactive); the common-tone lines were the most useful thing about it, since shared tones are what let one tuning modulate into another. The first active generator is the focused one: the wheel, the summary and the cardinality dropdown follow it.

Parameters:
  • bt (compute_biotuner, optional) – If given, its ratios are overlaid on the rim and used to seed the first generator at the best-fitting one.

  • ratios (sequence of float, optional) – Ratios to overlay, when you do not have a biotuner object.

  • weights (sequence of float, optional) – Per-ratio weights, e.g. peak amplitudes.

  • max_cardinality (int, default 18) – Initial ring count.

  • source (str, default ‘peaks_ratios’) – Which tuning to pull from bt.

  • n_generators (int, default 5) – How many generator rows to offer.

  • generators (sequence of float, optional) – Starting generator ratios, e.g. [3/2, 5/4]. Without these the first row is seeded from the best fit to the data, or the perfect fifth, and the remaining rows from the next-best fits.

Returns:

ipywidgets.Widget – Display it in a notebook cell.

Examples

>>> ui = mos_explorer(ratios=[1, 1.125, 1.25, 1.5])   
>>> ui                                                
fit_explorer(ratios: Sequence[float], *, weights: Sequence[float] | None = None, top_n: int = 8, **fit_kwargs)[source]#

Step through the ranked MOS fits for a set of ratios.

Shows each candidate’s degrees against the targets, its residuals, and where it sits in the labyrinth – so a close second can be inspected rather than taken on trust.

Examples

>>> fit_explorer([1, 1.125, 1.25, 1.5])   
scratch_explorer(scale, *, n_fingers: int | None = None)[source]#

Fourier Scratching, live (Milne et al. §5).

A slider per Fourier coefficient. Moving one reshapes the whole play state at once – which is the point of the technique: “the Fourier Scratching technique offers the ability to change the play states globally and smoothly using only a few parameters”.

Parameters:
  • scale (MOSScale or Mode) – The keyboard the fingers strike.

  • n_fingers (int, optional) – Defaults to the scale’s cardinality, the case Milne et al. Fig. 8 illustrates.

Examples

>>> scratch_explorer(MOSScale.from_signature(5, 2, tuning=12))  
web_explorer(generator: float = 1.5, *, cardinality: int | None = None, period: float = 2.0, max_cardinality: int = 24)[source]#

Turn a scale into a figure, live.

The design counterpart to mos_explorer(). Drag the generator and the star polygon reforms; step through the modes and the silhouette rotates; switch style and the same scale is redrawn as its generator chain, its step ring, its interval web or its whole nested family.

Each style encodes something rather than decorating: see biotuner.mos.design. The chain style in particular draws the star polygon {N/k} whose density is Carey’s WF(N, g), so the figure is the scale’s generator structure rather than a picture of it.

Parameters:
  • generator (float, default 1.5) – Starting generator, as a frequency ratio.

  • cardinality (int, optional) – Starting note count; defaults to the largest proper member of the generator’s family.

  • period (float, default 2.0)

  • max_cardinality (int, default 24)

Returns:

ipywidgets.Widget

Examples

>>> web_explorer(3 / 2)      
simplex_explorer(word: str = 'LLMsLMs', *, period: float = 2.0, field: str | None = 'propriety')[source]#

Drag a tuning around a ternary word’s triangle.

The counterpart of mos_explorer() one step size further out. An MOS has one degree of tuning freedom, so its tunings form an arc of the labyrinth; a three-step-size scale has two, so its tunings form a triangle (biotuner.mos.ternary). The barycentric point (u, v, w) is the share of the period taken by all the large, all the medium and all the small steps, and the sliders move it while the shaded field says what happens to propriety, variety or just-intonation error as it moves.

Parameters:
  • word (str, default ‘LLMsLMs’) – Step pattern over 'L', 'M', 's'. Fixes the signature: the dropdown offers the other worthwhile words with the same counts (see _simplex_words()), not other signatures.

  • period (float, default 2.0) – Period as a frequency ratio.

  • field ({‘propriety’, ‘variety’, ‘ji_error’, None}, default ‘propriety’) – Which field to shade initially.

Returns:

ipywidgets.Widget

Notes

Keeping the point legal: u and v are free but w = 1 - u - v is not, and a scale with a non-positive step does not exist – the edges of the triangle are where a step vanishes and the scale turns binary. Rather than let TernaryScale raise, the pair goes through _clamp_simplex() first, which pulls it back to eps from the nearest edge, writes the repaired values back into the sliders so the controls and the marked point never disagree, and prints what it did.

Examples

>>> simplex_explorer('LMLsLMs')                
>>> simplex_explorer('LMLsLMs', field=None)    
trajectory_explorer(trajectory: Sequence, *, times: Sequence[float] | None = None, max_cardinality: int = 18)[source]#

Scrub a recording window by window through the labyrinth.

mos_trajectory() returns one MOSFit per window, or None where nothing could be fitted. plot_mos_trajectory() shows the whole path at once; this shows one window at a time, with the path so far behind it, so a single window’s fit can be inspected instead of taken on trust from a summary line.

None windows are part of the record, not noise to be skipped: the slider stops on them, the labyrinth stays up with nothing highlighted, and the text panel says the window could not be fitted. Silently jumping to the next fitted window would hide how much of the recording produced no scale at all.

The fit panel is plot_mos_fit(), which owns a two-row figure and only accepts a single ax, so it is drawn as a second figure below the labyrinth rather than reimplemented here. Its targets are reconstructed from the fit by _fit_targets(), since a trajectory does not keep the windows.

Parameters:
  • trajectory (sequence of MOSFit or None) – As returned by mos_trajectory() or trajectory_from_windows().

  • times (sequence of float, optional) – One time per window; the window index is used if omitted.

  • max_cardinality (int, default 18) – Outermost labyrinth ring. A window whose scale is larger than this is still reported in the text panel, but its marker falls outside the rim.

Returns:

ipywidgets.Widget

Raises:

ValueError – If trajectory is empty, contains no successful fit, or times has a different length.

Examples

>>> from biotuner.mos.derive import trajectory_from_windows   
>>> traj = trajectory_from_windows(windows, max_cardinality=12)  
>>> trajectory_explorer(traj)                                 
dissonance_explorer(n_large: int = 5, n_small: int = 2, *, n_partials: int = 8, period: float = 2.0)[source]#

Retune the timbre with the scale, and watch the roughness follow.

Milne et al. §6: a rank-2 tuning has its own lattice, and a timbre whose partials are mapped onto that lattice beats against the scale far less than a harmonic one does. Two panels put both halves of that claim on screen.

Top – the Plomp-Levelt dissonance curve against interval width across one period, for the harmonic timbre and for the matched one, with the scale’s own degrees marked. A matched timbre’s minima migrate onto the degrees; a harmonic one’s stay at the just ratios wherever the scale happens to have put them.

Bottom – total scale dissonance as the generator sweeps its whole valid range, one curve per timbre. This is the panel that shows where matching wins and where it does not, and it does not always win: see _sweep_verdict(), whose measurement is printed under the figure rather than summarised charitably.

Parameters:
  • n_large, n_small (int, default 5, 2) – The signature. Fixed: the generator slider stays inside its valid range, so the scale on screen is always this one.

  • n_partials (int, default 8) – Starting partial count. Must lie in _PARTIALS_RANGE, the partials slider’s own bounds; a value outside it is refused rather than silently pulled to the nearest end, which would leave the explorer showing a timbre the caller did not ask for.

  • period (float, default 2.0)

Returns:

ipywidgets.Widget

Raises:

ValueError – If the signature is not a co-prime pair of positive counts, or n_partials lies outside the slider’s range.

Notes

What is cached, and why: the sweep is the expensive half – resolution scales times two timbres, each sounding every degree with every partial – while the generator slider changes only which vertical line is drawn on it. Sweeps are therefore kept in a dict on a closure variable, keyed by (n_large, n_small, n_partials, resolution): everything the curve depends on and nothing the slider touches. period and the fundamental are fixed for the lifetime of one explorer, so they are not part of the key.

Examples

>>> dissonance_explorer()             
>>> dissonance_explorer(4, 3)         
matrix_explorer(n_large: int = 5, n_small: int = 2, *, period: float = 2.0)[source]#

Watch propriety break, as the definition rather than as a boolean.

Milne et al. §2: a well-formed scale is coherent while Blackwood’s R = L / s stays below 2, and coherence means the generic and specific orderings agree – every third wider than every second, and so on. The generator slider deliberately runs the whole valid range, coherent part and improper part alike, so the boundary can be crossed rather than described.

Leftinterval_matrix() as a heatmap: one row per starting degree, one column per generic class, in cents. Every column holds two sizes for a non-degenerate MOS (Myhill’s property), and the column’s spread is what has to stay inside its lane.

Right – one horizontal strip per generic class, spanning the specific sizes that class takes. While the scale is proper the strips are disjoint and stack like a staircase; past R = 2 neighbouring strips slide into each other, and the shaded band is the overlap. That band is impropriety, not an illustration of it.

Parameters:
  • n_large, n_small (int, default 5, 2) – The signature. Must be co-prime, as any MOS signature is.

  • period (float, default 2.0)

Returns:

ipywidgets.Widget

Notes

The text panel prints propriety from both is_proper (the R <= 2 shortcut) and is_proper() (measured off the interval matrix), and flags any disagreement. They agree everywhere except at n_small == 1, where there is no constrained class pair at all and the measured verdict is “proper at every tuning” while the shortcut still calls R > 2 improper. That divergence is real, documented in is_proper(), and reproduced by matrix_explorer(2, 1).

Examples

>>> matrix_explorer()               
>>> matrix_explorer(2, 1)           # the propriety shortcut's blind spot
...