Moment-of-symmetry scales — the short tour#

Look at the white keys of a piano, C up to C. Seven notes. Measure the gaps between them and there are only two sizes: five whole tones and two semitones.

That is worth pausing on. Of the 792 ways to choose seven notes out of twelve, only 48 come out with two gap sizes. The other 744 have three.

The tidiness comes from how the scale is built. Start on F and go up by fifths: F, C, G, D, A, E, B. Fold each note back into a single octave and sort them, and the white keys are what you get — with exactly two gap sizes.

Scales built this way are called well formed, or moments of symmetry. The interval you keep stacking is the generator; the interval you fold back into is the period. Stop the same stack of fifths at five notes and the pentatonic falls out; carry on to twelve and you have the chromatic scale.

Three familiar scales, one generator, three places to stop. And the fifth is one angle out of every angle there is: around it lies a large microtonal hinterland an ordinary keyboard cannot reach.

biotuner.mos implements the combinatorics of

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.

and adds the question the paper does not ask: given a biosignal’s spectral peaks, which well-formed scale best explains them?

This notebook is the argument, not the manual. It shows the map, one scale on it, the three ways to travel across it, both directions of the biosignal fit, and how to read a fit without fooling yourself. The exhaustive tour is in mos.ipynb; the interactive widgets are in explorers.ipynb.

# Render figures inline whatever backend the environment picked. A docs
# build with MPLBACKEND=Agg makes plt.show() a no-op, which drops every
# figure out of this notebook and leaves the prose describing nothing.
%matplotlib inline

import textwrap

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import Audio
from matplotlib.lines import Line2D

import biotuner.mos as M

FIFTH = 3 / 2
plt.rcParams["figure.dpi"] = 96
pd.set_option("display.width", 150)
C:\Users\skite\Documents\Github\biotuner\.claude\worktrees\epic-morse-ded0d5\biotuner\biotuner_object.py:11: DeprecationWarning: 
The `fooof` package is being deprecated and replaced by the `specparam` (spectral parameterization) package.
This version of `fooof` (1.1) is fully functional, but will not be further updated.
New projects are recommended to update to using `specparam` (see Changelog for details).
  from fooof import FOOOF

1. The idea in one picture#

Every well-formed scale up to eighteen notes, drawn at once. This picture is the labyrinth of the paper’s title, and the rest of the notebook keeps coming back to it. It repays a slow read, so here it is one coordinate at a time.

Angle is the generator. The rim is labelled in cents and one full turn is one period, so a 702-cent fifth against a 1200-cent octave sits 0.585 of the way round. That is the bold ray running down and to the left.

Radius is the note count. Ring N carries every N-note moment of symmetry. Small scales live near the middle, crowded ones out at the edge. Colour says the same thing again, dark at the centre and bright at the rim; nothing is encoded in it that the radius does not already say.

An angle and a ring are enough to name a scale. What the picture adds is that not every pairing exists, and the crowded outer rings are the fussy ones: ring 18 takes fewer than one angle in five.

One thing to settle before the arcs make sense. The opening stacked one exact fifth, but nothing forces the generator to be exact. Flatten it a few cents and the seven notes all shift a little, and five wide gaps and two narrow ones is still what you have. A scale is not one angle. It is a stretch of them.

Each arc is such a stretch. Slide the generator off either end and the scale stops being itself, because at one end the small step has shrunk away to nothing and at the other it has grown until it equals the large one. The thicker segment inside the arc is where the scale is also coherent: large step under twice the small one.

The short tick partway along each arc is the tuning at which the two step sizes are exactly equal, and it splits the arc in two. One side of the tick is a scale; the other side is the same notes with wide and narrow swapped. So an arc holds a pair, not one scale — which is why the summary in section 2 prints a valid range covering only the stretch on its own side of the tick, and names the scale on the far side its inverse.

The spokes are the equal temperaments. N-EDO is the period cut into N equal parts. Each spoke runs in from the rim and touches the ring of its own note count without crossing it. An equal division has a single step size, so it is not itself a moment of symmetry: what a spoke lands on is one of those ticks, the tuning at which a scale on that ring has flattened out.

Now follow the fifth. Its ray is not a spoke, because a pure fifth is not an equal division of anything. Going outwards it picks up a marker on rings 3, 5, 7, 12 and 17, the pentatonic and the diatonic among them. Those are the only note counts at which a stack of fifths has two step sizes rather than three or more, and section 3 says why.

Every other angle in the picture is a generator no keyboard offers, with its own list of note counts.

fig, ax = M.plot_labyrinth(18, highlight=M.generator_fraction(FIFTH))
plt.show()
../../_images/5374a047426410c8171e4fc0b298ee98e2def04598e9058275e51bf3db1b409a.png

2. One scale, up close#

Take the diatonic off the map and look at it on its own.

Putting it on the map took two decisions, and MOSScale keeps them apart.

How many of each step. Five large, two small — 5L2s. That pair of counts is the scale’s signature, and it is what the ring in section 1 was counting.

How wide those steps actually are. That is what tuning=31 sets. Cut the octave into 31 equal parts and make every step a whole number of them: five parts for a large step, three for a small. Five large and two small then account for the octave exactly, 5×5 + 2×3 = 31.

One part is 1200/31 = 38.7 cents. That is where the summary gets its 193.5-cent large step, five parts, and its 696.774-cent generator, eighteen.

