NOTES — 06 · BLUEBERRY SMOOTHIE

Every release made it claim less

A page that draws your speech melody as a curve. Twenty versions in, it measures no more than it did at version four — what changed is that it now knows, and says, how little a single recording can prove. One of those versions deleted a whole feature. One of them destroyed the exact signal the page exists to measure, while thirty-four unit tests passed.

ENGLISH ONLY · ~15 MIN · AUDIO NEVER LEAVES THE DEVICE

01What it measures, and the verdict it will not give

Say a Korean sentence into the page and it draws your pitch as a curve in semitones relative to your own median, marks the syllable nuclei it found, and reports how far your pitch moved at the end of the utterance. YIN pitch tracking, syllable detection, phrase segmentation and octave correction all run in the tab; the audio never leaves the device.

The obvious product here is a dialect quiz. It does not do that, and the reason is not caution — it is that the measurement does not exist.

WHAT THE LITERATURE ACTUALLY SUPPORTS

Essentially one discriminator: does this speaker end a yes/no question with a sharp rise? Kyungsang speakers reportedly do not — a measured 0.2 semitones, below the perceptual threshold. Meanwhile the cue for a wh-question in Seoul Korean is not at the end of the utterance at all; it is dephrasing after the question word, which happens early. And every published baseline is read speech, which cannot be pooled with free conversation.

So a percentage next to a province name would be an invention. The page reports what it measured and stops.

That constraint drives everything below. If you cannot say "you are from X", the only defensible claim left is "within you, A differs from B" — and the entire engineering history of this page is about earning the right to say even that.

02Thirty-four passing tests and a destroyed signal

Pitch trackers make octave errors: YIN in particular reports f0/2 more often than f0×2, because of period doubling. So there is a correction stage that snaps outliers back. The first version chose, for each frame, the octave candidate closest to the recording's median pitch.

Consider a speaker whose median is 115 Hz, ending a question by rising to 170 Hz:

the real rise 170 Hz |log2(170 / 115)| = 0.564 octaves from the median the halving error 85 Hz |log2( 85 / 115)| = 0.436 octaves from the median

The wrong answer is closer. The correction stage takes a genuine question-final rise and flattens it into an octave error — it destroys precisely the one thing the page was built to measure, and it destroys it most reliably on the recordings where the effect is strongest.

Thirty-four unit tests were passing at the time. All of them. What found this was not a test of the code but a test of the claim: a synthetic utterance built to be linguistically plausible — 120 / 115 / 118 / 170 Hz, a flat sentence with a rise on the last syllable — run through the pipeline to check that the output said what a linguist would say. It did not.

THE GENERALISABLE PART

Unit tests check that a function does what you meant. They cannot check that what you meant was right. For a measuring instrument you also need a test that encodes a claim from outside the codebase — here, "a yes/no question rises" — and fails when the pipeline contradicts it. This is the second bug in this project caught that way and neither would have been caught otherwise.

03A reference that justified itself

The deeper defect was in the reference value. The median was computed from the uncorrected f0 track, so halved frames dragged the median down toward themselves — and once the median has moved toward an error, that error looks less like an outlier, so it survives. The more errors a recording has, the less the corrector fixes them.

Concretely: half a second read as 55 Hz on a 110 Hz speaker pulls the median to 105. The 55 Hz frame is then only 0.593 octaves out, barely past the free-of-charge band, leaving a residual cost of 0.186 against a correction cost of 0.40. Leaving the error in place wins.

The fix reads the median iteratively — fold only frames more than 0.75 octaves out, re-take the median, repeat — and makes correction costs asymmetric, cheaper upward than downward, because that is the direction the tracker's errors actually go.

A fully octave-invariant approach would have been more robust to contamination and was rejected for a specific reason: folding every frame to a single octave class destroys real intonation that approaches an octave. On a test curve of 190 / 230 / 300 / 170 / 150 Hz it folded the genuine 300 Hz peak down to 150, because log2(300/170) = 0.819 rounds to 1. The 0.75-octave threshold is the discriminator: octave errors land near 1.0 and get folded, real intonation peaks stay inside 0.75 and do not.

