Skip to content

Merge Timo's measurement rework, drive the sweep from CV, and add a debug harness - #1

Open
Ziforge wants to merge 61 commits into
masterfrom
merge/timo-measurement
Open

Ziforge wants to merge 61 commits into
masterfrom
merge/timo-measurement

Conversation

@Ziforge

@Ziforge Ziforge commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Merges the 53-commit measurement rework from TimoRozendal/VCOTuner into this fork's CV work, then builds on it.

Both forks branch from TheSlowGrowth, so this is a sibling merge rather than an upstream pull: Timo rewrote the measurement core, this fork built the CV output, calibration and UI on top of the old one.

The merge

Seven conflicts. The one that mattered was VCOTuner.cpp: the rework added early returns to the audio callback above where CV output used to sit, so taking either side as-is would have dropped the CV to 0 V between notes and after every sweep. CV filling is hoisted above those returns and now stands in for the blanket output clear, using the upstream per-channel null check rather than AudioBuffer::clear(), which was memsetting through unopened channels.

Visualizer keeps this fork's styling with the new failed-note and out-of-range markers ported onto its chartTop geometry — the upstream versions drew at absolute y=0, which here is the top info panel. Font::getStringWidth is gone in JUCE 8, so label widths use GlyphArrangement. One CI workflow rather than two: the upstream CI.yaml was dropped and its unit-test job, release-on-tag job, universal macOS binary and explicit -A x64 folded into build.yml.

Two pre-existing bugs

Both present in v0.3.0, confirmed by screenshotting that build.

Inactive tabs were invisible. drawTabAreaBehindFrontButton paints into a full-size child that JUCE places toBehind(frontTab) — in front of every other tab. Filling it opaquely erased them. The Chart tab was always there and always clickable, just never drawn.

±--.--c in the deviation readout — a bare ± in a narrow literal reaching JUCE as two Latin-1 bytes.

Measurement

A dropout is repaired instead of failing the note. Crossings were numbered by position, one cycle each, so a click's extra crossing shifted the cycle number of everything after it and the fitted line acquired a step. On a 440 Hz sine with a two-sample click, absorbing it gives 441.472 Hz — 5.78 cents off, with a small error bar; upstream's choice to fail instead was correct for that fit. Numbering by counting cycles removes the coupling: same signal, same click, now 440.000000 Hz with one crossing rejected of 401, and the rejection is reported rather than hidden. This reverses a spec upstream deliberately wrote down and tested; that test is rewritten, not deleted, and sustained jitter is pinned separately so the new allowance cannot be mistaken for tolerating an unsteady pitch.

The converter's real sample rate is measured rather than trusting the nominal 48000. Interfaces are out by tens to hundreds of ppm and 100 ppm is 0.173 cents — systematic, so a longer measurement does not reduce it. Measured on the development machine: −5.8 ppm, converging over ~17 s. It never affected live tuning (pitch offsets are ratios through the same clock) but it did affect every absolute reading. Refused when there are no host timestamps, when the correction is implausible, when the baseline is short, and restarted across a dropout.

The trigger level follows the signal between cycles. Latched for the whole measurement, a drifting DC offset makes the crossing happen at a progressively different phase, so crossing times acquire a ramp and the period comes out biased. At 110 Hz drifting 0.2/s: 0.65 cents latched, 0.021 cents tracked. At 50 Hz the latched level fails the note outright — the waveform walks clear of the threshold. Updated only at crossings, never within one, and slowly; on a clean signal that costs 3e-5 cents.

CV

The CV output now drives a tuning sweep. setActive(true) previously appeared only in CalibrationEngine, so this fork carried a CV output and a calibration engine for it and still required an external MIDI-to-CV interface to tune anything — an interface that is itself a DAC with its own scaling error in the measurement chain. A pitch source selector picks MIDI or CV; under CV the sweep sets a voltage and no MIDI is sent.

Calibration could push the output past the interface range — clamped before the correction but not after, so voltageToSample() handed the interface magnitudes past ±1 to clip silently, losing accuracy at exactly the extremes calibration was measured to fix.

The scaling arithmetic is now tested. Extracted to a JUCE-free vcotuner::CvScaling so the test binary can link it, with CVOutputManager delegating rather than holding a second copy.

Housekeeping

162 warning sites in Source/ down to zero — FontOptions, drawText onto the Rectangle<float> overload instead of truncating, override markers, size_t indices, dead fields and helpers removed. That noise was hiding a float-equality bug in the chart's in-tune line check, fixed here.

A static ModernLookAndFeel at file scope was racing JUCE's static Colours — JUCE asserts on it in debug; the undefined ordering was there in release too.

Verification

tools/debug_harness.sh — 14 gates, fail-closed, covering static checks, configure, build, tests, a UI smoke test that drives both tabs, and a realtime check that replaces global operator new and counts allocations on the audio path. tools/harness_selftest.py breaks each static guard in turn and asserts that gate fails, so green means checked rather than unchecked.

  • 62 tests, 2229 assertions — also green under ASan and UBSan
  • release and debug both build clean; debug run shows no assertion and no leak report
  • zero allocations on the audio path across 12 runs of 4000 blocks
  • harness 14/14 from a clean tree

Not verified: everything here was built on macOS arm64 only — this PR is how Windows and Linux get their first look, including the -A x64 change and the unit-test job, neither of which has ever run. And the CV sweep has not driven a real oscillator; the settling allowance is the existing 100 ms, tuned for a MIDI-to-CV interface, which is the first number to try if tracking looks off on hardware.

🤖 Generated with Claude Code

- Track running min/max continuously and derive midpoint/amplitude at
  the end of the warm-up window (finishWarmup), used by later tasks to
  place a Schmitt-trigger threshold instead of a hard 0.0 threshold.
- Report DetectorStatus::failedNoCrossings when the warmed-up amplitude
  is below silenceFloor, avoiding a divide-by-zero downstream.
- tests: cover DC-offset sine, asymmetric ramp, and silence handling.
- tests/CMakeLists.txt: run catch_discover_tests with DISCOVERY_MODE
  PRE_TEST so test enumeration happens after CodeSign; on Apple Silicon
  with the Xcode generator, enumerating at build time runs the
  unsigned binary and the kernel SIGKILLs it.