Same signature, different tuning, same scale. That freedom is what the arcs in section 1 were drawing.

This particular choice has a name. Stack four of these 696.774-cent fifths, drop two octaves, and you land on 387.1 cents — within a cent of the pure 5/4 major third at 386.3. A fifth flattened until that happens is meantone, and 387.1 is sitting in the printed degrees below.

Now read the summary from the top.

The word LLLsLLs is the order the two sizes come in, and it is not an arbitrary order: large and small are spread as evenly as two sizes can be. That is what makes it the Christoffel word for 5L2s. Each signature has exactly one.

The hardness R = L/s is 1.667 here, so a large step is two-thirds again as wide as a small one. R is the single number that says how a signature has been tuned, and two of its values are landmarks: at R = 1 the two sizes have met and the scale has flattened into an equal division, and at R = 2 the small step is exactly half the large one.

Between those two the scale is proper. Number the intervals by how many scale steps they span; proper means the numbering never lies about the widths, so every three-step interval is wider than every two-step one, every four-step wider than every three-step, all the way up. A listener can hear how many steps an interval covers without knowing which notes made it.

Propriety belongs to the tuning, not to the signature. Tune the same LLLsLLs in Pythagorean, fifths at 702 cents rather than 696.8, and it breaks: the tritone F–B spans three steps and measures 611.7 cents, while B–F spans four and measures 588.3. A four-step interval narrower than a three-step one is exactly what proper forbids.

Two cautions the printed summary will otherwise trip you on. First, coherent and proper are one property under two names here — the coherent range is the range over which the scale is proper, which is why the summary prints them on one line. Second, Pythagorean is still inside the printed valid range of 685.7 to 720 cents. It is a perfectly legal 5L2s; it has just left the coherent part of that range.

Under the summary are the degrees, the steps accumulated: seven pitch classes in cents above the tonic.

And the last line before them: embedded in 12 notes. Seven notes, and the smallest well-formed scale that can contain them has twelve. Not eleven, not thirteen. The next section is about where that number comes from.

diatonic = M.MOSScale.from_signature(5, 2, tuning=31)
print(diatonic.summary())
print()
print("degrees (cents):", [round(c, 1) for c in diatonic.cents])
5L2s  (7 notes)   LLLsLLs
  generator       696.774 c  (ratio 1.495518, g = 0.580645)
  period         1200.000 c  (ratio 2.000000)
  steps          L = 193.548 c,  s = 116.129 c,  R = 1.6667
  valid range    4/7 .. 3/5   (685.7 .. 720.0 c)
  coherent over  4/7 .. 7/12   -> proper
  landmarks      equalized 4/7 = 7-EDO,  s->0 at 3/5 = 5-EDO,  L->0 at 1/2 = 2-EDO
  inverse        2L5s
  embedded in    12 notes (equal at 7/12)

degrees (cents): [0.0, 193.5, 387.1, 580.6, 696.8, 890.3, 1083.9]

3. The map is a tree#

Why twelve? The chromatic scale has twelve notes and the diatonic sits inside it, and that is usually offered as a fact about Western music. It is a fact about arithmetic.

The labyrinth is a Stern–Brocot tree bent into a circle, and the note counts are where that tree puts them. The tree is built by one operation, the mediant: add the two numerators, add the two denominators. The mediant of 4/7 and 3/5 is 7/12.

The same addition on step counts gives every signature exactly two children, (nL, nL+ns) and (nL+ns, ns). For 5L2s that is 5L7s and 7L2s — and both have twelve notes.

There is no eleven-note or thirteen-note scale for the diatonic to embed in, because the addition does not produce one.

The plot below is that tree, unbent. Each node is a fraction p/q, meaning a generator of p steps out of q equal ones — the tuning at which that scale’s two step sizes become the same size. The grey pair under each node is the signature the node bounds together with that signature’s inverse, so 4/7 reads 2L5s | 5L2s, and 5L2s is the diatonic.

Taking mediants of fractions and taking mediants of step counts are the same move seen twice.

The red line traces the fifth’s own way down the tree: 1/2, 2/3, 3/5, 4/7, 7/12. Read off the denominators — 2, 3, 5, 7, 12 — and from 3 onwards they are the rings the fifth had markers on in section 1; the two-note scale is trivial and gets none. That is what section 1 promised this one would explain.

The printed list carries the same descent further, to 17, 29, 41 and 53, and stops there only because 53 is where we asked it to stop. Cardinality is just the note count, and sb_path reports the descent as the sequence of left and right branches taken to reach it.

The practical payoff: tracing a generator down the tree enumerates its whole scale family exactly, with no search.

g = M.generator_fraction(FIFTH)
notes, equalized = M.embedding(5, 2)
print("children of 5L2s   ", M.signature_children(5, 2),
      "  parent", M.signature_parent(5, 2))
print(f"5L2s embeds in      {notes} notes (equalized at {equalized})")
print("path down the tree ", M.sb_path(g, 53))
print("MOS cardinalities  ", M.mos_cardinalities(g, 53, include_trivial=True))

fig, ax = M.plot_stern_brocot(12, highlight_generator=g, figsize=(10.0, 5.2))
plt.show()
children of 5L2s    [(5, 7), (7, 2)]   parent (3, 2)
5L2s embeds in      12 notes (equalized at 7/12)
path down the tree  RLLRRLLL
MOS cardinalities   [2, 3, 5, 7, 12, 17, 29, 41, 53]
../../_images/c907fb96113c92fa3a3de4de6b29830a6beeb8db1b47e8753d8ccfb227d778b0.png