The regression suite for this has four groups, and one of them matters more than the rest: "real intonation must not move a single frame." A 115 → 170 Hz question rise, the wide curve above, a flat 88–95 Hz male voice — zero frames may change. That group caught my first fix destroying real intonation while happily repairing every error case. Had I only tested the repairs, it would have shipped.

04Built, shipped, deleted

A version added a lexical-tone tab: read the word 가지 three times with three different meanings displayed, measure the pitch step between its two syllables, and score the contrast. It was implemented, tested with 39 fresh assertions, and shipped.

Then came the question that ended it: why that word specifically — can't you tell from ordinary reading, or from free speech?

The honest answer was no, and the reason is structural. Measuring lexical tone requires knowing which syllable is the target, which requires forced alignment to the text; this page has none, and its syllable detector runs around 65% on real speech. Worse, lexical tone only surfaces in minimal triplets — same segments, different meanings — and inside a sentence the meaning is already fixed by context, so the contrast does not exist to be measured. There was no path to the measurement that did not go through a laboratory procedure.

The user's judgement: if it is that artificial, it does not belong on the page. The whole tab came out.

How the deletion was verified

Removal was done by hand across four source files, which is exactly the situation where something small gets left behind. So instead of trusting the test suite, the rebuilt file was compared byte for byte against the earlier commit — fetching the old file, hashing it in 200-line blocks, and matching against the same computation locally. Two divergences turned up:

  • A leftover blank line where the removed section had been — twenty bytes.
  • A function I had quietly generalised into a loop when adding the feature, and which the revert had not restored to its original six explicit lines.

Neither would have failed a test. Both were found by binary-searching the mismatching block down to a single line. The final file was byte-identical to the version it was meant to return to, which is a stronger statement than "all tests pass."

05One recording is not a measurement

The original comparison feature held one recording per condition. Read a statement, read a question, look at the difference. But the same person reading the same sentence twice does not produce the same number, so with one value per side there is no way — in principle, not in practice — to tell a condition difference from the instrument's own wobble.

It became repeated measures: up to twelve takes per condition, with an exact Mann–Whitney permutation test. Not a t-test, because at three to five takes there is no basis for assuming normality; the null distribution is enumerated exactly by dynamic programming instead.

The design decision I am proudest of on this page is what gets displayed alongside the p-value:

Takes per conditionPossible arrangementsSmallest two-sided p attainable
3 vs 3200.100
4 vs 4700.029
5 vs 52520.008
6 vs 69240.002
Recomputed for this write-up from the exact null distribution; the arrangement counts check out against C(m+n, m).

With three takes each, no result can reach 0.05. Not an unlikely one — an impossible one. A user who sees "p = 0.10" and concludes "no difference" has been misled by the interface, so the page prints the floor next to the p-value and has a separate verdict for cleanly separated but too few takes. It is a nudge toward four, implemented as arithmetic rather than advice.

06The session where more data gave a worse answer

A session of four takes per condition came out at Δ +1.63 st, p 0.029 — the first real result the page had ever produced. I advised going to six takes each and alternating A, B, A, B to remove drift. The result disappeared: Δ −0.25, p 0.699.

The user's question was the right one: I did six each and the p-value got worse?

Because the sample size sets the floor, not the value. Six versus six could have reached 0.002; it did not, because the data no longer separated. And the number that actually told the story was not p at all — it was the spread, which went from ±0.46 to ±1.77, nearly four times worse.

MY ADVICE HAD A COST I DID NOT MENTION

Alternating conditions did work: the between-condition pitch gap came out at 1 Hz, the smallest of any session. But drift does not vanish when you interleave — it moves from between conditions into within them, inflating the spread and pushing every statistic toward "no conclusion".

Block your recordings and you get bias between conditions. Interleave them and you get noise inside conditions. There is no arrangement that pays neither, and I recommended one without saying which price it charged.

Digging further showed the failure was not smooth drift but discrete: removing the linear trend barely helped, while removing a single take dropped one condition's spread from 2.05 to 0.79. One or two recordings were broken, not all of them. That pointed at a specific mechanism, and finding it took two more releases of pure diagnostics — per-take pitch outliers measured against the condition's own median (using the mean would let an outlier hide itself, the same self-justification trap as the octave reference), and per-take duration and syllable counts, also relative to the condition's own median because the detector's baseline rate is only ~65% and an absolute threshold would paint every take red.

All of those diagnostics flag and never reject. A take that looks odd might be a take where you really did speak that way.

07A missed syllable is not a blurred number

The mechanism turned out to be syllable detection, and the reason it matters is not accuracy.

The detector finds intensity peaks and requires a dip of at least 3 dB between them to call two syllables separate. Dips are made by closures — the actual silence in ㅂ, ㄷ, ㄱ. But nasal codas like ㅇ and ㄴ are voiced and carry energy straight into the next syllable. There is nothing to cut on. In the tongue-twister sentence the page offers, roughly 24 of 28 syllables end in a nasal, and the whole phrase collapses into a few peaks.

Now the crucial part. The final pitch movement is anchored to the second-to-last syllable nucleus. Lose one syllable and the anchor does not become imprecise — it lands on a different syllable, and the page measures a different interval entirely.

Same audio, same codeNuclei foundAnchor frameFinal movementVerdict
Free detection4 / 7100+2.10 strising
Constrained detection7 / 7127+1.36 stflat
0.74 semitones apart, and on opposite sides of the verdict boundary. This is the discrete failure from section 06.

The fix uses information the page already had: it knows which sentence you were asked to read, so it knows how many syllables to expect. When free detection finds fewer, it sweeps a grid of progressively looser thresholds and takes the setting closest to the target. Three rules keep that honest:

  • Thresholds only loosen, never tighten. Tightening would delete real sounds — a breath, a restart — to hit an expected count. Erasing what the speaker actually did is not measurement.
  • Overshooting costs double. Inventing a syllable that is not there is worse than missing one, and if free detection already meets the target the search does not run at all.
  • Ties go to the default, so equal-cost settings resolve toward the more conservative one.

And the recovery is announced on screen — syllable boundaries recovered: 4 → 7 — because a nucleus found by lowering a threshold is not as trustworthy as one that was obvious, and that difference belongs to the reader.

While wiring this I found the bug that would have made the whole exercise pointless: the final-movement function re-detected syllables by itself when it wasn't handed any, with default thresholds. Without passing the constrained nuclei through, the improvement would have changed the picture on screen and nothing about the number underneath it.

08The result, and exactly how narrow it is

Seven sessions in, indoors, six takes per condition, alternating:

A "밥 먹었어." statement −1.97 +0.34 −2.09 −1.66 −0.19 −0.65 B "밥 먹었어?" question +6.28 +5.65 +5.80 +7.28 +8.45 +6.70

I recomputed this from the raw takes while writing this page: mean A −1.037, mean B +6.693, Δ +7.730 st, pooled SD ±1.030, Cohen's d 7.50, U = 36/36 — complete separation — and a two-sided p = 0.00216, which is exactly the floor for six versus six. The data cannot separate any further than it already has; only sample size is holding the number up.

And here is the entire claim that result licenses:

WHAT MAY BE SAID

This speaker, on these two sentences, ends the yes/no question 7.73 semitones higher than the statement.

It says nothing about any dialect, nothing about any other speaker, and nothing about Korean. That narrow sentence is the first thing this page has ever been able to state with statistical backing.

The most interesting thing in the data is not the result

SessionStatement meanQuestion meanΔ
3rd, before the syllable fix−1.010+0.618+1.63
5th, outdoors, after the fix−0.883+6.248+7.13
7th, indoors, after the fix−1.037+6.693+7.73
The statement sits at about −1 in every session. Only the question moved — and it moved once, across the release that fixed syllable detection.

That asymmetry is exactly what the anchor explanation predicts. A question rises sharply on its final syllable, so losing that syllable throws the measurement away entirely; a statement falls gently, so an anchor off by one barely changes the value. The statement should not move and the question should. It did not move, and it did.

There is one more coincidence worth reporting. Back at version zero, the linguistic plausibility simulation from section 02 — the synthetic question written to encode what the literature says should happen — came out at +6.33 st. Two years of releases later, the real measurement lands at +6.25 to +6.69. The number that had been put in as theory is where the instrument eventually arrived.

I am not claiming this as proof. The sample is small, the sessions differ in a dozen ways, and there is one thing that would settle it which I cannot do: run the same audio through the old code and the new code. The audio is never kept. That is the real price of the privacy design — not an inconvenience but a permanent limit on what can be re-examined — and the only route left is comparing distributions tagged by app version, which is why the storage schema records one.

09Three releases that added only doubt

Three consecutive versions added no new measurement whatsoever. Each took a number the page was already printing and attached an honest interval to it. All three concluded that the number could not carry the claim.

ReleaseWhat was attachedWhat it revealed
Declination slopet-based 95% intervalWith the usual 5–7 phrases the direction is frequently unresolved — a conclusive −0.37 and a meaningless −0.37 had looked identical
Continuation-rise rateWilson 95% intervalAt seven phrases or fewer, no value of k is ever conclusive — verified exhaustively. One passage can never answer this question
Read vs free speechPhrase-level exact test, BonferroniAt four versus four the attainable floor cannot clear the corrected threshold at all
Wilson rather than the textbook interval because 6/6 and 0/6 are common at this sample size, and the textbook formula reports a zero-width interval for them — manufacturing certainty out of six observations.

Two of those tables print a limitation the page cannot correct for: these intervals assume independent observations, and successive phrases inside one passage are not independent — the text itself decides much of where a speaker will rise. The intervals are therefore a lower bound on the real uncertainty. Since the error runs in only one direction, "this interval is too wide to conclude" stays valid while "this interval is narrow" does not, and the page says so rather than silently correcting a thing it cannot estimate.

The common finding is uncomfortable and useful: a single recording of this page cannot support most of what it would like to say. Every one of the three ends in "this opens up once measurements accumulate", which turned accumulation from a nice-to-have into the only route past the wall.

10The one promise I had to change

Accumulation meant storage, and storage meant breaking a stated promise: nothing is stored, close the tab and it is all gone.

The plan listed three places that wording appeared. Counting them by hand found four. The missing one was the largest type on the page — the line directly under the title.

WHAT THAT NEAR-MISS LOOKED LIKE

Every careful edit made, every privacy string updated, a test suite green — and the biggest sentence on the page still telling visitors their results were never saved. The page would have been lying in its headline while being scrupulously accurate everywhere else.

The replacement had to be true in every state at once, so it names the conditions instead of making a blanket claim: audio never leaves your device, no account is needed, results are saved only if you turn it on. Storage requires a login and an explicit opt-in, and rows cannot exist without a user id — so for anyone who simply visits and tries it, the original promise remains literally true.

One more thing came out of this project's own history. Versions along the way genuinely changed the values that come out of the same voice — that is section 07's whole point. So computing a personal noise floor across pipeline generations would measure my code changes rather than the speaker's variability. Aggregation is confined to one generation, and other generations are reported as a count only.

Which is the summary of this page in one line: it began as an instrument that reported numbers, and most of the work since has gone into teaching it which of its own numbers not to trust.

Try it. Say a sentence and watch your own melody drawn in semitones. Nothing is uploaded, and nothing is stored unless you switch it on.

Open Blueberry Smoothie →