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
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)ofa/bandc/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 (Fractionguarantees 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 pathLRLRLR…. 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.xandybracket 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 reducedp/qin[0, 1]withq <= n.Generated by the standard next-term recurrence, so this is
O(len(F_n))rather thanO(n^2). The loop stops once the recurrence steps past1/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, …]ofx.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
xwith 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 fractionx(seemos_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:
objectOne 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.denominatorsteps.- Type:
Fraction
- depth#
Number of tree levels descended,
0for 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
nodeexactly. 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.
- sb_walk(g: float, max_cardinality: int = 53, max_depth: int = 512) Iterator[SBNode][source]#
Walk the Stern-Brocot tree from
0/1–1/1towardg.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 whengis hit exactly.- Parameters:
g (float) – Generator fraction in
(0, 1). Usegenerator_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 towardg.A compact fingerprint of where a generator sits in the labyrinth. One shorter than the number of nodes visited, since the root
1/2is 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, orNone.Nonemeanscardinalityis not an MOS cardinality ofg.
- 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
gyields 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 thecardinality-note MOS generated byg.- Raises:
ValueError – If
cardinalityis not an MOS cardinality ofg.
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 ofg.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
gand its complement1 - gproduce 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 above1/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:
objectThe 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_smallsmall steps shrink to zero, leavingn_largeequal steps. This is the far end of the scale’s valid range.- Type:
Fraction
- large_vanishes#
Where the inverse scale’s small steps (
n_largeof them) shrink to zero, leavingn_smallequal steps. Lies beyondequalized, 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#
- 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 + qtones and sits at the mediant of the embedded scale’s two boundary tunings.- Returns:
(cardinality, tuning) –
cardinality == 2 * n_large + n_small;tuningis 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 ofn_large L, n_small s.mode=0is the Christoffel (lower mechanical) word; increasingmoderotates left by one scale degree. Alln_large + n_smallrotations 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_largeL’s andn_smalls’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
gcardinalitytimes.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
gin 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
ginto(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
gand1 - gare musically identical. Folding gives a canonical representative – useful for deduplicating candidate generators, though the bright formg > 1/2is 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_centsmeans.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 - gbuild 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:
objectA 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). Usefrom_generator()to build one from a frequency ratio.period (float, default 2.0) – The period as a frequency ratio.
2.0is the octave; anything else is a pseudo-octave, which the labyrinth supports natively.validate (bool, default True) – Check that
generatorreally does producen_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
stepsdegrees ofedo-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; seestacking_brightens(). For the brightest mode’s pattern, usescale.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.1at the equalized tuning,2at the embedding EDO (the edge of propriety), and unbounded as the small step vanishes.
- 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 <= 2for 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 edo: int | None#
The EDO this scale is, when the generator is exactly rational.
Nonefor a generic tuning. The denominator cap matters: allowed to run to a million,limit_denominatorapproximates 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 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
- 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, honouringperiodthroughout, 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:
objectOne mode of a
MOSScale.- Parameters:
scale (MOSScale)
index (int) – Brightness rank,
0= brightest. Modekis rooted on thek-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]
- 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 - sin 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, originkgenerators 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'
- rotate(k: int = 1) Mode[source]#
σ^k: same pitch collection, finaliskscale 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
heightperiods pluswidthgenerators 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
otherdisagree.Both modes have the same number of degrees, so they are compared step by step: entry
(k, mine, theirs)means thek-th degree sits atminecents here andtheirscents inother. 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.
- 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
ginWF(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 youwf_numberplaces along the generator chain.Examples
The diatonic scale and its inverse belong to
WF(7, 2), the chromatic scale toWF(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 thei-th stacked generator lands once the scale is sorted. The inverse mapping – which chain position each sorted degree came from – is whatwf_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 × widthpatch of the modal ℤ² lattice.Row
j, columniholdsσ^j τ^iapplied to modebase: 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
Nmodes, 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” –nevenly 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-
Lword 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), wheremovedcomes fromMode.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
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 thek-step interval rising from degreei, 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(andperiod_cents, taken from the parent scale for aMode), 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 anN-note scale. There is no column fork = Nbecause 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}forkin1 .. 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 ofMOSScale.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
Falsehere even though it is the limit of a family of MOS. Guard withis_degenerateif that distinction matters;myhill_propertyreports 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’sR <= 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)swithd = m_{k+1} - m_kin{0, 1},m_kbeing the number of large steps in the small variant of classk. Ad = 1transition costs nothing; onlyd = 0transitions demandL <= 2s. Classes1 .. N-2contain exactlyn_small - 1of them.So
n_small == 1leaves no propriety constraint and such a scale is (strictly) proper at any hardness: 2L1s tuned to L = 500 c, s = 200 c hasR = 2.5yet its largest second, 500 c, is well below its smallest third, 700 c.MOSScale.is_propercalls that improper. Away fromn_small == 1the two agree, up totolat the boundary tuningR = 2where 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.1means equal steps;2is the propriety boundary for a well-formed scale;infwhen 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
iis a tuple of lengthN-1: positionk-1holds the rank of degreei’sk-step interval among the distinct sizes of that class –0for the small variant,1for the large. (Ranks above1only appear for scales that failmyhill_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
iof anN-note equal division sits ati / Nof the period; this returnsmax |degree_i - i/N|, measured from the scale’s lowest degree.0is the equal division itself, and the value grows as the two step sizes separate – a scalar companion toblackwood_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/14of 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.nanandinfare rejected rather than tolerated: they propagate through every summary statistic and would silently turn a wholemos_report()row intonan.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
Falsethe 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:
dict –
errors(signed, per target, in cents), plusmean_abs,max_abs,rmsandweighted_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 becausebiotuner.metricspulls 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 apandas.DataFramerow or a JSON file – which is how the plotting and derivation layers consume it.- Parameters:
scale (MOSScale) – Anything with a
to_dictmethod; 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 underji_*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
Nthe three families differ only in how much freedom they have to place their degrees:edoNequal steps. Nothing about the shape is free; only where the whole thing sits against the data.mosOne generator, stacked
Ntimes. Two step sizes, distributed with maximal evenness – the shape is a one-parameter family.ternaryThree 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:
objectHow much of a signal’s structure a generator accounts for.
evidenceanswers 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 – anyN-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
edoNequal steps1 (transposition)
mosone generator, two steps
2 (+ generator)
ternarythree 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
cardinalitywas 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
- 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)
0means 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.1means 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.1says 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. Seeternary_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_nessis not.- Type:
str, float, float, float
- ternary_word, ternary_signature
- Type:
str or None
- is_identifiable#
Falsewhencardinalityis not strictly belown_targets. Same idea asbiotuner.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 – anN-note equal division has exactlyNpitch 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_evidenceandidentifiable. 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.
Nonewhen the ternary rung was not computed.
- property adjusted_edo_error_cents: float#
edo_error_centsafter_adjusted_error().
- property adjusted_mos_error_cents: float#
mos_error_centsafter_adjusted_error().
- property adjusted_ternary_error_cents: float | None#
ternary_error_centsafter_adjusted_error().
- property raw_mos_ness: float#
mos_nesscomputed 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_cardinalityis 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:
the equal division – maximally even, no shape parameters;
the best well-formed scale – one generator;
the best admissible three-step scale – two shape parameters.
MOSness.mos_nessis how much of (1)’s error the generator removes;MOSness.two_step_sufficiencyis 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.0is 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 mostevidence, 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 ofMOSness.mos_nessaverages 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 biasesMOSness.mos_nessupward; 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_cardinalitycarries 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 onMOSnessreads, 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_nessis 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 like3**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.
0uses 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
cardinalitysits belowmin_cardinality; or if a cardinality above the number of targets is requested withoutallow_underdetermined.
Notes
The equal division’s own generator –
(N-1)/Nof the period, whose degenerate MOS is exactlyN-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, andMOSness.mos_nessis 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_zerore-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 ofnresiduals – 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 readMOSness.mos_nessagainst 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_sufficiencyis 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.
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 asRank2Temperament.hermite_mapping.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.Octave reduction. The generator is then folded into
[0, period)by adding multiples ofmapping[1]tomapping[0], which leavesperiods_per_octaveuntouched. Meantone’s twelfth becomes its fifth and the stored mapping becomes[[1, 1, 0], [0, 1, 4]], so thatprime_errorsis 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 / 80as a float is notFraction(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 ofratiomust 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 int –
msuch thatratio == prod(p ** e for p, e in zip(primes, m)).- Raises:
ValueError – If
ratiohas a prime factor outsideprimes, or ifprimesis 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 == 0and 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 int –
len(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:
objectA 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 outn - 2of 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()orbiotuner.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_centsinstead holds the octave pure during the fit.
- property prime_errors: Tuple[float, ...]#
Cents by which each prime is mistuned, in
primesorder.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
scaleis this temperament at (near enough) its optimum.Both the period and the generator must match within
tol_cents. The generator is compared againstcomplement_centstoo, 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
- 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) ** 2defines the same temperament as81/80but 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) - 2commas 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_centsis 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:
generatorWhere 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.
cardinalityWhich 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.
periodHow 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=Trueto fit it.
Plus one nuisance coordinate the labyrinth does not show, because it is not a property of the scale at all:
transpositionWhere 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.offsetreports the fitted transposition andMOSFit.modesays 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)is0.5000000000000001, not0.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_EPSILONasks 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 a5L6swhose 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 intests/mosthe 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_EPSILONasks whether two degrees are the same number andMIN_REFINED_STEPbounds 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:
objectOne candidate explanation of a signal as a well-formed scale.
- 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_centsplus the surplus-note penalty. Fits are ranked by this, not by raw error, so a big scale cannot win by covering everything.- Type:
float
- 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 whatassignmentsandresidualsrun 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
iis thei-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
- 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.
scaleis 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_degreesas frequency ratios, starting at 1/1.
- property aligned_cents: List[float]#
- property mode#
Which mode of
scalethe signal occupies, orNone.Noneonly 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
Nequally 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.0means 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, andexplain_fit()says so out loud.
- property evidence: float#
How many standard errors below chance the fit’s mean error sits.
error_centson 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 ofNevenly spaced degrees, a distribution with meanchance_error_centsand standard deviationchance / sqrt(3). The mean ofnsuch draws therefore has standard errorchance / sqrt(3n), andevidence = 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)
- class FitField(errors: ndarray, generators: ndarray, cardinalities: ndarray, period: float, n_targets: int, ratios: Tuple[float, ...] = ())[source]#
Bases:
objectEvery 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.
NaNwhere 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:
objectA 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, soerror_cents,coverageandevidencemean exactly what they mean on aMOSFit. 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.generatoris 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:
- 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 – seen_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
fitis re-exported here under the same name, so a caller holding aForwardScalenever has to reach through to the fit to get atresiduals,offsetorimprovement. The facade is complete on purpose: a partial one would imply the missing quantities mean something different in this direction, and they do not.- 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 – sogeneratoris 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.07is 2.275, which the generator (976.9 cents) no longer shows.
- property n_sources: int#
How many observed intervals proposed this generator.
- 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
intervalreduced 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 complement2 / 1.2347 = 1.6199at 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, useinterval.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#
- 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/19will 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:
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.
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.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.
0disables 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 mostn (n + 1) / 2of 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
0for 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 at0.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.
0uses 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_refinefits’ 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
Falseto 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 cardinalityrotations 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_stepsvalues spanningperiod_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 withinFOLD_TOLERANCE_CENTSof a pitch class already seen, summing the weights rather than dropping them.A scale cannot tell
1/1from2/1, so counting both as targets double-counts the unison, inflatesn_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. SetFalseto 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
ratiosis read as a multiset. Permuting it – carryingweightsalong – 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
7L5sand its inverse5L7sdescribe 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 inratios. 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 setinclude_ratios=False;19.31is 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 countForwardScale.n_sourcesis unaffected by the choice. Matchesgenerator_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.
Nonereturns 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 ranksfit_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 onForwardScale.n_sources(more corroboration first), then on the structural orderMOSFit._rank_keyuses.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_sourcescounts 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. Withfold=Truethe 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(carryingweightsalong with it) returns the same readings, in the same order, with bit-identical generators, signatures, scores, errors andsources. Only the per-target vectors follow the caller’s list, because they are defined to –targets,assignmentsandresidualsrun 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
7L5sat 699.75 cents for a5L7sat 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_extensionfor'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'runsfit_mos(): the generator is latent, searched for, and need not be an interval the signal contains.'forward'runsforward_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 theirerror_centsandcoveragecan be compared directly.**kwargs – Passed to
fit_mos()orforward_scales(), permode.
- 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 ifmodeis neither'inverse'nor'forward'.
Notes
Only
'peaks_ratios'and'extended_ratios'have a candidate weight vector at all (bt.ampsandbt.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_SOURCESexcept'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_targetsandn_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
reasonholding the exception and everything elseNaN. Silently dropping it would hide a real breakage:harm_tuningcurrently fails outright for everypeaks_functionother 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.evidencemeasures 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.underdeterminedmarks 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
Nonerather 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_biotunerand 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_biotunerconstructor.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;
Nonewhere peak extraction found nothing.- Raises:
ValueError – If
source='mos', which would fit an MOS to an MOS in every window; or ifsourceis 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-Nonepath 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
Nonefor 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 apeaks_extension– therefore come back as an all-Nonetrajectory, 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,
NaNwhere 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
UNDERDETERMINEDoutright.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
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. Partialkspreads the fingers evenly around the circle inkturns.Changing the number of fingers is a spectral edit too: grow by
PlayState.zero_pad(), shrink byPlayState.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:
objectThe
nfingers of the robot, as one complex vector.- Parameters:
f (array_like) – 1-D, coerced to
complex128and copied read-only, so aPlayStatereally is immutable rather than merely frozen at the attribute level.f[k]is fingerk: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 ofn.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.
phasesare 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 isfft(f) / n, this isn * ifft(a). The number of fingers islen(a), which is what makeszero_pad()andprune()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.numpyreturns angles in(-pi, pi]; reducing modulo2*pipushes a hair-negative angle up against2*pi, which is the far side of the keyboard from where it belongs. Anything within_PHASE_EPSof a full turn is therefore snapped back to0.
- 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
kand resynthesise.The performer’s single gesture. Every finger moves, but coherently: rotating coefficient
kbydadvances thek-th interval cycle bydwithout disturbing any other cycle.- Parameters:
k (int) – Coefficient index. Negative indices count from the end, as for a list, so
-1is 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 bypi.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 (
magnitudewithscale, orphasewithrotate): 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
mfingers 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
kstill points at it:partial(n, k).zero_pad(m)ispartial(m, k)up to floating-point round-off (henceallclose(), 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
mlowest-energy Fourier coefficients, leavingn - 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
1survives, not the1j:>>> [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
mhighest-energy Fourier coefficients.The complement of
prune():truncate(m)andprune(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
deltaradians 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 = 0isself,t = 1isother; 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) usescratch_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:
objectOne 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 onnfingers – a “pure partial”.f_j = exp(2*pi*i*j*k/n): all fingers strike equally hard, and their positions windktimes round the keyboard. Its spectrum is a unit impulse atk, so these are the basis Milne et al. Fig. 8 draws and the thing every other play state is a superposition of.Note that
kandk + ngive the same state, and that the set of finger positions is the same for everyk– only the order in which the fingers visit them changes, which is whyto_events()is wherekstarts 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 nbelow is integer arithmetic. Doing the reduction in floats instead leavesexp(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
iowns[2*pi*d_i, 2*pi*d_{i+1}), and the last degree’s key closes the circle at2*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 wordtheory.christoffel_word(n_large, n_small), since that word is by construction the floor-quantisation of the equal division. Seeto_events()for what that means in practice.- Parameters:
mode_or_scale (MOSScale or Mode) – Anything exposing
.degreesas ascending period fractions in[0, 1)starting at0.- 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_EPSrather 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/angleand2*pi*dare 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
kcoprime tongenerating 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)– for5L2sthat issLLsLLL(Locrian), for2L3sit isssLsL. Propriety is sufficient but far from necessary: sweeping the whole coherent generator range – endpointsR = 1andR = 2included – of every signature up to 12 notes, the Christoffel mode never fails, and5L2skeeps working out pastR = 6. There is no sharper threshold to quote, because the cut-off depends on the signature and not onRalone –5L2sstill works aboveR = 6where4L5shas already broken atR = 3.25.In any other mode it fails, and the failure is not subtle: in the brightest mode of 12-EDO
5L2sthe 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 only1/7of 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
.ratiosand.centswhen present. If only one is present the other is derived from it (cents = 1200*log2(ratio)), so the two fields of aNoteEventalways agree. If neither is, both come from.degreesagainst.periodif 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/.centscome 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 eachv.Every state is scratched from the same base
state, not from its predecessor, sovaluesis a path through the control’s absolute range and the trajectory is reproducible from any frame – what an animation or a scrubbable UI needs. Withattr='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
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 |
|---|---|---|
|
13.9 % (6.2–30.2) |
0 / 26 |
|
9.3 % (3.2–17.1) |
0 / 26 |
|
8.4 % |
1 / 26 |
|
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:
objectWhere one harmonic lands once it is pulled onto the scale’s lattice.
- harmonic#
The harmonic number
hthat was mapped,1being 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#
ratiominusjust_ratioin 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:
objectFallback spectrum container for when
harmonic_timbreis unavailable.dynamic_timbre()prefersbiotuner.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#
- map_harmonic(h: int, scale: MOSScale, max_beta: int = 24, beta_penalty: float = 0.0) PartialMap[source]#
Pull harmonic
honto the(period, generator)lattice ofscale.Finds the integer pair minimising
|alpha * log(period) + beta * log(generator) - log(h)|over
|beta| <= max_beta. Onlybetais searched: once it is fixed the bestalphais 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 of2 * max_beta + 1candidates.- Parameters:
h (int) – Harmonic number,
>= 1.scale (MOSScale) – Supplies
periodandgenerator_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.0is 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
betaandbeta ± denominatorname 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_partialsontoscale’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.
Nonegives the1/hroll-off of an idealised sawtooth. Whatever is passed is rescaled to peak at 1.**kwargs –
max_betaandbeta_penalty, forwarded tomap_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.Timbrewhen that subpackage imports, so the result drops straight intorender_additive, the exporters and the cross-modal sidecar. If the import fails – the subpackage is not a hard requirement ofbiotuner.mos– aSimpleTimbrewith the same core fields is returned instead, and the substitution is recorded inmetadata['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 ametadatadict 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;
dissmeasuresorts.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) –
Truesounds each degree with the lattice-matched partials ofmatched_partials();Falsewith 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)
**kwargs –
max_betaandbeta_penalty; ignored whenmatched=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).matchedis 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/12of 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
Ncarries everyN-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
Nspans the generators over which someN-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 repeatsntimes around the circle, giving the labyrinthn-fold rotational symmetry, and rings count notes per octave rather than per period, so only multiples ofnappear.1leaves 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
nLmssignature 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/dLbspair 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
MOSScaleor aMode.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 belowR = 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
fitalone 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 (
Noneentries) 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) –
sgrows 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_seriesgives 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
nforward readings. Worth setting: a single interval can generate a well-formed scale at a dozen cardinalities, andforward_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
FitFieldremembers 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_mossettled on.ax (matplotlib axes, optional) – Must be polar when
polaris 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
nfingers 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.
tuningHold 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().treeChange 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 why5L2s’s child5L7shas exactly the twelve notesembedding()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. Seetree_morph().voiceMove 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:
objectOne frame: a set of pitches, and whatever structure it happens to have.
- t#
Position along the morph,
0at the start scale and1at 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.
Nonefor 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.0when 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, ...]#
- 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:
objectA journey between two well-formed scales.
- strategy#
- Type:
str
- start, end
- Type:
- 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
- strategy: str#
- voices: Tuple[Tuple[int, ...], ...] = ()#
- property period_cents: float#
- labyrinth_path() List[Tuple[float, int] | None][source]#
(generator, cardinality)per frame;Nonewhere 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.
voicesrecords 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.
- 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:
5L2sto2L5scrosses 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
nsignatures yields(n - 1) * steps_per_edge + 1frames: 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
locatemeasures 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.voicespopulated, 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
Noneat the root1L1s.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 + nsanywhere 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
tuningmorph slides along one ring, atreemorph climbs between rings, and avoicemorph 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
columnthe figure also draws the other cardinalities the generator is well-formed at – the radial column ofmos_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 flips5L2sinto2L5s, 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 exactt.column (bool, default True) – Draw the generator’s whole column of well-formed cardinalities behind the path.
colorbar (bool, default True) – Show the
tscale. Turn it off when the panel sits next to a trajectory plot that already haston 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: atuningmorph fans its lines smoothly, atreemorph shows lines appearing and vanishing as the note count changes, and avoicemorph 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.STYLESvalue.'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
Morphobjects by strategy name, so their numbers can be reported alongside.
Notes
tuningneeds 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 withanim.save('morph.gif', writer='pillow'), or display it in a notebook withHTML(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_biotunerand 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
morphanswer 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
5L2sto4L3s), 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
4L3sat 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.Figure –
fig.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. Thechainstyle in particular draws the star polygon{N/k}whose density is Carey’sWF(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:
uandvare free butw = 1 - u - vis 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 letTernaryScaleraise, the pair goes through_clamp_simplex()first, which pulls it back toepsfrom 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 oneMOSFitper window, orNonewhere 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.Nonewindows 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 singleax, 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()ortrajectory_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
trajectoryis empty, contains no successful fit, ortimeshas 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_partialslies outside the slider’s range.
Notes
What is cached, and why: the sweep is the expensive half –
resolutionscales 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.periodand 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 / sstays 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.Left –
interval_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 = 2neighbouring 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(theR <= 2shortcut) andis_proper()(measured off the interval matrix), and flags any disagreement. They agree everywhere except atn_small == 1, where there is no constrained class pair at all and the measured verdict is “proper at every tuning” while the shortcut still callsR > 2improper. That divergence is real, documented inis_proper(), and reproduced bymatrix_explorer(2, 1).Examples
>>> matrix_explorer() >>> matrix_explorer(2, 1) # the propriety shortcut's blind spot ...