4. Travelling between scales#

The labyrinth has been a map of places so far. Milne et al. describe it as a surface a musician moves across, choosing structure and tuning together, and biotuner.mos.morph makes the movement itself the object.

Say you are on the familiar diatonic in 12-EDO and want to arrive at 4L3s in 19-EDO. What changes along the way? There are three answers, and they are genuinely different journeys rather than three spellings of one.

strategy

what moves

shape of the path

every frame well formed?

tuning_morph

the generator; the note count is held

along one ring

yes

tree_morph

the structure, one legal move at a time

hops between rings

yes

voice_morph

the notes themselves

leaves the map

no, deliberately

The last column is where they part company: two routes stay on the map the whole way, and the third steps off it on purpose.

Same two endpoints, three routes. The top row of the figure sketches each path on the labyrinth of section 1 — shape only, the numbers are in the table below.

Find the two markers first: a circle where the journey starts, a square where it ends, both outlined in red. The dots along the path are coloured by time, dark at the start and bright at the arrival, which is why the square looks yellow.

The pale red fan behind the path is not the path. It is every note count the current generator is well formed at, drawn so the journey reads as a wedge rather than a speck; watch it branch as the generator slides.

The tuning morph’s own route stays on the seven-note ring the whole way, which is what the table means by along one ring.

The bottom row is the same journey as voice leading, one line per tone, with the landmarks each route crosses marked in red.

start = M.MOSScale.from_signature(5, 2, tuning=12)   # the familiar diatonic
end = M.MOSScale.from_signature(4, 3, tuning=19)     # 4L3s in 19-EDO

fig, morphs = M.plot_morph_comparison(start, end, steps=48, figsize=(11.5, 8.0))
plt.show()

pd.DataFrame([
    {
        "strategy": name,
        "frames": len(m),
        "route": " → ".join(m.signatures()),
        "voice motion ¢": round(m.voice_leading_distance()),
        "off-scale frames": sum(1 for s in m if not s.is_well_formed),
    }
    for name, m in morphs.items()
])
../../_images/0df523bf790c9c30e116676166b925421551df36f95ce8583164959bf920a3e9.png
strategy frames route voice motion ¢ off-scale frames
0 tuning 48 5L2s → 3L4s → 4L3s 7147 0
1 tree 5 5L2s → 3L2s → 1L2s → 1L3s → 4L3s 1858 0
2 voice 48 5L2s → (off-scale) → 4L3s 3816 46

Three routes, then, not three descriptions of one. Voice motion comes to 7147 cents for the tuning morph, 1858 for the tree morph and 3816 for the voice morph. Only the tuning morph passes through equal temperaments on the way — five of them — and only it flips its signature twice.

Its bill is the one the picture explains. Look at the bottom-left panel: the lines fall off the bottom and reappear at the top. Sliding a generator drags notes over the period boundary, and every crossing is motion that has to be paid for. Sample the same slide more finely and the total climbs — 6095 cents at twelve frames, 7429 at 384 — so read 7147 as this route at this sampling. The ordering of the three is what is stable.

Why would a signature flip? Because a generator that keeps sliding eventually leaves the stretch its scale occupies and enters the next one — over the tick, where large and small trade places, or off the end, where a step size vanishes.

That flip is the thing to hear. Take the shortest journey that contains one: the diatonic to its own inverse. The generator slides from 700 cents down to 685.7, which is 7-EDO, and then keeps going. At 7-EDO the two step sizes are equal, the scale is momentarily an equal division, and on the far side 5L2s has become 2L5s — the same seven notes, with large and small swapped.

The audio is eight seconds, all seven voices gliding at once.

The flip is also a warning about what follows. Sections 5 onward put a recording on this map, and near a landmark two generators a few cents apart carry different signatures — which is why the last section of this notebook refuses to report a fitted generator as an identification.

flip = M.tuning_morph(start, start.inverse, steps=96)
print(flip.summary())

Audio(M.morph_audio(flip, fundamental=160.0, seconds=8.0, sample_rate=22050),
      rate=22050)
tuning morph:  5L2s (700.0 c) -> 2L5s (671.4 c)
  frames         96
  route          5L2s -> 2L5s
  voice motion   600.0 c total
  every frame is a well-formed scale
    t=0.179  passes 19-EDO (11/19)
    t=0.495  passes 7-EDO (4/7)
    t=0.505  5L2s becomes 2L5s: the large and small steps trade places
    t=0.874  passes 16-EDO (9/16)

5. From a biosignal#

Everything above is music theory. Now a recording.

A peak finder hands back a few frequencies. Divide them by one another and there is a set of ratios. Which well-formed scale do those ratios sit in?

Asking that turns the labyrinth into a search space with three coordinates — generator, cardinality (the note count), period — and fit_mos searches it. Candidate generators come from the signal itself: every observed ratio, and every ratio between ratios, plus a background grid. For each candidate, only the cardinalities that are genuinely well formed are tried.

The objective is amplitude-weighted mean cents error from each ratio to its nearest degree, plus a penalty per surplus note.

There is a fourth, nuisance coordinate. A scale carries no built-in starting note, so every candidate is free to rotate — to transpose — until its degrees line up with the data.

That freedom is not a convenience. Stacking fifths gives the pentatonic’s five notes, but rooted on whichever note the stack began from; the same five rooted elsewhere is the same scale in a different mode. Take the rotation away and a fit would be testing the scale and its starting note at once, and failing whenever the signal had picked a different one.

What comes back is a playable object, not a score. MOSScale hands over cents, ratios and modes, so a recording can be retuned to the scale it implies, two conditions can be compared by the scales they imply, or a piece can be written in the tuning a subject’s alpha rhythm suggests. The rest of the notebook is about how much weight that answer can carry.

Start with a signal whose peaks really are a stack of fifths, buried in noise, so there is a right answer to recover.

from biotuner.biotuner_object import compute_biotuner

rng = np.random.default_rng(7)
sf = 1000.0
t = np.arange(0, 30, 1 / sf)

base = 5.0
partials = [base, base * 1.5, base * 2.25, base * 3.375, base * 2.0]
amps = [1.0, 0.9, 0.75, 0.5, 0.65]
signal = sum(a * np.sin(2 * np.pi * f * t) for a, f in zip(amps, partials))
signal = signal + 0.25 * rng.standard_normal(t.size)

bt = compute_biotuner(sf, peaks_function="FOOOF", precision=0.1)
bt.peaks_extraction(signal, min_freq=2, max_freq=40, n_peaks=5)
print("peaks (Hz):", np.round(bt.peaks, 3))
print("ratios:    ", np.round(bt.peaks_ratios, 5))
print()

fit = bt.fit_mos(max_cardinality=16)
print(M.explain_fit(fit))
peaks (Hz): [ 5.    7.5   9.99 11.25 16.88]
ratios:     [1.125   1.12533 1.12613 1.332   1.5     1.50044 1.688   1.68969 1.998  ]
2L3s  (5 notes)   ssLsL
  generator       702.727 c  (ratio 1.500669, g = 0.585606)
  period         1200.000 c  (ratio 2.000000)
  steps          L = 291.820 c,  s = 205.453 c,  R = 1.4204
  valid range    1/2 .. 3/5   (600.0 .. 720.0 c)
  coherent over  4/7 .. 3/5   -> proper
  landmarks      equalized 3/5 = 5-EDO,  s->0 at 1/2 = 2-EDO,  L->0 at 2/3 = 3-EDO
  inverse        3L2s
  embedded in    7 notes (equal at 4/7)
  fit            error 0.577 c (weighted mean), max 1.149 c, rms 0.681 c
  chance         60.000 c for 5 degrees;  improvement 103.92x;  evidence 4.54 SE below chance
  coverage       100.0% of weight within tolerance;  7 targets, 0 unused degrees;  score 0.577
  folded         2 ratio(s) merged into a pitch class already present (an octave is not a second target)

Reading that block#

explain_fit prints three numbers and one flag. The numbers only mean anything together.

error on its own means nothing. Nearest-degree matching guarantees that a bigger scale sits at least as close to any data, so the raw error is a race to the largest ring allowed.

chance is the yardstick — what a random set of ratios would score against a scale of this size. Uniformly scattered points land on average a quarter of a step from the nearest of N equally spaced degrees, so chance is period / (4N): 60 cents for a five-note scale, 25 for a twelve-note one. Beating chance is the minimum, not the finding.

UNDERDETERMINED fires when the scale has more degrees than the data had targets (MOSFit.is_underdetermined). A scale with spare degrees can be rotated until everything lands on something, so its error stops being a measurement. Such a fit is flagged rather than dropped: five peaks cannot produce twelve targets, but the structure may still be right. It does not fire above — seven targets, five degrees.

evidence is the one to rank on: sqrt(3n) · (1 error / chance), the number of standard errors the weighted mean falls below chance. It counts how much data the margin was measured over, so it self-limits at sqrt(3n). Two exact targets can never exceed 2.45 however perfect they are, while seven exact targets reach 4.58.

The lower panel below draws the chance band directly. Its edge is the average distance a random ratio falls from the nearest of five degrees — an expected value, not a threshold. So the question is not whether the residuals are inside the grey but whether they fill it. Residuals scattered through the band are what chance looks like. These sit on the zero line.

The top panel has no vertical axis; the dots are parked at mid-height so they can be read against the degrees.

For that reading to be honest the degrees have to be drawn where the fit put them. MOSScale roots its degrees on wherever the generator chain happened to start, which is arbitrary, and the fit then transposes the whole scale onto the data — 496.3 cents here. The code below applies that shift before drawing, so every dot should be sitting on a line.

Count them and there are five, from nine ratios, for two different reasons. Folding merges what sits within a cent — 1.125 with 1.12533, 1.5 with 1.50044 — and that is what takes nine ratios to seven targets. The other two coincidences are only visual: 1.12613 is a separate target 1.7 cents above 1.125, as 1.68969 is 1.7 cents above 1.688, and 1.7 cents is narrower than a marker.

fig, axes = M.plot_mos_fit(fit, bt.peaks_ratios, figsize=(10.0, 5.0))
top, bot = axes[0], axes[-1]
top.set_title(top.get_title(), fontsize=11, pad=22)   # clear the degree numbers
bot.set_ylim(-75, 75)
fig.legend(
    handles=[
        Line2D([], [], color="#2E86AB", lw=1.8, label="degree the fit uses"),
        Line2D([], [], color="#dfe1e4", lw=1.8, label="degree nothing lands on"),
        Line2D([], [], color="#C73E1D", marker="o", ls="none", ms=6,
               label="observed ratio, folded into one period"),
    ],
    fontsize=8, frameon=False, ncol=3, loc="lower center", bbox_to_anchor=(0.5, -0.03),
)
plt.show()
../../_images/b2e431178ff5ce6c67fd0b2835c6472386256445e4c0542bf4b8e263c30b2c66.png

6. Two directions, two questions#

fit_mos treats the generator as latent. It solves for the value that best organises the peaks, whether or not the signal ever states that interval.

forward_scales runs the other way. It takes an interval the signal does state — a ratio, or the quotient of two peaks — declares it the generator, stacks it, and reads off the scale that falls out. Nothing is optimised; the generator equals the observed interval bit for bit.

They answer different questions and are allowed to disagree.

A word of warning about the names. fit_mos is the inverse problem, solved backwards from the data; forward_scales is the forward one. That inverse has nothing to do with 5L2s’s inverse 2L5s from sections 2 and 4. Same word, unrelated meanings, and both appear below.

Ask the inverse when you want the scale and do not care where the generator came from. Ask the forward when the provenance of the generator is the claim: “this signal’s alpha-to-beta ratio, stacked, gives a pentatonic” is only honest if the interval you stacked is one the signal produced.

Both score against the same targets with the same objective, so their error_cents, coverage and evidence can be read against each other.

plot_forward_vs_inverse puts them on one labyrinth. Each forward reading is a dot at its (generator, cardinality); the inverse fit is a star on its own ray.

A dot’s size is how many observed intervals proposed that generator, and its colour is the error, dark being better. So a large dark dot is a reading the signal argued for more than once and which also fits.

Only half the disc is drawn, 600 to 1200 cents. A generator and its complement build the same scale, so every reading folds into that half.

Two experiments below.

On this signal the two directions agree, which is the interesting outcome rather than the usual one: the latent generator turned out to be audible after all.

Then the generator is removed from the signal. Take a stack of fifths, [1, 9/8, 81/64, 3/2, 27/16], and delete the 3/2. The inverse still returns 2L3s at 701.955 cents with zero error, because 27/16 over 9/8 is a fifth and the search has no reason to care about the difference.

Read only the listed ratios as generators (include_intervals=False, so no quotients) and the forward direction cannot reach it at all.

forward = M.forward_scales(bt.peaks_ratios, min_cardinality=4, max_cardinality=12)
inverse = M.best_mos(bt.peaks_ratios, min_cardinality=4, max_cardinality=12)

print(f"inverse (latent generator): {inverse.signature} @ "
      f"{inverse.scale.generator_cents:.2f} ¢, error {inverse.error_cents:.2f} ¢")
print(f"forward (observed interval): {forward[0].signature} @ "
      f"{forward[0].scale.generator_cents:.2f} ¢, error {forward[0].error_cents:.2f} ¢, "
      f"proposed by {forward[0].n_sources} observed intervals")
print()

pd.DataFrame([
    {
        "signature": f.signature,
        "notes": f.scale.cardinality,
        "generator ¢": round(f.scale.generator_cents, 2),
        "error ¢": round(f.error_cents, 2),
        "coverage": round(f.coverage, 2),
        "evidence": round(f.evidence, 2),
        "intervals proposing it": f.n_sources,
    }
    for f in forward[:6]
])
inverse (latent generator): 2L3s @ 702.73 ¢, error 0.58 ¢
forward (observed interval): 2L3s @ 702.47 ¢, error 0.60 ¢, proposed by 9 observed intervals
signature notes generator ¢ error ¢ coverage evidence intervals proposing it
0 2L3s 5 702.47 0.60 1.0 4.54 9
1 5L2s 7 702.47 0.60 1.0 4.52 9
2 2L3s 5 703.17 0.68 1.0 4.53 5
3 5L2s 7 703.17 0.68 1.0 4.51 5
4 2L3s 5 701.44 1.40 1.0 4.48 2
5 5L2s 7 701.44 1.40 1.0 4.43 2
fig, ax = M.plot_forward_vs_inverse(forward, inverse, top_n=10)
plt.show()
../../_images/c631ebc8684433c8372f74f3b463936038a01716dd9c032c97c56870c26f3d87.png
missing = [1, 9 / 8, 81 / 64, 27 / 16]          # a stack of fifths, minus the fifth

inv = M.best_mos(missing, max_cardinality=12)
print(f"inverse                       {inv.signature} @ "
      f"{inv.scale.generator_cents:.4f} ¢, error {inv.error_cents:.6f} ¢")

for label, kwargs in [("forward, ratios only   ", dict(include_intervals=False)),
                      ("forward, ratios + pairs", dict())]:
    best = M.forward_scales(missing, min_cardinality=4, max_cardinality=12, **kwargs)[0]
    print(f"{label}       {best.signature} @ "
          f"{best.scale.generator_cents:.4f} ¢, error {best.error_cents:.2f} ¢")
inverse                       2L3s @ 701.9550 ¢, error 0.000000 ¢
forward, ratios only          1L3s @ 996.0900 ¢, error 22.56 ¢
forward, ratios + pairs       2L3s @ 701.9550 ¢, error 0.00 ¢

7. Which derivation?#

Section 5 fitted the peak ratios. But peak ratios are one reading of a recording among several.