…mment

- Add a test that feeds the DC-offset sine through processBlock in 64
  sample chunks (matching the real audio callback's 256-512 sample
  buffers) and asserts midpoint()/amplitude() are identical to the
  single-block result, closing a coverage gap a future refactor could
  silently break through (member-scoped running state, finishWarmup()
  guarded to fire once).
- Correct the processBlock comment: min/max keep updating continuously,
  but levelMidpoint/levelAmplitude are latched once in finishWarmup() and
  are not recomputed afterward. The previous wording ("follows slow level
  changes... not just the warm-up window") was inaccurate about the
  latched values, copied verbatim from the design spec, which has the
  same error. Behaviour is intentionally unchanged: a threshold that
  drifted mid-measurement would inject timing error into the periods
  being measured, so the level is latched once at end of warm-up.
The 'trigger level adapts to very quiet and very hot signals' test asserted
numPeriods() in [435, 441], but the fixture (48000 samples at 440 Hz,
480-sample warm-up) deterministically yields 434 for every amplitude, as
verified independently against the committed PeriodDetector. The count was
never in question; the bound was off by one. The test's actual purpose -
proving the trigger level scales with measured amplitude - is better
expressed by asserting the count is identical across quiet, mid, and hot
signals, with a loose sanity range around it.
The spec asserted the detector follows slow level changes; it does not,
and should not - a trigger threshold that drifts mid-measurement injects
timing error into exactly the periods being measured. The level is
latched at the end of warm-up by design.

The plan's amplitude-adaptation test asserted a period count of >=435,
written by estimate. The fixture yields 434 deterministically: warm-up
consumes 4.4 cycles, the trigger needs up to another half cycle to arm,
and the first crossing has no predecessor. The test now asserts
amplitude-independence, which is what it was named for.
The previous formula used a negated slope and paired the previous sample
with the wrong x coordinate, mirroring the fractional crossing position
within the sample interval. It produced roughly twice the jitter of no
interpolation at all, inflating error bars and causing spurious high
jitter aborts above ~MIDI 100.
Adds numValidPeriods()/validPeriods() and the stability-window check that
latches firstValidIndex once cfg.stabilityWindow consecutive periods fall
within cfg.stabilityTolerance of their average. updateStability() sets
DetectorStatus::stable once enough valid periods follow, and otherwise
distinguishes the two terminal failure states when storage runs out:
failedUnstable (never latched) versus failedBufferFull (latched, but
requiredPeriods wasn't reached in time). It is called at the end of
recordCrossing, after push_back, so it runs on the call that pushes the
period reaching maxPeriods - recordCrossing's early-return guard means
every later call is a no-op.

Known failing test: "a constantly changing rate never stabilises and
terminates" expects failedUnstable for a 200 Hz -> 2 kHz linear sweep, but
the detector reports stable instead. At the start of the sweep the first
five periods (209.3, 202.5, 196.3, 190.7, 185.5 samples) fall within the
10% stabilityTolerance window around their average, so firstValidIndex
latches after period 5. Because updateStability() never re-checks
steadiness once latched, the detector keeps counting through the ongoing
drift and reaches requiredPeriods=20 at period 25, by which point the
period has drifted to ~128.8 samples (roughly 39% off the latch point).
This is a real interaction between the specified latch-once stability
check and a slowly-varying sweep, not a typo in the bound. Left as-is per
task instructions not to loosen/tighten the test to force a pass; needs a
decision on whether stability should be re-validated continuously or the
sweep fixture should change.
…stable

updateStability() latched firstValidIndex on the first 5-period window
that held within stabilityTolerance, then never rechecked it: a signal
that satisfies one lucky window and then drifts arbitrarily far still
counted every subsequent period toward requiredPeriods and was declared
stable. A drifting oscillator - thermal drift, a mistracking VCO, a note
still gliding - could hit exactly this path and produce a confident,
wrong measurement, which is the failure class this branch exists to
remove. (The shipping app has the same hole: Source/VCOTuner.cpp sets
indexOfFirstValidPeriodLength once and never rechecks it either.)

Before declaring stable, now re-checks every period in validPeriods()
(the post-latch set only - the pre-latch entries are the settling
transient we intend to exclude) against the mean of that same set, using
the existing stabilityTolerance. Any period outside tolerance reports
failedUnstable instead, which is the status that fits: a rate that
never actually held steady.

Fixes the previously-reported false positive on "a constantly changing
rate never stabilises and terminates": the sweep's first five periods
(209.3 down to 185.5 samples) happened to satisfy the single-window
check, latching firstValidIndex=5; by the time 20 valid periods existed
(periods[5..24], 180.7 down to 128.8) the set no longer held within 10%
of its own average (average 150.6, boundary +-15.1; periods[5..8] and
periods[21..24] all exceed it) and the new check now correctly reports
failedUnstable instead of a spurious stable.
The plan's stability gate latched after one 5-period window and never
rechecked, so a drifting oscillator could satisfy one lucky window and
be reported as a confident measurement. The spec requires stable to mean
genuinely steady.
Address review findings on Task 6:
- Add a computeMeasurement case using the analytic {8, 11, 9, 12} fit so
  frequencyDeviation and pitchDeviation are asserted against literal,
  hand-derived nonzero values, plus guards against the two named
  mis-derivations (dropped /periodSamples, omitted 1/ln2). Every prior
  computeMeasurement test used uniform periods, so periodStdError was
  always zero and neither formula was actually exercised.
- Cover numPeriods == 2 for both fitPeriod and computeMeasurement: the
  only degenerate input that reaches the sse/((n-2)*sxx) division rather
  than returning early.
- Correct the fitPeriod docstring, which claimed a 3-period minimum; the
  code has always accepted 2 periods (1 degree of freedom), and that is
  correct as-is per review: a wide standard error communicates low
  confidence rather than discarding the measurement.
- Reword the sxx <= 0.0 guard's comment to make clear it is unreachable
  by design given the current input contract, not load-bearing.
Extract the state machine's timeout calculation into a pure, testable
function, computeTimeoutCycles(). The old formula
(roundToInt(1/f * numPeriods * 2 * 100)) rounds to zero at high
pitches - e.g. MIDI 120 at 20 periods gives round(0.0048 * 100) = 0 -
so the measurement gets roughly one 10ms timer tick to complete, well
under a typical MIDI-plus-audio round trip. That aborts every note
above roughly MIDI 108. The new function floors the result to 50
cycles (500ms at the default 10ms timer) and adds a fixed latency
allowance.

Adjusted the brief's "the latency allowance is included" test: at
440 Hz with numPeriods=20, the raw (unfloored) cycle counts for the
no-latency and 0.3s-latency cases are 10 and 40 - both still under the
50-cycle floor - so both collapse to 50 and "with > without" cannot
pass for any implementation that honours the documented floor. Raised
numPeriods to 400 (already used elsewhere in this file) so both sides
clear the floor and the comparison is meaningful.
reset() is called from the real-time audio callback and reserves the
period buffer there, which is a heap allocation on the audio thread and
can cause dropouts. prepare() reserves the same capacity up front from a
non-realtime thread, so the reserve inside reset() asks for a capacity
the buffer already has and does nothing.

No change to reset()/processBlock()/status() semantics.
A note that cannot be measured is recorded in a FailureTracker and
reported to the listeners through measurementFailed(), and the sweep
moves on to the next pitch instead of aborting with a modal dialog.
Only the three fatal errors still stop the run.

This also wires vcotuner::PeriodDetector into the audio callback, which
had to land in the same commit: removing lError and the LowLevelError
enum breaks the state machine cases that this change rewrites.

The reference measurement keeps aborting on failure. Every other note's
pitch is expressed relative to the reference frequency, so a sweep
without one would be meaningless.

Also:
- guards numInputChannels before reading channel 0, which previously
  read out of bounds on a device with no enabled input channels
- makes the start/stop flags atomic, and publishes the detector status
  through an atomic, since both are shared with the audio thread
- takes the frequency, pitch and their uncertainties from
  computeMeasurement()/fitPeriod() and the timeout from
  computeTimeoutCycles()
- stops the sweep on the no-frequency-change error, which previously
  reported the error and then carried on regardless
failCurrentNote() armed stopMeasurement unconditionally, including on the
path where the audio thread had already finished and cleared its own
state. prepMeasurement then handed over a new run without checking the
flag, so a callback arriving after the 100 ms settle window - a buffer
period above 100 ms is enough - consumed the stale request and killed the
run it was meant to start. The state machine read 'collecting', failed
the note, armed the flag again, and every remaining note in the sweep
failed the same way.

Now the flag is only armed when a run really is in flight, and
prepMeasurement waits for the audio thread to acknowledge it, matching
what prepareSingleMeasurement and prepareContinuousFrequencyMeasurement
already did.

Also:
- clears the output per channel, skipping the null pointers that JUCE
  gives for channels not enabled when the device was opened. Moving the
  clear ahead of the early returns had put it on that path.
- initialises continuousFreqMeasurementResult to -1 and resets it in
  startContinuousMeasurement. Keeping the previous reading when a pass
  does not settle means it is no longer written on every pass, and
  ReportPrepScreen advances the report wizard on its value.
- asserts at the reset() call site that cfg.maxPeriods still matches the
  capacity reserved in the constructor, which is what keeps the audio
  callback allocation-free.
Visualizer now implements VCOTuner::Listener::measurementFailed and
tracks failed pitches separately from measurements. Auto-scaling skips
failed notes so one bad reading no longer stretches the display range
and squashes the real tuning curve. Failed notes are drawn in
orangered instead of green/springgreen.

A pitch is removed from failedPitches as soon as newMeasurementReady
delivers a fresh reading for it, so a note that fails on one sweep
cycle and then measures correctly on the next (e.g. after the user
adjusts a trimmer) recovers its normal colour and rejoins
auto-scaling without waiting for a full clearCache().
Two defects from code review, both present in the original task brief:

1. measurementFailed() only touched failedPitches, never measurements.
   Since the drawing loop iterates measurements and uses its index as
   the column position, a pitch that failed before ever succeeding had
   no column at all -- the graph just compacted around the gap, so the
   common case (first-attempt failure, or any failure right after
   clearCache()) was invisible. Fixed by factoring the existing
   add-or-replace logic out of newMeasurementReady() into a shared
   upsertMeasurement() helper, and having measurementFailed() upsert a
   zeroed placeholder (numMeasurements == 0) keyed on midiPitch. A
   later successful reading for the same pitch overwrites the
   placeholder via the same helper, so a pitch never holds more than
   one column.

2. orangered vs green is a near-luminance-matched red/green pair with
   no reliable brightness cue, and the average-value line carried no
   other cue at all. A failed note's point/band data is also
   meaningless (zero would misleadingly read as perfectly in tune), so
   rather than just recolouring it, failed columns now render as a
   translucent full-height column fill plus a bold orangered X,
   replacing the point/band entirely. Colour is now reinforcement, not
   the only signal.

The auto-scaling exclusion (failedPitches.contains() in the min/max
loop) already skips placeholder entries for the same reason it skips
any failed pitch, so no change was needed there.
MainComponent now distinguishes live tuning from a report:

- Live tuning (cycle == true) never raises a dialog for a failed note.
  A status label below the graph names the currently failing notes,
  cleared on every tunerStarted() so a note that recovers on the next
  cycle drops off immediately. Long failure lists are capped at 10
  pitches with a "(+N more)" suffix.
- A report (a single sweep with a real end) gets one consolidated
  dialog listing every failure when tunerFinished() fires.
- Fatal errors are unchanged: they still abort and show a dialog via
  tunerStopped(), in both modes.

Also settles three messaging gaps left open by earlier tasks:

- Added a dedicated Errors::bufferFull message (VCOTuner.h/.cpp) and a
  matching describeError() case, instead of lumping a full measurement
  buffer in with 'unsteady rate'. describeError() lives in VCOTuner.cpp
  and is declared outside the class in VCOTuner.h, since Source/dsp/
  must not depend on JUCE's String.
- Reworded Errors::stableTimeout: it no longer asserts the crossings
  ARE arriving at a constant rate, since 'collecting' also covers a
  warm-up that never finished on a weak signal.
- Left MeasurementError::highJitterTimeOut in place with a comment
  recording that it's unreachable now that the detector always reaches
  a terminal status itself (MeasurementErrorTests.cpp still asserts on
  it), but dropped the matching VCOTuner::Errors::highJitterTimeOut
  string, which nothing referenced.
… ends

Task 12's summary dialog was gated on MainComponent::creatingReport, but
nothing ever set that flag: the Create Report button launches
ReportCreatorWindow with no reference back to MainComponent, and the
report's own measurement is driven entirely by
ReportDetailsEditorScreen (tuner->start()/startSingleMeasurement()),
which registers itself as a VCOTuner::Listener independently. The
summary was therefore unreachable in practice.

Moved the fix to where the report actually finishes:
ReportDetailsEditorScreen::tunerFinished() runs through two stages -
the main sweep, then a single-note reference re-measurement to check
for drift - so tunerFinished() fires more than once per report. The
true end is the reMeasuringReference stage's two terminal branches
(drift within margin, or the user chooses to keep a drifted result
anyway), both marked by tunerHasFinished = true. Neither the 'repeat
the measurement' branch nor the 'cancel' branch reaches this, since
those are not the report finishing.

Both branches now call the new showMeasurementFailureSummary()
(VCOTuner.h/.cpp), reusing the exact describeError()-based wording
from Task 12's MainComponent dialog so the two paths stay consistent.
tuner->getFailures() is unaffected by the reference re-measurement in
between (only a full sweep restart resets the failure tracker), so it
still reflects the main sweep's failures at this point.

Removed MainComponent::creatingReport, startCreatingReport() and the
reportRange constant that only startCreatingReport() used: all three
were part of the same dead, unwired mechanism (the real report uses
ReportProperties, not MainComponent::reportRange), and leaving them in
place risked a future double-dialog if someone ever did wire
startCreatingReport() up. MainComponent::tunerFinished() no longer
contains any dialog-raising code at all, which is a stronger guarantee
than before that live tuning can never show one.
Errors::bufferFull and describeError's bufferFull case described
failedUnstable (never reached a steady rate) instead of what
DetectorStatus::failedBufferFull actually means. PeriodDetector.cpp's
firstValidIndex check (see PeriodDetector.h's DetectorStatus comments)
shows the two terminal states at maxPeriods are distinguished exactly
because they're opposites: failedUnstable is 'never found a steady
window', failedBufferFull is 'found one, just not for long enough to
finish before the buffer filled'. The old wording told a user with a
perfectly steady oscillator that their signal never settled, which
could send them chasing a hardware problem that isn't there.

Reworded both strings to say the signal did settle but didn't hold
long enough before the buffer ran out - the 'try a lower resolution'
remedy was already correct and is unchanged. Kept the two strings
consistent with each other and distinct from highJitter's 'never
settles' framing, which is what motivated splitting bufferFull out as
its own case in the first place.
… note

failCurrentNote() advanced currentIndex, but currentIndex has exactly one
consumer: the "MIDI-to-CV interface isn't responding" check, which is gated
on currentIndex == 0 so that it runs on the first measurement. Advancing it
on failure meant that when the first note failed for any reason, the check
never ran again for the whole sweep - a user whose interface is on the wrong
MIDI channel then got a full-length sweep and a saved report showing a flat
line at the reference frequency, with no error at all.

currentIndex counts successful measurements again, restoring the invariant
"currentIndex == 0 <=> no note has been measured successfully yet", so the
check fires on the first successful measurement as it was meant to. Nothing
else reads currentIndex; the Visualizer's column ordering comes from
insertion order into its own array, keyed on midiPitch.
prepRefMeasurement was the only prep state that did not wait for a pending
stop request to drain before starting a new run. switchState(stopped) always
arms stopMeasurement, and MainComponent::comboBoxChanged does stop-then-
restart within a single message-thread call stack, so changing the pitch
range or resolution while running arms the flag roughly 100 ms before
startDetectorRun() sets startMeasurement. On a device whose buffer period is
longer than that (4096 frames at 44.1 kHz is 93 ms, 8192 is 186 ms) no audio
callback intervenes, the next one consumes the stale flag and kills the
reference run, and the user sees a spurious timeout error.

The plain guard the other three states carry trades that rare spurious abort
for a silent unbounded hang: only the audio callback clears the flag, so with
no device running nothing ever does. All four states now share one bounded
wait instead. awaitingStopRequest() counts the cycles spent waiting and, after
100 of them (one second at the 10 ms timer), reports the audio-device error
and stops rather than waiting forever. One second is several times the
longest realistic buffer period, so a legitimate slow start still proceeds.

The wait uses its own counter rather than cycleCounter: cycleCounter gates
the MIDI note-on (cycleCounter == 0) and the 100 ms oscillator settling
window, so advancing it while waiting would skip the note-on entirely and cut
the settling time short.
isFatal(), errorForStatus() and errorMessageForStatus() all deliberately omit
a default label so that -Wswitch flags a future MeasurementError enumerator
that nobody has classified. describeError() had one alongside its explicit
cases, which silently swallowed exactly that. Every enumerator was already
listed, so removing the label needs no new cases - just the trailing return
the other three already use.
The three CI jobs that produce the shipped artifacts configure with a bare
`cmake -B build`. With VCOTUNER_BUILD_TESTS defaulting to ON, each of them
FetchContent'd and compiled Catch2 from github.com on every push, and a plain
local configure needed network access. The design spec accepted that network
dependency for the test job only, and the unitTests job already passes
-DVCOTUNER_BUILD_TESTS=ON explicitly, so the default can be OFF.
TimoRozendal and others added 30 commits September 24, 2026 11:33
The three failure statuses each have a bespoke fixture, but nothing pinned
the arithmetic between requiredPeriods (up to 400), stabilityWindow (5) and
maxPeriods (600) across the settings a user can actually select. A
table-driven case over MIDI {24, 60, 96} x resolution {20, 100, 400} against
a clean sine now asserts stable and numValidPeriods() >= requiredPeriods for
each. Raising the top resolution past 595 fails here rather than in a user's
sweep.
Re-validating steadiness across the whole valid set means a single two-sample
click injected mid-capture into an otherwise perfect 440 Hz sine at
requiredPeriods = 400 flips the detector from stable to failedUnstable. That
was an unrecorded consequence of the change; this states it, so it is a
specification rather than a surprise.

The behaviour is deliberate: a glitched capture is failed and marked rather
than absorbed into the fit's error bars, where it would yield a wrong
frequency carrying a plausible-looking uncertainty. failedUnstable maps onto
the non-fatal highJitter error, so the sweep carries on past the note - also
asserted here.
Nothing asserted the invariant computeTimeoutCycles() exists for - that the
timeout covers the time the measurement needs. The existing `cycles >= 50`
check is tautological given the std::max floor, so a re-derivation that was
non-zero but too short would still pass. That is precisely the failure mode
of the original bug.
The comment on "the latency allowance is included" pointed at
task-8-report.md for the root-cause analysis. That file is gitignored and
will not exist in any checkout; the comment already explains the reasoning
without it.
updateStability() carried two near-verbatim copies of the same mean /
boundary / tolerance logic - one over the newest window, one over the whole
valid set. They now call a single file-local isWithinTolerance() helper, so
the two cannot drift about what "steady" means. No behaviour change; the
existing tests cover both paths.
runningMin and runningMax were updated for every sample of every block, but
finishWarmup() is their only reader and it runs exactly once - so after
warm-up the two comparisons per sample on the audio thread fed nothing. They
are now inside the warm-up branch, which covers exactly the same samples as
before (including the one that ends warm-up).

The comment above them implied the continuous tracking was deliberate. It was
a leftover of an earlier design that was explicitly retracted: the trigger
level is latched once, because a level that drifted mid-measurement would
inject timing error into the very periods being measured.
prepRefMeasurement had no guard at all rather than an unbounded one, so
"every prep state used to wait here forever" was true of three states, not
four. Say what the bound prevents instead.
currentIndex == 0 used to mean "first note of the sweep" back when any
failed measurement aborted the run. Now the sweep continues past
failures and currentIndex only advances on success, so it can still be
0 when the sweep reaches referencePitch. Since referencePitch is always
MIDI 60 in every shipped configuration and is always measured, a run of
early failures below 60 makes the reference pitch the first successful
measurement, whose frequency trivially equals referenceFrequency. That
tripped the "MIDI output is not working" gate and aborted the sweep
with a misleading error. Add a currentPitch != referencePitch guard so
the comparison is skipped for the one note where it is meaningless, and
correct the comment above it to explain why.

Also correct a test comment in PeriodDetectorTests.cpp that overclaimed
the resolution combo offers only "20, 100 and 400" periods per note;
the shipped set is {20, 50, 100, 200, 400} and the test samples three
representative values from it. The test's behavior is unchanged.
core.autocrlf has never been set and there was no .gitattributes, which
already let one contributor's editor silently flip two files from CRLF
to LF, caught and reverted by hand. Surveyed all tracked files outside
the deps/ submodule: 18 files, all direct children of Source/, are raw
CRLF; everything else, including Source/ReportProperties.cpp (a sibling
of the CRLF Source/ReportProperties.h), is LF.

The 18 CRLF files are stored as unnormalized CRLF blobs, never having
passed through Git's clean filter, so the usual `text eol=crlf` pattern
is unsafe here: Git always canonicalizes a text path's stored blob to
LF and only reinjects CRLF on checkout, which would rewrite all 18
blobs into a huge, blame-polluting diff despite the checked-out bytes
looking unchanged. They are marked `-text` instead, which keeps them
byte-for-byte as committed while still stopping any local autocrlf
setting or future broad gitattributes pattern from touching them.

Verified with `git add --renormalize . && git status --short`, which
shows only .gitattributes itself, plus `file` spot-checks confirming
Source/VCOTuner.cpp and Source/ReportProperties.h are still CRLF and
Source/ReportProperties.cpp and Source/dsp/PeriodDetector.cpp are
still LF. No binary files are currently tracked outside deps/; common
binary extensions are pre-declared for whatever gets added later.
Removes the MACOSX_DEPLOYMENT_TARGET=11.0 workaround the build has needed
on macOS: JUCE 6.1.5 called CGWindowListCreateImage, which Apple obsoleted
in the macOS 15 SDK, and the prefix was the only way to reach the nested
juceaide bootstrap.

Two API migrations were required.

audioDeviceIOCallback() no longer exists; it is replaced by
audioDeviceIOCallbackWithContext(), which also takes its channel pointers
as T* const* rather than T**. The override is now marked 'override'
deliberately: the base-class implementation of the new callback is an
empty body, so keeping the old signature still compiles and launches
while never receiving a sample. A fork of this project shipped in exactly
that state. 'override' turns that mistake into a compile error.

Font::getStringWidth() was removed along with the rest of the old text
metrics. The pitch-axis label spacing in Visualizer now uses
GlyphArrangement::getStringWidth(), which is what JUCE prescribes.
JUCE 8.0.15 requires CMake 3.22, so the 3.19 pin had to go. While there:
actions/checkout and upload-artifact moved from the deprecated v2 to v4,
the cmake setup action to v2, and the Windows generator from Visual
Studio 16 2019 to 17 2022, which is what windows-latest now provides.

Adds libfontconfig1-dev, newly required by juce_graphics in JUCE 8.

Also adds the dependency step to the unitTests job, which never had one.
The tests link no JUCE, but configuring the project still pulls JUCE in
and bootstraps juceaide, so that job needed the same packages and would
have failed on Linux regardless of this upgrade.
Drops the macOS build prefix, corrects the test counts to 33, and adds a
section for the two things the build cannot prove - that audio reaches
the detector and that MIDI actually sends - plus a check that the graph's
axis labels still lay out correctly after the text-metrics change.
Covers what changed for someone using the app rather than reading the
code: narrower and correctly-defined error bars, failed notes marked
instead of killing the sweep, far fewer false unstable-signal errors,
high notes completing, and drifting oscillators being flagged.

Includes the expected consequence of the stricter stability check - a
capture containing a click is now failed rather than absorbed into a
wider bar - so it reads as intended behaviour rather than a regression.

Adds build-from-source instructions, since the CMake floor moved to 3.22
and the test suite is not built by default, and the Gatekeeper
workaround for unsigned macOS downloads.
Closes the gap reported upstream as issue TheSlowGrowth#23. A reading whose centre
falls outside the range drew nothing at all, and a blank column reads as
'never measured' rather than 'worse than the range shows'.

This only bites in the report, which plots a fixed +/-15 cents, so a
badly tracking oscillator went blank at exactly the notes worth looking
at. The live graph auto-scales to fit and can never trigger it.

An arrow at the edge the reading ran off says which way it went, with
direction rather than colour carrying the meaning.
Records that the behaviour is report-only, since the live graph
auto-scales and can never trigger it, and notes what was verified
against a synthetic fixture versus what still needs a real report.
More than a patch: measurements are corrected rather than merely fixed
up, a failed note no longer ends the sweep, and the report now marks
readings that fall outside its range.
AudioDeviceSelectorComponent overrides its own height in resized() to
fit however many devices and channels are attached, so its bottom edge
is not where the caller put it. The MIDI channel label and combo were
positioned off that bottom edge, so when the selector grew they were
pushed off the bottom of the fixed-size dialog - making the channel
unreachable exactly when a device was connected, which is the only time
you need to set it.

The fixed-height controls are now laid out from the bottom of the dialog
upward and the selector gets what is left. The selector is also added
first so it sits behind them rather than on top, since it was previously
added last and would cover them if it overflowed.

The old behaviour was known - there was a comment in resized() saying the
selector overwrites its height and 'it doesnt seem to work'.
…hey behave

Links now go to this fork's releases, issues and clone URL rather than
upstream's, with an attribution line at the top. The forum link follows
the muffwiggler -> modwiggler rebrand (the old URL 301s there anyway).

The error-bar paragraph claimed 'far narrower bars'. Hardware testing
showed that is not what you see: on a steady signal the uncertainty is
below one pixel and no band is drawn at all, and the band reappears when
the pitch actually moves - turning a trimmer mid-sweep makes it grow.
Described that instead, since it is both what happens and the more
useful way to read it.
Builds all three platforms, then a release job collects the artifacts
and publishes them. It is gated on the tag ref, so ordinary pushes still
just build and test.

The macOS .app is zipped with ditto on the macOS runner rather than in
the release job: a bundle contains symlinks and zipping it on Linux
would mangle them.

The release body states plainly that only the macOS build has been run
against real hardware, and carries the Gatekeeper workaround, since the
builds are unsigned and macOS will claim the app is damaged.

Needs contents: write - GITHUB_TOKEN is read-only by default.
workflow_dispatch, so a build can be started without pushing a commit
or a tag - useful for re-running a release build after enabling Actions
on a fresh fork.
Both jobs were failing on the current runner images.

Windows: the generator was pinned to 'Visual Studio 17 2022', but
windows-latest is now windows-2025-vs2026 and CMake reported it could
not find any instance of Visual Studio. The pin is removed so CMake
picks whichever Visual Studio is installed, which also survives the
next image bump. -A x64 is kept.

Linux: packages were installed without running apt-get update first, so
apt asked for .deb versions the mirrors had already superseded and got
a 404 on libpciaccess-dev. This was pre-existing - the original workflow
never updated either - and affected both Linux jobs.
Pinning CMake 3.28 on windows-latest fails: the image is now
windows-2025-vs2026, and 3.28 predates Visual Studio 2026, so it finds
no usable generator and dies with 'CMAKE_C_COMPILER not set'. The
image's bundled CMake always knows the Visual Studio the image ships.

The other three jobs keep the pin - it does no harm there and the
project needs CMake 3.22 or later.
Brings in the 53-commit measurement overhaul (PeriodDetector, least-squares
period estimation, adaptive hysteresis trigger, per-note failure handling,
JUCE 8.0.15) and keeps this fork's CV output, calibration and modern UI.

Conflict resolutions:

- VCOTuner.cpp: the rework added early returns to the audio callback above
  where CV output used to sit, which would have dropped the CV to 0 V between
  notes and after a sweep. CV filling is hoisted above those returns and now
  stands in for the blanket output clear, using the upstream per-channel null
  check instead of AudioBuffer::clear() (which memset through unopened
  channels).

- Visualizer.cpp: kept this fork's ModernLookAndFeel bars and ported the new
  failed-note and out-of-range markers onto them, retargeted from absolute
  y=0/imageHeight to this fork's chartTop-based geometry so they land inside
  the chart rather than over the top info panel. Font::getStringWidth is gone
  in JUCE 8, so label widths use GlyphArrangement::getStringWidth.

- MainComponent.cpp: failureLabel takes a fixed row at the bottom; the tabbed
  component (not the raw Visualizer, which now lives inside it) yields the
  height for it.

- CI: dropped the upstream CI.yaml and folded its unit-test job, release-on-tag
  job, universal macOS binary and explicit -A x64 into this fork's build.yml,
  keeping one workflow.

Verified: configures and builds clean against JUCE 8.0.15; 33/33 tests pass
(1652 assertions).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both bugs predate the merge with TimoRozendal/VCOTuner -- the v0.3.0 build
shows them too -- but the JUCE 8 upgrade is a good moment to close them.

Inactive tabs were invisible. drawTabAreaBehindFrontButton paints into
TabbedButtonBar::BehindFrontTabComp, a full-size child sized to the whole bar
and placed toBehind(frontTab), so it sits in front of every tab except the
selected one. Filling it opaquely with the background colour erased them. The
Chart tab was still there and still clickable, just never drawn. Draw the
separator line only.

The deviation readout showed "A±--.--c". A bare "±" in a narrow literal reaches
JUCE as two Latin-1 bytes; build the glyph with CharPointer_UTF8 instead.

tools/debug_harness.sh runs the gates in order and is fail-closed:

  static   no conflict markers; no raw non-ASCII in narrow literals; no
           JUCE-7 Font::getStringWidth; the tab overlay is not an opaque fill;
           CV output is filled before the audio callback's early returns; the
           JUCE submodule matches what the tree pins
  build    configure (pinning Apple clang, since homebrew's arm-none-eabi-gcc
           otherwise wins CMake's compiler search and fails the ABI test)
  tests    the full ctest suite
  ui       launches the app, clicks through both tabs, captures each by
           CGWindowID -- forcing frontmost loses to the terminal

tools/harness_selftest.py breaks each static guard in turn and asserts that
gate fails, so a green run means checked rather than unchecked. The static
gates and the selftest are wired into the CI unit-test job.

Full run: 11 gates passed, 0 failed, 0 skipped; 33/33 tests, 1652 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A capture containing one click was failed outright. The reasoning was sound as
far as it went: folding the bad crossing into the fit yields a wrong frequency
carrying a plausible-looking uncertainty, which is worse than no reading. On a
440 Hz sine with a two-sample click, absorbing it gives 441.472 Hz -- off by
5.78 cents, with a small error bar. Failing is indeed better than that.

But that choice is only forced if the fit has to absorb it. The reason it did
is that crossings were numbered by position, one cycle each. A click inserts a
crossing, so every crossing after it is numbered one cycle ahead of the truth
and the line acquires a step -- which is why a single glitch moved the slope so
far. Numbering by counting cycles instead removes the coupling: each gap is
rounded to the nearest whole number of median periods, so the numbering
survives an inserted or missing crossing, and anything still off the grid is
dropped from the fit rather than absorbed by it.

The median is the scale used for that rounding because it survives a minority
of bad crossings; the mean is what an outlier drags towards itself.

Same signal, same click, after the change: 440.000000 Hz, 0.0000 cents, one
crossing rejected out of 401 -- and the rejection is reported, not hidden.

  fitPeriod       numbers crossings by cycle count; returns rejectedCrossings
                  and usedCrossings. A missed crossing is spanned (a whole
                  number of cycles, nothing to reject); a spurious one is
                  dropped. The anchor only advances to crossings that landed on
                  the grid, so a rejected crossing's time never becomes the
                  reference for the ones after it.

  PeriodDetector  the whole-set steadiness check counted any deviation as
                  disqualifying, so it failed the note before the fit ever ran.
                  It now counts how many periods sit off the median and permits
                  maxOutlierFraction (2%), floored at minOutliersAllowed (2),
                  since one dropout costs two periods. Compared against the
                  median rather than the mean, for the same reason as above.
                  countOutliers() reuses a scratch buffer reserved alongside
                  periods, so the audio thread still never allocates.

  measurement_t   carries rejectedCrossings; the chart marks a repaired column
                  with a small warning dot, so a note that needed repairing is
                  not shown as indistinguishable from a clean one.

The test that specified the old behaviour is rewritten rather than deleted: it
now asserts the frequency that comes out is correct to a thousandth of a
semitone and that the repair is reported. Sustained jitter is pinned separately
-- a phase-continuous sine swinging 400/480 Hz still fails, so the allowance
cannot be mistaken for tolerance of a genuinely unsteady pitch.

39 tests, 1675 assertions; harness 11/11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nal one

A period in samples becomes a frequency in Hz by dividing by the sample rate,
and the rate used was the one the device reports: a nominal 48000, not what the
converter's crystal actually runs at. Interfaces are out by tens to hundreds of
ppm, and 100 ppm is 0.173 cents. Unlike jitter that error is systematic, so a
longer measurement does not reduce it -- and now that the period fit reports
uncertainties well under a cent on a clean note, it is the dominant error left
in any absolute reading.

It never affected live tuning. The pitch offsets are ratios against a reference
pitch measured through the same clock, so a common scale factor cancels exactly.
What it does affect is every absolute number: the frequency readout, the
error-in-Hz display, and the frequencies written into a report.

ClockCalibrator fits host time against sample count, one point per audio block,
and takes the reciprocal of the slope as the true rate. That is the same
estimator the period fit uses and for the same reason: the slope through many
points is far better conditioned than any single difference. The sums are
accumulated online, so the audio thread stores nothing per block, never
allocates, and the baseline can grow for as long as the device runs.

Deliberate limits:

  no host timestamps  no estimate, ever. JUCE passes the device's own
                      timestamp; where the host does not supply one the
                      alternative would be reading a clock on the audio thread
                      and calling the scheduling noise on it a measurement.

  implausible result  refused. A correction past a few thousand ppm means a
                      broken timestamp source or a device that changed rate,
                      and applying it would be far worse than the error it
                      claims to fix.

  short baseline      refused. Jitter averages down over the span, so the
                      baseline is what bounds precision, not the block count.

  a dropout           restarts the run. Missing samples put a step between two
                      good segments; fitting across it reads the gap as a
                      sustained rate error.

The estimate is computed on the audio thread and published through two atomics,
so the message thread reads a finished answer rather than the running sums.
Every conversion from period to frequency now goes through effectiveSampleRate(),
and a new harness gate fails if one of them reverts to the nominal rate. The
timeout calculation deliberately stays on the nominal rate: it is about buffer
timing, where a hundred ppm means nothing.

The chart qualifies the frequency it shows with the correction behind it
("440.00 Hz  clk +15ppm"), on the one number the correction actually moves.

47 tests, 1696 assertions; harness 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng noise

Five things, found by reading the code and the compiler's own output rather
than by guessing at what might be wrong.

1. The CV output never drove a tuning sweep.

setActive(true) appeared only in CalibrationEngine, and the sweep called
trySendMidiNoteOn/Off and nothing else. So the fork carried a CV output and
a calibration engine for it, and still required an external MIDI-to-CV
interface to tune anything -- an interface which is itself a DAC with its own
scaling error sitting in the measurement chain.

VCOTuner now has a pitch source. With cvOutput selected the sweep sets a
voltage instead of sending MIDI, which takes that second converter out of the
chain and closes the loop: the same app sets the voltage and measures what came
back. The two functions are renamed playPitch/releasePitch, because a name that
says MIDI while sending CV is what misleads the next reader.

Releasing a pitch is a no-op under CV: a pitch CV has no note-off, the voltage
is the note. Holding it means the next note settles from a neighbouring voltage
rather than from 0 V, and leaves the oscillator sounding while a trimmer is
adjusted. A run is refused up front when CV is selected with no output channel,
rather than failing note by note, and the unresponsive-interface message now
covers both sources.

2. Calibration could push the output past the interface range.

outputVoltage() clamped, then applied gain * v + offset, and did not clamp
again -- so a corrected voltage near full scale left the range and
voltageToSample() handed the interface a magnitude past +/-1 to clip silently,
losing accuracy at exactly the extremes the calibration was measured to fix.

3. None of that arithmetic was tested.

The pure conversions are now vcotuner::CvScaling, free of JUCE so the test
binary can link them, with CVOutputManager delegating rather than keeping a
second copy. Twelve tests cover the defining properties: an octave is one volt,
pitch survives a round trip through voltage, the two standards are different
shapes, the sample mapping hits the rails exactly, and correction cannot escape
the range -- which is the test that would have caught 2.

4. The trigger level was latched for the whole measurement.

A drifting DC offset then moves the waveform under a threshold that stays put,
so the crossing happens at a progressively different phase each cycle: the
crossing times acquire a ramp and the fitted period comes out biased, not
merely noisy. Measured on a 110 Hz sine drifting at 0.2/s, the latched level is
out by 0.65 cents; following it between cycles brings that to 0.021. At 50 Hz
the latched level fails the note outright -- the waveform walks clear of the
threshold and stops crossing it.

The level is updated only at crossings, never within a cycle, so a period is
measured against the same threshold at both of its ends, and slowly, for the
reason it was latched in the first place: a threshold chasing noise would put
that noise into the crossing times. On a clean signal that costs 3e-5 cents,
four orders of magnitude below the bias it removes.

5. 162 warning sites in our own code, now zero.

Font -> FontOptions (JUCE 8), drawText's float coordinates onto the
Rectangle<float> overload instead of truncating into the int one, unused
parameters named out, missing override markers, container indices as size_t in
CalibrationTable's Gaussian elimination, and three dead fields plus one dead
helper removed. Low value one at a time -- but this is the noise the
float-equality bug in Visualizer's in-tune line check was hiding, which is
fixed here too.

Two more harness gates, each proven to fail when broken: the sweep must
dispatch on the pitch source, and the CV fill must stay ahead of the audio
callback's early returns.

61 tests, 2229 assertions; harness 13/13; zero warnings in Source/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A release build, a passing test suite and a green harness had all been taken as
evidence the branch was ready. Building it a different way found three things
none of them could see.

The debug build did not compile. MainComponent::measurementFailed uses its
`reason` argument only inside a jassert, which compiles away in release -- so
clang reported the parameter unused, the warning sweep commented the name out,
and release carried on building. Restored, with ignoreUnused() to say why the
name is kept.

A LookAndFeel was constructed during static initialisation. MainComponent held
`static ModernLookAndFeel modernLookAndFeel` at file scope, which JUCE asserts
on ("you're using a static LookAndFeel object"): it races JUCE's own static
Colours. The assertion only exists in a debug build, but the undefined
initialisation order is there in release too. It is now a function-local static,
constructed on first use after main() has started and destroyed after shutdown()
has dropped the components that refer to it.

The harness was not hermetic. This machine's shell profile exports an ARM cross
toolchain globally -- CC/CXX set to arm-none-eabi, and C_INCLUDE_PATH /
CPLUS_INCLUDE_PATH / LIBRARY_PATH pointing into arm-none-eabi-newlib. That is
what makes a plain `cmake -B build` fail here with "unrecognized command-line
option '-arch'", and the include paths put newlib headers ahead of the system
ones for every native compile started from that shell. The harness now clears
them, so a run means the same thing wherever it is started.

Also: the UI gate failed on a clean run and passed on a warm one. The bundle
lives on an external disk and is ad-hoc signed immediately before launching, so
the first launch after a full rebuild spends longer in Gatekeeper than the 20
second limit allowed -- the window was still on its way. The wait is now patient
and reports how long it took, because a gate that fails on timing teaches people
to ignore it.

New gate, proven to fail when broken: nothing on the audio path may allocate.
VCOTunerRealtimeCheck replaces global operator new and counts allocations across
12 detector runs of 4000 blocks each, covering reset(), processBlock(),
ClockCalibrator::addBlock() and the estimate() published from the callback. A
malloc there can block on a lock held by another thread, and the dropout it
causes surfaces as a failed note rather than as the memory bug it is. Currently
zero.

Verified: release and debug both build clean; 62 tests, 2229 assertions, also
green under ASan and UBSan; debug run exercises both tabs and a CV-driven sweep
with no assertion and no leak report; harness 14/14 from a clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's unit-test job built the target VCOTunerTests by name, so the realtime
allocation check added alongside it was never built there. ctest found the test
registered and the executable missing, reported "Not Run", and failed the suite
at 61 of 62 -- while the same commit was green locally, because the harness
builds everything rather than naming a target.

Naming the new executable in the workflow as well would fix this run and leave
the next one to fail the same way. The test directory now exposes one aggregate
target that depends on everything ctest will try to run, and CI builds that, so
adding a test target is a change in one file rather than two.

Verified by reproducing CI's steps locally -- fresh configure, build only that
target, run ctest: 62 of 62.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README described the CV output and the measurement changes as though they
were finished. They build on three platforms and pass their tests, but no part
of what this fork adds has driven a real oscillator, and a README that does not
say so is the wrong place to find that out.

A status section up front separates what is proven from what is not: the
MIDI-driven tuner underneath is the original application and has years of use;
the CV output, the CV calibration, the sample-rate correction, the dropout
repair and the trigger-level tracking are all new here and none of them have
been near an oscillator. Windows and Linux compile in CI and have never been
run at all.

It also records where to look first if CV tracking is wrong -- the 100 ms
settling allowance, inherited from the MIDI-to-CV path -- so that is not
rediscovered from scratch.

Two things the README had fallen behind on while I was here: CV output was not
in the feature list at all, and "How It Works" still described MIDI as the only
way a pitch reaches the oscillator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants