01 . a power-series model of the ear¶
learning notebook for the ThirdEar mechanism: build the plugin's stimulus in numpy, pass it through a toy model of the cochlea's nonlinearity, and watch the distortion product appear at a frequency that was never in the signal.
figure style follows the conventions of rougier's scientific visualization book
(cloned at ../../scientific-visualization-book/): minimal ink, direct labels,
no chartjunk. colors are the databurn.org palette: light neutral gray, one teal, one orange.
what this model is: a memoryless power-series nonlinearity, the simplest honest demonstration. what it is not: a cochlea. it cannot show place-dependence, the f2/f1 ratio window, or level curves; that needs the CARFAC model (next notebook).
import numpy as np
import matplotlib.pyplot as plt
# databurn.org palette: the page gray, ink, one teal, one orange
BONE, PANEL = '#E8EAEC', '#EEF0F1'
INK, DIM, HAIR = '#141516', '#55595D', '#C5C9CD'
TEAL, ORANGE = '#00857A', '#E14A00'
# rougier-informed defaults: light ink, open spines, mono annotations
# rougier, chapter 1 and the ten rules: one figure size for the medium
# (8 x 3 in at 110 dpi reads at notebook width without scaling), open
# spines, ticks outward and few, titles left-aligned as a caption
# would be, no legend boxes (labels sit on the data), one accent colour
# for the thing the figure is about
plt.rcParams.update({
'figure.facecolor': BONE, 'axes.facecolor': PANEL,
'axes.edgecolor': INK, 'axes.linewidth': 0.8,
'axes.spines.top': False, 'axes.spines.right': False,
'xtick.color': DIM, 'ytick.color': DIM, 'text.color': INK,
'xtick.direction': 'out', 'ytick.direction': 'out',
'xtick.major.size': 3, 'ytick.major.size': 3,
'axes.labelcolor': INK, 'font.family': 'monospace', 'font.size': 9,
'axes.titlesize': 10, 'axes.titlelocation': 'left',
'figure.figsize': (8, 3), 'figure.dpi': 110, 'savefig.dpi': 200,
'legend.frameon': False,
})
def db_at(x, hz):
'''level at exactly hz, in dB re a full-scale sine: a goertzel under a
blackman-harris window, the same instrument the plugin's honesty meter
and its tests use. an fft bin only agrees when hz sits on a bin.'''
n = len(x)
i = np.arange(n)
w = (0.35875 - 0.48829 * np.cos(2 * np.pi * i / n)
+ 0.14128 * np.cos(4 * np.pi * i / n) - 0.01168 * np.cos(6 * np.pi * i / n))
z = np.sum(x * w * np.exp(-2j * np.pi * hz * i / SR))
return 20 * np.log10(np.abs(z) / (w.sum() / 2) + 1e-12)
SR = 48_000
DUR = 1.0
t = np.arange(int(SR * DUR)) / SR
def solve_qdt(fp, carrier=3000.0, n=6):
'''mirror of thirdear's solver: exact harmonic comb of fp nearest carrier'''
m = max(2, round(carrier / fp))
return np.array([(m + k) * fp for k in range(n) if (m + k) * fp < 0.45 * SR])
def comb(freqs, phases=None):
'''phase-locked additive stimulus, peak-normalized like the plugin'''
if phases is None:
phases = np.zeros(len(freqs))
x = sum(np.sin(2 * np.pi * f * t + p) for f, p in zip(freqs, phases))
return x / len(freqs)
def spectrum_db(x):
w = np.hanning(len(x))
mag = np.abs(np.fft.rfft(x * w))
mag /= mag.max()
return np.fft.rfftfreq(len(x), 1 / SR), 20 * np.log10(np.maximum(mag, 1e-9))
fp = 110.0 # A2: the phantom the player asked for
freqs = solve_qdt(fp)
print(f'phantom fp = {fp} Hz -> primaries: {freqs} (spacing = fp everywhere)')
phantom fp = 110.0 Hz -> primaries: [2970. 3080. 3190. 3300. 3410. 3520.] (spacing = fp everywhere)
1. the stimulus is innocent¶
everything ThirdEar outputs is up near the carrier. the phantom's frequency is empty by construction: if energy were there, it would not be an illusion.
x = comb(freqs)
f, mag = spectrum_db(x)
fig, ax = plt.subplots()
ax.plot(f, mag, color=TEAL, lw=0.8)
ax.axvline(fp, color=ORANGE, lw=1.2, ls=(0, (4, 3)))
ax.annotate(f'phantom register: {fp:.0f} Hz\nNOTHING HERE', (fp, -20),
xytext=(fp * 1.6, -12), color=ORANGE, fontsize=8,
arrowprops=dict(arrowstyle='-', color=ORANGE, lw=0.7))
ax.annotate('the comb: 6 primaries,\nspaced exactly fp apart', (freqs[-1], -2),
xytext=(7000, -18), color=TEAL, fontsize=8,
arrowprops=dict(arrowstyle='-', color=TEAL, lw=0.7))
ax.set(xscale='log', xlim=(40, 21000), ylim=(-90, 3),
xlabel='frequency (Hz)', ylabel='level (dB re max)',
title='the signal that leaves the plugin')
ax.set_xticks([50, 100, 500, 1000, 5000, 20000],
['50', '100', '500', '1k', '5k', '20k'])
plt.tight_layout()
2. a compressive asymmetric nonlinearity manufactures the phantom¶
the cochlear amplifier compresses, and compresses asymmetrically. model that with the first terms of a power series (the same power series from our analog emulation notes):
y = x + a2x2 + a3x3
the even term (x^2) is what generates difference tones fj - fi: for our comb, every adjacent pair lands one exactly at fp, and they sum there.
the odd term (x^3) is a different story, and it depends on where the comb sits. its products 2fi - fj and fi + fj - fk land at (m + i + j - k)*fp for partial indices in 0..N-1, so the lowest one is (m - N + 1)*fp. for a comb whose lowest harmonic number m exceeds the partial count N (fp below carrier/(N + 0.5): 461 Hz at the 3 kHz default with six partials) that never reaches fp, and the cubic term contributes nothing there at all. above that it can: at C5 the comb is harmonics 6..11 and the odd term does land at fp, where at these coefficients it sits under the quadratic term everywhere. the cell after the next one measures both cases, each term alone, and then the gap over the instrument's whole range of partial counts and harmonic numbers (N in 2..8, m in 2..N): 13-42 dB, the figure printed there and the one the technical note's build asserts against its own recomputation. at A2 the phantom is purely a product of the asymmetric term; in general it is dominated by it.
one more thing the numbers below are not: calibrated. a2 = 0.25 and a3 = 0.15 are round numbers chosen to make the effect visible, so the -13 dB in section 2 demonstrates existence, and its closeness to P&P's -12.5 dB eleven-tone figure is coincidence.
that matters beyond bookkeeping. notebook 03 shows the mirror image: the Hopf normal form is purely cubic and produces a CDT but no QDT at all. two models, two disjoint halves of the same story.
def ear(x, a2=0.25, a3=0.15):
'''toy cochlear nonlinearity: compressive, asymmetric, memoryless'''
return x + a2 * x**2 - a3 * x**3
y = ear(x)
f, magy = spectrum_db(y)
fig, ax = plt.subplots()
ax.plot(f, magy, color=TEAL, lw=0.8)
for k in range(1, 4):
ax.axvline(k * fp, color=ORANGE, lw=0.8, alpha=0.5)
bin_fp = int(round(fp * DUR))
ax.annotate(f'the phantom, born:\n{fp:.0f} Hz and harmonics', (fp, magy[bin_fp]),
xytext=(300, -18), color=ORANGE, fontsize=8,
arrowprops=dict(arrowstyle='-', color=ORANGE, lw=0.7))
ax.set(xscale='log', xlim=(40, 21000), ylim=(-90, 3),
xlabel='frequency (Hz)', ylabel='level (dB re max)',
title='the same signal after the ear-like nonlinearity')
ax.set_xticks([50, 100, 500, 1000, 5000, 20000],
['50', '100', '500', '1k', '5k', '20k'])
plt.tight_layout()
print(f'level at fp before the nonlinearity: {mag[bin_fp]:6.1f} dB')
print(f'level at fp after the nonlinearity: {magy[bin_fp]:6.1f} dB')
level at fp before the nonlinearity: -180.0 dB level at fp after the nonlinearity: -13.0 dB
# the parity claim above, measured rather than asserted: each term alone,
# for a comb where the odd term cannot reach fp (A2, m = 27) and one where
# it can (C5, m = 6). db_at is re a full-scale sine, not re the loudest bin
# as in section 2, so the absolute numbers differ; the gap between the two
# terms is what matters. -240 is the instrument's floor.
def even_only(x, a2=0.25):
return x + a2 * x**2
def odd_only(x, a3=0.15):
return x - a3 * x**3
for fp_test in (fp, 523.25):
fr = solve_qdt(fp_test)
m = round(3000.0 / fp_test)
xx = comb(fr)
ev, od = db_at(even_only(xx), fp_test), db_at(odd_only(xx), fp_test)
print(f'fp = {fp_test:6.2f} Hz, harmonics {m}..{m + len(fr) - 1}: '
f'even term only {ev:7.1f} dB, odd term only {od:7.1f} dB at fp '
f'(odd {ev - od:5.1f} dB under)')
# and the range section 2 quotes: the same gap over every comb the
# instrument can play where the odd term reaches fp, N partials from
# harmonic m with m <= N, each at C5. the technical note's gap_data()
# is this loop, and its build asserts the two extremes against this line
gaps = {}
for N in range(2, 9):
for m in range(2, N + 1):
xx = comb(solve_qdt(523.25, carrier=m * 523.25, n=N))
gaps[(N, m)] = db_at(even_only(xx), 523.25) - db_at(odd_only(xx), 523.25)
lo, hi = min(gaps.values()), max(gaps.values())
print(f'cubic gap range: {lo:.1f}-{hi:.1f} dB over N=2..8, m=2..N '
f'(smallest at N,m = {min(gaps, key=gaps.get)}, largest at {max(gaps, key=gaps.get)})')
fp = 110.00 Hz, harmonics 27..32: even term only -29.2 dB, odd term only -240.0 dB at fp (odd 210.8 dB under) fp = 523.25 Hz, harmonics 6..11: even term only -29.2 dB, odd term only -65.7 dB at fp (odd 36.5 dB under)
cubic gap range: 13.0-41.9 dB over N=2..8, m=2..N (smallest at N,m = (3, 2), largest at (8, 8))
3. phase coherence is load-bearing¶
pressnitzer & patterson (2001): the distortion spectrum is the vector sum of every pair's contribution. in-phase primaries add; scrambled phases partially cancel. this is why the plugin resets all oscillator phases together at note-on.
a note on units: the levels in this section are hann-bin magnitudes re unit
(the same estimator the technical note uses), so they read 12 dB under
section 4 onward, which is dB re a full-scale sine via db_at. only
differences are compared across sections.
rng = np.random.default_rng(2234)
def fp_level(phases):
yy = ear(comb(freqs, phases))
w = np.hanning(len(yy))
m = np.abs(np.fft.rfft(yy * w))
return 20 * np.log10(m[bin_fp] / len(yy))
locked = fp_level(np.zeros(len(freqs)))
scrambled = np.array([fp_level(rng.uniform(0, 2 * np.pi, len(freqs)))
for _ in range(200)])
fig, ax = plt.subplots()
ax.hist(scrambled, bins=30, color=HAIR, edgecolor=DIM)
ax.text(scrambled.min(), ax.get_ylim()[1] * 0.9, '200 random-phase trials',
color=DIM, fontsize=8, va='top')
ax.axvline(locked, color=ORANGE, lw=2)
# the histogram peaks right under the locked line, so a leader from the
# left crosses the tallest bars whatever it is anchored to; the label sits
# beside its line instead, in room made for it on the right
ax.set_xlim(right=locked + 7)
ax.text(locked + 0.4, ax.get_ylim()[1] * 0.92, 'phase-locked\n(the plugin)',
color=ORANGE, fontsize=8, ha='left', va='top')
ax.set(xlabel='phantom (fp) component level (dB)', ylabel='trials',
title='what phase scrambling costs the phantom')
plt.tight_layout()
print(f'phase-locked fp level: {locked:.1f} dB')
print(f'random-phase median: {np.median(scrambled):.1f} dB '
f'(loss: {locked - np.median(scrambled):.1f} dB)')
phase-locked fp level: -41.2 dB random-phase median: -49.2 dB (loss: 8.0 dB)
4. alternating phase: pressnitzer & patterson's experiment 3¶
their control: shift every other harmonic by pi/2. adjacent pairs then differ by +pi/2, -pi/2, +pi/2, ... and their difference tones at fp cancel in twos, while the pairs two apart (which make 2fp) still agree. the percept rises an octave.
the trap, which ThirdEar 1.0.0 fell into: a shift of pi looks like the same idea and does nothing. the comb with every other partial flipped is the locked comb delayed by half a period of fp (sum (-1)^k cos((m+k) w t) is the locked sum at t + T/2), so every nonlinearity in the world gives it the same distortion spectrum. this cell measures all three. with N partials there are N-1 pairs; an odd pair count leaves one pair standing, 20 log10(1/(N-1)) below locked.
N = len(freqs)
settings = {
'locked': np.zeros(N),
'pi flip': np.array([0 if k % 2 == 0 else np.pi for k in range(N)]),
'pi/2 shift': np.array([0 if k % 2 == 0 else np.pi / 2 for k in range(N)]),
}
harmonics = [1, 2, 3, 4]
levels = {name: [db_at(ear(comb(freqs, ph)), k * fp) for k in harmonics]
for name, ph in settings.items()}
fig, ax = plt.subplots()
width = 0.26
tones = [HAIR, DIM, ORANGE]
for j, (name, lv) in enumerate(levels.items()):
xs = np.arange(len(harmonics)) + (j - 1) * width
ax.bar(xs, np.array(lv) + 70, width, bottom=-70, color=tones[j], edgecolor=INK, lw=0.4)
# the names sit on the first group, staggered so the two equal bars read;
# the short bar's name stacks so it stays over its own bar
ax.text(xs[0], lv[0] + (4.2 if j == 1 else 2.0), name if j < 2 else 'pi/2\nshift',
color=tones[j] if j else DIM, fontsize=7.5, ha='center', va='bottom')
floor = 20 * np.log10(1 / (N - 1))
ax.axhline(levels['locked'][0] + floor, color=ORANGE, lw=0.7, ls=(0, (4, 3)))
# the bars run to the floor, so the only clear ground is right of the last
# group: the reference line's label sits there, just above the line
ax.text(3.42, levels['locked'][0] + floor + 0.6,
f'closed form: one pair\nof {N - 1} left standing,\n{floor:.1f} dB under locked',
color=ORANGE, fontsize=7, ha='left', va='bottom')
ax.set(xticks=range(len(harmonics)), xticklabels=[f'{k}fp' for k in harmonics],
xlim=(-0.5, 4.5),
ylim=(-60, max(levels['locked']) + 10), ylabel='level (dB re full scale)',
title='what the ear gets from each phase setting, after the nonlinearity')
plt.tight_layout()
for name, lv in levels.items():
print(f'{name:11s}', ' '.join(f'{k}fp {v:7.1f}' for k, v in zip(harmonics, lv)))
print(f'pi flip vs locked at fp: {levels["pi flip"][0] - levels["locked"][0]:+.2f} dB (a delay)')
print(f'pi/2 vs locked at fp: {levels["pi/2 shift"][0] - levels["locked"][0]:+.2f} dB '
f'(closed form {floor:.2f})')
locked 1fp -29.2 2fp -31.1 3fp -33.6 4fp -37.1 pi flip 1fp -29.2 2fp -31.1 3fp -33.6 4fp -37.1 pi/2 shift 1fp -43.2 2fp -31.1 3fp -43.2 4fp -37.1 pi flip vs locked at fp: +0.00 dB (a delay) pi/2 vs locked at fp: -13.98 dB (closed form -13.98)
5. how the phantom builds with the pair count¶
every adjacent pair contributes one difference tone at fp. in this memoryless model the contributions are perfectly coherent, so N-1 pairs give (N-1) times the amplitude of one pair: 6 dB per doubling. listeners in P&P's experiment 2 reported about 3 dB per doubling. that gap is the model's most honest failure: a real cochlea delivers each pair's product from a slightly different place, with a slightly different phase, and the sum only partly reinforces. the plugin draws the 3 dB law on its panel (it is the listener's number); this cell draws both so the difference is visible rather than footnoted.
counts = [2, 3, 4, 5, 6, 8]
pair_levels = []
for n in counts:
fr = solve_qdt(fp, n=n)
# one pair's worth of amplitude per partial, so N is the only thing changing
x_n = sum(np.sin(2 * np.pi * f * t) for f in fr) / 2
pair_levels.append(db_at(ear(x_n), fp))
pair_levels = np.array(pair_levels)
pairs = np.array(counts) - 1
fig, ax = plt.subplots()
ax.plot(pairs, pair_levels, 'o-', color=INK, lw=1.0, ms=4)
ax.text(pairs[-1] * 1.06, pair_levels[-1], 'this model: 6 dB per doubling' + chr(10) + '(perfectly coherent sum)',
color=INK, fontsize=8, va='center')
base = pair_levels[0]
yy = base + 3.0 * np.log2(pairs)
ax.plot(pairs, yy, color=ORANGE, lw=0.8, ls=(0, (4, 3)))
ax.text(pairs[-1] * 1.06, yy[-1], '3 dB per doubling' + chr(10) + '(listeners, P&P exp. 2)', color=ORANGE,
fontsize=8, va='center')
ax.set(xscale='log', xlim=(0.9, 16), xticks=pairs, xticklabels=[str(q) for q in pairs],
xlabel='adjacent pairs (N - 1)', ylabel='level at fp (dB re full scale)',
title='the phantom versus the number of pairs, one pair-amplitude per partial')
ax.minorticks_off()
plt.tight_layout()
steps = np.diff(pair_levels) / np.diff(np.log2(pairs))
print('dB per doubling between successive counts:', np.round(steps, 2))
dB per doubling between successive counts: [6.02 6.02 6.02 6.02 6.02]
6. measuring an empty register without fooling yourself¶
the plugin's panel carries an honesty meter: the level at fp in the output,
against the loudest primary. it is easy to get this wrong. in CDT mode the lower
primary sits at fp/(2-r), only 0.28 fp above the phantom at r = 1.22, and a
2048-point hann at 48 kHz puts it three bins away. the shipping 1.0.0 meter
read "NOT EMPTY" at -29 dB over a register that was empty to -140 (review F14,
recorded in analyzer.h); the in-silico replica of that meter below reads
-11 dB re f1, because at r = 1.22 f1 is 3.15 bins away and the +-2 bin search
lands inside hann's main lobe. the fix is the instrument this notebook uses
everywhere: a longer frame, a blackman-harris window (-92 dB sidelobes), and a
goertzel at exactly fp.
r = 1.22
fp_c4 = 261.63
f1 = fp_c4 / (2 - r)
pair = (np.sin(2 * np.pi * f1 * t) + np.sin(2 * np.pi * r * f1 * t)) / 2
def hann_bin_meter(x, hz, n=2048):
'''what the 1.0.0 meter did: max over fp +- 2 bins of a 2048 hann fft'''
seg = x[:n] * np.hanning(n)
m = np.abs(np.fft.rfft(seg)) / (n / 4)
b = int(hz * n / SR)
return 20 * np.log10(m[max(1, b - 2):b + 3].max() + 1e-12)
reads = {
'hann 2048, fp +- 2 bins (1.0.0)': hann_bin_meter(pair, fp_c4),
'blackman-harris 8192 goertzel': db_at(pair[:8192], fp_c4),
'truth: 1 s goertzel': db_at(pair, fp_c4),
}
ref = db_at(pair, f1)
fig, ax = plt.subplots()
names = list(reads)
vals = np.array([reads[k] - ref for k in names])
ax.barh(range(len(names)), vals + 160, left=-160, color=[DIM, ORANGE, HAIR], edgecolor=INK, lw=0.4)
for i, (name, v) in enumerate(zip(names, vals)):
# a background on the label so the threshold line does not cut it
ax.text(v + 2, i, f'{name}: {v:.0f} dB', color=INK, fontsize=8, va='center',
bbox=dict(facecolor=ax.get_facecolor(), edgecolor='none', pad=1.5), zorder=3)
ax.axvline(-60, color=ORANGE, lw=0.7, ls=(0, (4, 3)), zorder=1)
ax.text(-62, -0.55, 'register empty below here', color=ORANGE, fontsize=8, ha='right', va='center')
ax.set(yticks=[], xlim=(-160, 0), ylim=(-0.8, len(names) - 0.4),
xlabel='reading at fp, dB re the lower primary',
title=f'the same empty register, three ways of asking (CDT pair at C4, r = {r})')
plt.tight_layout()
for k, v in reads.items():
print(f'{k:36s} {v - ref:7.1f} dB re f1')
hann 2048, fp +- 2 bins (1.0.0) -11.0 dB re f1 blackman-harris 8192 goertzel -105.6 dB re f1 truth: 1 s goertzel -123.0 dB re f1
honest limits and next steps¶
- this memoryless model proves existence, phase dependence and the alternating-phase cancellation (section 4), because all three are pair-phase algebra. it cannot show the f2/f1 ratio window (goldstein's curves), the level growth laws, or the 3 dB buildup (section 5), because those live in the traveling-wave overlap on the basilar membrane. next notebook: the CARFAC v2 cochlear model (numpy) for exactly that.
- the real ear also feeds back (MOC efferents) and compresses level-dependently:
see
../deep-dives/01-outer-hair-cells-and-oae.md. - the plugin checks itself against these numbers:
plugins/thirdear-cpp/tests/renders the shipping binary through the same square-law ear (phase.cppis section 4,clean.cppis section 6,ear.cppis section 3). - references: kendall/haworth/cadiz CMJ 2014 (mirrored in
../../research/papers/), pressnitzer & patterson 2001, and the eartone cheatsheet's condition list.