compute_biotuner turns one recording into ratios eight different ways — peak ratios, consonance-filtered ratios, extended peaks, dissonance-curve minima, harmonic-entropy minima, an Euler-Fokker genus, a common-harmonic tuning, a harmonic fit — and they do not agree. Each is a first-class input to the fit.

compare_mos_sources runs the whole set and ranks it, so the question stops being “which scale is this signal in?” and becomes “which way of asking produces a well-formed answer at all?”.

bt.peaks_extension(method="harmonic_fit", harm_function="mult", cons_limit=0.1)

comparison = bt.compare_mos_sources(max_cardinality=16)
table = comparison[[
    "source", "n_ratios", "n_targets", "signature", "cardinality",
    "error_cents", "improvement", "evidence", "coverage", "underdetermined",
]].round(3)
for r in comparison.itertuples():
    if r.reason:
        print(textwrap.fill(f"{r.source} raised and kept its row: {r.reason.split('. ')[0]}.", 96))

table
harm_tuning raised and kept its row: ValueError: harmonic_tuning() needs harmonic positions and
this object has none: self.all_harmonics is only measured by
peaks_extraction(peaks_function='harmonic_recurrence'), and peaks_function is 'FOOOF'.
source n_ratios n_targets signature cardinality error_cents improvement evidence coverage underdetermined
0 peaks_ratios 9.0 7.0 2L3s 5.0 0.577 1.039200e+02 4.538 1.000 False
1 extended_ratios 18.0 17.0 12L1s 13.0 9.949 2.320000e+00 4.063 0.722 False
2 diss_curve 8.0 8.0 7L3s 10.0 5.192 5.779000e+00 4.051 0.875 True
3 euler_fokker 9.0 8.0 4L7s 11.0 6.932 3.934000e+00 3.654 0.889 True
4 HE 5.0 5.0 1L8s 9.0 4.707 7.082000e+00 3.326 0.800 True
5 cons_ratios 2.0 2.0 3L1s 4.0 0.000 5.158593e+07 2.449 1.000 True
6 harm_fit_tuning 37.0 37.0 3L13s 16.0 16.285 1.151000e+00 1.385 0.541 False
7 harm_tuning NaN NaN None NaN NaN NaN NaN NaN None

Start with the error_cents column, because it is a trap. The smallest error in the table belongs to cons_ratios, at a few millionths of a cent — and it ranks sixth. It derived two ratios, and a four-note scale has enough spare degrees to be rotated until both land exactly. That number measures the scale’s slack, not the signal.

improvement does not rescue the ranking either: dividing by chance puts the same two-point fit on top by seven orders of magnitude.

evidence is the column that behaves, and it is what the rows are sorted by.

A source that raises keeps its row too, with the exception text in reason. A shorter table is not a report of a broken source.

Note how many rows carry underdetermined. That is the honest reading of five peaks, not a bug, and the last section of this notebook is about what to do with it.

8. How well formed is it?#

fit_mos answers “which scale”, and evidence says the answer beats noise. But how would you know if the whole exercise were nonsense?

Noise is a weak alternative. Any N-note scale with somewhere to slide beats noise. The alternative that matters is an equally spaced scale of the same size, which has all the same coverage and none of the structure. If the generator is contributing nothing, the equal division will fit the data as closely without it.

metrics.mos_ness is that comparison, run at one fixed cardinality across a ladder of increasing freedom:

family

shape

free parameters

edo

N equal steps

1 (transposition)

mos

one generator, two step sizes

2

ternary

three step sizes

3

Each rung down the table buys one more parameter. The statistic asks what the parameter buys back.

mos_ness is the share of the equal division’s error that the generator removes, after both errors are corrected for the parameters that produced them. It scores 1.0 on a real moment of symmetry and 0 on an equal division, where the generator has nothing left to remove. Being a ratio between two fits to the same data at the same cardinality, it survives what the fitted generator does not.

The third rung is worth watching too. two_step_sufficiency asks how much of what a third step size explains was already covered by two. A value of 1.0 says the third step is redundant and the signal is well formed rather than merely non-uniform. On the meantone scale below, the ternary fit collapses two of its three step sizes onto each other, which is the third step size visibly declining to exist.

Two of the three rows below are not measurements. The first two are constructed identities — seven degrees for seven targets — and mos_ness says as much in its own closing note. They are there to show the statistic reading 1.0 and 0.0 at the two ends of the ladder, not to be evidence about anything. Only the third row is a measurement.

edo7 = [2 ** (k / 7) for k in range(7)]

for label, ratios in [
    ("a real MOS (31-EDO meantone)", M.MOSScale.from_signature(5, 2, tuning=31).ratios),
    ("an equal division (7-EDO)", edo7),
    ("the signal's peak ratios", bt.peaks_ratios),
]:
    r = M.mos_ness(ratios, ternary=True)
    print(f"----- {label}")
    print(r.summary())
    print()
----- a real MOS (31-EDO meantone)
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    2L2M3s LMsLsMs         error    0.000 c   (3 parameters)
  step sizes     193.5, 116.1, 193.5 c  (two of them equal)
  two-step suff. 1.000                  ternary-ness 1.000
  note           cardinality 7 is not below the 7 targets: the fit has a degree
                 per observation and is not evidence
----- an equal division (7-EDO)
MOS-ness 0.000   at 7 notes   (cardinality chosen by: edo-evidence)
  targets        7 pitch classes, 0 merged, period 1200.000 c
  equal div.     7-EDO                  error    0.000 c   (1 parameter)
  well formed    6L1s @ 1028.571 c      error    0.000 c   (2 parameters)
  three steps    2L2M3s LsMLsMs         error    0.000 c   (3 parameters)
  step sizes     171.4, 171.4, 171.4 c  (two of them equal)
  two-step suff. 1.000                  ternary-ness 0.000
  note           cardinality 7 is not below the 7 targets: the fit has a degree
                 per observation and is not evidence
----- the signal's peak ratios
MOS-ness 0.960   at 5 notes   (cardinality chosen by: edo-evidence)
  targets        7 pitch classes, 2 merged, period 1200.000 c
  equal div.     5-EDO                  error   17.354 c   (1 parameter)
  well formed    2L3s @ 702.727 c       error    0.577 c   (2 parameters)
  three steps    1L1M3s LssMs           error    0.385 c   (3 parameters)
  step sizes     292.4, 290.7, 205.6 c
  two-step suff. 0.993                  ternary-ness 0.967

Reading a fit honestly#

These are lessons from applying the module to twenty nights of PhysioNet sleep EEG and ten subjects of motor-imagery EEG. They cost real time to learn and they are cheap to apply.

Feed it the peaks, not bt.peaks_ratios.

The latter is every pairwise quotient, so n peaks become up to n(n−1)/2 targets. Those are not n(n−1)/2 independent observations: they are all derived from the same n numbers. The fitter cannot know that, and will happily answer with a scale carrying more degrees than the recording had peaks. Pass bt.peaks with bt.amps as weights when the question is about the recording.

In the table below the ratios post the higher evidence — 4.54 against 3.85 — and that is the inflation, not a reason to prefer them. Evidence grows as sqrt(3n), so counting seven targets where five measurements were made inflates the score by sqrt(7/5), about eighteen per cent, for nothing.

Cap the cardinality below the target count.

Without a cap, seventy per cent of fits in those datasets had more notes than targets, and a six-cent error meant nothing. The harmonic-entropy source below is the pattern in miniature: nine notes for five targets at 4.7 cents looks excellent and is not a measurement. Capped at four notes, the real error appears.

The cap is a guard, not an improvement. Where the unconstrained fit already used no more notes than there were targets — the first row of the table — forcing it smaller only discards a degree the data had paid for, and the evidence falls.

print(f"{len(bt.peaks)} peaks -> {len(bt.peaks_ratios)} pairwise ratios "
      f"-> {M.fit_mos(bt.peaks_ratios, max_cardinality=16)[0].n_targets} pitch classes")
print()

rows = []
for label, ratios, weights in [
    ("peaks + amps", list(bt.peaks), list(bt.amps)),
    ("peaks_ratios", list(bt.peaks_ratios), None),
]:
    for cap in (16, 4):
        f = M.fit_mos(ratios, weights=weights, max_cardinality=cap)[0]
        rows.append({
            "input": label, "max_cardinality": cap,
            "targets": f.n_targets, "notes": f.scale.cardinality,
            "signature": f.signature,
            "generator ¢": round(f.scale.generator_cents, 1),
            "error ¢": round(f.error_cents, 2),
            "evidence": round(f.evidence, 2),
            "underdetermined": f.is_underdetermined,
        })

he = bt.get_tuning("HE")
for cap in (16, 4):
    f = M.fit_mos(he, max_cardinality=cap)[0]
    rows.append({
        "input": "harmonic entropy", "max_cardinality": cap,
        "targets": f.n_targets, "notes": f.scale.cardinality,
        "signature": f.signature,
        "generator ¢": round(f.scale.generator_cents, 1),
        "error ¢": round(f.error_cents, 2),
        "evidence": round(f.evidence, 2),
        "underdetermined": f.is_underdetermined,
    })
pd.DataFrame(rows)
5 peaks -> 9 pairwise ratios -> 7 pitch classes
input max_cardinality targets notes signature generator ¢ error ¢ evidence underdetermined
0 peaks + amps 16 5 5 2L3s 702.2 0.40 3.85 False
1 peaks + amps 4 5 4 1L3s 951.8 15.73 3.06 False
2 peaks_ratios 16 7 5 2L3s 702.7 0.58 4.54 False
3 peaks_ratios 4 7 4 1L3s 953.4 32.57 2.59 False
4 harmonic entropy 16 5 9 1L8s 1096.4 4.71 3.33 True
5 harmonic entropy 4 5 4 1L3s 965.7 42.16 1.70 False

The generator is not reproducible from a handful of noisy peaks.

Across reasonable peak-extraction settings on the EEG datasets it moved by 150 to 350 cents, and sleep-stage orderings built on it did not replicate across extraction methods. The same instability is visible on this deliberately clean synthetic signal in under a second: the signature holds while the generator wanders by a hundred cents. Report a fitted generator as what was compared, not as an identification.

The sweep below applies the two rules just given — peaks and amplitudes rather than pairwise ratios, and a ceiling one below the peak count but never under four, which is four notes in every row here. It therefore asks a harder question than section 5 did, and none of its rows returns the 2L3s at 702.7 c that headlines this notebook.

That is the demonstration, not a contradiction of it. The same generator that survives a sixteen-note ceiling on pairwise ratios does not survive a four-note ceiling on five peaks. The signature is what holds.

sweep = []
for peaks_function, n_peaks, precision in [
    ("FOOOF", 5, 0.1), ("EMD", 5, 0.5), ("harmonic_recurrence", 5, 0.5),
    ("fixed", 7, 1.0), ("adapt", 5, 0.5),
]:
    b = compute_biotuner(sf, peaks_function=peaks_function, precision=precision)
    b.peaks_extraction(signal, min_freq=2, max_freq=40, n_peaks=n_peaks)
    cap = max(4, len(b.peaks) - 1)          # the rule from the section above
    f = M.fit_mos(b.peaks, weights=b.amps, max_cardinality=cap)[0]
    sweep.append({
        "peaks_function": peaks_function,
        "peaks (Hz)": np.round(b.peaks, 2).tolist(),
        "cap": cap,
        "signature": f.signature,
        "generator ¢": round(f.scale.generator_cents, 1),
        "error ¢": round(f.error_cents, 2),
    })
pd.DataFrame(sweep)
Warning: 1 peaks were removed because they exceeded the maximum frequency of 40 Hz
Adaptive frequency bands:  [[np.float64(1.5), np.float64(2.3)], [np.float64(3.0), np.float64(4.6)], [np.float64(6.1), np.float64(9.3)], [np.float64(12.1), np.float64(18.5)], [np.float64(24.3), np.float64(37.1)]]
peaks_function peaks (Hz) cap signature generator ¢ error ¢
0 FOOOF [5.0, 7.5, 9.99, 11.25, 16.88] 4 1L3s 951.8 15.73
1 EMD [2.5, 5.0, 7.5, 11.5] 4 1L3s 953.3 1.57
2 harmonic_recurrence [5.0, 7.5, 10.0, 22.5, 17.0] 4 1L3s 966.0 11.84
3 fixed [5.0, 11.0, 17.0, 36.0] 4 1L3s 1044.2 16.51
4 adapt [3.5, 7.5, 17.0, 36.0] 4 1L3s 1055.0 17.91

mos_ness needs a null matched to your own target count — and, for spectral peaks, to your own minimum peak spacing.

The band quoted in the docstring (mean 0.09) is for twenty targets, and it is much too low for the seven or eight a peak finder returns.

Worse, how the surrogate is built decides the answer. A peak finder cannot return two peaks on top of each other, so a real set carries a minimum separation that a log-uniform draw does not — and the equal division in the denominator of mos_ness is precisely an evenness statistic.

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 null decided the sign of the answer.

Twelve draws of each kind is far too few to pin either band down. The two samples overlap almost entirely, and both reach above the 0.33 the docstring quotes as a maximum. What they are enough to show is that the surrogate rule moves the centre of the band, and that neither band is the one quoted. Generate yours from your own data, the same way, before calling a mos_ness high or low.

On the clean synthetic signal below the measured 0.96 clears both nulls, so the choice makes no difference here. On the EEG it decided the sign.

def log_uniform(n, lo, hi, rng):
    return np.sort(np.exp(rng.uniform(np.log(lo), np.log(hi), n)))


def spacing_matched(n, lo, hi, min_sep_octaves, rng, tries=500):
    # Log-uniform, but rejecting draws a peak finder could never have produced.
    for _ in range(tries):
        x = log_uniform(n, lo, hi, rng)
        if np.min(np.diff(np.log2(x))) >= min_sep_octaves:
            return x
    return x  # unreachable here; in real use, check rather than fall through


min_sep = float(np.min(np.diff(np.log2(np.sort(bt.peaks)))))
n_targets = M.mos_ness(bt.peaks_ratios, ternary=False).n_targets

null = {}
for label, draw in [
    ("log-uniform", lambda r: log_uniform(n_targets, 2.0, 40.0, r)),
    ("spacing-matched", lambda r: spacing_matched(n_targets, 2.0, 40.0, min_sep, r)),
]:
    r = np.random.default_rng(11)
    null[label] = np.array([M.mos_ness(draw(r), ternary=False).mos_ness
                            for _ in range(12)])

print(f"minimum observed peak spacing: {min_sep:.3f} octaves;  {n_targets} targets")
print(f"measured signal:  mos_ness "
      f"{M.mos_ness(bt.peaks_ratios, ternary=False).mos_ness:.3f}")
pd.DataFrame([
    {"surrogate": k, "median": round(float(np.median(v)), 3),
     "90th pct": round(float(np.quantile(v, 0.9)), 3),
     "max": round(float(v.max()), 3)}
    for k, v in null.items()
])
minimum observed peak spacing: 0.171 octaves;  7 targets
measured signal:  mos_ness 0.960
surrogate median 90th pct max
0 log-uniform 0.147 0.271 0.445
1 spacing-matched 0.219 0.386 0.749

Where next#

mos.ipynb — the full tour, in fourteen sections. Everything here at more length, plus the parts this notebook skipped: the modal algebra and the ℤ² height–width lattice, named temperaments computed from their commas, where the labyrinth approximates just intonation, trajectories through the labyrinth over time, Fourier Scratching, and Dynamic Tonality timbre matching.

explorers.ipynb — the ten interactive explorers. Drag the generator and watch the family, the wheel, the summary and the fit against your own signal recompute live; step through ranked fits; put a slider on every Fourier coefficient; move between scales with the morph explorer. They need pip install biotuner[interactive], and widgets do not render in a built book, so they have to be run.

mos_architecture.md — the design document: what each module does, which claims were tested, and the two places the paper needed sharpening.