From cbb1d25ebb32c174c3b618f0ed7df3e8f6043834 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 19:55:34 +0200 Subject: [PATCH 01/60] docs: add design spec for measurement accuracy and robustness fixes --- ...09-23-vcotuner-measurement-fixes-design.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md diff --git a/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md b/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md new file mode 100644 index 0000000..f490a39 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md @@ -0,0 +1,302 @@ +# VCOTuner measurement accuracy and robustness — design + +Date: 2026-09-23 +Status: approved, ready for implementation planning + +## Problem + +Three user-reported problems, which investigation showed share mostly one root cause: + +1. Measurements abort with "zero crossings ... don't seem to be coming in at a + constant rate" (`Errors::highJitter`) even when the oscillator sounds fine. + A single failed note discards the entire sweep. +2. Error bars are large and their meaning is unclear. +3. Uncertainty about whether sub-sample interpolation of zero crossings is used + and whether it applies. + +### Root cause 1: the interpolation formula is wrong + +`Source/VCOTuner.cpp:505-511` interpolates between the samples either side of a +zero crossing, but the algebra is incorrect: + +```cpp +double m = (lastSample - currentSample); // negated slope +double n = lastSample - m*(sampleCounter); // lastSample is at sampleCounter-1 +double zeroCrossingPos = -n / m; +``` + +With `f` the true fractional crossing position within the sample interval: + +- correct: `x0 = (sampleCounter-1) + f` +- this code: `x0 = (sampleCounter-1) + (1 - f)` + +The fractional part is **mirrored** within the interval. It is correct only at +`f = 0.5` and worst at the interval ends. + +Measured against a synthetic sine (jitter in samples, standard deviation of +measured period length): + +| variant | 440 Hz | 4186 Hz | 8372 Hz | +|--------------------------|--------|---------|---------| +| current code | 0.574 | 0.992 | 0.866 | +| correct interpolation | 0.000 | 0.007 | 0.022 | +| no interpolation at all | 0.287 | 0.499 | 0.442 | + +The current interpolation is roughly **twice as bad as not interpolating**. It +injects about one sample of jitter per crossing rather than removing it. + +Effect on what the user sees, on a mathematically perfect oscillator: + +| MIDI | current code | with fix | +|------|----------------|----------| +| 69 | ±9.5 cents | ±0.0 | +| 84 | ±30.7 cents | ±0.0 | +| 96 | ±47.1 cents | ±0.0 | +| 108 | aborts | ±0.0 | +| 120 | aborts | ±6.5 | + +The *mean* period is essentially unaffected (~0.005 cents), so reported tuning +values have always been correct. Only the spread is wrong — which is why the +oscillator sounds fine while the app complains. + +### Root cause 2: no hysteresis and no DC handling + +The detector uses a hard `0.0` threshold (`Source/VCOTuner.cpp:500`) with no +hysteresis and no DC handling. Noise or DC offset produces multiple triggers per +cycle. Simulated, 1 s of 220 Hz (220 true periods expected): + +| noise | DC offset | crossings found | with ±10% hysteresis | +|-------|-----------|-----------------|----------------------| +| 2% | 0 | 222 | 220 | +| 5% | 0 | 314 | 220 | +| 2% | 0.9 | 305 | 220 | +| 5% | 0.9 | 609 | 221 | + +This is the literal "multiple zero crossings" case, and a DC-coupled interface +makes it routine. + +### Root cause 3: error bars answer the wrong question + +`Source/VCOTuner.cpp:232-243` computes the standard deviation of *individual* +period measurements; `Source/Visualizer.cpp:135-136` draws it as the error bar +on the *mean*. Those are different quantities. See §2 for the correct estimator. + +Knock-on effect: `Source/Visualizer.cpp:36-44` auto-scales the plot to include +the error bars, so oversized bars compress the actual tuning curve into a thin +strip. + +### Secondary defects found + +- **Premature timeout at high pitch** (`Source/VCOTuner.cpp:270-275`). + `expectedCycles = roundToInt(expectedTime * 100)` evaluates to **0** for e.g. + 8372 Hz at 20 periods, giving the measurement ~10 ms to complete — less than + typical MIDI+audio round-trip latency. +- **Stall until timeout at high resolution** (`Source/VCOTuner.cpp:544-565`). + `maxNumPeriodLengths` is 600 but resolution reaches 400. If the signal + stabilises late, the buffer caps, neither the finish branch nor the abort + branch can fire, and the run stalls until the top-level timeout, then reports + the misleading `stableTimeout`. +- **Unguarded channel access** (`Source/VCOTuner.cpp:472-499`). Only + `inputChannelData == nullptr` is checked; `numInputChannels` never is, then + `getSample(0, i)` reads channel 0 unconditionally. +- **Divide by zero** (`Source/VCOTuner.cpp:240-241`): `/(numMeasurements - 1)` + yields NaN if only one period is collected. Currently unreachable via the UI. +- **Data race**: `startMeasurement` / `stopMeasurement` are plain `bool`s shared + between the audio and message threads. + +## Non-goals + +- Autocorrelation or FFT-based pitch detection. Upstream attempted this on + `feature/improvedMeasurement` and abandoned it ("works but takes way too + long"). Fixing the interpolation makes it unnecessary. +- Abstracting the JUCE timer / MIDI layer so whole sweeps run headlessly. + Considered and rejected: high mocking cost, little additional safety. +- Changing the report file format or the stored report schema. + +## Design + +Three pure C++ units with no JUCE dependency, plus a thin JUCE adapter. + +### §1 `PeriodDetector` — `Source/dsp/PeriodDetector.{h,cpp}` + +Owns everything from input samples to validated period lengths. + +**Level tracking, not DC blocking.** A high-pass blocker would tilt the waveform +and add phase distortion at the low end (default lowest pitch is MIDI 30 ≈ +46 Hz). Instead track min/max over a warm-up window at the start of each +measurement: + +``` +midpoint = (min + max) / 2 +amplitude = (max - min) / 2 +``` + +This handles DC offset, asymmetric waveforms and varying module output levels +with no filtering. + +**Where the warm-up window comes from.** The state machine's existing 100 ms +settle wait happens *before* `startMeasurement` is set, so the detector never +sees that audio. The detector therefore runs its own warm-up over the first +`warmupSamples` samples it receives: it updates min/max but emits no crossings. +`VCOTuner` sets `warmupSamples` to cover two cycles at the expected frequency, +which it already computes for the timeout (`Source/VCOTuner.cpp:270`), clamped +to a sane floor and ceiling. Level tracking continues after warm-up so the +detector follows slow level changes. + +**Silence and degenerate levels.** If `amplitude` falls below a small fixed +floor, the input is treated as silent: the detector reports +`failedNoCrossings` rather than computing a threshold from noise. This also +removes any divide-by-zero in the threshold calculation. + +**Hysteresis for arming, interpolation at the midpoint.** Re-arm when the signal +falls below `midpoint - hysteresisFraction * amplitude`; fire when it next +crosses `midpoint` upward. Noise immunity comes from the hysteresis, while the +timestamp still lands on the steepest and most repeatable part of the waveform. + +**Correct interpolation** at the fire point: + +```cpp +const double slope = currentSample - lastSample; // > 0 by construction +const double crossing = (sampleCounter - 1) - lastSample / slope; +``` + +Proposed interface: + +```cpp +struct PeriodDetectorConfig { + double sampleRate = 48000.0; + double hysteresisFraction = 0.1; // of measured amplitude + int stabilityWindow = 5; + double stabilityTolerance = 0.1; // 10% + int maxPeriods = 600; + int warmupSamples = 2048; // level-tracking window; VCOTuner + // overrides per note (see above) + double silenceFloor = 1e-4; // amplitude below this => silent +}; + +enum class DetectorStatus { + collecting, // still gathering + stable, // enough valid periods collected + failedUnstable, // never reached a stable rate + failedNoCrossings, // no crossings seen at all + failedBufferFull // ran out of storage before stabilising +}; +``` + +`failedBufferFull` is a distinct status specifically so the stall described +above becomes a real terminating state rather than a hang. + +### §2 `MeasurementStatistics` — `Source/dsp/MeasurementStatistics.{h,cpp}` + +Two corrections to earlier assumptions, both recorded here deliberately: + +1. Computing frequency from the total span is **not** an improvement over + averaging period lengths — they are algebraically identical, since the + periods telescope (`Σpᵢ = t_N − t_0`). The existing mean is already the + right estimator. +2. `σ/√N` is **not** the correct error bar either. Consecutive period lengths + share crossing times, so their errors are negatively correlated and `σ/√N` + overestimates the uncertainty by roughly a further factor of √N. + +The estimator used instead: **fit a straight line to crossing time against +index.** The slope is the period; the **standard error of the slope** is the +uncertainty. This is the textbook estimator, it degrades gracefully in the +presence of genuine VCO drift as well as timing jitter, and it is cleanly +testable against analytically-known inputs. + +Outputs: frequency, pitch, pitch offset, and frequency/pitch uncertainty derived +from the slope standard error. Must return a well-defined result (not NaN) for +degenerate inputs of fewer than three points. + +### §3 Error policy — `Source/dsp/MeasurementError.{h,cpp}` + +Pure classification: + +- **Fatal** — abort the run and show a dialog, as today: + `noMidiDeviceAvailable`, `audioDeviceStoppedDuringMeasurement`, + `noFrequencyChangeBetweenMeasurements` +- **Per-note** — mark the note and continue: + `highJitter`, `noZeroCrossings`, `highJitterTimeOut`, `stableTimeout`, + and a new `bufferFull` + +`bufferFull` is new, and exists because `DetectorStatus::failedBufferFull` +currently has no honest user-facing message: today that path stalls and is +eventually reported as `stableTimeout`, whose text ("coming in at a constant +rate ... much slower than they should be") describes something else entirely. +It needs its own message stating that the signal never settled within the +measurement buffer, and suggesting a lower resolution setting. + +Plus per-cycle failure tracking, reset at the start of every sweep. + +### §4 Integration + +`VCOTuner` keeps the state machine and becomes a thin adapter: + +- Owns a `PeriodDetector`, feeds it audio from the callback. +- New listener callback `measurementFailed(int pitch, MeasurementError reason)`. +- On a per-note failure: record it, notify, advance to the next pitch instead of + calling `switchState(stopped)`. +- Timeout gains a floor and latency headroom so `expectedCycles` can never be 0. +- `numInputChannels` guarded before sample access. +- `startMeasurement` / `stopMeasurement` become `std::atomic`. + +`Visualizer`: + +- Failed notes drawn in a distinct colour. +- Failed notes **excluded from auto-scaling**, so one failure cannot wreck the + vertical zoom. + +`MainComponent`: + +- Live tuning mode (`cycle == true`): never a dialog. A status line below the + graph names the currently failing notes. The list resets each cycle, so a note + that starts reading correctly drops off immediately. Pressing Stop reports + nothing; the final state stays on screen. +- Report mode (single sweep): one consolidated dialog listing all failures when + the sweep completes. +- Fatal errors: dialog and abort in both modes. + +### §5 Tests + +Catch2 via CMake `FetchContent`, a `VCOTunerTests` target, `ctest` integration, +and a job added to `.github/workflows/CI.yaml`. + +`PeriodDetector`: + +- Interpolation exactness on synthetic sine, saw and square across the pitch + range; recovered frequency within a tight cents tolerance. +- **Regression test pinning the mirrored-fraction bug** — asserts measured + jitter is below a threshold the old formula provably cannot meet. +- Hysteresis against the noise and DC-offset cases tabulated above: correct + crossing count where the old detector found 314 / 305 / 609. +- Level tracking on asymmetric waveforms and on widely differing input levels. +- Stability detection: reports `stable` only once the rate is genuinely steady. +- `failedNoCrossings` on silence; `failedBufferFull` on a never-stabilising + signal, asserting it terminates rather than hanging. + +`MeasurementStatistics`: + +- Known period sequences produce known frequency, pitch and pitch offset. +- Slope standard error matches an analytically computed value. +- Uncertainty shrinks as more periods are supplied. +- Degenerate inputs (0, 1, 2 points) return defined values, never NaN. + +`MeasurementError`: + +- Full classification table, fatal versus per-note. +- Per-cycle failure list resets between sweeps. + +Timeout: + +- `expectedCycles` is never 0 across the whole supported pitch and resolution + range — a table-driven test over every combination. + +## Risks + +- Extracting the detector touches the audio callback, the one place where a + regression is least visible in tests. Mitigation: the detector is pure and + heavily tested; the adapter layer stays as thin as possible. +- Changing the error bar definition makes new reports non-comparable with old + saved ones. Accepted: the old bars were wrong. +- Catch2 via `FetchContent` requires network access at configure time. CI + already fetches submodules, so this is consistent with existing practice. From 7dd350cbe20d0dc327dadb0eb392ab15d1198722 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:02:52 +0200 Subject: [PATCH 02/60] docs: add implementation plan for measurement accuracy fixes --- .../plans/2026-09-23-measurement-accuracy.md | 2051 +++++++++++++++++ 1 file changed, 2051 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-measurement-accuracy.md diff --git a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md new file mode 100644 index 0000000..4f472b3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md @@ -0,0 +1,2051 @@ +# VCOTuner Measurement Accuracy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the zero-crossing interpolation bug, add noise/DC-immune triggering, replace the error-bar statistic with a correct estimator, and stop one bad note from discarding an entire sweep — all behind unit tests. + +**Architecture:** Extract the measurement logic out of `VCOTuner`'s audio callback into three pure C++ units (`PeriodDetector`, `MeasurementStatistics`, `MeasurementError`) compiled as a `vcotuner_dsp` static library with no JUCE dependency. `VCOTuner` becomes a thin JUCE adapter that feeds samples in and reads results out. The pure library is linked by both the app and a Catch2 test binary. + +**Tech Stack:** C++17, JUCE 6.1.5 (submodule), CMake 3.12+, Catch2 v3 via `FetchContent`, CTest. + +**Spec:** `docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md` + +## Global Constraints + +- C++17 (`set(CMAKE_CXX_STANDARD 17)`, already in root `CMakeLists.txt:21`). +- Files under `Source/dsp/` MUST NOT include any JUCE header. They are compiled into `vcotuner_dsp`, which links no JUCE module. This is what keeps the tests fast and the logic portable. +- Namespace for all pure units: `vcotuner`. +- Catch2 pinned to `v3.5.2`. +- Commit messages use conventional-commit style (`fix:`, `feat:`, `test:`, `docs:`, `refactor:`), matching existing history. +- **Do not add `Co-Authored-By` or any Claude attribution trailer to commits.** +- Work happens on branch `feature/measurement-accuracy`. +- On macOS the build requires `MACOSX_DEPLOYMENT_TARGET=11.0` in the environment for both configure and build (JUCE 6.1.5 calls `CGWindowListCreateImage`, obsoleted in the macOS 15 SDK). Configure: `MACOSX_DEPLOYMENT_TARGET=11.0 cmake -G Xcode -B build -DCMAKE_OSX_ARCHITECTURES="arm64"`. + +--- + +### Task 1: Test infrastructure and `PeriodDetector` skeleton + +Sets up Catch2, the `vcotuner_dsp` library, the test target, CTest wiring and the CI job. Folded into one task because none of it is independently useful. + +**Files:** +- Create: `Source/dsp/PeriodDetector.h` +- Create: `Source/dsp/PeriodDetector.cpp` +- Create: `tests/CMakeLists.txt` +- Create: `tests/PeriodDetectorTests.cpp` +- Modify: `CMakeLists.txt` (add library + `add_subdirectory(tests)`) +- Modify: `.github/workflows/CI.yaml` (add test job) + +**Interfaces:** +- Consumes: nothing. +- Produces: `vcotuner::PeriodDetectorConfig`, `vcotuner::DetectorStatus`, `vcotuner::PeriodDetector` with `reset(const PeriodDetectorConfig&)`, `processBlock(const float*, int)`, `status() const`. Tasks 2–5 extend this class; Task 8 consumes it. + +- [ ] **Step 1: Write the header** + +```cpp +// Source/dsp/PeriodDetector.h +#pragma once + +#include + +namespace vcotuner +{ + +struct PeriodDetectorConfig +{ + double sampleRate = 48000.0; + double hysteresisFraction = 0.1; // of measured amplitude + int stabilityWindow = 5; // consecutive periods compared + double stabilityTolerance = 0.1; // 10% spread allowed + int maxPeriods = 600; // storage limit + int warmupSamples = 2048; // level-tracking window + double silenceFloor = 1e-4; // amplitude below this => silent + int requiredPeriods = 10; // valid periods needed for 'stable' +}; + +enum class DetectorStatus +{ + collecting, // still gathering + stable, // enough valid periods collected + failedUnstable, // never reached a steady rate + failedNoCrossings, // silent, or no crossings at all + failedBufferFull // ran out of storage before stabilising +}; + +class PeriodDetector +{ +public: + void reset (const PeriodDetectorConfig& config); + void processBlock (const float* samples, int numSamples); + + DetectorStatus status() const noexcept { return currentStatus; } + +private: + PeriodDetectorConfig cfg {}; + DetectorStatus currentStatus = DetectorStatus::collecting; +}; + +} // namespace vcotuner +``` + +- [ ] **Step 2: Write the minimal implementation** + +```cpp +// Source/dsp/PeriodDetector.cpp +#include "PeriodDetector.h" + +namespace vcotuner +{ + +void PeriodDetector::reset (const PeriodDetectorConfig& config) +{ + cfg = config; + currentStatus = DetectorStatus::collecting; +} + +void PeriodDetector::processBlock (const float*, int) +{ +} + +} // namespace vcotuner +``` + +- [ ] **Step 3: Write the failing test** + +```cpp +// tests/PeriodDetectorTests.cpp +#include +#include "dsp/PeriodDetector.h" + +using namespace vcotuner; + +TEST_CASE ("a freshly reset detector is collecting") +{ + PeriodDetector detector; + detector.reset (PeriodDetectorConfig {}); + REQUIRE (detector.status() == DetectorStatus::collecting); +} +``` + +- [ ] **Step 4: Write `tests/CMakeLists.txt`** + +```cmake +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.2) +FetchContent_MakeAvailable(Catch2) + +add_executable(VCOTunerTests + PeriodDetectorTests.cpp) + +target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) + +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +include(Catch) +catch_discover_tests(VCOTunerTests) +``` + +- [ ] **Step 5: Wire into the root `CMakeLists.txt`** + +Insert after the `include_directories(Source)` line (currently `CMakeLists.txt:60`): + +```cmake +# Pure DSP logic, no JUCE dependency. Linked by both the app and the tests. +add_library(vcotuner_dsp STATIC + Source/dsp/PeriodDetector.cpp) +target_include_directories(vcotuner_dsp PUBLIC Source) +target_compile_features(vcotuner_dsp PUBLIC cxx_std_17) + +option(VCOTUNER_BUILD_TESTS "Build the unit tests" ON) +if(VCOTUNER_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() +``` + +Then add `vcotuner_dsp` to the app's `target_link_libraries(VCOTuner PRIVATE ...)` list (currently `CMakeLists.txt:120`). + +- [ ] **Step 6: Configure and run the test to verify it passes** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake -G Xcode -B build -DCMAKE_OSX_ARCHITECTURES="arm64" +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Debug --target VCOTunerTests +ctest --test-dir build -C Debug --output-on-failure +``` +Expected: 1 test passes. If Catch2 fails to fetch, check network access — that is the one configure-time network dependency. + +- [ ] **Step 7: Add the CI job** + +Append to `.github/workflows/CI.yaml`: + +```yaml + ############################################################################### + # builds and runs the unit tests + unitTests: + runs-on: ubuntu-latest + + steps: + - name: Setup cmake + uses: jwlawson/actions-setup-cmake@v1.8 + with: + cmake-version: '3.19.x' + + - name: Checkout + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Configure + run: | + cmake -G "Unix Makefiles" -B build -DVCOTUNER_BUILD_TESTS=ON + + - name: Build tests + run: | + cmake --build build --target VCOTunerTests + + - name: Run tests + run: | + ctest --test-dir build --output-on-failure +``` + +- [ ] **Step 8: Commit** + +```bash +git add Source/dsp tests CMakeLists.txt .github/workflows/CI.yaml +git commit -m "test: add Catch2 test harness and PeriodDetector skeleton" +``` + +--- + +### Task 2: Level tracking and silence detection + +**Files:** +- Modify: `Source/dsp/PeriodDetector.h` +- Modify: `Source/dsp/PeriodDetector.cpp` +- Modify: `tests/PeriodDetectorTests.cpp` + +**Interfaces:** +- Consumes: Task 1's `PeriodDetector`. +- Produces: `double midpoint() const noexcept`, `double amplitude() const noexcept`. Task 3 uses these to place its trigger thresholds. + +- [ ] **Step 1: Write the failing tests** + +```cpp +// append to tests/PeriodDetectorTests.cpp +#include +#include +#include + +using Catch::Approx; + +namespace +{ + // Generates a sine with a given DC offset and amplitude. + std::vector makeSine (double freq, double sampleRate, int numSamples, + double amplitude = 1.0, double dc = 0.0) + { + std::vector out ((size_t) numSamples); + for (int i = 0; i < numSamples; ++i) + out[(size_t) i] = (float) (dc + amplitude + * std::sin (2.0 * M_PI * freq * i / sampleRate)); + return out; + } +} + +TEST_CASE ("level tracking finds the midpoint of a DC-offset signal") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 4800; // 100 ms at 48 kHz + const auto samples = makeSine (220.0, 48000.0, 4800, 0.8, 0.3); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), (int) samples.size()); + + REQUIRE (detector.midpoint() == Approx (0.3).margin (0.01)); + REQUIRE (detector.amplitude() == Approx (0.8).margin (0.01)); +} + +TEST_CASE ("level tracking handles an asymmetric waveform") +{ + // Ramp from -0.2 to +1.0: midpoint 0.4, amplitude 0.6. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 1200; + std::vector samples (1200); + for (int i = 0; i < 1200; ++i) + samples[(size_t) i] = (float) (-0.2 + 1.2 * ((i % 100) / 100.0)); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), (int) samples.size()); + + REQUIRE (detector.midpoint() == Approx (0.4).margin (0.02)); + REQUIRE (detector.amplitude() == Approx (0.6).margin (0.02)); +} + +TEST_CASE ("silence is reported as failedNoCrossings, not a divide by zero") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + std::vector silence (480, 0.0f); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (silence.data(), (int) silence.size()); + + REQUIRE (detector.status() == DetectorStatus::failedNoCrossings); + REQUIRE (std::isfinite (detector.midpoint())); + REQUIRE (std::isfinite (detector.amplitude())); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Debug --target VCOTunerTests +ctest --test-dir build -C Debug --output-on-failure +``` +Expected: FAIL — `midpoint`/`amplitude` are not members of `PeriodDetector`. + +- [ ] **Step 3: Add the accessors and state to the header** + +Add to the public section: + +```cpp + double midpoint() const noexcept { return levelMidpoint; } + double amplitude() const noexcept { return levelAmplitude; } +``` + +Add to the private section: + +```cpp + long long sampleCounter = 0; + int warmupRemaining = 0; + double runningMin = 0.0; + double runningMax = 0.0; + double levelMidpoint = 0.0; + double levelAmplitude = 0.0; + bool haveLevel = false; +``` + +- [ ] **Step 4: Implement level tracking** + +```cpp +void PeriodDetector::reset (const PeriodDetectorConfig& config) +{ + cfg = config; + currentStatus = DetectorStatus::collecting; + sampleCounter = 0; + warmupRemaining = config.warmupSamples; + runningMin = 1e30; + runningMax = -1e30; + levelMidpoint = 0.0; + levelAmplitude = 0.0; + haveLevel = false; +} + +void PeriodDetector::processBlock (const float* samples, int numSamples) +{ + if (samples == nullptr || numSamples <= 0) + return; + + for (int i = 0; i < numSamples; ++i) + { + const double s = (double) samples[i]; + + // Level tracking runs continuously so the detector follows slow + // level changes, not just the warm-up window. + if (s < runningMin) runningMin = s; + if (s > runningMax) runningMax = s; + + if (warmupRemaining > 0) + { + if (--warmupRemaining == 0) + finishWarmup(); + } + + ++sampleCounter; + } +} + +void PeriodDetector::finishWarmup() +{ + levelMidpoint = (runningMax + runningMin) * 0.5; + levelAmplitude = (runningMax - runningMin) * 0.5; + + if (levelAmplitude < cfg.silenceFloor) + { + levelAmplitude = 0.0; + currentStatus = DetectorStatus::failedNoCrossings; + return; + } + + haveLevel = true; +} +``` + +Declare `void finishWarmup();` in the private section of the header. + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Debug --target VCOTunerTests +ctest --test-dir build -C Debug --output-on-failure +``` +Expected: all 4 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Source/dsp/PeriodDetector.h Source/dsp/PeriodDetector.cpp tests/PeriodDetectorTests.cpp +git commit -m "feat: add signal level tracking and silence detection to PeriodDetector" +``` + +--- + +### Task 3: Hysteresis triggering (integer crossing positions) + +Isolates the noise/DC immunity fix from the interpolation fix so each is provably tested on its own. Crossings are recorded at integer sample positions here; Task 4 makes them sub-sample accurate. + +**Files:** +- Modify: `Source/dsp/PeriodDetector.h` +- Modify: `Source/dsp/PeriodDetector.cpp` +- Modify: `tests/PeriodDetectorTests.cpp` + +**Interfaces:** +- Consumes: Task 2's `midpoint()` / `amplitude()`. +- Produces: `int numPeriods() const noexcept`, `const double* periodData() const noexcept`. Task 5 adds validity filtering on top; Task 6 consumes the period array. + +- [ ] **Step 1: Write the failing tests** + +These are the exact cases from the spec, where the current detector finds 314 / 305 / 609 crossings instead of 220. + +```cpp +// append to tests/PeriodDetectorTests.cpp +#include + +namespace +{ + std::vector makeNoisySine (double freq, double sampleRate, int numSamples, + double noise, double dc, unsigned seed = 7) + { + std::mt19937 rng (seed); + std::uniform_real_distribution dist (-noise, noise); + std::vector out ((size_t) numSamples); + for (int i = 0; i < numSamples; ++i) + out[(size_t) i] = (float) (dc + std::sin (2.0 * M_PI * freq * i / sampleRate) + + dist (rng)); + return out; + } +} + +TEST_CASE ("hysteresis rejects noise-induced false crossings") +{ + // 1 second of 220 Hz => 220 periods. Warm-up consumes roughly the first + // 2 cycles, so allow a small shortfall rather than demanding exactly 219. + const int numSamples = 48000; + + struct Case { double noise; double dc; }; + const Case cases[] = { {0.02, 0.0}, {0.05, 0.0}, {0.02, 0.9}, {0.05, 0.9} }; + + for (const auto& c : cases) + { + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 2000; + cfg.requiredPeriods = 100000; // never declare 'stable'; just count + const auto samples = makeNoisySine (220.0, 48000.0, numSamples, c.noise, c.dc); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), numSamples); + + INFO ("noise=" << c.noise << " dc=" << c.dc); + // The old hard-threshold detector produced up to 609 here. + REQUIRE (detector.numPeriods() >= 215); + REQUIRE (detector.numPeriods() <= 221); + } +} + +TEST_CASE ("trigger level adapts to very quiet and very hot signals") +{ + for (double amp : { 0.01, 0.5, 4.0 }) + { + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 2000; + cfg.requiredPeriods = 100000; + const auto samples = makeSine (440.0, 48000.0, 48000, amp, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + INFO ("amplitude=" << amp); + REQUIRE (detector.numPeriods() >= 435); + REQUIRE (detector.numPeriods() <= 441); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: FAIL — `numPeriods` is not a member. + +- [ ] **Step 3: Add storage and accessors to the header** + +Public: + +```cpp + int numPeriods() const noexcept { return (int) periods.size(); } + const double* periodData() const noexcept { return periods.data(); } +``` + +Private: + +```cpp + std::vector periods; + double lastCrossing = -1.0; + double lastSample = 0.0; + bool armed = false; + bool haveLastSample = false; +``` + +- [ ] **Step 4: Implement the Schmitt trigger** + +Reset additions: + +```cpp + periods.clear(); + periods.reserve ((size_t) config.maxPeriods); + lastCrossing = -1.0; + lastSample = 0.0; + armed = false; + haveLastSample = false; +``` + +In `processBlock`, after the warm-up block and before `++sampleCounter`: + +```cpp + if (haveLevel && currentStatus == DetectorStatus::collecting) + processCrossing (s); +``` + +New member: + +```cpp +void PeriodDetector::processCrossing (double s) +{ + const double hysteresis = cfg.hysteresisFraction * levelAmplitude; + + // Re-arm only after the signal has dropped clearly below the midpoint. + // Noise between the rails cannot retrigger. + if (! armed) + { + if (s < levelMidpoint - hysteresis) + armed = true; + } + else if (haveLastSample && lastSample < levelMidpoint && s >= levelMidpoint) + { + armed = false; + recordCrossing ((double) sampleCounter); + } + + lastSample = s; + haveLastSample = true; +} + +void PeriodDetector::recordCrossing (double position) +{ + if ((int) periods.size() >= cfg.maxPeriods) + return; + + if (lastCrossing >= 0.0) + periods.push_back (position - lastCrossing); + + lastCrossing = position; +} +``` + +Declare both in the private section. Note `recordCrossing` discards the first crossing (no preceding one to measure from), which also removes the bogus first entry the old code stored. + +- [ ] **Step 5: Run the tests to verify they pass** + +Expected: both new tests PASS, earlier tests still PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Source/dsp/PeriodDetector.h Source/dsp/PeriodDetector.cpp tests/PeriodDetectorTests.cpp +git commit -m "fix: reject false zero crossings with a hysteresis trigger" +``` + +--- + +### Task 4: Sub-sample interpolation — the core accuracy fix + +**Files:** +- Modify: `Source/dsp/PeriodDetector.cpp` +- Modify: `tests/PeriodDetectorTests.cpp` + +**Interfaces:** +- Consumes: Task 3's `recordCrossing`. +- Produces: no new API — the period values simply become sub-sample accurate. + +- [ ] **Step 1: Write the failing tests** + +The jitter bounds below are the crux of the whole plan: the old mirrored formula measured ~0.57 samples of jitter at 440 Hz and ~0.99 at 4186 Hz, and even *no* interpolation gives ~0.29 / ~0.50. A bound of 0.05 is unreachable by anything except correct interpolation. + +```cpp +// append to tests/PeriodDetectorTests.cpp +#include + +namespace +{ + double periodJitter (const PeriodDetector& d) + { + const int n = d.numPeriods(); + if (n < 2) return 1e9; + const double* p = d.periodData(); + const double mean = std::accumulate (p, p + n, 0.0) / n; + double acc = 0.0; + for (int i = 0; i < n; ++i) acc += (p[i] - mean) * (p[i] - mean); + return std::sqrt (acc / n); + } + + PeriodDetectorConfig countingConfig() + { + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 4000; + cfg.requiredPeriods = 100000; + return cfg; + } +} + +TEST_CASE ("interpolation makes period measurement sub-sample accurate") +{ + // Regression guard for the mirrored-fraction bug. The old formula + // produced ~0.57 samples of jitter here; no interpolation at all gives + // ~0.29. Neither can reach 0.05. + for (double freq : { 110.0, 440.0, 1318.51, 4186.01 }) + { + const auto samples = makeSine (freq, 48000.0, 48000, 0.9, 0.0); + PeriodDetector detector; + detector.reset (countingConfig()); + detector.processBlock (samples.data(), 48000); + + INFO ("freq=" << freq << " jitter=" << periodJitter (detector)); + REQUIRE (detector.numPeriods() > 50); + REQUIRE (periodJitter (detector) < 0.05); + } +} + +TEST_CASE ("recovered frequency is accurate to well under a cent") +{ + for (double freq : { 110.0, 440.0, 1318.51, 4186.01 }) + { + const auto samples = makeSine (freq, 48000.0, 48000, 0.9, 0.0); + PeriodDetector detector; + detector.reset (countingConfig()); + detector.processBlock (samples.data(), 48000); + + const int n = detector.numPeriods(); + const double* p = detector.periodData(); + const double meanPeriod = std::accumulate (p, p + n, 0.0) / n; + const double measured = 48000.0 / meanPeriod; + const double cents = 1200.0 * std::log2 (measured / freq); + + INFO ("freq=" << freq << " cents error=" << cents); + REQUIRE (std::abs (cents) < 0.1); + } +} + +TEST_CASE ("interpolation is accurate on a saw wave") +{ + const double freq = 440.0; + std::vector samples (48000); + for (int i = 0; i < 48000; ++i) + { + const double phase = std::fmod (freq * i / 48000.0, 1.0); + samples[(size_t) i] = (float) (2.0 * phase - 1.0); + } + + PeriodDetector detector; + detector.reset (countingConfig()); + detector.processBlock (samples.data(), 48000); + + REQUIRE (periodJitter (detector) < 0.05); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: FAIL on the jitter assertions — integer crossings give ~0.29 samples of jitter, well above 0.05. + +- [ ] **Step 3: Implement correct interpolation** + +Replace the `recordCrossing ((double) sampleCounter);` call in `processCrossing` with: + +```cpp + armed = false; + + // Linear interpolation between the two samples straddling the + // midpoint. The previous sample sits at sampleCounter - 1. + // correct: x0 = (sampleCounter - 1) + f + // The old code computed (sampleCounter - 1) + (1 - f), mirroring the + // fraction within the interval and roughly doubling the jitter versus + // no interpolation at all. + const double slope = s - lastSample; // > 0 by the branch + const double f = (slope != 0.0) + ? (levelMidpoint - lastSample) / slope + : 0.0; + recordCrossing ((double) (sampleCounter - 1) + f); +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Expected: all PASS. If a jitter assertion still fails, the fraction is being applied to the wrong base sample — check that it is `sampleCounter - 1`, not `sampleCounter`. + +- [ ] **Step 5: Commit** + +```bash +git add Source/dsp/PeriodDetector.cpp tests/PeriodDetectorTests.cpp +git commit -m "fix: correct sub-sample zero-crossing interpolation formula + +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." +``` + +--- + +### Task 5: Stability detection and terminal statuses + +**Files:** +- Modify: `Source/dsp/PeriodDetector.h` +- Modify: `Source/dsp/PeriodDetector.cpp` +- Modify: `tests/PeriodDetectorTests.cpp` + +**Interfaces:** +- Consumes: Task 4's period array. +- Produces: `int numValidPeriods() const noexcept`, `const double* validPeriods() const noexcept`, and terminal `DetectorStatus` values. Task 8 branches on the status; Task 6 consumes the valid range. + +- [ ] **Step 1: Write the failing tests** + +```cpp +// append to tests/PeriodDetectorTests.cpp +TEST_CASE ("a steady signal reaches the stable status") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.requiredPeriods = 20; + const auto samples = makeSine (440.0, 48000.0, 48000, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.status() == DetectorStatus::stable); + REQUIRE (detector.numValidPeriods() >= 20); +} + +TEST_CASE ("a constantly changing rate never stabilises and terminates") +{ + // A sweep from 200 Hz to 2 kHz never holds a steady period. The detector + // must reach a terminal state rather than hanging - the old code left the + // measurement running here and stalled until the top-level timeout. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 200; + cfg.requiredPeriods = 20; + + std::vector samples (48000); + double phase = 0.0; + for (int i = 0; i < 48000; ++i) + { + const double f = 200.0 + 1800.0 * (i / 48000.0); + phase += 2.0 * M_PI * f / 48000.0; + samples[(size_t) i] = (float) std::sin (phase); + } + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.status() == DetectorStatus::failedUnstable); +} + +TEST_CASE ("a steady signal that outruns the buffer reports failedBufferFull") +{ + // Stabilises immediately, but the buffer cannot hold enough periods to + // satisfy requiredPeriods. Distinct from failedUnstable: the signal is + // fine, the resolution setting is simply too high for the storage. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 20; + cfg.requiredPeriods = 500; + const auto samples = makeSine (440.0, 48000.0, 48000, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.status() == DetectorStatus::failedBufferFull); +} + +TEST_CASE ("valid periods exclude the unstable run-in") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.requiredPeriods = 10; + const auto samples = makeSine (440.0, 48000.0, 48000, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.numValidPeriods() <= detector.numPeriods()); + REQUIRE (detector.validPeriods() != nullptr); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: FAIL — `numValidPeriods` is not a member. + +- [ ] **Step 3: Add the API and state** + +Public: + +```cpp + int numValidPeriods() const noexcept; + const double* validPeriods() const noexcept; +``` + +Private: `int firstValidIndex = -1;` and `void updateStability();`. Reset it to `-1` in `reset`. + +- [ ] **Step 4: Implement stability detection** + +```cpp +int PeriodDetector::numValidPeriods() const noexcept +{ + if (firstValidIndex < 0) return 0; + return (int) periods.size() - firstValidIndex; +} + +const double* PeriodDetector::validPeriods() const noexcept +{ + if (firstValidIndex < 0) return periods.data(); + return periods.data() + firstValidIndex; +} + +void PeriodDetector::updateStability() +{ + const int n = (int) periods.size(); + + if (firstValidIndex < 0 && n >= cfg.stabilityWindow) + { + double sum = 0.0; + for (int i = n - cfg.stabilityWindow; i < n; ++i) + sum += periods[(size_t) i]; + const double average = sum / cfg.stabilityWindow; + const double boundary = average * cfg.stabilityTolerance; + + bool steady = true; + for (int i = n - cfg.stabilityWindow; i < n; ++i) + if (std::abs (periods[(size_t) i] - average) >= boundary) + steady = false; + + if (steady) + firstValidIndex = n; + } + + if (firstValidIndex >= 0 && numValidPeriods() >= cfg.requiredPeriods) + { + currentStatus = DetectorStatus::stable; + return; + } + + // Storage exhausted before collecting what we need. This must be a + // terminal state: the old code left the measurement running here, so it + // stalled until the top level timed out and then blamed the wrong thing. + // Distinguish the two causes - never steady at all, versus steady but not + // for long enough - because they need different advice to the user. + if (n >= cfg.maxPeriods) + currentStatus = (firstValidIndex < 0) ? DetectorStatus::failedUnstable + : DetectorStatus::failedBufferFull; +} +``` + +Call `updateStability();` at the end of `recordCrossing`, and add `#include ` to the .cpp. + +- [ ] **Step 5: Run the tests to verify they pass** + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Source/dsp/PeriodDetector.h Source/dsp/PeriodDetector.cpp tests/PeriodDetectorTests.cpp +git commit -m "feat: add stability detection and terminal statuses to PeriodDetector" +``` + +--- + +### Task 6: `MeasurementStatistics` — regression-slope estimator + +**Files:** +- Create: `Source/dsp/MeasurementStatistics.h` +- Create: `Source/dsp/MeasurementStatistics.cpp` +- Create: `tests/MeasurementStatisticsTests.cpp` +- Modify: `CMakeLists.txt` (add source to `vcotuner_dsp`) +- Modify: `tests/CMakeLists.txt` (add test source) + +**Interfaces:** +- Consumes: a period array (from Task 5's `validPeriods()`). +- Produces: `vcotuner::PeriodFit`, `vcotuner::MeasurementResult`, `fitPeriod(const double*, int)`, `computeMeasurement(const double*, int, double, double, int)`. Task 8 calls `computeMeasurement`. + +- [ ] **Step 1: Write the failing tests** + +The standard error below is computed by hand. Crossing times reconstruct to `t = [0, 8, 19, 28, 40]`, whose least-squares fit has slope exactly 10, residuals `[1, -1, 0, -1, 1]`, `SSE = 4`, `Sxx = 10`, so `SE = sqrt(4 / (3 * 10)) = sqrt(2/15) = 0.3651484`. + +```cpp +// tests/MeasurementStatisticsTests.cpp +#include +#include +#include +#include +#include "dsp/MeasurementStatistics.h" + +using namespace vcotuner; +using Catch::Approx; + +TEST_CASE ("a perfectly uniform period sequence has zero uncertainty") +{ + const std::vector periods (20, 100.0); + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.periodSamples == Approx (100.0)); + REQUIRE (fit.periodStdError == Approx (0.0).margin (1e-9)); +} + +TEST_CASE ("slope standard error matches the analytic value") +{ + // Crossing times 0, 8, 19, 28, 40 -> slope 10, SSE 4, Sxx 10. + const std::vector periods { 8.0, 11.0, 9.0, 12.0 }; + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.periodSamples == Approx (10.0).margin (1e-9)); + REQUIRE (fit.periodStdError == Approx (std::sqrt (2.0 / 15.0)).margin (1e-9)); +} + +TEST_CASE ("uncertainty shrinks as more periods are collected") +{ + auto jittered = [] (int count) + { + std::vector p ((size_t) count); + for (int i = 0; i < count; ++i) + p[(size_t) i] = 100.0 + ((i % 2 == 0) ? 0.5 : -0.5); + return p; + }; + + const auto few = jittered (10); + const auto many = jittered (200); + + const auto fitFew = fitPeriod (few.data(), (int) few.size()); + const auto fitMany = fitPeriod (many.data(), (int) many.size()); + + REQUIRE (fitMany.periodStdError < fitFew.periodStdError); +} + +TEST_CASE ("degenerate inputs return defined values, never NaN") +{ + const double one[] = { 100.0 }; + + for (auto fit : { fitPeriod (nullptr, 0), fitPeriod (one, 1) }) + { + REQUIRE_FALSE (fit.valid); + REQUIRE (std::isfinite (fit.periodSamples)); + REQUIRE (std::isfinite (fit.periodStdError)); + } +} + +TEST_CASE ("measurement converts periods to frequency and pitch") +{ + // 100 samples per period at 48 kHz = 480 Hz. Reference 480 Hz at MIDI 69 + // means the measured pitch is exactly the reference pitch. + const std::vector periods (50, 100.0); + const auto result = computeMeasurement (periods.data(), (int) periods.size(), + 48000.0, 480.0, 69); + + REQUIRE (result.valid); + REQUIRE (result.frequency == Approx (480.0)); + REQUIRE (result.pitch == Approx (69.0)); + REQUIRE (result.pitchDeviation == Approx (0.0).margin (1e-9)); +} + +TEST_CASE ("an octave above the reference reads as twelve semitones") +{ + const std::vector periods (50, 50.0); // 960 Hz + const auto result = computeMeasurement (periods.data(), (int) periods.size(), + 48000.0, 480.0, 69); + + REQUIRE (result.frequency == Approx (960.0)); + REQUIRE (result.pitch == Approx (81.0)); +} +``` + +- [ ] **Step 2: Add the sources to the build** + +In root `CMakeLists.txt`, add `Source/dsp/MeasurementStatistics.cpp` to `add_library(vcotuner_dsp STATIC ...)`. +In `tests/CMakeLists.txt`, add `MeasurementStatisticsTests.cpp` to `add_executable(VCOTunerTests ...)`. + +- [ ] **Step 3: Run the tests to verify they fail** + +Expected: FAIL to compile — `dsp/MeasurementStatistics.h` does not exist. + +- [ ] **Step 4: Write the header** + +```cpp +// Source/dsp/MeasurementStatistics.h +#pragma once + +namespace vcotuner +{ + +struct PeriodFit +{ + bool valid = false; + double periodSamples = 0.0; + double periodStdError = 0.0; +}; + +struct MeasurementResult +{ + bool valid = false; + double frequency = 0.0; + double frequencyDeviation = 0.0; + double pitch = 0.0; + double pitchDeviation = 0.0; +}; + +/** Least-squares fit of crossing time against crossing index. + + The periods are cumulated back into crossing times, then fitted with a + straight line. The slope is the period estimate and the standard error of + that slope is its uncertainty. + + This replaces taking the standard deviation of the individual periods, + which answers a different question: consecutive periods share a crossing + time, so their errors are negatively correlated and the spread of the + periods badly overstates the uncertainty of their mean. + + Requires at least 3 periods for a meaningful standard error; fewer + returns valid == false with finite zeroed fields. +*/ +PeriodFit fitPeriod (const double* periods, int numPeriods); + +/** Converts a period sequence into frequency, pitch and their uncertainties. */ +MeasurementResult computeMeasurement (const double* periods, int numPeriods, + double sampleRate, + double referenceFrequency, + int referencePitch); + +} // namespace vcotuner +``` + +- [ ] **Step 5: Write the implementation** + +```cpp +// Source/dsp/MeasurementStatistics.cpp +#include "MeasurementStatistics.h" + +#include +#include + +namespace vcotuner +{ + +PeriodFit fitPeriod (const double* periods, int numPeriods) +{ + PeriodFit fit; + + if (periods == nullptr || numPeriods < 2) + return fit; + + // Cumulate periods back into crossing times: n+1 points for n periods. + const int n = numPeriods + 1; + std::vector times ((size_t) n); + times[0] = 0.0; + for (int i = 0; i < numPeriods; ++i) + times[(size_t) (i + 1)] = times[(size_t) i] + periods[i]; + + double meanX = 0.0, meanY = 0.0; + for (int i = 0; i < n; ++i) { meanX += i; meanY += times[(size_t) i]; } + meanX /= n; + meanY /= n; + + double sxx = 0.0, sxy = 0.0; + for (int i = 0; i < n; ++i) + { + const double dx = i - meanX; + sxx += dx * dx; + sxy += dx * (times[(size_t) i] - meanY); + } + + if (sxx <= 0.0) + return fit; + + const double slope = sxy / sxx; + const double intercept = meanY - slope * meanX; + + fit.periodSamples = slope; + + // Standard error of the slope needs at least one degree of freedom. + if (n > 2) + { + double sse = 0.0; + for (int i = 0; i < n; ++i) + { + const double residual = times[(size_t) i] - (intercept + slope * i); + sse += residual * residual; + } + fit.periodStdError = std::sqrt (sse / ((n - 2) * sxx)); + fit.valid = true; + } + + return fit; +} + +MeasurementResult computeMeasurement (const double* periods, int numPeriods, + double sampleRate, + double referenceFrequency, + int referencePitch) +{ + MeasurementResult result; + + const auto fit = fitPeriod (periods, numPeriods); + if (! fit.valid || fit.periodSamples <= 0.0 + || referenceFrequency <= 0.0 || sampleRate <= 0.0) + return result; + + result.frequency = sampleRate / fit.periodSamples; + + // Relative uncertainty carries straight across from period to frequency. + const double relative = fit.periodStdError / fit.periodSamples; + result.frequencyDeviation = result.frequency * relative; + + result.pitch = 12.0 * std::log2 (result.frequency / referenceFrequency) + + referencePitch; + + // d(pitch)/d(f) = 12 / (f * ln2), so the semitone uncertainty is just the + // relative uncertainty scaled by 12 / ln2. + result.pitchDeviation = 12.0 * relative / std::log (2.0); + result.valid = true; + + return result; +} + +} // namespace vcotuner +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Expected: all PASS, including the analytic `sqrt(2/15)` value. + +- [ ] **Step 7: Commit** + +```bash +git add Source/dsp/MeasurementStatistics.h Source/dsp/MeasurementStatistics.cpp \ + tests/MeasurementStatisticsTests.cpp CMakeLists.txt tests/CMakeLists.txt +git commit -m "feat: estimate period and uncertainty by least-squares fit" +``` + +--- + +### Task 7: Error classification and per-cycle failure tracking + +**Files:** +- Create: `Source/dsp/MeasurementError.h` +- Create: `Source/dsp/MeasurementError.cpp` +- Create: `tests/MeasurementErrorTests.cpp` +- Modify: `CMakeLists.txt`, `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: nothing. +- Produces: `vcotuner::MeasurementError`, `isFatal(MeasurementError)`, `vcotuner::FailureTracker` with `beginSweep()`, `recordFailure(int, MeasurementError)`, `hasFailures()`, `failures()`. Tasks 10 and 12 consume all of these. + +- [ ] **Step 1: Write the failing tests** + +```cpp +// tests/MeasurementErrorTests.cpp +#include +#include "dsp/MeasurementError.h" + +using namespace vcotuner; + +TEST_CASE ("setup errors are fatal and abort the run") +{ + REQUIRE (isFatal (MeasurementError::noFrequencyChange)); + REQUIRE (isFatal (MeasurementError::noMidiDevice)); + REQUIRE (isFatal (MeasurementError::audioDeviceStopped)); +} + +TEST_CASE ("per-note measurement failures are not fatal") +{ + REQUIRE_FALSE (isFatal (MeasurementError::highJitter)); + REQUIRE_FALSE (isFatal (MeasurementError::noZeroCrossings)); + REQUIRE_FALSE (isFatal (MeasurementError::highJitterTimeOut)); + REQUIRE_FALSE (isFatal (MeasurementError::stableTimeout)); + REQUIRE_FALSE (isFatal (MeasurementError::bufferFull)); + REQUIRE_FALSE (isFatal (MeasurementError::none)); +} + +TEST_CASE ("the failure list resets at the start of each sweep") +{ + FailureTracker tracker; + + tracker.beginSweep(); + tracker.recordFailure (84, MeasurementError::highJitter); + tracker.recordFailure (96, MeasurementError::noZeroCrossings); + REQUIRE (tracker.hasFailures()); + REQUIRE (tracker.failures().size() == 2); + + // A new cycle starts clean, so a note that now reads correctly + // disappears from the status line immediately. + tracker.beginSweep(); + REQUIRE_FALSE (tracker.hasFailures()); + REQUIRE (tracker.failures().empty()); +} + +TEST_CASE ("failures record both the pitch and the reason") +{ + FailureTracker tracker; + tracker.beginSweep(); + tracker.recordFailure (84, MeasurementError::highJitter); + + REQUIRE (tracker.failures()[0].midiPitch == 84); + REQUIRE (tracker.failures()[0].reason == MeasurementError::highJitter); +} +``` + +- [ ] **Step 2: Add sources to the build, then run to verify failure** + +Add `Source/dsp/MeasurementError.cpp` to `vcotuner_dsp` and `MeasurementErrorTests.cpp` to the test target. +Expected: FAIL to compile — header does not exist. + +- [ ] **Step 3: Write the header** + +```cpp +// Source/dsp/MeasurementError.h +#pragma once + +#include + +namespace vcotuner +{ + +enum class MeasurementError +{ + none, + // Per-note: mark the note and carry on with the sweep. + highJitter, + noZeroCrossings, + highJitterTimeOut, + stableTimeout, + bufferFull, + // Fatal: nothing further can succeed, so abort and tell the user. + noFrequencyChange, + noMidiDevice, + audioDeviceStopped +}; + +/** True when the error makes the rest of the run pointless. */ +bool isFatal (MeasurementError error) noexcept; + +struct NoteFailure +{ + int midiPitch = 0; + MeasurementError reason = MeasurementError::none; +}; + +/** Records which notes failed during the current sweep. + + The list is cleared by beginSweep(), so in cycling mode it always + describes the most recent pass rather than accumulating history. +*/ +class FailureTracker +{ +public: + void beginSweep(); + void recordFailure (int midiPitch, MeasurementError reason); + + bool hasFailures() const noexcept { return ! entries.empty(); } + const std::vector& failures() const noexcept { return entries; } + +private: + std::vector entries; +}; + +} // namespace vcotuner +``` + +- [ ] **Step 4: Write the implementation** + +```cpp +// Source/dsp/MeasurementError.cpp +#include "MeasurementError.h" + +namespace vcotuner +{ + +bool isFatal (MeasurementError error) noexcept +{ + switch (error) + { + case MeasurementError::noFrequencyChange: + case MeasurementError::noMidiDevice: + case MeasurementError::audioDeviceStopped: + return true; + + case MeasurementError::none: + case MeasurementError::highJitter: + case MeasurementError::noZeroCrossings: + case MeasurementError::highJitterTimeOut: + case MeasurementError::stableTimeout: + case MeasurementError::bufferFull: + return false; + } + return false; +} + +void FailureTracker::beginSweep() +{ + entries.clear(); +} + +void FailureTracker::recordFailure (int midiPitch, MeasurementError reason) +{ + entries.push_back (NoteFailure { midiPitch, reason }); +} + +} // namespace vcotuner +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Source/dsp/MeasurementError.h Source/dsp/MeasurementError.cpp \ + tests/MeasurementErrorTests.cpp CMakeLists.txt tests/CMakeLists.txt +git commit -m "feat: classify measurement errors and track per-sweep failures" +``` + +--- + +### Task 8: Timeout floor calculation + +Extracted as a pure function specifically so the high-pitch abort bug is testable. + +**Files:** +- Create: `Source/dsp/MeasurementTiming.h` +- Create: `Source/dsp/MeasurementTiming.cpp` +- Create: `tests/MeasurementTimingTests.cpp` +- Modify: `CMakeLists.txt`, `tests/CMakeLists.txt` + +**Interfaces:** +- Consumes: nothing. +- Produces: `int computeTimeoutCycles(double expectedFrequency, int numPeriods, double timerIntervalSeconds, double latencyAllowanceSeconds)`. Task 9 calls this from the state machine. + +- [ ] **Step 1: Write the failing tests** + +```cpp +// tests/MeasurementTimingTests.cpp +#include +#include "dsp/MeasurementTiming.h" + +using namespace vcotuner; + +TEST_CASE ("the timeout is never zero anywhere in the supported range") +{ + // The old expression roundToInt(expectedTime * 100) evaluated to 0 above + // roughly MIDI 108, giving a measurement ~10 ms to finish - less than + // typical MIDI plus audio round-trip latency. + for (int midi = 0; midi <= 127; ++midi) + { + const double freq = 440.0 * std::pow (2.0, (midi - 69) / 12.0); + for (int periods : { 10, 20, 50, 100, 200, 400 }) + { + const int cycles = computeTimeoutCycles (freq, periods, 0.01, 0.3); + INFO ("midi=" << midi << " periods=" << periods); + REQUIRE (cycles >= 50); + } + } +} + +TEST_CASE ("low pitches get a proportionally longer timeout") +{ + const int low = computeTimeoutCycles (46.25, 400, 0.01, 0.3); + const int high = computeTimeoutCycles (4186.0, 400, 0.01, 0.3); + REQUIRE (low > high); +} + +TEST_CASE ("the latency allowance is included") +{ + const int without = computeTimeoutCycles (440.0, 20, 0.01, 0.0); + const int with = computeTimeoutCycles (440.0, 20, 0.01, 0.3); + REQUIRE (with > without); +} + +TEST_CASE ("invalid input still yields a usable timeout") +{ + REQUIRE (computeTimeoutCycles (0.0, 20, 0.01, 0.3) >= 50); + REQUIRE (computeTimeoutCycles (-1.0, 20, 0.01, 0.3) >= 50); +} +``` + +Add `#include ` at the top of the test file. + +- [ ] **Step 2: Add to the build and run to verify failure** + +Expected: FAIL to compile — header does not exist. + +- [ ] **Step 3: Write the header** + +```cpp +// Source/dsp/MeasurementTiming.h +#pragma once + +namespace vcotuner +{ + +/** How many timer cycles to wait before declaring a measurement timed out. + + Covers the time needed to observe numPeriods cycles of the expected + frequency, doubled for headroom, plus a fixed allowance for MIDI and + audio round-trip latency, and floored so it can never reach zero. +*/ +int computeTimeoutCycles (double expectedFrequency, + int numPeriods, + double timerIntervalSeconds, + double latencyAllowanceSeconds); + +} // namespace vcotuner +``` + +- [ ] **Step 4: Write the implementation** + +```cpp +// Source/dsp/MeasurementTiming.cpp +#include "MeasurementTiming.h" + +#include +#include + +namespace vcotuner +{ + +int computeTimeoutCycles (double expectedFrequency, + int numPeriods, + double timerIntervalSeconds, + double latencyAllowanceSeconds) +{ + constexpr int minimumCycles = 50; // 500 ms at the default 10 ms timer + + if (expectedFrequency <= 0.0 || numPeriods <= 0 || timerIntervalSeconds <= 0.0) + return minimumCycles; + + const double measurementTime = (numPeriods / expectedFrequency) * 2.0; + const double totalTime = measurementTime + std::max (0.0, latencyAllowanceSeconds); + const int cycles = (int) std::ceil (totalTime / timerIntervalSeconds); + + return std::max (cycles, minimumCycles); +} + +} // namespace vcotuner +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Source/dsp/MeasurementTiming.h Source/dsp/MeasurementTiming.cpp \ + tests/MeasurementTimingTests.cpp CMakeLists.txt tests/CMakeLists.txt +git commit -m "fix: floor the measurement timeout so high pitches are not cut off" +``` + +--- + +### Task 9: Wire `PeriodDetector` into `VCOTuner`'s audio callback + +**Files:** +- Modify: `Source/VCOTuner.h` (members, includes) +- Modify: `Source/VCOTuner.cpp:466-573` (audio callback) + +**Interfaces:** +- Consumes: `PeriodDetector`, `DetectorStatus`, `MeasurementError`. +- Produces: `VCOTuner::lastDetectorStatus` readable by the state machine in Task 10. + +- [ ] **Step 1: Replace the detector state in the header** + +In `Source/VCOTuner.h`, add `#include "dsp/PeriodDetector.h"` and `#include `. + +Delete these members: `periodLengths[maxNumPeriodLengths]`, `indexOfFirstValidPeriodLength`, `periodLengthsHead`, `lError`, `lastZeroCrossing`, `lastSample`, `sampleCounter`, the `LowLevelError` enum, and `maxNumPeriodLengths`. + +Add: + +```cpp + vcotuner::PeriodDetector detector; + std::atomic startMeasurement { false }; + std::atomic stopMeasurement { false }; + std::atomic detectorStatusFlag { 0 }; // vcotuner::DetectorStatus +``` + +Change the existing `bool startMeasurement; bool stopMeasurement;` declarations to the atomics above. The comment block above them about message-thread/audio-thread ownership can go — the atomics document it. + +- [ ] **Step 2: Rewrite the audio callback** + +Replace the body of `audioDeviceIOCallback` (`Source/VCOTuner.cpp:466-573`) with: + +```cpp +void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, + int numInputChannels, + float** outputChannelData, + int numOutputChannels, + int numSamples) +{ + if (outputChannelData != nullptr) + { + AudioBuffer outputBuffer (outputChannelData, numOutputChannels, numSamples); + outputBuffer.clear(); + } + + if (stopMeasurement) + { + startMeasurement = false; + stopMeasurement = false; + initialized = false; + } + + if (! startMeasurement) + return; + + // Guard the channel access: numInputChannels was never checked before, + // so a device with no enabled input channels read out of bounds. + if (inputChannelData == nullptr || numInputChannels <= 0 + || inputChannelData[0] == nullptr) + return; + + if (! initialized) + { + vcotuner::PeriodDetectorConfig cfg; + cfg.sampleRate = sampleRate; + cfg.requiredPeriods = numPeriodSamples; + cfg.warmupSamples = currentWarmupSamples; + detector.reset (cfg); + initialized = true; + } + + detector.processBlock (inputChannelData[0], numSamples); + detectorStatusFlag = (int) detector.status(); + + if (detector.status() != vcotuner::DetectorStatus::collecting) + { + initialized = false; + startMeasurement = false; + } +} +``` + +- [ ] **Step 3: Add the warm-up sizing member** + +In `Source/VCOTuner.h` add `int currentWarmupSamples = 2048;`. In `prepMeasurement` and `prepRefMeasurement`, before setting `startMeasurement = true`, size it to two cycles of the expected frequency: + +```cpp + { + const double expectedFreq = (state == prepRefMeasurement || referenceFrequency <= 0.0f) + ? 440.0 * std::pow (2.0, (currentPitch - 69) / 12.0) + : referenceFrequency * std::pow (2.0, (currentPitch - referencePitch) / 12.0); + const double twoCycles = (expectedFreq > 0.0) ? (2.0 * sampleRate / expectedFreq) : 2048.0; + currentWarmupSamples = jlimit (256, 48000, (int) twoCycles); + } +``` + +- [ ] **Step 4: Build the app to verify it compiles** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Debug --target VCOTuner +``` +Expected: compiles. The state machine still references removed members — Task 10 fixes those. If the build blocks progress, do Tasks 9 and 10 as one commit. + +- [ ] **Step 5: Commit** + +```bash +git add Source/VCOTuner.h Source/VCOTuner.cpp +git commit -m "refactor: use PeriodDetector in the audio callback + +Also guards numInputChannels before reading channel 0 and makes the +measurement start/stop flags atomic, which were plain bools shared +between the audio and message threads." +``` + +--- + +### Task 10: Continue the sweep past a failed note + +**Files:** +- Modify: `Source/VCOTuner.h` (listener, tracker) +- Modify: `Source/VCOTuner.cpp:129-290` (ref measurement and measurement states) + +**Interfaces:** +- Consumes: Tasks 6, 7, 8, 9. +- Produces: `VCOTuner::Listener::measurementFailed(int midiPitch, vcotuner::MeasurementError reason)`, `VCOTuner::getFailures()`. Tasks 11 and 12 consume both. + +- [ ] **Step 1: Extend the listener interface** + +In `Source/VCOTuner.h`, add to `class Listener`: + +```cpp + virtual void measurementFailed (int /*midiPitch*/, + vcotuner::MeasurementError /*reason*/) {} +``` + +Add to `VCOTuner`'s public section: + +```cpp + const std::vector& getFailures() const + { return failureTracker.failures(); } +``` + +and to the private section `vcotuner::FailureTracker failureTracker;`, plus the includes for `dsp/MeasurementError.h` and `dsp/MeasurementStatistics.h`. + +- [ ] **Step 2: Map detector status to an error** + +Add a private helper to `Source/VCOTuner.cpp`: + +```cpp +static vcotuner::MeasurementError errorForStatus (vcotuner::DetectorStatus status) +{ + using vcotuner::DetectorStatus; + using vcotuner::MeasurementError; + + switch (status) + { + case DetectorStatus::failedNoCrossings: return MeasurementError::noZeroCrossings; + case DetectorStatus::failedUnstable: return MeasurementError::highJitter; + case DetectorStatus::failedBufferFull: return MeasurementError::bufferFull; + case DetectorStatus::collecting: return MeasurementError::stableTimeout; + case DetectorStatus::stable: return MeasurementError::none; + } + return MeasurementError::none; +} +``` + +- [ ] **Step 3: Replace the failure branch in the `measurement` state** + +Declare the helper in the private section of `Source/VCOTuner.h`: + +```cpp + void failCurrentNote (vcotuner::MeasurementError reason); +``` + +In `Source/VCOTuner.cpp`, the `measurement` case currently does `errors.add(Errors::highJitter); switchState(stopped);`. Replace with a helper call, and replace the timeout branch (`Source/VCOTuner.cpp:270-285`) likewise: + +```cpp +void VCOTuner::failCurrentNote (vcotuner::MeasurementError reason) +{ + trySendMidiNoteOff (currentPitch); + stopMeasurement = true; + + failureTracker.recordFailure (currentPitch, reason); + listeners.call (&Listener::measurementFailed, currentPitch, reason); + + currentPitch += pitchIncrement; + currentIndex++; + + if (currentPitch <= highestPitch) + switchState (prepMeasurement); + else + switchState (finished); +} +``` + +Every per-note failure path in the `measurement` state calls `failCurrentNote(...)` instead of `switchState(stopped)`. + +The three fatal errors keep the old behaviour: `errors.add(...)` then `switchState(stopped)`. + +- [ ] **Step 4: Use the new statistics and timeout** + +The state machine reads `detector` from the message thread while the audio +thread owns it. This is safe only because the audio thread publishes +`startMeasurement = false` *after* its final write to the detector, and the +state machine reads the detector only once it has observed that flag clear — +the same handshake the original code relied on, now with an atomic flag +making the ordering explicit. Do not read the detector anywhere else. + +In the success branch, replace the manual averaging and deviation block (`Source/VCOTuner.cpp:207-243`) with: + +```cpp + const auto result = vcotuner::computeMeasurement (detector.validPeriods(), + detector.numValidPeriods(), + sampleRate, + referenceFrequency, + referencePitch); + if (! result.valid) + { + failCurrentNote (vcotuner::MeasurementError::highJitter); + break; + } + + measurement_t m; + m.timestamp = Time::getCurrentTime(); + m.frequency = result.frequency; + m.pitch = result.pitch; + m.midiPitch = currentPitch; + m.pitchOffset = result.pitch - currentPitch; + m.freqDeviation = result.frequencyDeviation; + m.pitchDeviation = result.pitchDeviation; + m.numMeasurements = detector.numValidPeriods(); + listeners.call (&Listener::newMeasurementReady, m); +``` + +Replace the timeout computation with: + +```cpp + const double expectedFrequency = referenceFrequency + * std::pow (2.0, (currentPitch - referencePitch) / 12.0); + const int expectedCycles = vcotuner::computeTimeoutCycles (expectedFrequency, + numPeriodSamples, + 0.01, 0.3); +``` + +- [ ] **Step 5: Reset the tracker at the start of each sweep** + +In `switchState`, when entering `prepRefMeasurement`, call `failureTracker.beginSweep();`. This is what makes the live-mode status line describe the current cycle rather than accumulating history. + +- [ ] **Step 6: Build and run all tests** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Debug +ctest --test-dir build -C Debug --output-on-failure +``` +Expected: app compiles, all unit tests still PASS. + +- [ ] **Step 7: Commit** + +```bash +git add Source/VCOTuner.h Source/VCOTuner.cpp +git commit -m "feat: continue the sweep when a single note fails to measure" +``` + +--- + +### Task 11: Show failed notes in the `Visualizer` + +**Files:** +- Modify: `Source/Visualizer.h` +- Modify: `Source/Visualizer.cpp:25-50` (auto-scaling), `Source/Visualizer.cpp:129-145` (drawing) + +**Interfaces:** +- Consumes: Task 10's `measurementFailed`. +- Produces: no API consumed by later tasks. + +- [ ] **Step 1: Record failures in the visualizer** + +In `Source/Visualizer.h`, add `void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override;` and `Array failedPitches;`. + +In `Source/Visualizer.cpp`: + +```cpp +void Visualizer::measurementFailed (int midiPitch, vcotuner::MeasurementError) +{ + failedPitches.addIfNotAlreadyThere (midiPitch); + repaint(); +} +``` + +`Visualizer` clears its data in exactly one place — `clearCache()`, currently an inline one-liner at `Source/Visualizer.h:29`. Extend it so the failure list is cleared with the measurements: + +```cpp + void clearCache() { measurements.clear(); failedPitches.clear(); } +``` + +- [ ] **Step 2: Exclude failed notes from auto-scaling** + +`Source/Visualizer.cpp:36-44` currently grows the display range to include every error bar. A note with a huge deviation compresses the real curve into a thin strip, so skip failed notes: + +```cpp + for (int i = 0; i < measurements.size(); i++) + { + if (failedPitches.contains (measurements[i].midiPitch)) + continue; + + double value = measurements[i].pitchOffset; + double deviation = measurements[i].pitchDeviation; + if (value - deviation < min) min = value - deviation; + if (value + deviation > max) max = value + deviation; + } +``` + +- [ ] **Step 3: Draw failed notes in a distinct colour** + +In the measurement-drawing loop (`Source/Visualizer.cpp:129-145`), before drawing each point: + +```cpp + const bool failed = failedPitches.contains (measurements[i].midiPitch); + const Colour bandColour = failed ? Colours::orangered.withAlpha (0.35f) + : Colours::springgreen.withAlpha (0.4f); + const Colour pointColour = failed ? Colours::orangered : Colours::green; +``` + +Use `bandColour` for the deviation rectangle and `pointColour` for the average line. + +- [ ] **Step 4: Build and verify visually** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Release +open build/VCOTuner_artefacts/Release/VCOTuner.app +``` +Expected: the app launches. Full verification is in Task 13. + +- [ ] **Step 5: Commit** + +```bash +git add Source/Visualizer.h Source/Visualizer.cpp +git commit -m "feat: mark failed notes and exclude them from graph auto-scaling" +``` + +--- + +### Task 12: Non-interrupting reporting in `MainComponent` + +**Files:** +- Modify: `Source/MainComponent.h` (status label, listener override) +- Modify: `Source/MainComponent.cpp:333-355` (`tunerStopped` / `tunerFinished`) + +**Interfaces:** +- Consumes: Tasks 7 and 10. +- Produces: nothing. + +- [ ] **Step 1: Add the failure status label** + +In `Source/MainComponent.h` add `Label failureLabel;` and +`void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override;`. +Add it as a child component in the constructor and give it a row in `resized()` beneath the graph. + +- [ ] **Step 2: Update the label as failures arrive** + +```cpp +void MainComponent::measurementFailed (int, vcotuner::MeasurementError) +{ + const auto& failures = tuner.getFailures(); + + if (failures.empty()) + { + failureLabel.setText ({}, dontSendNotification); + return; + } + + StringArray pitches; + for (const auto& f : failures) + pitches.add (String (f.midiPitch)); + + failureLabel.setText ("Not reading: MIDI " + pitches.joinIntoString (", "), + dontSendNotification); +} +``` + +Clear the label in `tunerStarted`, so each cycle starts clean. + +- [ ] **Step 3: Only show a dialog in report mode** + +Replace `MainComponent::tunerStopped` (`Source/MainComponent.cpp:333`): + +```cpp +void MainComponent::tunerStopped() +{ + // Fatal errors only. Per-note failures never reach here; they go to the + // status line via measurementFailed. + StringArray errors = tuner.getLastErrors(); + for (int i = 0; i < errors.size(); i++) + NativeMessageBox::showMessageBox (AlertWindow::WarningIcon, "Error!", errors[i]); + + startStop.setButtonText ("Start"); + cycle = false; + creatingReport = false; +} +``` + +And `tunerFinished`: + +```cpp +void MainComponent::tunerFinished() +{ + startStop.setButtonText ("Start"); + + // A report is a single sweep with a real end, so summarise there. Live + // tuning cycles indefinitely, so it must never raise a dialog. + if (creatingReport) + { + creatingReport = false; + + const auto& failures = tuner.getFailures(); + if (! failures.empty()) + { + StringArray lines; + for (const auto& f : failures) + lines.add (" - MIDI " + String (f.midiPitch) + + " - " + describeError (f.reason)); + + NativeMessageBox::showMessageBox (AlertWindow::InfoIcon, + "Measurement finished", + String (failures.size()) + " of the measured notes could not be read:\n\n" + + lines.joinIntoString ("\n")); + } + } + + if (cycle) + tuner.toggleState(); +} +``` + +- [ ] **Step 4: Add the error description helper** + +In `Source/VCOTuner.cpp`, expose a short description used by the summary: + +```cpp +String describeError (vcotuner::MeasurementError error) +{ + using vcotuner::MeasurementError; + switch (error) + { + case MeasurementError::highJitter: + case MeasurementError::highJitterTimeOut: return "unsteady rate"; + case MeasurementError::noZeroCrossings: return "no signal detected"; + case MeasurementError::stableTimeout: return "timed out"; + case MeasurementError::bufferFull: return "never settled; try a lower resolution"; + default: return "failed"; + } +} +``` + +Declare it in `Source/VCOTuner.h` outside the class. + +- [ ] **Step 5: Build and run the full test suite** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Debug +ctest --test-dir build -C Debug --output-on-failure +``` +Expected: builds, all unit tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Source/MainComponent.h Source/MainComponent.cpp Source/VCOTuner.h Source/VCOTuner.cpp +git commit -m "feat: report failed notes without interrupting the sweep" +``` + +--- + +### Task 13: Manual verification against real hardware + +The GUI and audio-device paths cannot be meaningfully unit tested without mocking JUCE's timer, MIDI and audio stack, which the spec rules out as a non-goal. They get a written checklist instead, so "tested" stays honest about what is automated and what is not. + +**Files:** +- Create: `docs/superpowers/plans/2026-09-23-manual-verification.md` + +- [ ] **Step 1: Write the checklist** + +```markdown +# Manual verification — measurement accuracy changes + +Run against a real VCO and MIDI-to-CV interface. + +## Accuracy +- [ ] Sweep a known-good VCO. Error bars are visibly narrow (a few cents), + not tens of cents. +- [ ] Raising the resolution setting visibly narrows the error bars further. +- [ ] Reported pitch offsets match the previous version's centre values; + only the bars should have changed. + +## High pitch +- [ ] A sweep reaching MIDI 108 and above completes instead of aborting. +- [ ] MIDI 120 at the lowest resolution setting completes. + +## Robustness +- [ ] A DC-coupled interface with visible DC offset measures correctly. +- [ ] A noisy or quiet source measures correctly rather than raising + "zero crossings ... not coming in at a constant rate". +- [ ] Deliberately unplugging the audio input mid-sweep marks notes as + failed and continues, rather than killing the run. + +## Non-interrupting failures +- [ ] In live tuning, a failing note turns orange, the status line names it, + and the sweep keeps cycling with no dialog. +- [ ] Fixing the note mid-run makes it green and drops it from the status + line on the next cycle. +- [ ] Pressing Stop raises no dialog. +- [ ] A report run with failures shows one summary dialog at the end. + +## Fatal errors still abort +- [ ] Deselecting the MIDI output device raises a dialog and stops the run. +- [ ] Disconnecting the audio device mid-run raises a dialog and stops. + +## Regression +- [ ] Creating and saving a report still produces a valid .png. +- [ ] All three regime and resolution combinations still run. +``` + +- [ ] **Step 2: Run the full suite and the app one final time** + +```bash +MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Release +ctest --test-dir build -C Debug --output-on-failure +open build/VCOTuner_artefacts/Release/VCOTuner.app +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/plans/2026-09-23-manual-verification.md +git commit -m "docs: add manual verification checklist for measurement changes" +``` + +--- + +## Coverage map + +| Spec requirement | Task | +|---|---| +| Correct interpolation formula | 4 | +| Level tracking instead of DC blocking | 2 | +| Warm-up window sourced from expected frequency | 2, 9 | +| Silence / degenerate level handling | 2 | +| Hysteresis arming, interpolation at midpoint | 3, 4 | +| `failedBufferFull` as a terminal state | 5 | +| Regression-slope period and uncertainty | 6 | +| Fatal versus per-note error classification | 7 | +| New `bufferFull` error and message | 7, 12 | +| Per-cycle failure tracking, reset each sweep | 7, 10 | +| Timeout floor and latency allowance | 8 | +| `numInputChannels` guard | 9 | +| `std::atomic` measurement flags | 9 | +| `measurementFailed` listener, continue on failure | 10 | +| Failed notes coloured, excluded from auto-scale | 11 | +| Live status line, report-mode dialog, silent stop | 12 | +| Divide-by-zero on short period sequences | 6 | +| GUI and hardware paths | 13 (manual) | From 5d23ef70c9f463d825f5d8e68ab49da7f492246a Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:12:57 +0200 Subject: [PATCH 03/60] test: add Catch2 test harness and PeriodDetector skeleton --- .github/workflows/CI.yaml | 26 +++++++++++++++++++++ CMakeLists.txt | 13 +++++++++++ Source/dsp/PeriodDetector.cpp | 17 ++++++++++++++ Source/dsp/PeriodDetector.h | 43 +++++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 16 +++++++++++++ tests/PeriodDetectorTests.cpp | 12 ++++++++++ 6 files changed, 127 insertions(+) create mode 100644 Source/dsp/PeriodDetector.cpp create mode 100644 Source/dsp/PeriodDetector.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/PeriodDetectorTests.cpp diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 9a503c5..ff80ff1 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -103,4 +103,30 @@ jobs: name: VCOTuner_Linux path: build/VCOTuner_artefacts/VCOTuner + ############################################################################### + # builds and runs the unit tests + unitTests: + runs-on: ubuntu-latest + + steps: + - name: Setup cmake + uses: jwlawson/actions-setup-cmake@v1.8 + with: + cmake-version: '3.19.x' + - name: Checkout + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Configure + run: | + cmake -G "Unix Makefiles" -B build -DVCOTUNER_BUILD_TESTS=ON + + - name: Build tests + run: | + cmake --build build --target VCOTunerTests + + - name: Run tests + run: | + ctest --test-dir build --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index df4efd3..c197190 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,6 +56,18 @@ juce_generate_juce_header(VCOTuner) include_directories(Source) +# Pure DSP logic, no JUCE dependency. Linked by both the app and the tests. +add_library(vcotuner_dsp STATIC + Source/dsp/PeriodDetector.cpp) +target_include_directories(vcotuner_dsp PUBLIC Source) +target_compile_features(vcotuner_dsp PUBLIC cxx_std_17) + +option(VCOTUNER_BUILD_TESTS "Build the unit tests" ON) +if(VCOTUNER_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + target_sources(VCOTuner PRIVATE Source/MainComponent.cpp @@ -120,6 +132,7 @@ target_compile_definitions(VCOTuner target_link_libraries(VCOTuner PRIVATE # GuiAppData # If we'd created a binary data target, we'd link to it here + vcotuner_dsp juce::juce_gui_basics juce::juce_gui_extra juce::juce_audio_devices diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp new file mode 100644 index 0000000..190b86b --- /dev/null +++ b/Source/dsp/PeriodDetector.cpp @@ -0,0 +1,17 @@ +// Source/dsp/PeriodDetector.cpp +#include "PeriodDetector.h" + +namespace vcotuner +{ + +void PeriodDetector::reset (const PeriodDetectorConfig& config) +{ + cfg = config; + currentStatus = DetectorStatus::collecting; +} + +void PeriodDetector::processBlock (const float*, int) +{ +} + +} // namespace vcotuner diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h new file mode 100644 index 0000000..253659c --- /dev/null +++ b/Source/dsp/PeriodDetector.h @@ -0,0 +1,43 @@ +// Source/dsp/PeriodDetector.h +#pragma once + +#include + +namespace vcotuner +{ + +struct PeriodDetectorConfig +{ + double sampleRate = 48000.0; + double hysteresisFraction = 0.1; // of measured amplitude + int stabilityWindow = 5; // consecutive periods compared + double stabilityTolerance = 0.1; // 10% spread allowed + int maxPeriods = 600; // storage limit + int warmupSamples = 2048; // level-tracking window + double silenceFloor = 1e-4; // amplitude below this => silent + int requiredPeriods = 10; // valid periods needed for 'stable' +}; + +enum class DetectorStatus +{ + collecting, // still gathering + stable, // enough valid periods collected + failedUnstable, // never reached a steady rate + failedNoCrossings, // silent, or no crossings at all + failedBufferFull // ran out of storage before stabilising +}; + +class PeriodDetector +{ +public: + void reset (const PeriodDetectorConfig& config); + void processBlock (const float* samples, int numSamples); + + DetectorStatus status() const noexcept { return currentStatus; } + +private: + PeriodDetectorConfig cfg {}; + DetectorStatus currentStatus = DetectorStatus::collecting; +}; + +} // namespace vcotuner diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..79d8f3a --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,16 @@ +include(FetchContent) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.2) +FetchContent_MakeAvailable(Catch2) + +add_executable(VCOTunerTests + PeriodDetectorTests.cpp) + +target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) + +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +include(Catch) +catch_discover_tests(VCOTunerTests) diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp new file mode 100644 index 0000000..106d0c9 --- /dev/null +++ b/tests/PeriodDetectorTests.cpp @@ -0,0 +1,12 @@ +// tests/PeriodDetectorTests.cpp +#include +#include "dsp/PeriodDetector.h" + +using namespace vcotuner; + +TEST_CASE ("a freshly reset detector is collecting") +{ + PeriodDetector detector; + detector.reset (PeriodDetectorConfig {}); + REQUIRE (detector.status() == DetectorStatus::collecting); +} From 390ba7f4d408c142d91f79d1c0cc04d582e96a01 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:19:23 +0200 Subject: [PATCH 04/60] feat: add signal level tracking and silence detection to PeriodDetector - 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. --- Source/dsp/PeriodDetector.cpp | 46 ++++++++++++++++++++++-- Source/dsp/PeriodDetector.h | 13 +++++++ tests/CMakeLists.txt | 2 +- tests/PeriodDetectorTests.cpp | 68 +++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 190b86b..37c14c2 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -7,11 +7,53 @@ namespace vcotuner void PeriodDetector::reset (const PeriodDetectorConfig& config) { cfg = config; - currentStatus = DetectorStatus::collecting; + currentStatus = DetectorStatus::collecting; + sampleCounter = 0; + warmupRemaining = config.warmupSamples; + runningMin = 1e30; + runningMax = -1e30; + levelMidpoint = 0.0; + levelAmplitude = 0.0; + haveLevel = false; } -void PeriodDetector::processBlock (const float*, int) +void PeriodDetector::processBlock (const float* samples, int numSamples) { + if (samples == nullptr || numSamples <= 0) + return; + + for (int i = 0; i < numSamples; ++i) + { + const double s = (double) samples[i]; + + // Level tracking runs continuously so the detector follows slow + // level changes, not just the warm-up window. + if (s < runningMin) runningMin = s; + if (s > runningMax) runningMax = s; + + if (warmupRemaining > 0) + { + if (--warmupRemaining == 0) + finishWarmup(); + } + + ++sampleCounter; + } +} + +void PeriodDetector::finishWarmup() +{ + levelMidpoint = (runningMax + runningMin) * 0.5; + levelAmplitude = (runningMax - runningMin) * 0.5; + + if (levelAmplitude < cfg.silenceFloor) + { + levelAmplitude = 0.0; + currentStatus = DetectorStatus::failedNoCrossings; + return; + } + + haveLevel = true; } } // namespace vcotuner diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h index 253659c..16e2c00 100644 --- a/Source/dsp/PeriodDetector.h +++ b/Source/dsp/PeriodDetector.h @@ -35,9 +35,22 @@ class PeriodDetector DetectorStatus status() const noexcept { return currentStatus; } + double midpoint() const noexcept { return levelMidpoint; } + double amplitude() const noexcept { return levelAmplitude; } + private: + void finishWarmup(); + PeriodDetectorConfig cfg {}; DetectorStatus currentStatus = DetectorStatus::collecting; + + long long sampleCounter = 0; + int warmupRemaining = 0; + double runningMin = 0.0; + double runningMax = 0.0; + double levelMidpoint = 0.0; + double levelAmplitude = 0.0; + bool haveLevel = false; }; } // namespace vcotuner diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 79d8f3a..b750e84 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,4 +13,4 @@ target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) include(Catch) -catch_discover_tests(VCOTunerTests) +catch_discover_tests(VCOTunerTests DISCOVERY_MODE PRE_TEST) diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index 106d0c9..ad363fd 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -10,3 +10,71 @@ TEST_CASE ("a freshly reset detector is collecting") detector.reset (PeriodDetectorConfig {}); REQUIRE (detector.status() == DetectorStatus::collecting); } + +#include +#include +#include + +using Catch::Approx; + +namespace +{ + constexpr double kPi = 3.14159265358979323846; + + // Generates a sine with a given DC offset and amplitude. + std::vector makeSine (double freq, double sampleRate, int numSamples, + double amplitude = 1.0, double dc = 0.0) + { + std::vector out ((size_t) numSamples); + for (int i = 0; i < numSamples; ++i) + out[(size_t) i] = (float) (dc + amplitude + * std::sin (2.0 * kPi * freq * i / sampleRate)); + return out; + } +} + +TEST_CASE ("level tracking finds the midpoint of a DC-offset signal") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 4800; // 100 ms at 48 kHz + const auto samples = makeSine (220.0, 48000.0, 4800, 0.8, 0.3); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), (int) samples.size()); + + REQUIRE (detector.midpoint() == Approx (0.3).margin (0.01)); + REQUIRE (detector.amplitude() == Approx (0.8).margin (0.01)); +} + +TEST_CASE ("level tracking handles an asymmetric waveform") +{ + // Ramp from -0.2 to +1.0: midpoint 0.4, amplitude 0.6. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 1200; + std::vector samples (1200); + for (int i = 0; i < 1200; ++i) + samples[(size_t) i] = (float) (-0.2 + 1.2 * ((i % 100) / 100.0)); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), (int) samples.size()); + + REQUIRE (detector.midpoint() == Approx (0.4).margin (0.02)); + REQUIRE (detector.amplitude() == Approx (0.6).margin (0.02)); +} + +TEST_CASE ("silence is reported as failedNoCrossings, not a divide by zero") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + std::vector silence (480, 0.0f); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (silence.data(), (int) silence.size()); + + REQUIRE (detector.status() == DetectorStatus::failedNoCrossings); + REQUIRE (std::isfinite (detector.midpoint())); + REQUIRE (std::isfinite (detector.amplitude())); +} From 000137b761876b084de78b31fa0be1932d881481 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:24:47 +0200 Subject: [PATCH 05/60] test: cover multi-block level tracking; fix: correct latched-level comment - 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. --- Source/dsp/PeriodDetector.cpp | 5 +++-- tests/PeriodDetectorTests.cpp | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 37c14c2..8a5cd1d 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -26,8 +26,9 @@ void PeriodDetector::processBlock (const float* samples, int numSamples) { const double s = (double) samples[i]; - // Level tracking runs continuously so the detector follows slow - // level changes, not just the warm-up window. + // Min/max keep updating, but the trigger level is latched once at the + // end of warm-up: a threshold that drifts mid-measurement would inject + // timing error into exactly the periods we are trying to measure. if (s < runningMin) runningMin = s; if (s > runningMax) runningMax = s; diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index ad363fd..cea16ea 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -12,6 +12,7 @@ TEST_CASE ("a freshly reset detector is collecting") } #include +#include #include #include @@ -47,6 +48,33 @@ TEST_CASE ("level tracking finds the midpoint of a DC-offset signal") REQUIRE (detector.amplitude() == Approx (0.8).margin (0.01)); } +TEST_CASE ("level tracking is unaffected by block chunking") +{ + // Same DC-offset sine as "level tracking finds the midpoint of a + // DC-offset signal", but fed through processBlock in small chunks — + // the way the real-time audio callback delivers 256-512 sample + // buffers, never one block spanning the whole warm-up window. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 4800; // 100 ms at 48 kHz + const auto samples = makeSine (220.0, 48000.0, 4800, 0.8, 0.3); + + PeriodDetector singleBlock; + singleBlock.reset (cfg); + singleBlock.processBlock (samples.data(), (int) samples.size()); + + PeriodDetector chunked; + chunked.reset (cfg); + const int chunkSize = 64; + for (int offset = 0; offset < (int) samples.size(); offset += chunkSize) + { + const int n = std::min (chunkSize, (int) samples.size() - offset); + chunked.processBlock (samples.data() + offset, n); + } + + REQUIRE (chunked.midpoint() == singleBlock.midpoint()); + REQUIRE (chunked.amplitude() == singleBlock.amplitude()); +} + TEST_CASE ("level tracking handles an asymmetric waveform") { // Ramp from -0.2 to +1.0: midpoint 0.4, amplitude 0.6. From 2a8d27b2abc54d299ae7a70c4871cc308b2f566b Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:32:16 +0200 Subject: [PATCH 06/60] fix: reject false zero crossings with a hysteresis trigger --- Source/dsp/PeriodDetector.cpp | 42 ++++++++++++++++++++++ Source/dsp/PeriodDetector.h | 11 ++++++ tests/PeriodDetectorTests.cpp | 67 +++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 8a5cd1d..a01311f 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -15,6 +15,13 @@ void PeriodDetector::reset (const PeriodDetectorConfig& config) levelMidpoint = 0.0; levelAmplitude = 0.0; haveLevel = false; + + periods.clear(); + periods.reserve ((size_t) config.maxPeriods); + lastCrossing = -1.0; + lastSample = 0.0; + armed = false; + haveLastSample = false; } void PeriodDetector::processBlock (const float* samples, int numSamples) @@ -38,6 +45,9 @@ void PeriodDetector::processBlock (const float* samples, int numSamples) finishWarmup(); } + if (haveLevel && currentStatus == DetectorStatus::collecting) + processCrossing (s); + ++sampleCounter; } } @@ -57,4 +67,36 @@ void PeriodDetector::finishWarmup() haveLevel = true; } +void PeriodDetector::processCrossing (double s) +{ + const double hysteresis = cfg.hysteresisFraction * levelAmplitude; + + // Re-arm only after the signal has dropped clearly below the midpoint. + // Noise between the rails cannot retrigger. + if (! armed) + { + if (s < levelMidpoint - hysteresis) + armed = true; + } + else if (haveLastSample && lastSample < levelMidpoint && s >= levelMidpoint) + { + armed = false; + recordCrossing ((double) sampleCounter); + } + + lastSample = s; + haveLastSample = true; +} + +void PeriodDetector::recordCrossing (double position) +{ + if ((int) periods.size() >= cfg.maxPeriods) + return; + + if (lastCrossing >= 0.0) + periods.push_back (position - lastCrossing); + + lastCrossing = position; +} + } // namespace vcotuner diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h index 16e2c00..94014bf 100644 --- a/Source/dsp/PeriodDetector.h +++ b/Source/dsp/PeriodDetector.h @@ -38,8 +38,13 @@ class PeriodDetector double midpoint() const noexcept { return levelMidpoint; } double amplitude() const noexcept { return levelAmplitude; } + int numPeriods() const noexcept { return (int) periods.size(); } + const double* periodData() const noexcept { return periods.data(); } + private: void finishWarmup(); + void processCrossing (double s); + void recordCrossing (double position); PeriodDetectorConfig cfg {}; DetectorStatus currentStatus = DetectorStatus::collecting; @@ -51,6 +56,12 @@ class PeriodDetector double levelMidpoint = 0.0; double levelAmplitude = 0.0; bool haveLevel = false; + + std::vector periods; + double lastCrossing = -1.0; + double lastSample = 0.0; + bool armed = false; + bool haveLastSample = false; }; } // namespace vcotuner diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index cea16ea..ed234a8 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -106,3 +106,70 @@ TEST_CASE ("silence is reported as failedNoCrossings, not a divide by zero") REQUIRE (std::isfinite (detector.midpoint())); REQUIRE (std::isfinite (detector.amplitude())); } + +#include + +namespace +{ + // M_PI is not standard C++ (needs _USE_MATH_DEFINES on MSVC); reuse the + // kPi constant declared above instead. + std::vector makeNoisySine (double freq, double sampleRate, int numSamples, + double noise, double dc, unsigned seed = 7) + { + std::mt19937 rng (seed); + std::uniform_real_distribution dist (-noise, noise); + std::vector out ((size_t) numSamples); + for (int i = 0; i < numSamples; ++i) + out[(size_t) i] = (float) (dc + std::sin (2.0 * kPi * freq * i / sampleRate) + + dist (rng)); + return out; + } +} + +TEST_CASE ("hysteresis rejects noise-induced false crossings") +{ + // 1 second of 220 Hz => 220 periods. Warm-up consumes roughly the first + // 2 cycles, so allow a small shortfall rather than demanding exactly 219. + const int numSamples = 48000; + + struct Case { double noise; double dc; }; + const Case cases[] = { {0.02, 0.0}, {0.05, 0.0}, {0.02, 0.9}, {0.05, 0.9} }; + + for (const auto& c : cases) + { + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 2000; + cfg.requiredPeriods = 100000; // never declare 'stable'; just count + const auto samples = makeNoisySine (220.0, 48000.0, numSamples, c.noise, c.dc); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), numSamples); + + INFO ("noise=" << c.noise << " dc=" << c.dc); + // The old hard-threshold detector produced up to 609 here. + REQUIRE (detector.numPeriods() >= 215); + REQUIRE (detector.numPeriods() <= 221); + } +} + +TEST_CASE ("trigger level adapts to very quiet and very hot signals") +{ + for (double amp : { 0.01, 0.5, 4.0 }) + { + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 2000; + cfg.requiredPeriods = 100000; + const auto samples = makeSine (440.0, 48000.0, 48000, amp, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + INFO ("amplitude=" << amp); + REQUIRE (detector.numPeriods() >= 435); + REQUIRE (detector.numPeriods() <= 441); + } +} From 0f6de900cddd2c1774d548e3be71bf96ed6bd904 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:34:29 +0200 Subject: [PATCH 07/60] test: assert amplitude-independence, not a miscalibrated absolute bound 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. --- tests/PeriodDetectorTests.cpp | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index ed234a8..7b27e93 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -156,6 +156,13 @@ TEST_CASE ("hysteresis rejects noise-induced false crossings") TEST_CASE ("trigger level adapts to very quiet and very hot signals") { + // The absolute count follows from the fixture: 48000 samples at 440 Hz + // is 440 cycles, warm-up consumes 480 samples (4.4 cycles), the trigger + // needs up to another half cycle to arm, and the first crossing is + // discarded for having no predecessor. That lands on 434. What matters + // is that the count does not move with amplitude. + std::vector counts; + for (double amp : { 0.01, 0.5, 4.0 }) { PeriodDetectorConfig cfg; @@ -167,9 +174,14 @@ TEST_CASE ("trigger level adapts to very quiet and very hot signals") PeriodDetector detector; detector.reset (cfg); detector.processBlock (samples.data(), 48000); - - INFO ("amplitude=" << amp); - REQUIRE (detector.numPeriods() >= 435); - REQUIRE (detector.numPeriods() <= 441); + counts.push_back (detector.numPeriods()); } + + INFO ("counts: " << counts[0] << ", " << counts[1] << ", " << counts[2]); + // A threshold fixed in absolute terms rather than scaled to the measured + // amplitude would miss every crossing at 0.01 and still fire at 4.0. + REQUIRE (counts[0] == counts[1]); + REQUIRE (counts[1] == counts[2]); + REQUIRE (counts[0] >= 430); + REQUIRE (counts[0] <= 441); } From 0ecaedf5a662a86b690211f45e6e69b90c54d6eb Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:35:49 +0200 Subject: [PATCH 08/60] docs: correct latched-level claim and miscalibrated test bound 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. --- .../plans/2026-09-23-measurement-accuracy.md | 20 +++++++++++++++---- ...09-23-vcotuner-measurement-fixes-design.md | 11 ++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md index 4f472b3..b17853c 100644 --- a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md +++ b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md @@ -471,6 +471,13 @@ TEST_CASE ("hysteresis rejects noise-induced false crossings") TEST_CASE ("trigger level adapts to very quiet and very hot signals") { + // The absolute count follows from the fixture: 48000 samples at 440 Hz is + // 440 cycles, warm-up consumes 480 samples (4.4 cycles), the trigger needs + // up to another half cycle to arm, and the first crossing is discarded for + // having no predecessor. That lands on 434. What matters here is that the + // count does not move with amplitude. + std::vector counts; + for (double amp : { 0.01, 0.5, 4.0 }) { PeriodDetectorConfig cfg; @@ -482,11 +489,16 @@ TEST_CASE ("trigger level adapts to very quiet and very hot signals") PeriodDetector detector; detector.reset (cfg); detector.processBlock (samples.data(), 48000); - - INFO ("amplitude=" << amp); - REQUIRE (detector.numPeriods() >= 435); - REQUIRE (detector.numPeriods() <= 441); + counts.push_back (detector.numPeriods()); } + + INFO ("counts: " << counts[0] << ", " << counts[1] << ", " << counts[2]); + // A threshold fixed in absolute terms rather than scaled to the measured + // amplitude would miss every crossing at 0.01 and still fire at 4.0. + REQUIRE (counts[0] == counts[1]); + REQUIRE (counts[1] == counts[2]); + REQUIRE (counts[0] >= 430); + REQUIRE (counts[0] <= 441); } ``` diff --git a/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md b/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md index f490a39..0f80a19 100644 --- a/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md +++ b/docs/superpowers/specs/2026-09-23-vcotuner-measurement-fixes-design.md @@ -140,8 +140,15 @@ sees that audio. The detector therefore runs its own warm-up over the first `warmupSamples` samples it receives: it updates min/max but emits no crossings. `VCOTuner` sets `warmupSamples` to cover two cycles at the expected frequency, which it already computes for the timeout (`Source/VCOTuner.cpp:270`), clamped -to a sane floor and ceiling. Level tracking continues after warm-up so the -detector follows slow level changes. +to a sane floor and ceiling. + +**The trigger level is latched at the end of warm-up, not tracked continuously.** +An earlier draft of this spec said the opposite — that level tracking continues +so the detector follows slow level changes. That was wrong, and the code is +right: a threshold that drifts mid-measurement injects timing error into exactly +the periods being measured, which is the error this design exists to remove. +`runningMin`/`runningMax` keep updating, but `levelMidpoint`/`levelAmplitude` +are computed once, at the end of warm-up, and held for the measurement. **Silence and degenerate levels.** If `amplitude` falls below a small fixed floor, the input is treated as silent: the detector reports From b0ec7ca50971a0f4147590a60d032b4f24a22f25 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 20:52:45 +0200 Subject: [PATCH 09/60] fix: correct sub-sample zero-crossing interpolation formula 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. --- Source/dsp/PeriodDetector.cpp | 13 +++++- tests/PeriodDetectorTests.cpp | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index a01311f..0e0565a 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -81,7 +81,18 @@ void PeriodDetector::processCrossing (double s) else if (haveLastSample && lastSample < levelMidpoint && s >= levelMidpoint) { armed = false; - recordCrossing ((double) sampleCounter); + + // Linear interpolation between the two samples straddling the + // midpoint. The previous sample sits at sampleCounter - 1. + // correct: x0 = (sampleCounter - 1) + f + // The old code computed (sampleCounter - 1) + (1 - f), mirroring the + // fraction within the interval and roughly doubling the jitter versus + // no interpolation at all. + const double slope = s - lastSample; // > 0 by the branch + const double f = (slope != 0.0) + ? (levelMidpoint - lastSample) / slope + : 0.0; + recordCrossing ((double) (sampleCounter - 1) + f); } lastSample = s; diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index 7b27e93..fdeef8e 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -185,3 +185,83 @@ TEST_CASE ("trigger level adapts to very quiet and very hot signals") REQUIRE (counts[0] >= 430); REQUIRE (counts[0] <= 441); } + +#include + +namespace +{ + double periodJitter (const PeriodDetector& d) + { + const int n = d.numPeriods(); + if (n < 2) return 1e9; + const double* p = d.periodData(); + const double mean = std::accumulate (p, p + n, 0.0) / n; + double acc = 0.0; + for (int i = 0; i < n; ++i) acc += (p[i] - mean) * (p[i] - mean); + return std::sqrt (acc / n); + } + + PeriodDetectorConfig countingConfig() + { + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 4000; + cfg.requiredPeriods = 100000; + return cfg; + } +} + +TEST_CASE ("interpolation makes period measurement sub-sample accurate") +{ + // Regression guard for the mirrored-fraction bug. The old formula + // produced ~0.57 samples of jitter here; no interpolation at all gives + // ~0.29. Neither can reach 0.05. + for (double freq : { 110.0, 440.0, 1318.51, 4186.01 }) + { + const auto samples = makeSine (freq, 48000.0, 48000, 0.9, 0.0); + PeriodDetector detector; + detector.reset (countingConfig()); + detector.processBlock (samples.data(), 48000); + + INFO ("freq=" << freq << " jitter=" << periodJitter (detector)); + REQUIRE (detector.numPeriods() > 50); + REQUIRE (periodJitter (detector) < 0.05); + } +} + +TEST_CASE ("recovered frequency is accurate to well under a cent") +{ + for (double freq : { 110.0, 440.0, 1318.51, 4186.01 }) + { + const auto samples = makeSine (freq, 48000.0, 48000, 0.9, 0.0); + PeriodDetector detector; + detector.reset (countingConfig()); + detector.processBlock (samples.data(), 48000); + + const int n = detector.numPeriods(); + const double* p = detector.periodData(); + const double meanPeriod = std::accumulate (p, p + n, 0.0) / n; + const double measured = 48000.0 / meanPeriod; + const double cents = 1200.0 * std::log2 (measured / freq); + + INFO ("freq=" << freq << " cents error=" << cents); + REQUIRE (std::abs (cents) < 0.1); + } +} + +TEST_CASE ("interpolation is accurate on a saw wave") +{ + const double freq = 440.0; + std::vector samples (48000); + for (int i = 0; i < 48000; ++i) + { + const double phase = std::fmod (freq * i / 48000.0, 1.0); + samples[(size_t) i] = (float) (2.0 * phase - 1.0); + } + + PeriodDetector detector; + detector.reset (countingConfig()); + detector.processBlock (samples.data(), 48000); + + REQUIRE (periodJitter (detector) < 0.05); +} From bf7c74644d0cf398bd5197984ac24b6bb1a43aaf Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 21:04:25 +0200 Subject: [PATCH 10/60] feat: add stability detection and terminal statuses to PeriodDetector 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. --- Source/dsp/PeriodDetector.cpp | 61 +++++++++++++++++++++++++++-- Source/dsp/PeriodDetector.h | 5 +++ tests/PeriodDetectorTests.cpp | 74 +++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 0e0565a..fbafc44 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -1,5 +1,6 @@ // Source/dsp/PeriodDetector.cpp #include "PeriodDetector.h" +#include namespace vcotuner { @@ -18,10 +19,11 @@ void PeriodDetector::reset (const PeriodDetectorConfig& config) periods.clear(); periods.reserve ((size_t) config.maxPeriods); - lastCrossing = -1.0; - lastSample = 0.0; - armed = false; - haveLastSample = false; + lastCrossing = -1.0; + lastSample = 0.0; + armed = false; + haveLastSample = false; + firstValidIndex = -1; } void PeriodDetector::processBlock (const float* samples, int numSamples) @@ -108,6 +110,57 @@ void PeriodDetector::recordCrossing (double position) periods.push_back (position - lastCrossing); lastCrossing = position; + + updateStability(); +} + +int PeriodDetector::numValidPeriods() const noexcept +{ + if (firstValidIndex < 0) return 0; + return (int) periods.size() - firstValidIndex; +} + +const double* PeriodDetector::validPeriods() const noexcept +{ + if (firstValidIndex < 0) return periods.data(); + return periods.data() + firstValidIndex; +} + +void PeriodDetector::updateStability() +{ + const int n = (int) periods.size(); + + if (firstValidIndex < 0 && n >= cfg.stabilityWindow) + { + double sum = 0.0; + for (int i = n - cfg.stabilityWindow; i < n; ++i) + sum += periods[(size_t) i]; + const double average = sum / cfg.stabilityWindow; + const double boundary = average * cfg.stabilityTolerance; + + bool steady = true; + for (int i = n - cfg.stabilityWindow; i < n; ++i) + if (std::abs (periods[(size_t) i] - average) >= boundary) + steady = false; + + if (steady) + firstValidIndex = n; + } + + if (firstValidIndex >= 0 && numValidPeriods() >= cfg.requiredPeriods) + { + currentStatus = DetectorStatus::stable; + return; + } + + // Storage exhausted before collecting what we need. This must be a + // terminal state: the old code left the measurement running here, so it + // stalled until the top level timed out and then blamed the wrong thing. + // Distinguish the two causes - never steady at all, versus steady but not + // for long enough - because they need different advice to the user. + if (n >= cfg.maxPeriods) + currentStatus = (firstValidIndex < 0) ? DetectorStatus::failedUnstable + : DetectorStatus::failedBufferFull; } } // namespace vcotuner diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h index 94014bf..646d365 100644 --- a/Source/dsp/PeriodDetector.h +++ b/Source/dsp/PeriodDetector.h @@ -41,10 +41,14 @@ class PeriodDetector int numPeriods() const noexcept { return (int) periods.size(); } const double* periodData() const noexcept { return periods.data(); } + int numValidPeriods() const noexcept; + const double* validPeriods() const noexcept; + private: void finishWarmup(); void processCrossing (double s); void recordCrossing (double position); + void updateStability(); PeriodDetectorConfig cfg {}; DetectorStatus currentStatus = DetectorStatus::collecting; @@ -62,6 +66,7 @@ class PeriodDetector double lastSample = 0.0; bool armed = false; bool haveLastSample = false; + int firstValidIndex = -1; }; } // namespace vcotuner diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index fdeef8e..07afd0c 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -265,3 +265,77 @@ TEST_CASE ("interpolation is accurate on a saw wave") REQUIRE (periodJitter (detector) < 0.05); } + +TEST_CASE ("a steady signal reaches the stable status") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.requiredPeriods = 20; + const auto samples = makeSine (440.0, 48000.0, 48000, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.status() == DetectorStatus::stable); + REQUIRE (detector.numValidPeriods() >= 20); +} + +TEST_CASE ("a constantly changing rate never stabilises and terminates") +{ + // A sweep from 200 Hz to 2 kHz never holds a steady period. The detector + // must reach a terminal state rather than hanging - the old code left the + // measurement running here and stalled until the top-level timeout. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 200; + cfg.requiredPeriods = 20; + + std::vector samples (48000); + double phase = 0.0; + for (int i = 0; i < 48000; ++i) + { + const double f = 200.0 + 1800.0 * (i / 48000.0); + phase += 2.0 * kPi * f / 48000.0; + samples[(size_t) i] = (float) std::sin (phase); + } + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.status() == DetectorStatus::failedUnstable); +} + +TEST_CASE ("a steady signal that outruns the buffer reports failedBufferFull") +{ + // Stabilises immediately, but the buffer cannot hold enough periods to + // satisfy requiredPeriods. Distinct from failedUnstable: the signal is + // fine, the resolution setting is simply too high for the storage. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.maxPeriods = 20; + cfg.requiredPeriods = 500; + const auto samples = makeSine (440.0, 48000.0, 48000, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.status() == DetectorStatus::failedBufferFull); +} + +TEST_CASE ("valid periods exclude the unstable run-in") +{ + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.requiredPeriods = 10; + const auto samples = makeSine (440.0, 48000.0, 48000, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), 48000); + + REQUIRE (detector.numValidPeriods() <= detector.numPeriods()); + REQUIRE (detector.validPeriods() != nullptr); +} From e10da7ed0b266ae5111f449bc22448417568796b Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 21:09:01 +0200 Subject: [PATCH 11/60] fix: re-validate steadiness over the full valid set before declaring 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. --- Source/dsp/PeriodDetector.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index fbafc44..950ebba 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -149,6 +149,28 @@ void PeriodDetector::updateStability() if (firstValidIndex >= 0 && numValidPeriods() >= cfg.requiredPeriods) { + // The latch above only proves the rate held steady across one window. + // Re-check the whole collected set before declaring success: a drifting + // oscillator can satisfy a single window and then wander far outside + // tolerance, which is exactly what failedUnstable is for. + const double* p = validPeriods(); + const int valid = numValidPeriods(); + + double sum = 0.0; + for (int i = 0; i < valid; ++i) + sum += p[i]; + const double average = sum / valid; + const double boundary = average * cfg.stabilityTolerance; + + for (int i = 0; i < valid; ++i) + { + if (std::abs (p[i] - average) >= boundary) + { + currentStatus = DetectorStatus::failedUnstable; + return; + } + } + currentStatus = DetectorStatus::stable; return; } From ff94513b0fa734375e8fa7ba2519b280ce3609c7 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 21:10:21 +0200 Subject: [PATCH 12/60] docs: require re-validating steadiness before declaring 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. --- .../plans/2026-09-23-measurement-accuracy.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md index b17853c..27986d8 100644 --- a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md +++ b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md @@ -879,6 +879,31 @@ void PeriodDetector::updateStability() if (firstValidIndex >= 0 && numValidPeriods() >= cfg.requiredPeriods) { + // The latch above only proves the rate held steady across one window. + // Re-check the whole collected set before declaring success: a drifting + // oscillator can satisfy a single window and then wander far outside + // tolerance, which is exactly what failedUnstable is for. The shipping + // app has this hole - it sets indexOfFirstValidPeriodLength once and + // never rechecks - so a thermally drifting VCO reads as a confident + // measurement. + const double* p = validPeriods(); + const int valid = numValidPeriods(); + + double sum = 0.0; + for (int i = 0; i < valid; ++i) + sum += p[i]; + const double average = sum / valid; + const double boundary = average * cfg.stabilityTolerance; + + for (int i = 0; i < valid; ++i) + { + if (std::abs (p[i] - average) >= boundary) + { + currentStatus = DetectorStatus::failedUnstable; + return; + } + } + currentStatus = DetectorStatus::stable; return; } From 9dab41bce3443a3fe918d5ffcb91cd02fb2b3643 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Wed, 23 Sep 2026 21:16:58 +0200 Subject: [PATCH 13/60] feat: estimate period and uncertainty by least-squares fit --- CMakeLists.txt | 3 +- Source/dsp/MeasurementStatistics.cpp | 90 ++++++++++++++++++++++++++++ Source/dsp/MeasurementStatistics.h | 45 ++++++++++++++ tests/CMakeLists.txt | 3 +- tests/MeasurementStatisticsTests.cpp | 85 ++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 Source/dsp/MeasurementStatistics.cpp create mode 100644 Source/dsp/MeasurementStatistics.h create mode 100644 tests/MeasurementStatisticsTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c197190..4e90d06 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,7 +58,8 @@ include_directories(Source) # Pure DSP logic, no JUCE dependency. Linked by both the app and the tests. add_library(vcotuner_dsp STATIC - Source/dsp/PeriodDetector.cpp) + Source/dsp/PeriodDetector.cpp + Source/dsp/MeasurementStatistics.cpp) target_include_directories(vcotuner_dsp PUBLIC Source) target_compile_features(vcotuner_dsp PUBLIC cxx_std_17) diff --git a/Source/dsp/MeasurementStatistics.cpp b/Source/dsp/MeasurementStatistics.cpp new file mode 100644 index 0000000..60970e1 --- /dev/null +++ b/Source/dsp/MeasurementStatistics.cpp @@ -0,0 +1,90 @@ +// Source/dsp/MeasurementStatistics.cpp +#include "MeasurementStatistics.h" + +#include +#include + +namespace vcotuner +{ + +PeriodFit fitPeriod (const double* periods, int numPeriods) +{ + PeriodFit fit; + + if (periods == nullptr || numPeriods < 2) + return fit; + + // Cumulate periods back into crossing times: n+1 points for n periods. + const int n = numPeriods + 1; + std::vector times ((size_t) n); + times[0] = 0.0; + for (int i = 0; i < numPeriods; ++i) + times[(size_t) (i + 1)] = times[(size_t) i] + periods[i]; + + double meanX = 0.0, meanY = 0.0; + for (int i = 0; i < n; ++i) { meanX += i; meanY += times[(size_t) i]; } + meanX /= n; + meanY /= n; + + double sxx = 0.0, sxy = 0.0; + for (int i = 0; i < n; ++i) + { + const double dx = i - meanX; + sxx += dx * dx; + sxy += dx * (times[(size_t) i] - meanY); + } + + if (sxx <= 0.0) + return fit; + + const double slope = sxy / sxx; + const double intercept = meanY - slope * meanX; + + fit.periodSamples = slope; + + // Standard error of the slope needs at least one degree of freedom. + if (n > 2) + { + double sse = 0.0; + for (int i = 0; i < n; ++i) + { + const double residual = times[(size_t) i] - (intercept + slope * i); + sse += residual * residual; + } + fit.periodStdError = std::sqrt (sse / ((n - 2) * sxx)); + fit.valid = true; + } + + return fit; +} + +MeasurementResult computeMeasurement (const double* periods, int numPeriods, + double sampleRate, + double referenceFrequency, + int referencePitch) +{ + MeasurementResult result; + + const auto fit = fitPeriod (periods, numPeriods); + if (! fit.valid || fit.periodSamples <= 0.0 + || referenceFrequency <= 0.0 || sampleRate <= 0.0) + return result; + + result.frequency = sampleRate / fit.periodSamples; + + // Relative uncertainty carries straight across from period to frequency. + const double relative = fit.periodStdError / fit.periodSamples; + result.frequencyDeviation = result.frequency * relative; + + result.pitch = 12.0 * std::log2 (result.frequency / referenceFrequency) + + referencePitch; + + // d(pitch)/d(f) = 12 / (f * ln2), so the semitone uncertainty is just the + // relative uncertainty scaled by 12 / ln2. + result.pitchDeviation = 12.0 * relative / std::log (2.0); + result.valid = true; + + return result; +} + +} // namespace vcotuner diff --git a/Source/dsp/MeasurementStatistics.h b/Source/dsp/MeasurementStatistics.h new file mode 100644 index 0000000..f8230ea --- /dev/null +++ b/Source/dsp/MeasurementStatistics.h @@ -0,0 +1,45 @@ +// Source/dsp/MeasurementStatistics.h +#pragma once + +namespace vcotuner +{ + +struct PeriodFit +{ + bool valid = false; + double periodSamples = 0.0; + double periodStdError = 0.0; +}; + +struct MeasurementResult +{ + bool valid = false; + double frequency = 0.0; + double frequencyDeviation = 0.0; + double pitch = 0.0; + double pitchDeviation = 0.0; +}; + +/** Least-squares fit of crossing time against crossing index. + + The periods are cumulated back into crossing times, then fitted with a + straight line. The slope is the period estimate and the standard error of + that slope is its uncertainty. + + This replaces taking the standard deviation of the individual periods, + which answers a different question: consecutive periods share a crossing + time, so their errors are negatively correlated and the spread of the + periods badly overstates the uncertainty of their mean. + + Requires at least 3 periods for a meaningful standard error; fewer + returns valid == false with finite zeroed fields. +*/ +PeriodFit fitPeriod (const double* periods, int numPeriods); + +/** Converts a period sequence into frequency, pitch and their uncertainties. */ +MeasurementResult computeMeasurement (const double* periods, int numPeriods, + double sampleRate, + double referenceFrequency, + int referencePitch); + +} // namespace vcotuner diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b750e84..02e1e4f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,7 +7,8 @@ FetchContent_Declare( FetchContent_MakeAvailable(Catch2) add_executable(VCOTunerTests - PeriodDetectorTests.cpp) + PeriodDetectorTests.cpp + MeasurementStatisticsTests.cpp) target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) diff --git a/tests/MeasurementStatisticsTests.cpp b/tests/MeasurementStatisticsTests.cpp new file mode 100644 index 0000000..2902103 --- /dev/null +++ b/tests/MeasurementStatisticsTests.cpp @@ -0,0 +1,85 @@ +// tests/MeasurementStatisticsTests.cpp +#include +#include +#include +#include +#include "dsp/MeasurementStatistics.h" + +using namespace vcotuner; +using Catch::Approx; + +TEST_CASE ("a perfectly uniform period sequence has zero uncertainty") +{ + const std::vector periods (20, 100.0); + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.periodSamples == Approx (100.0)); + REQUIRE (fit.periodStdError == Approx (0.0).margin (1e-9)); +} + +TEST_CASE ("slope standard error matches the analytic value") +{ + // Crossing times 0, 8, 19, 28, 40 -> slope 10, SSE 4, Sxx 10. + const std::vector periods { 8.0, 11.0, 9.0, 12.0 }; + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.periodSamples == Approx (10.0).margin (1e-9)); + REQUIRE (fit.periodStdError == Approx (std::sqrt (2.0 / 15.0)).margin (1e-9)); +} + +TEST_CASE ("uncertainty shrinks as more periods are collected") +{ + auto jittered = [] (int count) + { + std::vector p ((size_t) count); + for (int i = 0; i < count; ++i) + p[(size_t) i] = 100.0 + ((i % 2 == 0) ? 0.5 : -0.5); + return p; + }; + + const auto few = jittered (10); + const auto many = jittered (200); + + const auto fitFew = fitPeriod (few.data(), (int) few.size()); + const auto fitMany = fitPeriod (many.data(), (int) many.size()); + + REQUIRE (fitMany.periodStdError < fitFew.periodStdError); +} + +TEST_CASE ("degenerate inputs return defined values, never NaN") +{ + const double one[] = { 100.0 }; + + for (auto fit : { fitPeriod (nullptr, 0), fitPeriod (one, 1) }) + { + REQUIRE_FALSE (fit.valid); + REQUIRE (std::isfinite (fit.periodSamples)); + REQUIRE (std::isfinite (fit.periodStdError)); + } +} + +TEST_CASE ("measurement converts periods to frequency and pitch") +{ + // 100 samples per period at 48 kHz = 480 Hz. Reference 480 Hz at MIDI 69 + // means the measured pitch is exactly the reference pitch. + const std::vector periods (50, 100.0); + const auto result = computeMeasurement (periods.data(), (int) periods.size(), + 48000.0, 480.0, 69); + + REQUIRE (result.valid); + REQUIRE (result.frequency == Approx (480.0)); + REQUIRE (result.pitch == Approx (69.0)); + REQUIRE (result.pitchDeviation == Approx (0.0).margin (1e-9)); +} + +TEST_CASE ("an octave above the reference reads as twelve semitones") +{ + const std::vector periods (50, 50.0); // 960 Hz + const auto result = computeMeasurement (periods.data(), (int) periods.size(), + 48000.0, 480.0, 69); + + REQUIRE (result.frequency == Approx (960.0)); + REQUIRE (result.pitch == Approx (81.0)); +} From 109943a71b1512eb794f0793014ec1255d9c21e2 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 09:18:14 +0200 Subject: [PATCH 14/60] test: cover nonzero deviation propagation and the two-period boundary 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. --- Source/dsp/MeasurementStatistics.cpp | 5 ++ Source/dsp/MeasurementStatistics.h | 8 ++- tests/MeasurementStatisticsTests.cpp | 76 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/Source/dsp/MeasurementStatistics.cpp b/Source/dsp/MeasurementStatistics.cpp index 60970e1..e202aec 100644 --- a/Source/dsp/MeasurementStatistics.cpp +++ b/Source/dsp/MeasurementStatistics.cpp @@ -34,6 +34,11 @@ PeriodFit fitPeriod (const double* periods, int numPeriods) sxy += dx * (times[(size_t) i] - meanY); } + // Unreachable by design, not load-bearing: sxx depends only on the + // crossing indices 0..n-1, never on the period values, and the + // numPeriods < 2 guard above already ensures n >= 3, for which sxx is + // always strictly positive. Kept as a defensive guard against future + // changes to how x-values are chosen. if (sxx <= 0.0) return fit; diff --git a/Source/dsp/MeasurementStatistics.h b/Source/dsp/MeasurementStatistics.h index f8230ea..a72241b 100644 --- a/Source/dsp/MeasurementStatistics.h +++ b/Source/dsp/MeasurementStatistics.h @@ -31,8 +31,12 @@ struct MeasurementResult time, so their errors are negatively correlated and the spread of the periods badly overstates the uncertainty of their mean. - Requires at least 3 periods for a meaningful standard error; fewer - returns valid == false with finite zeroed fields. + Requires at least 2 periods (3 crossing times) to return a fit; fewer + returns valid == false with finite zeroed fields. Exactly 2 periods + leaves a single degree of freedom (n - 2 == 1), which is still a + well-defined standard error but a deliberately wide one -- a large + error bar is precisely how low confidence should be communicated, + rather than discarding the measurement outright. */ PeriodFit fitPeriod (const double* periods, int numPeriods); diff --git a/tests/MeasurementStatisticsTests.cpp b/tests/MeasurementStatisticsTests.cpp index 2902103..4968801 100644 --- a/tests/MeasurementStatisticsTests.cpp +++ b/tests/MeasurementStatisticsTests.cpp @@ -60,6 +60,34 @@ TEST_CASE ("degenerate inputs return defined values, never NaN") } } +TEST_CASE ("two periods is the boundary case: one degree of freedom, still finite") +{ + // Two periods give three crossing times, so n - 2 == 1: the only + // degenerate input (besides 0 and 1 periods) that actually reaches the + // sse/((n-2)*sxx) division rather than returning early. The controller's + // ruling is that a 1-degree-of-freedom standard error is well-defined + // and deliberately wide, not invalid, so `valid` is true here (unlike + // the 0- and 1-period cases above). + const double two[] = { 100.0, 120.0 }; + const auto fit = fitPeriod (two, 2); + + REQUIRE (fit.valid); + REQUIRE (std::isfinite (fit.periodSamples)); + REQUIRE (std::isfinite (fit.periodStdError)); +} + +TEST_CASE ("computeMeasurement at the two-period boundary stays finite and valid") +{ + const double two[] = { 100.0, 120.0 }; + const auto result = computeMeasurement (two, 2, 48000.0, 480.0, 69); + + REQUIRE (result.valid); + REQUIRE (std::isfinite (result.frequency)); + REQUIRE (std::isfinite (result.frequencyDeviation)); + REQUIRE (std::isfinite (result.pitch)); + REQUIRE (std::isfinite (result.pitchDeviation)); +} + TEST_CASE ("measurement converts periods to frequency and pitch") { // 100 samples per period at 48 kHz = 480 Hz. Reference 480 Hz at MIDI 69 @@ -83,3 +111,51 @@ TEST_CASE ("an octave above the reference reads as twelve semitones") REQUIRE (result.frequency == Approx (960.0)); REQUIRE (result.pitch == Approx (81.0)); } + +TEST_CASE ("measurement propagates a nonzero deviation using the exact formulas") +{ + // Every other computeMeasurement test above uses perfectly uniform + // periods, so periodStdError == 0 there and both deviation outputs are + // forced to zero regardless of whether the propagation formulas are + // right. This case uses the same analytic {8, 11, 9, 12} fit as + // "slope standard error matches the analytic value" so the expected + // deviations are hand-derived, not recomputed from the code's own + // formula (which would be circular): + // + // periodSamples = 10 + // periodStdError = sqrt(2/15) = 0.3651483716701107 + // relative = periodStdError / periodSamples + // = 0.3651483716701107 / 10 + // = 0.03651483716701107 + // frequency = sampleRate / periodSamples = 48000 / 10 = 4800 Hz + // frequencyDeviation = frequency * relative + // = 4800 * 0.03651483716701107 + // = 175.27121840165314 + // pitchDeviation = 12 * relative / ln(2) + // = 0.43817804600413284 / 0.6931471805599453 + // = 0.6321572939965786 (about 63.2 cents) + // + // referenceFrequency is set equal to the measured frequency (4800 Hz) + // so the pitch assertion is exact: 0 semitones from a 69 reference. + const std::vector periods { 8.0, 11.0, 9.0, 12.0 }; + const auto result = computeMeasurement (periods.data(), (int) periods.size(), + 48000.0, 4800.0, 69); + + REQUIRE (result.valid); + REQUIRE (result.frequency == Approx (4800.0)); + REQUIRE (result.pitch == Approx (69.0).margin (1e-9)); + + REQUIRE (result.frequencyDeviation == Approx (175.27121840165314).margin (1e-6)); + REQUIRE (result.pitchDeviation == Approx (0.6321572939965786).margin (1e-6)); + + // Guard against the two specific mis-derivations named in review, either + // of which a uniform-period test cannot catch since both silently + // produce zero there. + // + // Dropping the division by periodSamples when computing `relative` + // would leave frequencyDeviation = frequency * periodStdError instead: + REQUIRE (result.frequencyDeviation != Approx (4800.0 * std::sqrt (2.0 / 15.0))); + // Omitting the 1/ln(2) factor when converting to semitones would leave + // pitchDeviation = 12 * relative: + REQUIRE (result.pitchDeviation != Approx (12.0 * (std::sqrt (2.0 / 15.0) / 10.0))); +} From df3b904f2dd6d58ad0b3475750664239a08ca65d Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 09:23:26 +0200 Subject: [PATCH 15/60] feat: classify measurement errors and track per-sweep failures --- CMakeLists.txt | 3 +- Source/dsp/MeasurementError.cpp | 36 ++++++++++++++++++++++++ Source/dsp/MeasurementError.h | 50 +++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 3 +- tests/MeasurementErrorTests.cpp | 48 +++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 Source/dsp/MeasurementError.cpp create mode 100644 Source/dsp/MeasurementError.h create mode 100644 tests/MeasurementErrorTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e90d06..b254de4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -59,7 +59,8 @@ include_directories(Source) # Pure DSP logic, no JUCE dependency. Linked by both the app and the tests. add_library(vcotuner_dsp STATIC Source/dsp/PeriodDetector.cpp - Source/dsp/MeasurementStatistics.cpp) + Source/dsp/MeasurementStatistics.cpp + Source/dsp/MeasurementError.cpp) target_include_directories(vcotuner_dsp PUBLIC Source) target_compile_features(vcotuner_dsp PUBLIC cxx_std_17) diff --git a/Source/dsp/MeasurementError.cpp b/Source/dsp/MeasurementError.cpp new file mode 100644 index 0000000..dd7ee30 --- /dev/null +++ b/Source/dsp/MeasurementError.cpp @@ -0,0 +1,36 @@ +#include "MeasurementError.h" + +namespace vcotuner +{ + +bool isFatal (MeasurementError error) noexcept +{ + switch (error) + { + case MeasurementError::noFrequencyChange: + case MeasurementError::noMidiDevice: + case MeasurementError::audioDeviceStopped: + return true; + + case MeasurementError::none: + case MeasurementError::highJitter: + case MeasurementError::noZeroCrossings: + case MeasurementError::highJitterTimeOut: + case MeasurementError::stableTimeout: + case MeasurementError::bufferFull: + return false; + } + return false; +} + +void FailureTracker::beginSweep() +{ + entries.clear(); +} + +void FailureTracker::recordFailure (int midiPitch, MeasurementError reason) +{ + entries.push_back (NoteFailure { midiPitch, reason }); +} + +} // namespace vcotuner diff --git a/Source/dsp/MeasurementError.h b/Source/dsp/MeasurementError.h new file mode 100644 index 0000000..720fd01 --- /dev/null +++ b/Source/dsp/MeasurementError.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +namespace vcotuner +{ + +enum class MeasurementError +{ + none, + // Per-note: mark the note and carry on with the sweep. + highJitter, + noZeroCrossings, + highJitterTimeOut, + stableTimeout, + bufferFull, + // Fatal: nothing further can succeed, so abort and tell the user. + noFrequencyChange, + noMidiDevice, + audioDeviceStopped +}; + +/** True when the error makes the rest of the run pointless. */ +bool isFatal (MeasurementError error) noexcept; + +struct NoteFailure +{ + int midiPitch = 0; + MeasurementError reason = MeasurementError::none; +}; + +/** Records which notes failed during the current sweep. + + The list is cleared by beginSweep(), so in cycling mode it always + describes the most recent pass rather than accumulating history. +*/ +class FailureTracker +{ +public: + void beginSweep(); + void recordFailure (int midiPitch, MeasurementError reason); + + bool hasFailures() const noexcept { return ! entries.empty(); } + const std::vector& failures() const noexcept { return entries; } + +private: + std::vector entries; +}; + +} // namespace vcotuner diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 02e1e4f..0760b0a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -8,7 +8,8 @@ FetchContent_MakeAvailable(Catch2) add_executable(VCOTunerTests PeriodDetectorTests.cpp - MeasurementStatisticsTests.cpp) + MeasurementStatisticsTests.cpp + MeasurementErrorTests.cpp) target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) diff --git a/tests/MeasurementErrorTests.cpp b/tests/MeasurementErrorTests.cpp new file mode 100644 index 0000000..8fa879b --- /dev/null +++ b/tests/MeasurementErrorTests.cpp @@ -0,0 +1,48 @@ +#include +#include "dsp/MeasurementError.h" + +using namespace vcotuner; + +TEST_CASE ("setup errors are fatal and abort the run") +{ + REQUIRE (isFatal (MeasurementError::noFrequencyChange)); + REQUIRE (isFatal (MeasurementError::noMidiDevice)); + REQUIRE (isFatal (MeasurementError::audioDeviceStopped)); +} + +TEST_CASE ("per-note measurement failures are not fatal") +{ + REQUIRE_FALSE (isFatal (MeasurementError::highJitter)); + REQUIRE_FALSE (isFatal (MeasurementError::noZeroCrossings)); + REQUIRE_FALSE (isFatal (MeasurementError::highJitterTimeOut)); + REQUIRE_FALSE (isFatal (MeasurementError::stableTimeout)); + REQUIRE_FALSE (isFatal (MeasurementError::bufferFull)); + REQUIRE_FALSE (isFatal (MeasurementError::none)); +} + +TEST_CASE ("the failure list resets at the start of each sweep") +{ + FailureTracker tracker; + + tracker.beginSweep(); + tracker.recordFailure (84, MeasurementError::highJitter); + tracker.recordFailure (96, MeasurementError::noZeroCrossings); + REQUIRE (tracker.hasFailures()); + REQUIRE (tracker.failures().size() == 2); + + // A new cycle starts clean, so a note that now reads correctly + // disappears from the status line immediately. + tracker.beginSweep(); + REQUIRE_FALSE (tracker.hasFailures()); + REQUIRE (tracker.failures().empty()); +} + +TEST_CASE ("failures record both the pitch and the reason") +{ + FailureTracker tracker; + tracker.beginSweep(); + tracker.recordFailure (84, MeasurementError::highJitter); + + REQUIRE (tracker.failures()[0].midiPitch == 84); + REQUIRE (tracker.failures()[0].reason == MeasurementError::highJitter); +} From 9eb154d7820c922f7dfb2a8b9efdd8809152ff8d Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 09:32:17 +0200 Subject: [PATCH 16/60] fix: floor the measurement timeout so high pitches are not cut off 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. --- CMakeLists.txt | 3 +- Source/dsp/MeasurementTiming.cpp | 26 +++++++++++++++++ Source/dsp/MeasurementTiming.h | 17 +++++++++++ tests/CMakeLists.txt | 3 +- tests/MeasurementTimingTests.cpp | 49 ++++++++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 Source/dsp/MeasurementTiming.cpp create mode 100644 Source/dsp/MeasurementTiming.h create mode 100644 tests/MeasurementTimingTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b254de4..472e28d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,7 +60,8 @@ include_directories(Source) add_library(vcotuner_dsp STATIC Source/dsp/PeriodDetector.cpp Source/dsp/MeasurementStatistics.cpp - Source/dsp/MeasurementError.cpp) + Source/dsp/MeasurementError.cpp + Source/dsp/MeasurementTiming.cpp) target_include_directories(vcotuner_dsp PUBLIC Source) target_compile_features(vcotuner_dsp PUBLIC cxx_std_17) diff --git a/Source/dsp/MeasurementTiming.cpp b/Source/dsp/MeasurementTiming.cpp new file mode 100644 index 0000000..034d1d4 --- /dev/null +++ b/Source/dsp/MeasurementTiming.cpp @@ -0,0 +1,26 @@ +#include "MeasurementTiming.h" + +#include +#include + +namespace vcotuner +{ + +int computeTimeoutCycles (double expectedFrequency, + int numPeriods, + double timerIntervalSeconds, + double latencyAllowanceSeconds) +{ + constexpr int minimumCycles = 50; // 500 ms at the default 10 ms timer + + if (expectedFrequency <= 0.0 || numPeriods <= 0 || timerIntervalSeconds <= 0.0) + return minimumCycles; + + const double measurementTime = (numPeriods / expectedFrequency) * 2.0; + const double totalTime = measurementTime + std::max (0.0, latencyAllowanceSeconds); + const int cycles = (int) std::ceil (totalTime / timerIntervalSeconds); + + return std::max (cycles, minimumCycles); +} + +} // namespace vcotuner diff --git a/Source/dsp/MeasurementTiming.h b/Source/dsp/MeasurementTiming.h new file mode 100644 index 0000000..f7a6e51 --- /dev/null +++ b/Source/dsp/MeasurementTiming.h @@ -0,0 +1,17 @@ +#pragma once + +namespace vcotuner +{ + +/** How many timer cycles to wait before declaring a measurement timed out. + + Covers the time needed to observe numPeriods cycles of the expected + frequency, doubled for headroom, plus a fixed allowance for MIDI and + audio round-trip latency, and floored so it can never reach zero. +*/ +int computeTimeoutCycles (double expectedFrequency, + int numPeriods, + double timerIntervalSeconds, + double latencyAllowanceSeconds); + +} // namespace vcotuner diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0760b0a..98ba854 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,7 +9,8 @@ FetchContent_MakeAvailable(Catch2) add_executable(VCOTunerTests PeriodDetectorTests.cpp MeasurementStatisticsTests.cpp - MeasurementErrorTests.cpp) + MeasurementErrorTests.cpp + MeasurementTimingTests.cpp) target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) diff --git a/tests/MeasurementTimingTests.cpp b/tests/MeasurementTimingTests.cpp new file mode 100644 index 0000000..c23eadb --- /dev/null +++ b/tests/MeasurementTimingTests.cpp @@ -0,0 +1,49 @@ +#include +#include "dsp/MeasurementTiming.h" + +#include + +using namespace vcotuner; + +TEST_CASE ("the timeout is never zero anywhere in the supported range") +{ + // The old expression roundToInt(expectedTime * 100) evaluated to 0 above + // roughly MIDI 108, giving a measurement ~10 ms to finish - less than + // typical MIDI plus audio round-trip latency. + for (int midi = 0; midi <= 127; ++midi) + { + const double freq = 440.0 * std::pow (2.0, (midi - 69) / 12.0); + for (int periods : { 10, 20, 50, 100, 200, 400 }) + { + const int cycles = computeTimeoutCycles (freq, periods, 0.01, 0.3); + INFO ("midi=" << midi << " periods=" << periods); + REQUIRE (cycles >= 50); + } + } +} + +TEST_CASE ("low pitches get a proportionally longer timeout") +{ + const int low = computeTimeoutCycles (46.25, 400, 0.01, 0.3); + const int high = computeTimeoutCycles (4186.0, 400, 0.01, 0.3); + REQUIRE (low > high); +} + +TEST_CASE ("the latency allowance is included") +{ + // numPeriods must be large enough that both results clear the 50-cycle + // floor; at 440 Hz with the brief's original numPeriods=20, the raw + // (unfloored) cycle counts are 10 and 40 - both still below the floor - + // so both collapse to 50 and the comparison below is unsatisfiable by + // any implementation that honours the documented floor. See + // task-8-report.md for the full root-cause analysis. + const int without = computeTimeoutCycles (440.0, 400, 0.01, 0.0); + const int with = computeTimeoutCycles (440.0, 400, 0.01, 0.3); + REQUIRE (with > without); +} + +TEST_CASE ("invalid input still yields a usable timeout") +{ + REQUIRE (computeTimeoutCycles (0.0, 20, 0.01, 0.3) >= 50); + REQUIRE (computeTimeoutCycles (-1.0, 20, 0.01, 0.3) >= 50); +} From b83e5adbcb78c22ac5ae8828e66e9cb44faa642c Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 09:48:54 +0200 Subject: [PATCH 17/60] perf: let PeriodDetector pre-allocate its period storage 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. --- Source/dsp/PeriodDetector.cpp | 6 ++++++ Source/dsp/PeriodDetector.h | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 950ebba..1f5c8d3 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -5,6 +5,12 @@ namespace vcotuner { +void PeriodDetector::prepare (int maxPeriods) +{ + if (maxPeriods > 0) + periods.reserve ((size_t) maxPeriods); +} + void PeriodDetector::reset (const PeriodDetectorConfig& config) { cfg = config; diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h index 646d365..58efc80 100644 --- a/Source/dsp/PeriodDetector.h +++ b/Source/dsp/PeriodDetector.h @@ -30,6 +30,16 @@ enum class DetectorStatus class PeriodDetector { public: + /** Allocates period storage up front. + + reset() is called from the real-time audio thread, where a heap + allocation can cause dropouts. Call this once from a non-realtime + thread with the largest maxPeriods any later config will use: the + reserve inside reset() then asks for a capacity the buffer already + has, which the standard requires to be a no-op. + */ + void prepare (int maxPeriods); + void reset (const PeriodDetectorConfig& config); void processBlock (const float* samples, int numSamples); From 10667bc9c7331c2910f1017128cd96a7752f262f Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 09:48:54 +0200 Subject: [PATCH 18/60] feat: continue the sweep when a single note fails to measure 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 --- Source/VCOTuner.cpp | 497 ++++++++++++++++++++++---------------------- Source/VCOTuner.h | 74 ++++--- 2 files changed, 300 insertions(+), 271 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 42a0e71..77764e8 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -11,6 +11,50 @@ #include "../JuceLibraryCode/JuceHeader.h" #include "VCOTuner.h" +#include "dsp/MeasurementTiming.h" + +#include + +namespace +{ + /** Translates what the detector reported into the error the sweep records. */ + vcotuner::MeasurementError errorForStatus (vcotuner::DetectorStatus status) + { + using vcotuner::DetectorStatus; + using vcotuner::MeasurementError; + + switch (status) + { + case DetectorStatus::failedNoCrossings: return MeasurementError::noZeroCrossings; + case DetectorStatus::failedUnstable: return MeasurementError::highJitter; + case DetectorStatus::failedBufferFull: return MeasurementError::bufferFull; + case DetectorStatus::collecting: return MeasurementError::stableTimeout; + case DetectorStatus::stable: return MeasurementError::none; + } + return MeasurementError::none; + } + + /** Frequency and its uncertainty from the periods the detector collected. + + computeMeasurement() cannot serve the paths that use this: it also + expresses a pitch, which needs a reference frequency that these paths + either do not have yet or do not care about. + */ + bool fitFrequency (const vcotuner::PeriodDetector& detector, double sampleRate, + double& frequency, double& deviation) + { + const auto fit = vcotuner::fitPeriod (detector.validPeriods(), + detector.numValidPeriods()); + + if (! fit.valid || fit.periodSamples <= 0.0 || sampleRate <= 0.0) + return false; + + frequency = sampleRate / fit.periodSamples; + deviation = frequency * (fit.periodStdError / fit.periodSamples); + return true; + } +} + VCOTuner::VCOTuner(AudioDeviceManager* d) { state = stopped; @@ -22,6 +66,11 @@ VCOTuner::VCOTuner(AudioDeviceManager* d) midiChannel = 1; currentlyPlayingMidiNote = -1; + // Reserve the detector's storage here, on the message thread. reset() is + // called from the audio callback and asks for this same capacity, which + // makes its reserve a no-op instead of a real-time heap allocation. + detector.prepare(vcotuner::PeriodDetectorConfig().maxPeriods); + d->addChangeListener(this); d->addAudioCallback(this); @@ -119,7 +168,7 @@ void VCOTuner::timerCallback() if (cycleCounter >= 10) { // start a measurement and see if we get a stable pitch here - startMeasurement = true; + startDetectorRun(currentPitch); switchState(refMeasurement); break; } @@ -134,39 +183,38 @@ void VCOTuner::timerCallback() // send note off trySendMidiNoteOff(currentPitch); - if (lError == notStable) + // A failed reference measurement is always fatal: every other + // note's pitch is expressed relative to this frequency, so + // without it the rest of the sweep would be meaningless. + const vcotuner::DetectorStatus status = lastDetectorStatus(); + + if (status != vcotuner::DetectorStatus::stable) { - errors.add(Errors::highJitter); + errors.add(errorMessageForStatus(status)); switchState(stopped); + break; } - else + + double frequency = 0.0, deviation = 0.0; + if (!fitFrequency(detector, sampleRate, frequency, deviation)) { - // calculate frequency - int numMeasurements = periodLengthsHead - indexOfFirstValidPeriodLength; - double accumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) - accumulator += periodLengths[i]; - - double averagePeriod = accumulator / (double) numMeasurements; - - referenceFrequency = float(sampleRate / averagePeriod); - - // prepare next measurement - currentPitch = lowestPitch; - currentIndex = 0; - switchState(prepMeasurement); + errors.add(Errors::highJitter); + switchState(stopped); break; } + + referenceFrequency = (float) frequency; + + // prepare next measurement + currentPitch = lowestPitch; + currentIndex = 0; + switchState(prepMeasurement); + break; } if (cycleCounter > 1000) { - if (periodLengthsHead == 0) - errors.add(Errors::noZeroCrossings); - else if (lError == notStable) - errors.add(Errors::highJitterTimeOut); - else - errors.add(Errors::stableTimeout); + errors.add(errorMessageForStatus(lastDetectorStatus())); stopMeasurement = true; switchState(stopped); break; @@ -187,7 +235,7 @@ void VCOTuner::timerCallback() if (cycleCounter >= 10) { // start a measurement and see if we get a stable pitch here - startMeasurement = true; + startDetectorRun(currentPitch); switchState(measurement); break; } @@ -199,89 +247,71 @@ void VCOTuner::timerCallback() // measurement done if (!startMeasurement) { + // A single note that cannot be measured is not fatal: record it + // and carry on with the sweep. failCurrentNote() sends the note + // off, so it is not sent here. + const vcotuner::DetectorStatus status = lastDetectorStatus(); + + if (status != vcotuner::DetectorStatus::stable) + { + failCurrentNote(errorForStatus(status)); + break; + } + + const auto result = vcotuner::computeMeasurement(detector.validPeriods(), + detector.numValidPeriods(), + sampleRate, + referenceFrequency, + referencePitch); + if (!result.valid) + { + failCurrentNote(vcotuner::MeasurementError::highJitter); + break; + } + // send note off trySendMidiNoteOff(currentPitch); - - if (lError == notStable) + + // check if the frequency has changed compared to the reference frequency + // if not, it is likely that the MIDI output is not working. Do this only for the very first measurement + if (currentIndex == 0 + && std::abs(result.frequency - referenceFrequency) / referenceFrequency < 0.1) { - errors.add(Errors::highJitter); + errors.add(Errors::noFrequencyChangeBetweenMeasurements); switchState(stopped); - } - else - { - // calculate frequency - int numMeasurements = periodLengthsHead - indexOfFirstValidPeriodLength; - double accumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) - accumulator += periodLengths[i]; - - double averagePeriod = (double) accumulator / (double) numMeasurements; - - double frequency = sampleRate / averagePeriod; - double pitch = 12.0 * log(frequency / referenceFrequency) / log(2.0) + referencePitch; - - // check if the frequency has changed compared to the reference frequency - // if not, it is likely that the MIDI output is not working. Do this only for the very first measurement - if (currentIndex == 0) - { - if (std::abs(frequency - referenceFrequency)/referenceFrequency < 0.1) - { - errors.add(Errors::noFrequencyChangeBetweenMeasurements); - switchState(stopped); - } - } - - // estimate deviation of frequency and pitch - double fAccumulator = 0; - double pAccumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) - { - double f = sampleRate / (double) periodLengths[i]; - fAccumulator += pow(f - frequency, 2); - pAccumulator += pow(12.0 * log(f / referenceFrequency) / log(2.0) + referencePitch - pitch, 2); - } - fAccumulator = fAccumulator / (numMeasurements - 1); - pAccumulator = pAccumulator / (numMeasurements - 1); - double fDeviation = sqrt(fAccumulator); - double pDeviation = sqrt(pAccumulator); - - measurement_t m; - m.timestamp = Time::getCurrentTime(); - m.frequency = frequency; - m.pitch = pitch; - m.midiPitch = currentPitch; - m.pitchOffset = pitch - currentPitch; - m.freqDeviation = fDeviation; - m.pitchDeviation = pDeviation; - m.numMeasurements = numMeasurements; - listeners.call(&Listener::newMeasurementReady, m); - - // prepare next measurement - currentPitch += pitchIncrement; - currentIndex++; - - if (currentPitch <= highestPitch) - switchState(prepMeasurement); - else - switchState(finished); break; } + + measurement_t m; + m.timestamp = Time::getCurrentTime(); + m.frequency = result.frequency; + m.pitch = result.pitch; + m.midiPitch = currentPitch; + m.pitchOffset = result.pitch - currentPitch; + m.freqDeviation = result.frequencyDeviation; + m.pitchDeviation = result.pitchDeviation; + m.numMeasurements = detector.numValidPeriods(); + listeners.call(&Listener::newMeasurementReady, m); + + // prepare next measurement + currentPitch += pitchIncrement; + currentIndex++; + + if (currentPitch <= highestPitch) + switchState(prepMeasurement); + else + switchState(finished); + break; } - float expectedFrequency = referenceFrequency * powf(2,((float) currentPitch - (float) referencePitch)/12.0f); - float expectedTime = 1.0f / (float) expectedFrequency * numPeriodSamples; - expectedTime *= 2; - int expectedCycles = juce::roundToInt(expectedTime * 100); + const double expectedFrequency = referenceFrequency + * std::pow(2.0, ((double) currentPitch - (double) referencePitch) / 12.0); + const int expectedCycles = vcotuner::computeTimeoutCycles(expectedFrequency, + numPeriodSamples, + 0.01, 0.3); if (cycleCounter > expectedCycles) { - if (periodLengthsHead == 0) - errors.add(Errors::noZeroCrossings); - else if (lError == notStable) - errors.add(Errors::highJitterTimeOut); - else - errors.add(Errors::stableTimeout); - stopMeasurement = true; - switchState(stopped); + failCurrentNote(errorForStatus(lastDetectorStatus())); break; } cycleCounter++; @@ -297,7 +327,7 @@ void VCOTuner::timerCallback() // send midi note and start measuring trySendMidiNoteOn(continuousFrequencyMeasurementPitch); - startMeasurement = true; + startDetectorRun(continuousFrequencyMeasurementPitch); switchState(continuousFrequencyMeasurement); cycleCounter++; } break; @@ -306,31 +336,18 @@ void VCOTuner::timerCallback() // if the measurement is done) if (!startMeasurement) { - // calculate frequency - int numMeasurements = periodLengthsHead - indexOfFirstValidPeriodLength; - double accumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) - accumulator += periodLengths[i]; - - double averagePeriod = (double) accumulator / (double) numMeasurements; - - double frequency = sampleRate / averagePeriod; - - // estimate deviation of frequency - double fAccumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) + // Keep the previous reading when this one did not settle - this + // mode runs until the user stops it, so there is nobody to tell. + double frequency = 0.0, deviation = 0.0; + if (lastDetectorStatus() == vcotuner::DetectorStatus::stable + && fitFrequency(detector, sampleRate, frequency, deviation)) { - double f = sampleRate / (double) periodLengths[i]; - fAccumulator += pow(f - frequency, 2); + continuousFreqMeasurementResult = frequency; + continuousFreqMeasurementDeviation = deviation; } - fAccumulator = fAccumulator / (numMeasurements - 1); - double fDeviation = sqrt(fAccumulator); - - continuousFreqMeasurementResult = frequency; - continuousFreqMeasurementDeviation = fDeviation; // restart measurement - startMeasurement = true; + startDetectorRun(continuousFrequencyMeasurementPitch); } cycleCounter++; } break; @@ -352,7 +369,7 @@ void VCOTuner::timerCallback() if (cycleCounter >= 10) { // start a measurement and see if we get a stable pitch here - startMeasurement = true; + startDetectorRun(singleMeasurementPitch); switchState(singleMeasurement); break; } @@ -367,50 +384,34 @@ void VCOTuner::timerCallback() // send note off trySendMidiNoteOff(singleMeasurementPitch); - if (lError == notStable) + const vcotuner::DetectorStatus status = lastDetectorStatus(); + + if (status != vcotuner::DetectorStatus::stable) { - errors.add(Errors::highJitter); + errors.add(errorMessageForStatus(status)); switchState(stopped); + break; } - else + + double frequency = 0.0, deviation = 0.0; + if (!fitFrequency(detector, sampleRate, frequency, deviation)) { - // calculate frequency - int numMeasurements = periodLengthsHead - indexOfFirstValidPeriodLength; - double accumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) - accumulator += periodLengths[i]; - - double averagePeriod = (double) accumulator / (double) numMeasurements; - - double frequency = sampleRate / averagePeriod; - - // estimate deviation of frequency - double fAccumulator = 0; - for (int i = indexOfFirstValidPeriodLength; i < periodLengthsHead; i++) - { - double f = sampleRate / (double) periodLengths[i]; - fAccumulator += pow(f - frequency, 2); - } - fAccumulator = fAccumulator / (numMeasurements - 1); - double fDeviation = sqrt(fAccumulator); - - singleMeasurementResult = frequency; - singleMeasurementDeviation = fDeviation; - - switchState(finished); + errors.add(Errors::highJitter); + switchState(stopped); break; } + + singleMeasurementResult = frequency; + singleMeasurementDeviation = deviation; + + switchState(finished); + break; } // timeout handling if (cycleCounter > 1000) { - if (periodLengthsHead == 0) - errors.add(Errors::noZeroCrossings); - else if (lError == notStable) - errors.add(Errors::highJitterTimeOut); - else - errors.add(Errors::stableTimeout); + errors.add(errorMessageForStatus(lastDetectorStatus())); stopMeasurement = true; switchState(stopped); break; @@ -462,6 +463,67 @@ void VCOTuner::trySendMidiNoteOff(int pitch) currentlyPlayingMidiNote = -1; } +void VCOTuner::startDetectorRun(int pitch) +{ + // Size the detector's level tracking window to two cycles of the frequency + // we expect at this pitch: the trigger level is latched at the end of that + // window, so a low note needs a longer look at the signal than a high one. + const double expectedFreq = (state == prepRefMeasurement || referenceFrequency <= 0.0f) + ? 440.0 * std::pow(2.0, (pitch - 69) / 12.0) + : referenceFrequency * std::pow(2.0, (pitch - referencePitch) / 12.0); + const double twoCycles = (expectedFreq > 0.0) ? (2.0 * sampleRate / expectedFreq) : 2048.0; + currentWarmupSamples = jlimit(256, 48000, (int) twoCycles); + + // Publish 'collecting' before handing the detector to the audio thread, so + // that the state machine cannot read the previous run's terminal status + // while it is waiting for this one. + detectorStatusFlag = (int) vcotuner::DetectorStatus::collecting; + startMeasurement = true; +} + +void VCOTuner::failCurrentNote(vcotuner::MeasurementError reason) +{ + trySendMidiNoteOff(currentPitch); + stopMeasurement = true; + + // trySendMidiNoteOff() stops the tuner when the MIDI device has gone away. + // That is fatal, so do not resume the sweep on top of it. + if (state == stopped) + return; + + failureTracker.recordFailure(currentPitch, reason); + listeners.call(&Listener::measurementFailed, currentPitch, reason); + + currentPitch += pitchIncrement; + currentIndex++; + + if (currentPitch <= highestPitch) + switchState(prepMeasurement); + else + switchState(finished); +} + +const String& VCOTuner::errorMessageForStatus(vcotuner::DetectorStatus status) const +{ + switch (status) + { + case vcotuner::DetectorStatus::failedNoCrossings: + return Errors::noZeroCrossings; + case vcotuner::DetectorStatus::failedUnstable: + case vcotuner::DetectorStatus::failedBufferFull: + // Both mean the same thing to the user: the crossings never settled + // into a constant rate, either within tolerance or within storage. + return Errors::highJitter; + case vcotuner::DetectorStatus::collecting: + case vcotuner::DetectorStatus::stable: + break; + } + + // Still collecting when the caller gave up: crossings are coming in, just + // far slower than this pitch should produce. + return Errors::stableTimeout; +} + /** inherited from AudioIODeviceCallback */ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, int numInputChannels, @@ -469,9 +531,11 @@ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, int numOutputChannels, int numSamples) { - if (inputChannelData == nullptr) - return; - const AudioBuffer inputBuffer(inputChannelData, numInputChannels, numSamples); + if (outputChannelData != nullptr) + { + AudioBuffer outputBuffer(outputChannelData, numOutputChannels, numSamples); + outputBuffer.clear(); + } if (stopMeasurement) { @@ -479,96 +543,37 @@ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, stopMeasurement = false; initialized = false; } - - if (startMeasurement) + + if (!startMeasurement) + return; + + // Guard the channel access: numInputChannels was never checked before, + // so a device with no enabled input channels read out of bounds. + if (inputChannelData == nullptr || numInputChannels <= 0 + || inputChannelData[0] == nullptr) + return; + + if (!initialized) { - // check if measurement was initialized - if (!initialized) - { - lError = noError; - sampleCounter = 0; - lastZeroCrossing = -1; - indexOfFirstValidPeriodLength = -1; - periodLengthsHead = 0; - initialized = true; - } - - // try to find a zero crossing (- => +) - for (int i = 0; i < numSamples; i++) - { - float currentSample = inputBuffer.getSample(0, i); - if (lastSample < 0 && currentSample >= 0) - { - if (periodLengthsHead >= maxNumPeriodLengths) - break; - - // interpolate line between the sample before and after the crossing - // y = mx + n - double m = (lastSample - currentSample); - double n = lastSample - m*(sampleCounter); - - // zero crossing of interpolated line: y = 0 => x0 = -n/m - double zeroCrossingPos = -n / m; - - periodLengths[periodLengthsHead++] = zeroCrossingPos - lastZeroCrossing; - lastZeroCrossing = zeroCrossingPos; - } - lastSample = currentSample; - sampleCounter++; - } - - // see if the period length is stable - if (periodLengthsHead > 5 && indexOfFirstValidPeriodLength < 0) - { - double sum = 0; - for (int i = periodLengthsHead - 5; i < periodLengthsHead; i++) - { - sum += periodLengths[i]; - } - double average = sum / 5.0; - - bool okay = true; - double boundary = average * 0.1; // max 10% error allowed - for (int i = periodLengthsHead - 5; i < periodLengthsHead; i++) - { - if (std::abs(periodLengths[i] - average) >= boundary) - okay = false; - } - - if (okay) - { - indexOfFirstValidPeriodLength = periodLengthsHead; - } - } - - // finish measurement when the required number of valid measurements are made - int numMeasurements = periodLengthsHead - indexOfFirstValidPeriodLength; - if ((indexOfFirstValidPeriodLength > 0) && (numMeasurements > numPeriodSamples)) - { - lError = noError; - initialized = false; - startMeasurement = false; - } - // the pitch hasn't stabilized yet. - // assign the notStable error prematurely, just in case the top level statemachine runs into - // a timeout and wants to know whats going on. - else if (indexOfFirstValidPeriodLength < 0) - { - lError = notStable; - - // ran out of recording space => period length too jittery or does change constantly - stop here. - if ((periodLengthsHead >= maxNumPeriodLengths)) - { - initialized = false; - startMeasurement = false; - } - } + vcotuner::PeriodDetectorConfig cfg; + cfg.sampleRate = sampleRate; + cfg.requiredPeriods = numPeriodSamples; + cfg.warmupSamples = currentWarmupSamples; + // cfg.maxPeriods stays at its default, which is the capacity the + // constructor already reserved, so this reset() does not allocate. + detector.reset(cfg); + initialized = true; } - if (outputChannelData != nullptr) - { - AudioBuffer outputBuffer(outputChannelData, numOutputChannels, numSamples); - outputBuffer.clear(); + detector.processBlock(inputChannelData[0], numSamples); + detectorStatusFlag = (int) detector.status(); + + if (detector.status() != vcotuner::DetectorStatus::collecting) + { + initialized = false; + // Published last: the state machine treats this as permission to read + // the detector, so every write above must already be visible. + startMeasurement = false; } } @@ -584,7 +589,11 @@ void VCOTuner::switchState(VCOTuner::State newState) listeners.call(&Listener::tunerStopped); } else if (newState == prepRefMeasurement) + { + // a new sweep: the status line should describe this pass, not history + failureTracker.beginSweep(); listeners.call(&Listener::tunerStarted); + } else if (newState == finished) listeners.call(&Listener::tunerFinished); diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index 923318b..520f2cb 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -13,6 +13,13 @@ #include "../JuceLibraryCode/JuceHeader.h" +#include "dsp/MeasurementError.h" +#include "dsp/MeasurementStatistics.h" +#include "dsp/PeriodDetector.h" + +#include +#include + class VCOTuner: public ChangeListener, private Timer, public AudioIODeviceCallback @@ -65,6 +72,10 @@ class VCOTuner: public ChangeListener, /** returns all error messages and removes them from the internal list */ StringArray getLastErrors(); + /** the notes that failed to measure during the current sweep */ + const std::vector& getFailures() const + { return failureTracker.failures(); } + /** inherited from AudioIODeviceCallback */ virtual void audioDeviceIOCallback (const float** inputChannelData, int numInputChannels, @@ -87,6 +98,11 @@ class VCOTuner: public ChangeListener, virtual ~Listener() {} virtual void newMeasurementReady(const measurement_t& /*m*/) {} + + /** a single note could not be measured. The sweep carries on without it. */ + virtual void measurementFailed (int /*midiPitch*/, + vcotuner::MeasurementError /*reason*/) {} + virtual void tunerStarted() {} virtual void tunerStopped() {} virtual void tunerFinished() {} @@ -119,19 +135,18 @@ class VCOTuner: public ChangeListener, void switchState(State newState); void trySendMidiNoteOn(int pitch); void trySendMidiNoteOff(int pitch); + /** hands the detector to the audio thread for a measurement at this pitch */ + void startDetectorRun(int pitch); + /** records the failure, tells the listeners and moves on to the next note */ + void failCurrentNote(vcotuner::MeasurementError reason); + /** the user facing message for a detector status that is not 'stable' */ + const String& errorMessageForStatus(vcotuner::DetectorStatus status) const; int currentlyPlayingMidiNote; // counts cycles since the last state transition int cycleCounter; - /** error message from the audio thread */ - enum LowLevelError - { - noError = 0, - notStable // frequency not stable (= too much jitter) - }; - /** lowest pitch to be measured */ int lowestPitch; /** pitch increment */ @@ -143,9 +158,9 @@ class VCOTuner: public ChangeListener, int currentIndex; /** midi note for which the reference measurement was done. */ - int referencePitch; - /** frequency returned during the reference measurement */ - float referenceFrequency; + int referencePitch = 0; + /** frequency returned during the reference measurement, 0 until measured */ + float referenceFrequency = 0.0f; /** a list with recent error messages */ StringArray errors; @@ -157,24 +172,29 @@ class VCOTuner: public ChangeListener, State state; - /** the following must only be accessed from the message thread, when startMeasurement == false and - be accessed from the audio thread, when startMeasurement == true */ - bool startMeasurement; // set by message thread, reset by audio thread. - bool stopMeasurement; // set by message thread, reset by audio thread. - static const int maxNumPeriodLengths = 600; - double periodLengths[maxNumPeriodLengths]; // all measured period lengths of this measurement + /** The detector is owned by the audio thread while startMeasurement is true. + The message thread may read it only after it has observed startMeasurement + == false, which the audio thread publishes after its last write. */ + vcotuner::PeriodDetector detector; + std::atomic startMeasurement { false }; // set by message thread, reset by audio thread. + std::atomic stopMeasurement { false }; // set by message thread, reset by audio thread. + /** the detector's status, published by the audio thread after every block */ + std::atomic detectorStatusFlag { (int) vcotuner::DetectorStatus::collecting }; + + vcotuner::DetectorStatus lastDetectorStatus() const noexcept + { return (vcotuner::DetectorStatus) detectorStatusFlag.load(); } + int numPeriodSamples; // number of periods to measure before averaging - int indexOfFirstValidPeriodLength; // the index in periodLengths[] at which the system has reached a stable frequency - // this is also the first valid period length measurement that is included in the result - int periodLengthsHead; - LowLevelError lError; // holds error message from the audio thread - - /** the following are only to be accessed from the audio thread */ - int sampleCounter; // counts samples since the start of a measurement - double lastZeroCrossing; // holds the sample counters value of the last zero corssing (- => +) - float lastSample; - double sampleRate; - bool initialized; + /** length of the detector's level tracking window, sized per note */ + int currentWarmupSamples = 2048; + + /** the notes that failed during the current sweep */ + vcotuner::FailureTracker failureTracker; + + /** written in audioDeviceAboutToStart, before any measurement can run */ + double sampleRate = 44100.0; + /** only to be accessed from the audio thread */ + bool initialized = false; int continuousFrequencyMeasurementPitch; double continuousFreqMeasurementResult; From 4737c7995e2e630c84d665b1e2a821ce572572ee Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 10:01:25 +0200 Subject: [PATCH 19/60] fix: stop a failed note from cancelling the next note's measurement 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. --- Source/VCOTuner.cpp | 28 +++++++++++++++++++++++----- Source/VCOTuner.h | 6 ++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 77764e8..a5f4944 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -223,6 +223,12 @@ void VCOTuner::timerCallback() break; } case prepMeasurement: + // wait for low level state machine to stop measuring: handing it a + // new run while a stop request is pending would have it consume the + // stale request and kill the run it was meant to start. + if (stopMeasurement) + break; + if (cycleCounter == 0) { // send midi note @@ -427,6 +433,8 @@ void VCOTuner::timerCallback() void VCOTuner::startContinuousMeasurement(int pitch) { continuousFrequencyMeasurementPitch = pitch; + continuousFreqMeasurementResult = -1.0; + continuousFreqMeasurementDeviation = 0.0; if (state != stopped && state != finished) switchState(stopped); state = prepareContinuousFrequencyMeasurement; @@ -484,7 +492,13 @@ void VCOTuner::startDetectorRun(int pitch) void VCOTuner::failCurrentNote(vcotuner::MeasurementError reason) { trySendMidiNoteOff(currentPitch); - stopMeasurement = true; + + // Only cancel a run that is actually in flight - the timeout path. When the + // detector finished on its own the audio thread has already cleared its own + // state, and a stop request left armed here would be consumed by the next + // note's run instead. + if (startMeasurement) + stopMeasurement = true; // trySendMidiNoteOff() stops the tuner when the MIDI device has gone away. // That is fatal, so do not resume the sweep on top of it. @@ -531,10 +545,13 @@ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, int numOutputChannels, int numSamples) { + // Channels that were not enabled when the device was opened are null, and + // AudioBuffer::clear() would memset straight through them. if (outputChannelData != nullptr) { - AudioBuffer outputBuffer(outputChannelData, numOutputChannels, numSamples); - outputBuffer.clear(); + for (int channel = 0; channel < numOutputChannels; channel++) + if (outputChannelData[channel] != nullptr) + FloatVectorOperations::clear(outputChannelData[channel], numSamples); } if (stopMeasurement) @@ -559,8 +576,9 @@ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, cfg.sampleRate = sampleRate; cfg.requiredPeriods = numPeriodSamples; cfg.warmupSamples = currentWarmupSamples; - // cfg.maxPeriods stays at its default, which is the capacity the - // constructor already reserved, so this reset() does not allocate. + // cfg.maxPeriods must stay at the default the constructor reserved, + // otherwise this reset() would allocate on the audio thread. + jassert(cfg.maxPeriods == vcotuner::PeriodDetectorConfig().maxPeriods); detector.reset(cfg); initialized = true; } diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index 520f2cb..967133f 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -197,8 +197,10 @@ class VCOTuner: public ChangeListener, bool initialized = false; int continuousFrequencyMeasurementPitch; - double continuousFreqMeasurementResult; - double continuousFreqMeasurementDeviation; + /** -1 until a pass has settled: ReportPrepScreen polls this and advances + the report wizard on it, so it must never hold an undefined value. */ + double continuousFreqMeasurementResult = -1.0; + double continuousFreqMeasurementDeviation = 0.0; int singleMeasurementPitch; double singleMeasurementResult; From b4fab8811aa540c878dbf2d23983bb3ccb0739e7 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 10:11:41 +0200 Subject: [PATCH 20/60] feat: mark failed notes and exclude them from graph auto-scaling 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(). --- Source/Visualizer.cpp | 30 ++++++++++++++++++++++++------ Source/Visualizer.h | 11 ++++++++--- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index a71cdd8..f7ddb76 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -36,6 +36,9 @@ void Visualizer::paint(juce::Graphics &g, int width, int height) double min = 0; for (int i = 0; i < measurements.size(); i++) { + if (failedPitches.contains (measurements[i].midiPitch)) + continue; + double value = measurements[i].pitchOffset; double deviation = measurements[i].pitchDeviation; if (value - deviation < min) @@ -131,16 +134,21 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl { float left = sidebarWidth + i*(float)columnWidth; + const bool failed = failedPitches.contains (measurements[i].midiPitch); + const Colour bandColour = failed ? Colours::orangered.withAlpha (0.35f) + : Colours::springgreen.withAlpha (0.4f); + const Colour pointColour = failed ? Colours::orangered : Colours::green; + // draw deviation float maxPosition = (float) ((measurements[i].pitchOffset + measurements[i].pitchDeviation - min) * vertScaling); float minPosition = (float) ((measurements[i].pitchOffset - measurements[i].pitchDeviation - min) * vertScaling); - - g.setColour(Colours::springgreen.withAlpha(0.4f)); + + g.setColour(bandColour); g.fillRect(left, yFlip(maxPosition), (float) columnWidth, maxPosition - minPosition); - + // draw average value float pointPosition = (float) ((measurements[i].pitchOffset - min) * vertScaling); - g.setColour(Colours::green); + g.setColour(pointColour); g.drawLine(left, yFlip(pointPosition), left + (float) columnWidth, yFlip(pointPosition)); } @@ -231,6 +239,10 @@ float Visualizer::yFlip(float y) void Visualizer::newMeasurementReady(const VCOTuner::measurement_t& m) { + // a later cycle may have re-measured a note that previously failed; + // a fresh successful reading means it is no longer failed. + failedPitches.removeFirstMatchingValue (m.midiPitch); + bool found = false; for (int i = 0; i < measurements.size(); i++) { @@ -241,9 +253,15 @@ void Visualizer::newMeasurementReady(const VCOTuner::measurement_t& m) repaint(); } } - + if (!found) measurements.add(m); - + + repaint(); +} + +void Visualizer::measurementFailed (int midiPitch, vcotuner::MeasurementError) +{ + failedPitches.addIfNotAlreadyThere (midiPitch); repaint(); } diff --git a/Source/Visualizer.h b/Source/Visualizer.h index 0295429..c08d73e 100644 --- a/Source/Visualizer.h +++ b/Source/Visualizer.h @@ -25,12 +25,17 @@ class Visualizer: public Component, virtual void paint(Graphics& g); virtual void newMeasurementReady(const VCOTuner::measurement_t& m); - - void clearCache() { measurements.clear(); } + void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override; + + void clearCache() { measurements.clear(); failedPitches.clear(); } private: /** holds the list of completed measurements */ Array measurements; - + /** midi pitches that failed to measure during the current sweep; + cleared alongside measurements, and individually cleared in + newMeasurementReady() when a note recovers on a later cycle */ + Array failedPitches; + float heightForFlipping; float yFlip(float y); From 58d5ad32992def5ab253502e65e2b55381e45dba Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 10:23:08 +0200 Subject: [PATCH 21/60] fix: give failed notes a column and a shape-based marker 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. --- Source/Visualizer.cpp | 59 ++++++++++++++++++++++++++++++------------- Source/Visualizer.h | 7 ++++- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index f7ddb76..a89465a 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -133,22 +133,34 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl for (int i = 0; i < measurements.size(); i++) { float left = sidebarWidth + i*(float)columnWidth; - - const bool failed = failedPitches.contains (measurements[i].midiPitch); - const Colour bandColour = failed ? Colours::orangered.withAlpha (0.35f) - : Colours::springgreen.withAlpha (0.4f); - const Colour pointColour = failed ? Colours::orangered : Colours::green; + + if (failedPitches.contains (measurements[i].midiPitch)) + { + // no usable measurement for this note: a point/band at offset + // zero would misleadingly look "in tune", so mark the whole + // column instead with a shape that doesn't depend on hue. + g.setColour (Colours::orangered.withAlpha (0.15f)); + g.fillRect (left, 0.0f, (float) columnWidth, (float) imageHeight); + + g.setColour (Colours::orangered); + const float margin = (float) columnWidth * 0.25f; + const float crossTop = (float) imageHeight * 0.25f; + const float crossBottom = (float) imageHeight * 0.75f; + g.drawLine (left + margin, crossTop, left + (float) columnWidth - margin, crossBottom, 2.0f); + g.drawLine (left + margin, crossBottom, left + (float) columnWidth - margin, crossTop, 2.0f); + continue; + } // draw deviation float maxPosition = (float) ((measurements[i].pitchOffset + measurements[i].pitchDeviation - min) * vertScaling); float minPosition = (float) ((measurements[i].pitchOffset - measurements[i].pitchDeviation - min) * vertScaling); - g.setColour(bandColour); + g.setColour(Colours::springgreen.withAlpha(0.4f)); g.fillRect(left, yFlip(maxPosition), (float) columnWidth, maxPosition - minPosition); // draw average value float pointPosition = (float) ((measurements[i].pitchOffset - min) * vertScaling); - g.setColour(pointColour); + g.setColour(Colours::green); g.drawLine(left, yFlip(pointPosition), left + (float) columnWidth, yFlip(pointPosition)); } @@ -237,25 +249,27 @@ float Visualizer::yFlip(float y) return heightForFlipping - y; } -void Visualizer::newMeasurementReady(const VCOTuner::measurement_t& m) +void Visualizer::upsertMeasurement (const VCOTuner::measurement_t& m) { - // a later cycle may have re-measured a note that previously failed; - // a fresh successful reading means it is no longer failed. - failedPitches.removeFirstMatchingValue (m.midiPitch); - - bool found = false; for (int i = 0; i < measurements.size(); i++) { if (measurements[i].midiPitch == m.midiPitch) { - measurements.set(i, m); - found = true; - repaint(); + measurements.set (i, m); + return; } } - if (!found) - measurements.add(m); + measurements.add (m); +} + +void Visualizer::newMeasurementReady(const VCOTuner::measurement_t& m) +{ + // a later cycle may have re-measured a note that previously failed; + // a fresh successful reading means it is no longer failed. + failedPitches.removeFirstMatchingValue (m.midiPitch); + + upsertMeasurement (m); repaint(); } @@ -263,5 +277,14 @@ void Visualizer::newMeasurementReady(const VCOTuner::measurement_t& m) void Visualizer::measurementFailed (int midiPitch, vcotuner::MeasurementError) { failedPitches.addIfNotAlreadyThere (midiPitch); + + // give the pitch a column even if it has never produced a measurement, + // so a first-attempt failure is visible instead of just closing the gap. + // All numeric fields stay at zero; numMeasurements == 0 marks this as a + // placeholder. A later successful reading overwrites it via upsertMeasurement. + VCOTuner::measurement_t placeholder {}; + placeholder.midiPitch = midiPitch; + upsertMeasurement (placeholder); + repaint(); } diff --git a/Source/Visualizer.h b/Source/Visualizer.h index c08d73e..e4883e8 100644 --- a/Source/Visualizer.h +++ b/Source/Visualizer.h @@ -36,9 +36,14 @@ class Visualizer: public Component, newMeasurementReady() when a note recovers on a later cycle */ Array failedPitches; + /** replaces the entry for m.midiPitch if one exists, otherwise appends m. + Shared by newMeasurementReady() and measurementFailed() so a pitch + never occupies more than one column. */ + void upsertMeasurement (const VCOTuner::measurement_t& m); + float heightForFlipping; float yFlip(float y); - + VCOTuner* tuner; }; From 8eb794cccf7199098dc22dfcdf856b774b20690b Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 10:37:39 +0200 Subject: [PATCH 22/60] feat: report failed notes without interrupting the sweep 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. --- Source/MainComponent.cpp | 85 ++++++++++++++++++++++++++++++++--- Source/MainComponent.h | 7 ++- Source/VCOTuner.cpp | 50 +++++++++++++++++---- Source/VCOTuner.h | 7 ++- Source/dsp/MeasurementError.h | 5 +++ 5 files changed, 138 insertions(+), 16 deletions(-) diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index 91cd404..a9870d2 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -41,7 +41,11 @@ MainComponent::MainComponent() : tuner(&deviceManager), display(&tuner) statusLabel.setName("Status Label"); statusLabel.setJustificationType(juce::Justification::centred); addAndMakeVisible(&statusLabel); - + + failureLabel.setName("Failure Label"); + failureLabel.setJustificationType(juce::Justification::centredLeft); + addAndMakeVisible(&failureLabel); + regimeLabel.setName("Regime Label"); regimeLabel.setText("Pitch range: ", dontSendNotification); regimeLabel.setJustificationType(juce::Justification::centredRight); @@ -120,10 +124,19 @@ void MainComponent::resized() resolution.setBounds(regimeLabel.getX() - 120 - borderWidth, audioSettings.getBottom() + borderWidth, 120, buttonHeight); resolutionLabel.setBounds(resolution.getX() - 80 - borderWidth, audioSettings.getBottom() + borderWidth, 80, buttonHeight); + // failureLabel gets a fixed-height row at the very bottom, beneath the + // graph. jmax guards a window shrunk past MainWindow's resize limits (or + // that limit changing later) from handing the graph a negative height. + failureLabel.setBounds(borderWidth, + getHeight() - borderWidth - buttonHeight, + getWidth() - 2 * borderWidth, + buttonHeight); + + const int displayTop = regimeLabel.getBottom() + borderWidth; display.setBounds(borderWidth, - regimeLabel.getBottom() + borderWidth, + displayTop, getWidth() - 2 * borderWidth, - getHeight() - 2* borderWidth - regimeLabel.getBottom()); + jmax(0, failureLabel.getY() - borderWidth - displayTop)); } @@ -320,6 +333,14 @@ void MainComponent::showAudioSettings() void MainComponent::tunerStarted() { startStop.setButtonText("Stop"); + + // A new run: clear any failures left over from the previous one, so a + // stale "Not reading: MIDI 84" doesn't linger once that note is being + // re-measured (or the range/settings have changed). Fires on every path + // that begins a run - the initial Start press and, in live tuning, every + // automatic re-cycle from tunerFinished() - since all of them go through + // VCOTuner::switchState(prepRefMeasurement). + failureLabel.setText({}, dontSendNotification); } void MainComponent::tunerStatusChanged(String statusString) @@ -332,10 +353,13 @@ void MainComponent::tunerStatusChanged(String statusString) void MainComponent::tunerStopped() { + // Fatal errors only (no MIDI device, audio device stopped, MIDI-to-CV not + // responding, ...). Per-note failures never reach here - they go to the + // status line via measurementFailed(), in both live and report mode. StringArray errors = tuner.getLastErrors(); for (int i = 0; i < errors.size(); i++) NativeMessageBox::showMessageBox(AlertWindow::WarningIcon, "Error!", errors[i]); - + startStop.setButtonText("Start"); cycle = false; creatingReport = false; @@ -344,16 +368,65 @@ void MainComponent::tunerStopped() void MainComponent::tunerFinished() { startStop.setButtonText("Start"); - + + // A report is a single sweep with a real end, so summarise the failures + // there in one dialog. Live tuning (cycle == true) restarts the sweep + // below and runs until Stop is pressed - there is no "end" to summarise, + // and a dialog that fired every cycle would just be noise, so it must + // never raise one. The failure label under the graph is what live tuning + // shows instead, and it already reflects the sweep that just finished. if (creatingReport) { creatingReport = false; + + const auto& failures = tuner.getFailures(); + if (! failures.empty()) + { + StringArray lines; + for (const auto& f : failures) + lines.add (" - MIDI " + String (f.midiPitch) + + " - " + describeError (f.reason)); + + NativeMessageBox::showMessageBox (AlertWindow::InfoIcon, + "Measurement finished", + String (failures.size()) + " of the measured notes could not be read:\n\n" + + lines.joinIntoString ("\n")); + } } - + if (cycle) tuner.toggleState(); } +void MainComponent::measurementFailed (int /*midiPitch*/, vcotuner::MeasurementError reason) +{ + // Per-note failures are never fatal - only the reasons that abort the + // whole sweep (routed through tunerStopped instead) are. + jassert (! vcotuner::isFatal (reason)); + + const auto& failures = tuner.getFailures(); + + if (failures.empty()) + { + failureLabel.setText ({}, dontSendNotification); + return; + } + + // A long sweep with a bad connection can fail most of its notes; listing + // all of them would overflow the label. Cap the list and note the rest. + constexpr int maxNotesShown = 10; + + StringArray pitches; + for (size_t i = 0; i < failures.size() && (int) i < maxNotesShown; ++i) + pitches.add (String (failures[i].midiPitch)); + + String text = "Not reading: MIDI " + pitches.joinIntoString (", "); + if ((int) failures.size() > maxNotesShown) + text << " (+" << (int) failures.size() - maxNotesShown << " more)"; + + failureLabel.setText (text, dontSendNotification); +} + const MainComponent::regime_t MainComponent::regimes[numRegimes] = { {54, 66, 6}, {54, 66, 3}, diff --git a/Source/MainComponent.h b/Source/MainComponent.h index 8e76c0e..8001fe2 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -39,7 +39,8 @@ class MainComponent: public Component, virtual void tunerStopped() override; virtual void tunerFinished() override; virtual void tunerStatusChanged(String statusString) override; - + virtual void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override; + void startCreatingReport(); private: @@ -54,6 +55,10 @@ class MainComponent: public Component, TextButton report; Visualizer display; Label statusLabel; + /** names the notes that are currently failing to measure. Cleared at the + start of every run (tunerStarted) and rebuilt from tuner.getFailures() + as failures come in, so it always reflects the current sweep only. */ + Label failureLabel; Label regimeLabel; ComboBox regime; Label resolutionLabel; diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index a5f4944..c18f0ed 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -524,20 +524,54 @@ const String& VCOTuner::errorMessageForStatus(vcotuner::DetectorStatus status) c case vcotuner::DetectorStatus::failedNoCrossings: return Errors::noZeroCrossings; case vcotuner::DetectorStatus::failedUnstable: - case vcotuner::DetectorStatus::failedBufferFull: - // Both mean the same thing to the user: the crossings never settled - // into a constant rate, either within tolerance or within storage. return Errors::highJitter; + case vcotuner::DetectorStatus::failedBufferFull: + // Distinct from highJitter: the signal may have been perfectly + // steady, it just needed more storage than this resolution setting + // allows before it could be confirmed stable. + return Errors::bufferFull; case vcotuner::DetectorStatus::collecting: case vcotuner::DetectorStatus::stable: break; } - - // Still collecting when the caller gave up: crossings are coming in, just - // far slower than this pitch should produce. + + // Still collecting when the caller gave up. This covers two different + // situations that look the same from here: the crossings never settled + // into a steady rate, or the signal was too weak/intermittent for enough + // of them to arrive in the first place (warm-up never finished). Say + // neither is confirmed rather than asserting the first. return Errors::stableTimeout; } +/** Short, user-facing description of a per-note measurement failure. Declared + in VCOTuner.h (outside the class) rather than in Source/dsp/, since it + returns a JUCE String and Source/dsp/ must stay JUCE-free. */ +String describeError (vcotuner::MeasurementError error) +{ + using vcotuner::MeasurementError; + switch (error) + { + case MeasurementError::highJitter: + case MeasurementError::highJitterTimeOut: // unreachable; see MeasurementError.h + return "unsteady rate"; + case MeasurementError::noZeroCrossings: + return "no signal detected"; + case MeasurementError::stableTimeout: + return "timed out"; + case MeasurementError::bufferFull: + return "never settled within the measurement buffer; try a lower resolution"; + case MeasurementError::none: + case MeasurementError::noFrequencyChange: + case MeasurementError::noMidiDevice: + case MeasurementError::audioDeviceStopped: + default: + // The fatal reasons never reach here - they abort the sweep and + // are reported through tunerStopped() instead of the per-note + // failure list this describes. + return "failed"; + } +} + /** inherited from AudioIODeviceCallback */ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, int numInputChannels, @@ -676,9 +710,9 @@ const String VCOTuner::Errors::highJitter = "There are zero crossings in the inc const String VCOTuner::Errors::noZeroCrossings = "The incoming audio signal does not seem to contain any zero-crossings. Are you sure the oscillator signal is getting through to us? Check your audio device settings."; -const String VCOTuner::Errors::highJitterTimeOut = "Timeout. " + highJitter; +const String VCOTuner::Errors::bufferFull = "The signal never settled within the measurement buffer - it kept producing new zero-crossings without ever reaching a steady rate. Try a lower resolution setting (fewer periods per note); that gives each note more storage headroom before this limit is hit."; -const String VCOTuner::Errors::stableTimeout = "There are some zero crossings in the incoming signal and they seem to come in at a constant rate - but they are coming in much slower than they should be. Are you recording from the right oscillator?"; +const String VCOTuner::Errors::stableTimeout = "The measurement did not finish in time. Either the incoming zero-crossings never settled into a steady rate, or the signal was too weak or intermittent for enough of them to arrive in the first place. Are you recording from the right oscillator, on the right channel, and is its level high enough?"; const String VCOTuner::Errors::noFrequencyChangeBetweenMeasurements = "Apparently the frequency of the oscillator is not changing between measurements. Please check if your MIDI-to-CV interface is set to the correct MIDI channel and make sure that it is selected as the default midi output device in the audio and midi settings."; diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index 967133f..c97aa91 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -210,7 +210,7 @@ class VCOTuner: public ChangeListener, { static const String highJitter; static const String noZeroCrossings; - static const String highJitterTimeOut; + static const String bufferFull; static const String stableTimeout; static const String noFrequencyChangeBetweenMeasurements; static const String noMidiDeviceAvailable; @@ -218,5 +218,10 @@ class VCOTuner: public ChangeListener, }; }; +/** Short, user-facing description of a per-note measurement failure, for the + end-of-report summary dialog in MainComponent. Lives here rather than in + Source/dsp/ because it returns a JUCE String. */ +String describeError (vcotuner::MeasurementError error); + #endif // VCOTUNER_H_INCLUDED diff --git a/Source/dsp/MeasurementError.h b/Source/dsp/MeasurementError.h index 720fd01..487e8e1 100644 --- a/Source/dsp/MeasurementError.h +++ b/Source/dsp/MeasurementError.h @@ -11,6 +11,11 @@ enum class MeasurementError // Per-note: mark the note and carry on with the sweep. highJitter, noZeroCrossings, + // Currently unreachable: the detector always reaches a terminal status by + // itself (see DetectorStatus), so a top-level timeout only ever observes + // 'collecting', which maps to stableTimeout instead. Kept rather than + // removed because MeasurementErrorTests.cpp asserts isFatal() on it; + // delete both together if this enumerator is ever pruned. highJitterTimeOut, stableTimeout, bufferFull, From ab66e3139f98a3fd1e1ce0b237d7317f1ec76f0d Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 10:48:01 +0200 Subject: [PATCH 23/60] fix: show the end-of-report failure summary where the report actually 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. --- Source/MainComponent.cpp | 54 +++++++--------------------- Source/MainComponent.h | 8 ++--- Source/ReportDetailsEditorScreen.cpp | 15 ++++++-- Source/VCOTuner.cpp | 15 ++++++++ Source/VCOTuner.h | 11 ++++-- 5 files changed, 52 insertions(+), 51 deletions(-) diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index a9870d2..8fc719b 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -85,8 +85,7 @@ MainComponent::MainComponent() : tuner(&deviceManager), display(&tuner) tuner.setMidiChannel(1); cycle = false; - creatingReport = false; - + // for first-time starters, display a help message and the audio settings if ((!getAppProperties().getUserSettings()->containsKey("hideWelcomeScreen")) || (getAppProperties().getUserSettings()->getIntValue("hideWelcomeScreen") != 1)) @@ -186,13 +185,6 @@ void MainComponent::buttonClicked (Button* bttn) } } -void MainComponent::startCreatingReport() -{ - tuner.setNumMeasurementRange(reportRange.startNote, reportRange.interval, reportRange.endNote); - display.clearCache(); - creatingReport = true; -} - void MainComponent::comboBoxChanged (ComboBox* comboBoxThatHasChanged) { if (comboBoxThatHasChanged == ®ime) @@ -345,10 +337,7 @@ void MainComponent::tunerStarted() void MainComponent::tunerStatusChanged(String statusString) { - if (creatingReport) - statusLabel.setText("Creating Report: " + statusString, juce::dontSendNotification); - else - statusLabel.setText(statusString, juce::dontSendNotification); + statusLabel.setText(statusString, juce::dontSendNotification); } void MainComponent::tunerStopped() @@ -362,38 +351,23 @@ void MainComponent::tunerStopped() startStop.setButtonText("Start"); cycle = false; - creatingReport = false; } void MainComponent::tunerFinished() { startStop.setButtonText("Start"); - // A report is a single sweep with a real end, so summarise the failures - // there in one dialog. Live tuning (cycle == true) restarts the sweep - // below and runs until Stop is pressed - there is no "end" to summarise, - // and a dialog that fired every cycle would just be noise, so it must - // never raise one. The failure label under the graph is what live tuning - // shows instead, and it already reflects the sweep that just finished. - if (creatingReport) - { - creatingReport = false; - - const auto& failures = tuner.getFailures(); - if (! failures.empty()) - { - StringArray lines; - for (const auto& f : failures) - lines.add (" - MIDI " + String (f.midiPitch) - + " - " + describeError (f.reason)); - - NativeMessageBox::showMessageBox (AlertWindow::InfoIcon, - "Measurement finished", - String (failures.size()) + " of the measured notes could not be read:\n\n" - + lines.joinIntoString ("\n")); - } - } - + // Live tuning (cycle == true) restarts the sweep below and runs until + // Stop is pressed - there is no "end" to summarise here, and a dialog + // that fired every cycle would just be noise, so this must never raise + // one. The failure label under the graph is what live tuning shows + // instead, and it already reflects the sweep that just finished. + // + // The "Create Report" wizard drives its own sweep independently of this + // class (see ReportDetailsEditorScreen::tunerFinished(), which shows the + // end-of-report failure summary via showMeasurementFailureSummary() at + // the point the report's measurement is actually complete) - there is no + // report-mode case for this class to handle. if (cycle) tuner.toggleState(); } @@ -465,7 +439,5 @@ const char* MainComponent::resolutionsTexts[numResolutions] = { "400 - never accurate enough" }; -const MainComponent::regime_t MainComponent::reportRange = {24, 96, 1}; - const String MainComponent::welcomeText = String("Welcome to the VCO Tuner!") + newLine + newLine + "Please follow these steps to get running:" + newLine + "1) connect a MIDI-CV interface to your Computer" + newLine + "2) connect the CV output of the interface to your oscillators frequency input" + newLine + "3) Connect one of the oscillators basic waveforms (sine, saw, triangle, pulse, etc.) directly to your soundcard (use attenuation to avoid clipping)." + newLine + newLine + "When you close this dialog, the audio settings panel will open. Please select your audio and midi device there." + newLine + newLine + "Have fun!" + newLine + newLine + "PS: If you find bugs, please raise an issue on the github repository under https://github.com/TheSlowGrowth/VCOTuner. Thanks!"; diff --git a/Source/MainComponent.h b/Source/MainComponent.h index 8001fe2..d5e50da 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -41,8 +41,6 @@ class MainComponent: public Component, virtual void tunerStatusChanged(String statusString) override; virtual void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override; - void startCreatingReport(); - private: //============================================================================== AudioDeviceManager deviceManager; @@ -73,14 +71,12 @@ class MainComponent: public Component, static const int numRegimes = 12; static const regime_t regimes[numRegimes]; static const char* regimeTexts[numRegimes]; - static const regime_t reportRange; static const int numResolutions = 5; static const int resolutions[numResolutions]; static const char* resolutionsTexts[numResolutions]; - + bool cycle; - bool creatingReport; - + static const String welcomeText; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent) diff --git a/Source/ReportDetailsEditorScreen.cpp b/Source/ReportDetailsEditorScreen.cpp index 308a984..ab2b944 100644 --- a/Source/ReportDetailsEditorScreen.cpp +++ b/Source/ReportDetailsEditorScreen.cpp @@ -240,7 +240,13 @@ void ReportDetailsEditorScreen::tunerFinished() // keep existing case 2: tuner->removeListener(this); - + + // The whole report (main sweep + reference re-check) + // is done and its result is being kept, so this is + // the real end of the report - summarise the sweep's + // per-note failures here, once. + showMeasurementFailureSummary (tuner->getFailures()); + tunerHasFinished = true; if (submitted) parent->next(); @@ -251,7 +257,12 @@ void ReportDetailsEditorScreen::tunerFinished() else { tuner->removeListener(this); - + + // The whole report (main sweep + reference re-check) is done, + // so this is the real end of the report - summarise the + // sweep's per-note failures here, once. + showMeasurementFailureSummary (tuner->getFailures()); + tunerHasFinished = true; if (submitted) parent->next(); diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index c18f0ed..a052e3f 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -572,6 +572,21 @@ String describeError (vcotuner::MeasurementError error) } } +void showMeasurementFailureSummary (const std::vector& failures) +{ + if (failures.empty()) + return; + + StringArray lines; + for (const auto& f : failures) + lines.add (" - MIDI " + String (f.midiPitch) + " - " + describeError (f.reason)); + + NativeMessageBox::showMessageBox (AlertWindow::InfoIcon, + "Measurement finished", + String (failures.size()) + " of the measured notes could not be read:\n\n" + + lines.joinIntoString ("\n")); +} + /** inherited from AudioIODeviceCallback */ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, int numInputChannels, diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index c97aa91..3888906 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -219,9 +219,16 @@ class VCOTuner: public ChangeListener, }; /** Short, user-facing description of a per-note measurement failure, for the - end-of-report summary dialog in MainComponent. Lives here rather than in - Source/dsp/ because it returns a JUCE String. */ + end-of-report summary dialog. Lives here rather than in Source/dsp/ + because it returns a JUCE String. */ String describeError (vcotuner::MeasurementError error); +/** Shows the end-of-report summary dialog (one NativeMessageBox naming every + failed note) when failures is non-empty; does nothing otherwise. Shared + by every path that finishes a report, so the wording only lives in one + place. Currently called from ReportDetailsEditorScreen, at the point + where the report's measurement is actually complete. */ +void showMeasurementFailureSummary (const std::vector& failures); + #endif // VCOTUNER_H_INCLUDED From fb831bf91db5802da32dc8d31170b550e6f1a052 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 10:58:59 +0200 Subject: [PATCH 24/60] fix: correct the bufferFull message's causal claim 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. --- Source/VCOTuner.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index a052e3f..6deab9e 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -559,7 +559,7 @@ String describeError (vcotuner::MeasurementError error) case MeasurementError::stableTimeout: return "timed out"; case MeasurementError::bufferFull: - return "never settled within the measurement buffer; try a lower resolution"; + return "settled, but not long enough; try a lower resolution"; case MeasurementError::none: case MeasurementError::noFrequencyChange: case MeasurementError::noMidiDevice: @@ -725,7 +725,7 @@ const String VCOTuner::Errors::highJitter = "There are zero crossings in the inc const String VCOTuner::Errors::noZeroCrossings = "The incoming audio signal does not seem to contain any zero-crossings. Are you sure the oscillator signal is getting through to us? Check your audio device settings."; -const String VCOTuner::Errors::bufferFull = "The signal never settled within the measurement buffer - it kept producing new zero-crossings without ever reaching a steady rate. Try a lower resolution setting (fewer periods per note); that gives each note more storage headroom before this limit is hit."; +const String VCOTuner::Errors::bufferFull = "The signal did settle into a steady rate - it just didn't hold that rate long enough to finish the measurement before the buffer ran out of storage. Try a lower resolution setting (fewer periods per note); needing fewer periods means the same buffer is enough to complete the measurement."; const String VCOTuner::Errors::stableTimeout = "The measurement did not finish in time. Either the incoming zero-crossings never settled into a steady rate, or the signal was too weak or intermittent for enough of them to arrive in the first place. Are you recording from the right oscillator, on the right channel, and is its level high enough?"; From ad07c57638c921fd98cd693dfa2fb3c4b6445c3d Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:02:19 +0200 Subject: [PATCH 25/60] docs: record the plan amendments made during implementation --- .../plans/2026-09-23-measurement-accuracy.md | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md index 27986d8..cb363f6 100644 --- a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md +++ b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md @@ -1439,8 +1439,12 @@ TEST_CASE ("low pitches get a proportionally longer timeout") TEST_CASE ("the latency allowance is included") { - const int without = computeTimeoutCycles (440.0, 20, 0.01, 0.0); - const int with = computeTimeoutCycles (440.0, 20, 0.01, 0.3); + // Both sides must clear the 50-cycle floor for the comparison to mean + // anything: at 440 Hz with 20 periods the raw counts are 10 and 40, which + // both clamp to 50, making `with > without` unsatisfiable by construction. + // 400 periods puts them at 182 and 212. + const int without = computeTimeoutCycles (440.0, 400, 0.01, 0.0); + const int with = computeTimeoutCycles (440.0, 400, 0.01, 0.3); REQUIRE (with > without); } @@ -2086,3 +2090,52 @@ git commit -m "docs: add manual verification checklist for measurement changes" | Live status line, report-mode dialog, silent stop | 12 | | Divide-by-zero on short period sequences | 6 | | GUI and hardware paths | 13 (manual) | + +--- + +## Amendments made during implementation + +Recorded here rather than silently rewriting the task bodies above, so the +original plan and the decisions that changed it both stay visible. + +**Task 3 — test bound corrected.** The amplitude-adaptation test asserted a +period count of `>= 435`, written by estimate. The fixture yields 434 +deterministically: warm-up consumes 4.4 cycles, the Schmitt 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. + +**Task 5 — stability gate strengthened.** The gate as planned latched after a +single 5-period window and never re-validated, so a drifting oscillator could +satisfy one lucky window and be reported as a confident measurement. The spec +requires `stable` to mean genuinely steady, so `updateStability` now re-checks +the whole collected set before declaring success. The shipping app has the same +hole at `Source/VCOTuner.cpp` (it sets `indexOfFirstValidPeriodLength` once and +never rechecks). + +**Task 8 — latency test fixture corrected.** `"the latency allowance is +included"` used 20 periods at 440 Hz, where both raw counts clamp to the 50-cycle +floor, making `with > without` unsatisfiable by construction. Now 400 periods. + +**Task 9 — no allocation on the audio thread.** The planned callback called +`detector.reset(cfg)`, and `reset` reserves the period buffer — a heap allocation +on the real-time thread. `PeriodDetector::prepare(int)` was added so the +constructor reserves once, off the audio path. + +**Tasks 9 and 10 — landed as one commit set.** Task 9 removes members Task 10's +code still references, so Task 9 could not compile alone. + +**Task 11 — failed notes need a column and a shape.** As planned, `measurements` +was populated only by `newMeasurementReady`, and both the auto-scale and drawing +loops index by position in that array — so a note failing before it had ever +succeeded got no column and was not drawn at all. `measurementFailed` now upserts +a placeholder so the pitch owns a column. The marker is also a translucent fill +plus an "×" rather than the same mark recoloured: drawing a point at offset zero +would mislead (zero is where a perfectly tuned note sits), and the planned +green/orangered pair differs by only ~0.05 relative luminance, which is the +classic red/green confusion case with no brightness fallback. + +**Task 12 — report summary moved to where reports end.** `startCreatingReport()` +had no caller anywhere, on master either, so `creatingReport` was never true and +the planned report-mode dialog was unreachable. The summary now fires from +`ReportDetailsEditorScreen::tunerFinished()` at its two genuine completion +points; the dead members were removed. From 72ad34ebfefa1b73e20be94ec2f14e11fea78b36 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:05:02 +0200 Subject: [PATCH 26/60] docs: add manual verification checklist for measurement changes --- .../plans/2026-09-23-manual-verification.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-manual-verification.md diff --git a/docs/superpowers/plans/2026-09-23-manual-verification.md b/docs/superpowers/plans/2026-09-23-manual-verification.md new file mode 100644 index 0000000..a250724 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-manual-verification.md @@ -0,0 +1,187 @@ +# Manual verification — measurement accuracy changes + +Work through this with a real VCO and a real MIDI-to-CV interface. It is the +entire verification for everything the automated test suite cannot reach — +treat it as the actual gate for this branch, not a formality to skim before a +merge. + +## What the automated suite covers, and what it can't + +31 Catch2 tests (`ctest --test-dir build -C Debug`) cover the pure +`vcotuner_dsp` library — logic with no JUCE dependency, in `Source/dsp/`: + +| File | Tests | Covers | +|---|---|---| +| `PeriodDetectorTests.cpp` | 14 | Zero-crossing interpolation, hysteresis arming, level tracking / warm-up (the DC/noise immunity), silence and degenerate-input handling, stability detection, terminal statuses (`failedUnstable`, `failedNoCrossings`, `failedBufferFull`) | +| `MeasurementStatisticsTests.cpp` | 9 | The regression-slope period/uncertainty fit that replaced the old standard-deviation-of-periods error bar, the divide-by-zero guard on short period sequences, the two-period boundary | +| `MeasurementErrorTests.cpp` | 4 | Fatal vs. per-note error classification, per-sweep failure list, reset on each new sweep | +| `MeasurementTimingTests.cpp` | 4 | The timeout-floor fix, across the entire MIDI 0–127 range, confirming it never evaluates to zero cycles | + +What none of this touches: JUCE at all. That means the `VCOTuner` state +machine, the real audio callback receiving real hardware buffers, real MIDI +output, `AudioDeviceManager` device-loss callbacks, and every GUI class +(`MainComponent`, `Visualizer`, the report wizard screens) are untested by +machine. Mocking JUCE's timer/MIDI/audio stack to close that gap was ruled a +non-goal for this work — so this document is where that gap is closed by a +person instead. If a box below is unchecked, that behaviour is unverified, +full stop; it is not covered "in spirit" by the unit tests next to it. + +## Setup + +- [ ] A VCO with a clean waveform output (saw, square, triangle or sine), + through a MIDI-to-CV interface, into an audio interface input. +- [ ] Build Release and launch it: + ``` + MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Release + open build/VCOTuner_artefacts/Release/VCOTuner.app + ``` +- [ ] Audio and MIDI devices selected in the in-app audio settings panel, and + the MIDI-to-CV interface confirmed to be the selected MIDI output. + +## 1. Accuracy and error bars + +- [ ] Start live tuning on a known-good VCO with the "huge > normal (24-96, + +6)" regime and resolution "100 - okay". Confirm the shaded band around + each point (the error bar) is a few cents tall, not the tens-of-cents + bands the old build produced. +- [ ] With the same VCO and range, switch resolution from "20 - quick & dirty" + to "400 - never accurate enough" (its label is a leftover from the old + behaviour, not a warning about the current one) and let a full cycle + complete at each setting. Confirm the bars visibly narrow as resolution + increases — this specifically did not happen before this branch. +- [ ] Compare the plotted centre value (the horizontal green line per column, + not the shaded band) for several notes against a report or screenshot + made before this branch, or against `master`. Confirm the centre values + are materially unchanged — only the bar width should differ, since the + interpolation fix corrects jitter, not the mean. + +## 2. High pitch + +- [ ] Run the "huge > fine (24-96, +1)" regime, the highest pitch reachable + through the shipped UI (both live-tuning regimes and report generation + cap at MIDI 96 — see `Source/MainComponent.cpp`'s `regimes` array and + `Source/ReportProperties.h`'s `highestPitch`). Confirm MIDI 96 completes + normally at every resolution setting, in particular "400 - never + accurate enough", which takes the longest per note. +- [ ] To confirm the original bug is actually fixed in situ, not only by the + unit test that exercises `computeTimeoutCycles` numerically across MIDI + 0–127: temporarily edit `Source/ReportProperties.h`, changing + `highestPitch` from `96` to `120`, rebuild, and run a report (or add a + 13th entry to `MainComponent::regimes` in `Source/MainComponent.cpp` + with `endNote = 120` and use live tuning instead — either works). + Confirm a sweep reaching MIDI 108 and above completes rather than + aborting instantly. **Revert the edit afterward** — it is a + verification aid for this checklist, not a product change, and must + not be committed. + +## 3. Robustness + +- [ ] If your audio interface is DC-coupled and the VCO output carries a + visible DC offset (check on a scope, or note if the interface's own + meters show it), sweep it directly without additional AC-coupling and + confirm it measures correctly instead of over-triggering. +- [ ] Introduce mild noise into the signal path (a slightly hot gain stage, + an unshielded cable, or similar) and confirm the sweep measures rather + than raising the "zero crossings ... don't seem to be coming in at a + constant rate" dialog/status. +- [ ] Attenuate the VCO output to a very quiet level (well below the + interface's nominal input level, but still above the noise floor) and + confirm it still measures — the trigger threshold now scales to the + measured amplitude rather than using a fixed level. +- [ ] Drive the VCO output hot (close to clipping the input, but not + clipping) and confirm it also still measures correctly at that level. +- [ ] Deliberately unplug the audio input cable mid-sweep. Confirm the + affected note(s) are marked as failed and the sweep continues, rather + than the whole run dying silently or hanging. + +## 4. Failed notes — the core new behaviour + +- [ ] In live tuning, deliberately make one note fail on the very **first** + attempt of the session (e.g. temporarily disconnect the VCO's CV input + so one specific note mistunes into silence or off-range, or unplug the + audio input for a moment while that note is playing, then restore it). + Confirm that note appears on the graph as a distinct marker the very + first time it is drawn — this specifically failed to render at all in + an earlier revision (no column was drawn for a note that had never + succeeded). +- [ ] Look closely at the failed marker: a translucent orange-red fill over + the full height of that note's column, plus a bold "×" drawn across it. + Confirm it reads as clearly distinct from a normal green + measurement column at a glance, including with red/green colour vision + simulated or checked (the shape, not just the colour, should carry the + meaning). +- [ ] With the same note still failing, confirm the status line under the + graph names it (e.g. "Not reading: MIDI 84") while live tuning cycles. +- [ ] Fix whatever caused that note to fail (reconnect the cable / CV). Let + the sweep complete its current cycle and start the next one. Confirm + the note renders as a normal green measurement on the next cycle and + drops off the status line. +- [ ] While a note is failing in live tuning, confirm no dialog box appears + at any point during the cycling — not when the note first fails, not + on any subsequent cycle. +- [ ] With a note still failing, press Stop. Confirm no dialog box appears. +- [ ] Run a report (not live tuning) with at least one note failing somewhere + in the range. Confirm exactly **one** summary dialog appears, at the + end of the whole report (after the reference-pitch re-check, not after + the main sweep), titled "Measurement finished", listing each failed + MIDI pitch and a short reason (e.g. "unsteady rate", "no signal + detected", "timed out", "settled, but not long enough; try a lower + resolution"). +- [ ] Known cosmetic overlap to check, not necessarily to fix: the blue + reference-pitch highlight is drawn after the failed-note marker, so if + the reference pitch itself fails to measure, the blue tint draws over + its orange-red column. Look at this specific case (make the reference + pitch note fail) and judge whether the failed marker (the fill and the + "×") is still legible through the blue tint. Note your judgement either + way — this is a known, accepted trade-off unless it turns out the + marker becomes unreadable. + +## 5. Fatal errors must still abort + +- [ ] Start a sweep, then deselect the MIDI output device in the audio + settings panel (or otherwise make no MIDI device available). Confirm a + dialog appears and the run stops. +- [ ] Start a sweep, then disconnect the audio device entirely (unplug the + interface, or select "no device" if your OS allows it) mid-run. + Confirm a dialog appears and the run stops. +- [ ] In both cases above, confirm the dialog appears immediately — not only + after the current note's full timeout — and that it is a single + "Error!" dialog, not the per-note failure summary from section 4. + +## 6. Report generation + +- [ ] Run "Create Report" end to end on a real oscillator and save the + result. Confirm it produces a valid, viewable `.png` file. +- [ ] With the reference pitch held rock-steady (no drift beyond + `ReportProperties::desiredDriftMargin`), confirm the report completes + with **no** drift dialog at all ("within margin") and the failure + summary (if any notes failed) still appears once, at the very end. +- [ ] Deliberately cause drift beyond the margin during a report (nudge the + VCO's tuning slightly partway through, or use an oscillator that is + still warming up). When the "Warning: High drift!" dialog appears: + - [ ] Click **"Repeat"**. Confirm the sweep restarts from the beginning, + any previously failed notes from the aborted attempt are cleared + (the status/summary reflects only the repeated attempt), and the + report proceeds normally from there. + - [ ] On a separate run, click **"Keep the poor results"**. Confirm the + report proceeds using the drifted measurement, and the end-of-report + failure summary (if any notes failed) appears exactly once, + immediately after this choice. + - [ ] On a separate run, click **"Cancel"**. Confirm the report wizard + closes without producing a report and without showing the per-note + failure summary dialog. + +## 7. Regression + +- [ ] Run at least one sweep in each of the four range regimes (narrow, + medium, large, huge) and, within one of them, each of the five + resolution settings (20, 50, 100, 200, 400). Confirm all combinations + complete without errors, hangs, or crashes. +- [ ] Deliberately produce a dead note (e.g. silence the audio input for one + note only) at the lowest pitch in range and the highest resolution + (MIDI 24, resolution 400). Time how long it takes to report failure and + move on. Expect roughly 25 seconds. Confirm that, watching it happen, + this reads as "the app is patiently waiting out a timeout" rather than + "the app has hung" — note your subjective impression, since this + figure was accepted as a deliberate trade-off (thorough integration + time at very low pitch) rather than a bug. From 2d7290069977b57afb72d70f6c78d13cd89b3a7b Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:32:55 +0200 Subject: [PATCH 27/60] fix: keep the unresponsive-interface check alive after a failed first 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. --- Source/VCOTuner.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 6deab9e..fefbbe0 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -508,8 +508,11 @@ void VCOTuner::failCurrentNote(vcotuner::MeasurementError reason) failureTracker.recordFailure(currentPitch, reason); listeners.call(&Listener::measurementFailed, currentPitch, reason); + // currentIndex deliberately does not advance here: it counts *successful* + // measurements, and the "MIDI-to-CV interface isn't responding" check keys + // on currentIndex == 0 to run on the first one. Advancing it on failure + // would skip that check for the whole sweep whenever the first note fails. currentPitch += pitchIncrement; - currentIndex++; if (currentPitch <= highestPitch) switchState(prepMeasurement); From 7a5334b61fe672139706ee3d00f6e2dbd6ec0a97 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:15 +0200 Subject: [PATCH 28/60] fix: bound every prep state's wait for a pending stop request 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. --- Source/VCOTuner.cpp | 53 ++++++++++++++++++++++++++++++++++++++++----- Source/VCOTuner.h | 21 ++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index fefbbe0..c539999 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -17,6 +17,15 @@ namespace { + /** How long a prep state waits for a pending stop request to be consumed. + + Only the audio callback clears stopMeasurement, so the wait is bounded: + 100 cycles of the 10 ms timer is one second, which is several times the + longest realistic buffer period (8192 frames at 44.1 kHz is 186 ms), + yet finite when no device is running at all. + */ + constexpr int maxStopWaitCycles = 100; + /** Translates what the detector reported into the error the sweep records. */ vcotuner::MeasurementError errorForStatus (vcotuner::DetectorStatus status) { @@ -154,6 +163,14 @@ void VCOTuner::timerCallback() case stopped: break; case prepRefMeasurement: + // wait for the low level state machine to stop measuring (bounded; + // see awaitingStopRequest). Changing the pitch-range or resolution + // combo while running stops and restarts the tuner within a single + // message-thread call stack, so a stop request can still be pending + // here when the restart arrives. + if (awaitingStopRequest()) + break; + if (cycleCounter == 0) { // send reference midi note @@ -225,8 +242,9 @@ void VCOTuner::timerCallback() case prepMeasurement: // wait for low level state machine to stop measuring: handing it a // new run while a stop request is pending would have it consume the - // stale request and kill the run it was meant to start. - if (stopMeasurement) + // stale request and kill the run it was meant to start. Bounded; + // see awaitingStopRequest. + if (awaitingStopRequest()) break; if (cycleCounter == 0) @@ -327,8 +345,9 @@ void VCOTuner::timerCallback() break; case prepareContinuousFrequencyMeasurement: { - // wait for low level state machine to stop measuring - if (stopMeasurement) + // wait for low level state machine to stop measuring (bounded; + // see awaitingStopRequest) + if (awaitingStopRequest()) break; // send midi note and start measuring @@ -359,8 +378,9 @@ void VCOTuner::timerCallback() } break; case prepareSingleMeasurement: { - // wait for low level state machine to stop measuring - if (stopMeasurement) + // wait for low level state machine to stop measuring (bounded; + // see awaitingStopRequest) + if (awaitingStopRequest()) break; if (cycleCounter == 0) @@ -489,6 +509,26 @@ void VCOTuner::startDetectorRun(int pitch) startMeasurement = true; } +bool VCOTuner::awaitingStopRequest() +{ + if (!stopMeasurement) + { + stopWaitCounter = 0; + return false; + } + + if (++stopWaitCounter <= maxStopWaitCycles) + return true; + + // Nothing has consumed the stop request for a full second, so no audio + // callback is running. Every prep state used to wait here forever, which + // looked to the user exactly like the tuner having frozen. Report it with + // the message that already describes this situation and stop. + errors.add(Errors::audioDeviceStoppedDuringMeasurement); + switchState(stopped); // resets stopWaitCounter along with cycleCounter + return true; +} + void VCOTuner::failCurrentNote(vcotuner::MeasurementError reason) { trySendMidiNoteOff(currentPitch); @@ -650,6 +690,7 @@ void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, void VCOTuner::switchState(VCOTuner::State newState) { cycleCounter = 0; + stopWaitCounter = 0; state = newState; if (state == stopped) { diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index 3888906..0af86fe 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -139,6 +139,20 @@ class VCOTuner: public ChangeListener, void startDetectorRun(int pitch); /** records the failure, tells the listeners and moves on to the next note */ void failCurrentNote(vcotuner::MeasurementError reason); + /** Bounded wait for the audio thread to consume a pending stop request. + + Every prep state must let a pending stop request drain before starting + a new run, otherwise the low level state machine consumes the stale + request and kills the run it was meant to start. Only the audio + callback clears that flag, so with no device running nothing ever + would: after one second this reports the failure and stops the tuner + instead of waiting forever. + + @returns true while the caller must not proceed (either still waiting, + or the tuner has just been stopped); false when the flag is + clear and the state may carry on. + */ + bool awaitingStopRequest(); /** the user facing message for a detector status that is not 'stable' */ const String& errorMessageForStatus(vcotuner::DetectorStatus status) const; int currentlyPlayingMidiNote; @@ -146,6 +160,13 @@ class VCOTuner: public ChangeListener, // counts cycles since the last state transition int cycleCounter; + /** counts consecutive cycles a prep state has spent waiting for a pending + stop request to be consumed. Kept separate from cycleCounter, which + gates the MIDI note-on (cycleCounter == 0) and the 100 ms oscillator + settling window: advancing that one while waiting would skip the + note-on entirely and shorten the settling time. */ + int stopWaitCounter = 0; + /** lowest pitch to be measured */ int lowestPitch; From 7cdf1da945d6702f7dfa760e22b78d902d2bdc7b Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:22 +0200 Subject: [PATCH 29/60] refactor: drop describeError's default label 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. --- Source/VCOTuner.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index c539999..122d5cc 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -607,12 +607,16 @@ String describeError (vcotuner::MeasurementError error) case MeasurementError::noFrequencyChange: case MeasurementError::noMidiDevice: case MeasurementError::audioDeviceStopped: - default: - // The fatal reasons never reach here - they abort the sweep and - // are reported through tunerStopped() instead of the per-note - // failure list this describes. - return "failed"; + break; + // No default label, deliberately, matching isFatal(), + // errorForStatus() and errorMessageForStatus(): -Wswitch then flags a + // future enumerator that nobody has classified here. } + + // The fatal reasons never reach here - they abort the sweep and are + // reported through tunerStopped() instead of the per-note failure list + // this describes. + return "failed"; } void showMeasurementFailureSummary (const std::vector& failures) From ef55c9ab6b0f8dc7f0e47e85d0299cd5660b7ba4 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:22 +0200 Subject: [PATCH 30/60] build: don't build the unit tests by default 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. --- CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 472e28d..baac7f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,7 +65,11 @@ add_library(vcotuner_dsp STATIC target_include_directories(vcotuner_dsp PUBLIC Source) target_compile_features(vcotuner_dsp PUBLIC cxx_std_17) -option(VCOTUNER_BUILD_TESTS "Build the unit tests" ON) +# OFF by default: the three artifact-producing CI jobs configure with a bare +# `cmake -B build`, and ON would have each of them FetchContent Catch2 from +# github.com on every push. A plain local configure would need network access +# too. The unitTests job passes -DVCOTUNER_BUILD_TESTS=ON explicitly. +option(VCOTUNER_BUILD_TESTS "Build the unit tests" OFF) if(VCOTUNER_BUILD_TESTS) enable_testing() add_subdirectory(tests) From 89510cec51a16aafb7134d92753cb46c1ff9ec5e Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:41 +0200 Subject: [PATCH 31/60] test: pin run-in exclusion with the detector's actual counts "valid periods exclude the unstable run-in" asserted only that numValidPeriods() <= numPeriods() and that validPeriods() is non-null. Both hold by construction for any firstValidIndex >= 0, so the test passed whether or not the run-in was excluded at all - it verified nothing despite its name. It now asserts the numbers the fixture actually produces: at 440 Hz / 48 kHz with warmupSamples 480 and requiredPeriods 10 the detector holds 15 periods, 10 of them valid, with validPeriods() offset 5 past periodData(). --- tests/PeriodDetectorTests.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index 07afd0c..e802d7f 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -336,6 +336,13 @@ TEST_CASE ("valid periods exclude the unstable run-in") detector.reset (cfg); detector.processBlock (samples.data(), 48000); - REQUIRE (detector.numValidPeriods() <= detector.numPeriods()); - REQUIRE (detector.validPeriods() != nullptr); + // Concrete, because the obvious relational assertions + // (numValidPeriods() <= numPeriods(), validPeriods() != nullptr) hold by + // construction and would still pass with run-in exclusion deleted. + // At 440 Hz / 48 kHz the detector latches after the first stabilityWindow + // (5) periods and stops at requiredPeriods (10) valid ones, so it holds 15 + // periods of which the first 5 are the discarded run-in. + REQUIRE (detector.numPeriods() == 15); + REQUIRE (detector.numValidPeriods() == 10); + REQUIRE (detector.validPeriods() == detector.periodData() + 5); } From c7b83ae163c0c98d1c8f365940e12c17fba8fb36 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:51 +0200 Subject: [PATCH 32/60] test: sweep the shipped pitch and resolution space for a terminal status 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. --- tests/PeriodDetectorTests.cpp | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index e802d7f..ba9f313 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -346,3 +346,47 @@ TEST_CASE ("valid periods exclude the unstable run-in") REQUIRE (detector.numValidPeriods() == 10); REQUIRE (detector.validPeriods() == detector.periodData() + 5); } + +TEST_CASE ("every shipped pitch and resolution reaches stable") +{ + // The bespoke fixtures above each pin one terminal status. This pins the + // arithmetic between requiredPeriods, stabilityWindow and maxPeriods + // across the settings a user can actually select: the pitch-range combo + // spans MIDI 24..96 and the resolution combo offers 20, 100 and 400 + // periods per note. maxPeriods (600) has to hold requiredPeriods plus the + // stabilityWindow (5) run-in that validPeriods() discards. Raising the top + // resolution past 595 fails here rather than in a user's sweep. + const double sampleRate = 48000.0; + + for (int midi : { 24, 60, 96 }) + { + for (int requiredPeriods : { 20, 100, 400 }) + { + const double freq = 440.0 * std::pow (2.0, (midi - 69) / 12.0); + + PeriodDetectorConfig cfg; + cfg.requiredPeriods = requiredPeriods; + // VCOTuner::startDetectorRun() sizes the warm-up window to two + // cycles of the expected frequency, clamped to [256, 48000]. + cfg.warmupSamples = std::min (48000, + std::max (256, (int) (2.0 * sampleRate / freq))); + + // Enough signal for the run-in, the required periods and a margin. + const int numSamples = cfg.warmupSamples + + (int) ((requiredPeriods + 2 * cfg.stabilityWindow + 4) + * sampleRate / freq) + + 1000; + const auto samples = makeSine (freq, sampleRate, numSamples, 0.9, 0.0); + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), numSamples); + + INFO ("midi=" << midi << " freq=" << freq + << " requiredPeriods=" << requiredPeriods + << " numPeriods=" << detector.numPeriods()); + REQUIRE (detector.status() == DetectorStatus::stable); + REQUIRE (detector.numValidPeriods() >= requiredPeriods); + } + } +} From 3695f1f2d47b3491421e19b09081d7dc35c72260 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:51 +0200 Subject: [PATCH 33/60] test: record that one click fails the note rather than skewing the fit 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. --- tests/PeriodDetectorTests.cpp | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index ba9f313..859b560 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -390,3 +390,53 @@ TEST_CASE ("every shipped pitch and resolution reaches stable") } } } + +#include "dsp/MeasurementError.h" + +TEST_CASE ("one click fails the note instead of being absorbed into the fit") +{ + // This is a deliberate consequence of re-validating steadiness over the + // whole valid set rather than a single window, written down here so it is + // a specification rather than a surprise: a capture containing one glitch + // is failed and marked, not silently folded into the fit's error bars, + // where it would produce a wrong frequency carrying a plausible-looking + // uncertainty. The note is reported through the non-fatal highJitter + // error, so the sweep carries on past it (asserted at the bottom). + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.requiredPeriods = 400; + + const double freq = 440.0, sampleRate = 48000.0; + const int numSamples = 60000; // ~545 periods; maxPeriods (600) is not hit + + // Control: the same signal without the click is stable. + { + const auto clean = makeSine (freq, sampleRate, numSamples, 0.9, 0.0); + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (clean.data(), numSamples); + REQUIRE (detector.status() == DetectorStatus::stable); + } + + auto samples = makeSine (freq, sampleRate, numSamples, 0.9, 0.0); + + // Two samples of click, halfway through the capture, on a part of the + // waveform that sits below the trigger midpoint - so it forces a spurious + // crossing rather than merely nudging an existing one. + int clickAt = numSamples / 2; + while (clickAt < numSamples - 2 && samples[(size_t) clickAt] > -0.5f) + ++clickAt; + samples[(size_t) clickAt] = 1.5f; + samples[(size_t) clickAt + 1] = 1.5f; + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), numSamples); + + INFO ("clickAt=" << clickAt << " numPeriods=" << detector.numPeriods()); + REQUIRE (detector.status() == DetectorStatus::failedUnstable); + + // VCOTuner maps failedUnstable onto highJitter, which is not fatal: the + // note is recorded and the sweep moves on to the next one. + REQUIRE_FALSE (isFatal (MeasurementError::highJitter)); +} From 7c4197c876b0f215ced9821f2dbc6db69f0c1c33 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:59 +0200 Subject: [PATCH 34/60] test: assert the timeout actually covers the measurement it guards 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. --- tests/MeasurementTimingTests.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/MeasurementTimingTests.cpp b/tests/MeasurementTimingTests.cpp index c23eadb..07e2976 100644 --- a/tests/MeasurementTimingTests.cpp +++ b/tests/MeasurementTimingTests.cpp @@ -18,6 +18,10 @@ TEST_CASE ("the timeout is never zero anywhere in the supported range") const int cycles = computeTimeoutCycles (freq, periods, 0.01, 0.3); INFO ("midi=" << midi << " periods=" << periods); REQUIRE (cycles >= 50); + // Non-zero is not enough: the timeout has to actually cover the + // time the measurement needs, or a re-derivation could be short + // yet still clear the floor - the original bug's failure mode. + REQUIRE (cycles * 0.01 >= (double) periods / freq); } } } From d0b0d05597866328bc83812f88d34fd265f31b16 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:33:59 +0200 Subject: [PATCH 35/60] docs: make a timing test's comment self-contained 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. --- tests/MeasurementTimingTests.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/MeasurementTimingTests.cpp b/tests/MeasurementTimingTests.cpp index 07e2976..0febf1d 100644 --- a/tests/MeasurementTimingTests.cpp +++ b/tests/MeasurementTimingTests.cpp @@ -39,8 +39,7 @@ TEST_CASE ("the latency allowance is included") // floor; at 440 Hz with the brief's original numPeriods=20, the raw // (unfloored) cycle counts are 10 and 40 - both still below the floor - // so both collapse to 50 and the comparison below is unsatisfiable by - // any implementation that honours the documented floor. See - // task-8-report.md for the full root-cause analysis. + // any implementation that honours the documented floor. const int without = computeTimeoutCycles (440.0, 400, 0.01, 0.0); const int with = computeTimeoutCycles (440.0, 400, 0.01, 0.3); REQUIRE (with > without); From b188875f6bf68fc90a9aab30c93db6a3b2b20460 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:34:09 +0200 Subject: [PATCH 36/60] refactor: share one steadiness check between both stability tests 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. --- Source/dsp/PeriodDetector.cpp | 68 +++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 1f5c8d3..e8ab0bc 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -5,6 +5,35 @@ namespace vcotuner { +namespace +{ + /** True when every one of `count` periods lies within `tolerance` (a + fraction of their own mean) of that mean. + + Both stability checks in updateStability() ask this same question - one + of the newest window, one of the whole valid set - so they share the + definition rather than each carrying their own copy of it. + */ + bool isWithinTolerance (const double* periods, int count, double tolerance) + { + if (periods == nullptr || count <= 0) + return false; + + double sum = 0.0; + for (int i = 0; i < count; ++i) + sum += periods[i]; + + const double average = sum / count; + const double boundary = average * tolerance; + + for (int i = 0; i < count; ++i) + if (std::abs (periods[i] - average) >= boundary) + return false; + + return true; + } +} + void PeriodDetector::prepare (int maxPeriods) { if (maxPeriods > 0) @@ -138,18 +167,9 @@ void PeriodDetector::updateStability() if (firstValidIndex < 0 && n >= cfg.stabilityWindow) { - double sum = 0.0; - for (int i = n - cfg.stabilityWindow; i < n; ++i) - sum += periods[(size_t) i]; - const double average = sum / cfg.stabilityWindow; - const double boundary = average * cfg.stabilityTolerance; - - bool steady = true; - for (int i = n - cfg.stabilityWindow; i < n; ++i) - if (std::abs (periods[(size_t) i] - average) >= boundary) - steady = false; - - if (steady) + if (isWithinTolerance (periods.data() + (n - cfg.stabilityWindow), + cfg.stabilityWindow, + cfg.stabilityTolerance)) firstValidIndex = n; } @@ -159,25 +179,11 @@ void PeriodDetector::updateStability() // Re-check the whole collected set before declaring success: a drifting // oscillator can satisfy a single window and then wander far outside // tolerance, which is exactly what failedUnstable is for. - const double* p = validPeriods(); - const int valid = numValidPeriods(); - - double sum = 0.0; - for (int i = 0; i < valid; ++i) - sum += p[i]; - const double average = sum / valid; - const double boundary = average * cfg.stabilityTolerance; - - for (int i = 0; i < valid; ++i) - { - if (std::abs (p[i] - average) >= boundary) - { - currentStatus = DetectorStatus::failedUnstable; - return; - } - } - - currentStatus = DetectorStatus::stable; + currentStatus = isWithinTolerance (validPeriods(), + numValidPeriods(), + cfg.stabilityTolerance) + ? DetectorStatus::stable + : DetectorStatus::failedUnstable; return; } From fa59942f5a3b0e4c874330ed27153f6162fee3a4 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:34:09 +0200 Subject: [PATCH 37/60] perf: stop tracking min/max once warm-up is over 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. --- Source/dsp/PeriodDetector.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index e8ab0bc..3c40e48 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -70,14 +70,16 @@ void PeriodDetector::processBlock (const float* samples, int numSamples) { const double s = (double) samples[i]; - // Min/max keep updating, but the trigger level is latched once at the - // end of warm-up: a threshold that drifts mid-measurement would inject - // timing error into exactly the periods we are trying to measure. - if (s < runningMin) runningMin = s; - if (s > runningMax) runningMax = s; - + // The trigger level is latched once, at the end of warm-up: a level + // that drifted mid-measurement would inject timing error into exactly + // the periods we are trying to measure. So min/max are only tracked + // while warm-up is running - finishWarmup() is their only reader, and + // it runs exactly once. if (warmupRemaining > 0) { + if (s < runningMin) runningMin = s; + if (s > runningMax) runningMax = s; + if (--warmupRemaining == 0) finishWarmup(); } From b2c1dbeb89d1678167c36c5dfebf89acb54e84c0 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:35:08 +0200 Subject: [PATCH 38/60] docs: correct the bounded-wait comment's claim about the old behaviour 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. --- Source/VCOTuner.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 122d5cc..d673710 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -521,9 +521,9 @@ bool VCOTuner::awaitingStopRequest() return true; // Nothing has consumed the stop request for a full second, so no audio - // callback is running. Every prep state used to wait here forever, which - // looked to the user exactly like the tuner having frozen. Report it with - // the message that already describes this situation and stop. + // callback is running to clear it. Waiting on regardless would look to the + // user exactly like the tuner having frozen, so report it with the message + // that already describes this situation and stop. errors.add(Errors::audioDeviceStoppedDuringMeasurement); switchState(stopped); // resets stopWaitCounter along with cycleCounter return true; From fafdc0db50b3d8f32a2e751a4864b08e9299e3f6 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:53:10 +0200 Subject: [PATCH 39/60] fix: skip the reference pitch in the no-frequency-change gate 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. --- Source/VCOTuner.cpp | 8 +++++++- tests/PeriodDetectorTests.cpp | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index d673710..0600abb 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -297,8 +297,14 @@ void VCOTuner::timerCallback() trySendMidiNoteOff(currentPitch); // check if the frequency has changed compared to the reference frequency - // if not, it is likely that the MIDI output is not working. Do this only for the very first measurement + // if not, it is likely that the MIDI output is not working. This check runs + // on the first successfully measured note other than the reference pitch + // itself: currentIndex only advances on success, so it can still be 0 when + // the sweep reaches referencePitch, and comparing referencePitch's own + // frequency against referenceFrequency would be meaningless (they are the + // same measurement by construction). if (currentIndex == 0 + && currentPitch != referencePitch && std::abs(result.frequency - referenceFrequency) / referenceFrequency < 0.1) { errors.add(Errors::noFrequencyChangeBetweenMeasurements); diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index 859b560..205dc35 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -352,10 +352,12 @@ TEST_CASE ("every shipped pitch and resolution reaches stable") // The bespoke fixtures above each pin one terminal status. This pins the // arithmetic between requiredPeriods, stabilityWindow and maxPeriods // across the settings a user can actually select: the pitch-range combo - // spans MIDI 24..96 and the resolution combo offers 20, 100 and 400 - // periods per note. maxPeriods (600) has to hold requiredPeriods plus the - // stabilityWindow (5) run-in that validPeriods() discards. Raising the top - // resolution past 595 fails here rather than in a user's sweep. + // spans MIDI 24..96, and requiredPeriods takes 20, 100 and 400 as three + // representative values sampled from the shipped resolution combo's full + // set of {20, 50, 100, 200, 400} periods per note. maxPeriods (600) has to + // hold requiredPeriods plus the stabilityWindow (5) run-in that + // validPeriods() discards. Raising the top resolution past 595 fails here + // rather than in a user's sweep. const double sampleRate = 48000.0; for (int midi : { 24, 60, 96 }) From 79bc873bc13e33174326a30fb5bf86533dd6634f Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 11:58:56 +0200 Subject: [PATCH 40/60] chore: add .gitattributes to lock in existing line endings 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. --- .gitattributes | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ff4c881 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,94 @@ +# Line-ending policy for this repository. +# +# History here is a mix of CRLF and LF, core.autocrlf has never been set, +# and that combination has already caused one accidental CRLF->LF +# renormalization of two files that had to be caught and reverted by hand. +# This file does NOT change any existing file's line endings. It only +# pins down, per path, what is already committed, so a local autocrlf +# setting or a future overly-broad gitattributes pattern can never touch +# these bytes again. +# +# Important: the 18 CRLF files below are stored as raw, unnormalized CRLF +# blobs (they were committed before any EOL handling existed, so Git never +# ran its LF-canonicalizing clean filter on them). Git's `text`/`eol` +# attributes ALWAYS canonicalize a text path's *stored* blob to LF and +# reinject the requested ending only on checkout; turning them on for an +# already-raw-CRLF blob therefore rewrites the stored object (a full-file +# diff, caught by `git add --renormalize .` even though the checked-out +# bytes end up looking the same). To leave these blobs byte-for-byte +# untouched, they are marked `-text`: Git will never apply EOL conversion +# to them, in either direction, regardless of core.autocrlf or any other +# pattern in this file. This still stops the class of bug that caused the +# original incident (a global or broad automatic conversion silently +# rewriting them); it does not auto-fix a file an editor has already +# flipped to LF, which would need a human to convert it back and commit +# that as a normal, visible change. +# +# Do not add new wildcard patterns (e.g. `Source/*.cpp`) without checking +# every file they would match: Source/ReportProperties.cpp is LF while its +# sibling Source/ReportProperties.h is CRLF, so a naive `Source/*.cpp -text` +# (or a broader LF rule placed after the CRLF list below) would silently +# include or exclude the wrong file. Git resolves conflicting attribute +# lines by taking the LAST matching line in this file, not the most +# specific one, so ordering matters as much as the patterns themselves. + +# Default for everything not listed below, and for any new file: treat as +# text when Git's own heuristic agrees, and always store/checkout as LF. +* text=auto eol=lf + +# These 18 files (all direct children of Source/) are raw CRLF in history +# and must stay byte-for-byte as committed; see the note above for why +# that means `-text` rather than `text eol=crlf`. Every other file, +# including Source/ReportProperties.cpp, falls through to the `* eol=lf` +# default above and is intentionally left out of this list. +Source/MainComponent.cpp -text +Source/MainComponent.h -text +Source/MainWindow.cpp -text +Source/MainWindow.h -text +Source/ReportCreatorWindow.cpp -text +Source/ReportCreatorWindow.h -text +Source/ReportDetailsEditorScreen.cpp -text +Source/ReportDetailsEditorScreen.h -text +Source/ReportDisplayScreen.cpp -text +Source/ReportDisplayScreen.h -text +Source/ReportPrepScreen.cpp -text +Source/ReportPrepScreen.h -text +Source/ReportProperties.h -text +Source/Startup.cpp -text +Source/VCOTuner.cpp -text +Source/VCOTuner.h -text +Source/Visualizer.cpp -text +Source/Visualizer.h -text + +# Explicitly documented (not just left to the default above) because it is +# the one file that would be swept up by the CRLF list's most obvious +# glob-based shorthand: Source/ReportProperties.cpp is LF, unlike every +# other direct child of Source/. Stated here as an exact path, not a +# wildcard, so it can never be shadowed by a broader pattern added later. +Source/ReportProperties.cpp text eol=lf + +# Everything under Source/dsp/ is LF and already covered by the default +# above; spelled out here so the split reads as deliberate rather than +# coincidental. +Source/dsp/*.cpp text eol=lf +Source/dsp/*.h text eol=lf + +# No binary files are tracked outside the deps/ submodule today, but if +# any are added later (icons, images, audio fixtures, fonts, archives), +# they must never go through text/EOL conversion or textual diffing. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.icns binary +*.bmp binary +*.pdf binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.zip binary +*.jar binary From 3095666d8d1df74ee2d004f7a25ecf17309a06e7 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 12:33:16 +0200 Subject: [PATCH 41/60] build: upgrade JUCE from 6.1.5 to 8.0.15 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. --- CMakeLists.txt | 2 +- Source/VCOTuner.cpp | 7 ++++--- Source/VCOTuner.h | 19 +++++++++++++------ Source/Visualizer.cpp | 6 +++--- deps/JUCE | 2 +- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index baac7f2..4fd981e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ # CMake's behaviour is compatible with the named version. This is a standard CMake command, so more # information can be found in the CMake docs. -cmake_minimum_required(VERSION 3.12) +cmake_minimum_required(VERSION 3.22) # The top-level CMakeLists.txt file for a project must contain a literal, direct call to the # `project()` command. `project()` sets up some helpful variables that describe source/binary diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 0600abb..a12f6e4 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -641,11 +641,12 @@ void showMeasurementFailureSummary (const std::vector& fa } /** inherited from AudioIODeviceCallback */ -void VCOTuner::audioDeviceIOCallback (const float** inputChannelData, +void VCOTuner::audioDeviceIOCallbackWithContext (const float* const* inputChannelData, int numInputChannels, - float** outputChannelData, + float* const* outputChannelData, int numOutputChannels, - int numSamples) + int numSamples, + const AudioIODeviceCallbackContext&) { // Channels that were not enabled when the device was opened are null, and // AudioBuffer::clear() would memset straight through them. diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index 0af86fe..edc65f3 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -76,12 +76,19 @@ class VCOTuner: public ChangeListener, const std::vector& getFailures() const { return failureTracker.failures(); } - /** inherited from AudioIODeviceCallback */ - virtual void audioDeviceIOCallback (const float** inputChannelData, - int numInputChannels, - float** outputChannelData, - int numOutputChannels, - int numSamples); + /** inherited from AudioIODeviceCallback. + + Marked `override` deliberately. JUCE 8 removed the older + audioDeviceIOCallback(), and a near-miss signature without `override` + still compiles - it just silently becomes a function JUCE never calls, + leaving the tuner unable to hear anything. `override` turns that + mistake into a compile error. */ + void audioDeviceIOCallbackWithContext (const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const AudioIODeviceCallbackContext& context) override; /** inherited from AudioIODeviceCallback */ virtual void audioDeviceAboutToStart (AudioIODevice* device); diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index a89465a..c958b68 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -172,7 +172,7 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl const int numPitchTextIntervals = 5; const int pitchTextIntervals[numPitchTextIntervals] = {1, 2, 5, 10, 20}; int currentPitchTextIntervalIndex = 0; - while (g.getCurrentFont().getStringWidth("123.") > pitchTextIntervals[currentPitchTextIntervalIndex] * columnWidth) + while (GlyphArrangement::getStringWidth(g.getCurrentFont(), "123.") > pitchTextIntervals[currentPitchTextIntervalIndex] * columnWidth) { currentPitchTextIntervalIndex++; if (currentPitchTextIntervalIndex >= numPitchTextIntervals) @@ -197,7 +197,7 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl for (int i = startLine; i <= endLine; i += pitchTextInterval) { g.setColour(Colours::black); - float textWidth = g.getCurrentFont().getStringWidth(String(measurements[i].midiPitch)); + float textWidth = GlyphArrangement::getStringWidth(g.getCurrentFont(), String(measurements[i].midiPitch)); float left = sidebarWidth + i * float(columnWidth); float x = left + float(columnWidth)/2.0f - textWidth/2.0f; float y = imageHeight; @@ -213,7 +213,7 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl g.setColour(Colours::blue.withAlpha(0.05f)); g.fillRect(Rectangle(left, 0, float(columnWidth), imageHeight)); } - if (pitchTextInterval == 1 && columnWidth < g.getCurrentFont().getStringWidth("123.")) + if (pitchTextInterval == 1 && columnWidth < GlyphArrangement::getStringWidth(g.getCurrentFont(), "123.")) { if (i % 2 == 0) { diff --git a/deps/JUCE b/deps/JUCE index 0abbba3..91ad83a 160000 --- a/deps/JUCE +++ b/deps/JUCE @@ -1 +1 @@ -Subproject commit 0abbba3b18c3263137eeaeaa11c917a3425ce585 +Subproject commit 91ad83ae34a81e0833b1a2b0866f54846370ae53 From 1848aa2d7b481687b86115430d4ab81dc5f5a9dd Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 12:33:40 +0200 Subject: [PATCH 42/60] ci: modernise the workflow for JUCE 8 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. --- .github/workflows/CI.yaml | 40 ++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index ff80ff1..084646e 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -12,25 +12,25 @@ jobs: steps: - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 + uses: jwlawson/actions-setup-cmake@v2 with: - cmake-version: '3.19.x' + cmake-version: '3.28.x' - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive - name: Configure run: | - cmake.exe -G "Visual Studio 16 2019" -A x64 -B build + cmake.exe -G "Visual Studio 17 2022" -A x64 -B build - name: Build run: | cmake --build build --config Release - name: Upload executable - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: VCOTuner_Windows path: build\VCOTuner_artefacts\Release\VCOTuner.exe @@ -42,16 +42,16 @@ jobs: steps: - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 + uses: jwlawson/actions-setup-cmake@v2 with: - cmake-version: '3.19.x' + cmake-version: '3.28.x' - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive @@ -64,7 +64,7 @@ jobs: cmake --build build --config Release - name: Upload executable - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: VCOTuner_MacOS path: build/VCOTuner_artefacts/Release/** @@ -76,16 +76,16 @@ jobs: steps: - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 + uses: jwlawson/actions-setup-cmake@v2 with: - cmake-version: '3.19.x' + cmake-version: '3.28.x' - name: Setup dependencies run: | - sudo apt-get -y install g++ libfreetype6-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev + sudo apt-get -y install g++ libfreetype6-dev libfontconfig1-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive @@ -98,7 +98,7 @@ jobs: cmake --build build --config Release - name: Upload executable - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: VCOTuner_Linux path: build/VCOTuner_artefacts/VCOTuner @@ -110,12 +110,18 @@ jobs: steps: - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v1.8 + uses: jwlawson/actions-setup-cmake@v2 with: - cmake-version: '3.19.x' + cmake-version: '3.28.x' + + # The tests themselves link no JUCE, but configuring the project still + # pulls in JUCE and bootstraps juceaide, which needs JUCE's build deps. + - name: Setup dependencies + run: | + sudo apt-get -y install g++ libfreetype6-dev libfontconfig1-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive From 90c84cdc99a3a524a1d044dcc43163ac5a784e5e Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 12:33:47 +0200 Subject: [PATCH 43/60] docs: record the JUCE 8 upgrade in the verification checklist 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. --- .../plans/2026-09-23-manual-verification.md | 43 +++++++++++++++++-- .../plans/2026-09-23-measurement-accuracy.md | 9 ++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-09-23-manual-verification.md b/docs/superpowers/plans/2026-09-23-manual-verification.md index a250724..6240c55 100644 --- a/docs/superpowers/plans/2026-09-23-manual-verification.md +++ b/docs/superpowers/plans/2026-09-23-manual-verification.md @@ -7,12 +7,12 @@ merge. ## What the automated suite covers, and what it can't -31 Catch2 tests (`ctest --test-dir build -C Debug`) cover the pure +33 Catch2 tests (`ctest --test-dir build -C Debug`) cover the pure `vcotuner_dsp` library — logic with no JUCE dependency, in `Source/dsp/`: | File | Tests | Covers | |---|---|---| -| `PeriodDetectorTests.cpp` | 14 | Zero-crossing interpolation, hysteresis arming, level tracking / warm-up (the DC/noise immunity), silence and degenerate-input handling, stability detection, terminal statuses (`failedUnstable`, `failedNoCrossings`, `failedBufferFull`) | +| `PeriodDetectorTests.cpp` | 16 | Zero-crossing interpolation, hysteresis arming, level tracking / warm-up (the DC/noise immunity), silence and degenerate-input handling, stability detection, terminal statuses (`failedUnstable`, `failedNoCrossings`, `failedBufferFull`) | | `MeasurementStatisticsTests.cpp` | 9 | The regression-slope period/uncertainty fit that replaced the old standard-deviation-of-periods error bar, the divide-by-zero guard on short period sequences, the two-period boundary | | `MeasurementErrorTests.cpp` | 4 | Fatal vs. per-note error classification, per-sweep failure list, reset on each new sweep | | `MeasurementTimingTests.cpp` | 4 | The timeout-floor fix, across the entire MIDI 0–127 range, confirming it never evaluates to zero cycles | @@ -32,9 +32,12 @@ full stop; it is not covered "in spirit" by the unit tests next to it. through a MIDI-to-CV interface, into an audio interface input. - [ ] Build Release and launch it: ``` - MACOSX_DEPLOYMENT_TARGET=11.0 cmake --build build --config Release + cmake --build build --config Release open build/VCOTuner_artefacts/Release/VCOTuner.app ``` + (The `MACOSX_DEPLOYMENT_TARGET=11.0` prefix this used to need is gone as + of the JUCE 8 upgrade — JUCE 6.1.5 called `CGWindowListCreateImage`, + which Apple obsoleted in the macOS 15 SDK.) - [ ] Audio and MIDI devices selected in the in-app audio settings panel, and the MIDI-to-CV interface confirmed to be the selected MIDI output. @@ -185,3 +188,37 @@ full stop; it is not covered "in spirit" by the unit tests next to it. "the app has hung" — note your subjective impression, since this figure was accepted as a deliberate trade-off (thorough integration time at very low pitch) rather than a bug. + +## JUCE 8 upgrade — the two things a build cannot prove + +Added when the project moved from JUCE 6.1.5 to 8.0.15. JUCE 8 removed +`AudioIODeviceCallback::audioDeviceIOCallback()` and replaced it with +`audioDeviceIOCallbackWithContext()`. The base-class implementation of the new +one is an empty body, so a class that keeps the old signature still compiles +cleanly, launches, and renders its UI — while never receiving a single sample. +A fork of this project shipped in exactly that state. + +Our override is marked `override`, which makes that specific mistake a compile +error rather than a silent failure. These checks exist because the build still +cannot prove the path is live end to end. + +- [ ] **Audio actually reaches the detector.** Start a sweep with the + oscillator connected. If any note produces a frequency reading at all, + the callback is being called. A sweep where *every* note fails with + "no signal detected" is the signature of a dead callback — distinguish + it from a genuinely disconnected input by checking the input level in + the audio settings panel first. +- [ ] **MIDI actually sends.** Confirm the oscillator's pitch audibly changes + as the sweep advances. If the pitch never moves, the sweep should abort + with the "MIDI-to-CV interface is not responding" error rather than + silently reporting a flat line. + +Both changed in the upgrade and neither is covered by the automated suite, +which links no JUCE at all. + +- [ ] **Graph axis labels still lay out correctly.** JUCE 8 removed + `Font::getStringWidth()` and changed text metrics; the pitch-axis label + spacing was migrated to `GlyphArrangement::getStringWidth()`. Check the + MIDI note numbers along the bottom of the graph are evenly spaced, not + overlapping, and not dropping out at narrow window widths. Resize the + window to its minimum and back. diff --git a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md index cb363f6..ced3305 100644 --- a/docs/superpowers/plans/2026-09-23-measurement-accuracy.md +++ b/docs/superpowers/plans/2026-09-23-measurement-accuracy.md @@ -2139,3 +2139,12 @@ had no caller anywhere, on master either, so `creatingReport` was never true and the planned report-mode dialog was unreachable. The summary now fires from `ReportDetailsEditorScreen::tunerFinished()` at its two genuine completion points; the dead members were removed. + +**Superseded after this plan was completed — the macOS build workaround.** Every +build command above is prefixed with `MACOSX_DEPLOYMENT_TARGET=11.0`, which was +required while the project was on JUCE 6.1.5: that version called +`CGWindowListCreateImage`, obsoleted in the macOS 15 SDK, and the prefix was the +only way to reach the nested `juceaide` bootstrap. The subsequent upgrade to +JUCE 8.0.15 removed the need for it. The commands are left as written because +this document records how the work was actually done; for current build +instructions see `2026-09-23-manual-verification.md`. From bf8ec86a9962466aaf3a9b6a6832475ec6149a94 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 12:58:55 +0200 Subject: [PATCH 44/60] docs: describe the measurement changes in the README 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. --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index 6bea208..757275b 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,65 @@ This video shows how to use it: " target="_blank">Youtube tutorial video +## What's new + +**Measurements are much more accurate.** The app finds each zero crossing more precisely than the sample rate alone allows, by interpolating between the two samples either side of it. That calculation was wrong — it mirrored the result within the sample interval, which added roughly *twice* as much timing jitter as doing no interpolation at all. With it fixed, the jitter at 440 Hz drops from about a third of a sample to essentially nothing. + +You'll notice this as **far narrower error bars**. On a perfectly good oscillator they used to be tens of cents wide; on a clean signal they're now a fraction of a cent. Raising the resolution setting narrows them further, which it previously did not. + +**The error bars were also measuring the wrong thing.** They showed the spread of the individual period readings rather than the uncertainty of the averaged result that's actually plotted. They now show the uncertainty of the number on screen. + +**A bad note no longer ruins the whole sweep.** Any note that failed to measure used to pop up a dialog and stop the run. Now it's marked on the graph with an orange "×", named in a status line beneath it, and the sweep carries on. Fix the cause mid-run — tweak a trimmer, reseat a cable — and that note goes back to normal on the next pass. + +Live tuning never interrupts you with a dialog at all. Report mode shows a single summary at the end listing anything that failed. Errors that genuinely mean nothing can work — no MIDI device selected, the audio device disappearing, the MIDI-to-CV interface not responding — still stop the run and tell you why. + +**Far fewer false "unstable signal" errors.** The old detector triggered on a fixed threshold at zero with no noise immunity, so noise or a DC offset on the input produced several false triggers per cycle — on a noisy signal it could find 609 crossings where 220 was correct. That's what produced the "zero crossings ... don't seem to be coming in at a constant rate" error on oscillators that sounded perfectly fine. The trigger now adapts to the measured signal level and ignores noise between its thresholds. Very quiet and very hot signals both measure correctly with no adjustment. + +**High notes complete.** Each note's timeout was calculated in a way that rounded down to zero at the top of the range, so the measurement gave up before the audio could physically arrive. High notes now get a sensible minimum. + +**A drifting oscillator is now flagged instead of quietly measured.** The stability check used to confirm the signal was steady across five consecutive cycles and then never look again — so an oscillator that drifted after that point was still reported as a confident reading. It now re-checks the whole measurement before accepting it. + +One thing to expect from that: a note whose capture contains an audible click or dropout will now be marked as failed rather than absorbed into a wider error bar. At the highest resolution setting, where each note is measured over hundreds of cycles, a single glitch anywhere in the capture is enough to do it. That's deliberate — a flagged note is more useful than a plausible wrong number — but if you start seeing failures where you didn't before, suspect the audio path before the oscillator. + +**Runs on current macOS.** Updated to JUCE 8, which removes the build workarounds previously needed on macOS 15 and later. + ## Download [Head over to the "release" section of this repository to download the latest release.](https://github.com/TheSlowGrowth/VCOTuner/releases/latest) +macOS downloads are not signed by Apple, so Gatekeeper will refuse to open them and claim the app is damaged. It isn't — right-click the app and choose Open, or clear the quarantine flag: + +``` +xattr -dr com.apple.quarantine VCOTuner.app +``` + +## Building from source + +Requires CMake 3.22 or later and a C++17 compiler. + +``` +git clone --recursive https://github.com/TheSlowGrowth/VCOTuner.git +cd VCOTuner +cmake -B build +cmake --build build --config Release +``` + +On Linux, install the dependencies first: + +``` +sudo apt-get install g++ libfreetype6-dev libfontconfig1-dev libx11-dev \ + libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev \ + libasound2-dev freeglut3-dev libxcomposite-dev +``` + +The measurement code has an automated test suite. It is not built by default: + +``` +cmake -B build -DVCOTUNER_BUILD_TESTS=ON +cmake --build build +ctest --test-dir build +``` + ## Help to improve it [If you find bugs, please raise an issue here!](https://github.com/TheSlowGrowth/VCOTuner/issues) From 2e157203d239f4ce6d53e98d9522e1bf390af40f Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 13:10:06 +0200 Subject: [PATCH 45/60] feat: mark measurements that fall outside the plotted range Closes the gap reported upstream as issue #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. --- Source/Visualizer.cpp | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index c958b68..1e1c217 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -151,6 +151,39 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl continue; } + // A reading whose centre falls outside the plotted range would draw + // nothing at all, and a blank column reads as "this note was never + // measured" rather than "this note is worse than the range shows". + // That matters most in the report, which plots a fixed +/-15 cents + // (see ReportDisplayScreen), so a badly tracking oscillator goes + // blank at exactly the notes worth looking at. Mark the edge it ran + // off instead. The direction carries the meaning, so it still reads + // without relying on colour. + const double offset = measurements[i].pitchOffset; + + if (offset > max || offset < min) + { + const float margin = (float) columnWidth * 0.25f; + const float span = (float) columnWidth - 2.0f * margin; + const float depth = jmin (span, (float) imageHeight * 0.12f); + const float centre = left + margin + span / 2.0f; + + Path arrow; + + if (offset > max) + arrow.addTriangle (left + margin, depth, + left + margin + span, depth, + centre, 0.0f); + else + arrow.addTriangle (left + margin, (float) imageHeight - depth, + left + margin + span, (float) imageHeight - depth, + centre, (float) imageHeight); + + g.setColour (Colours::green); + g.fillPath (arrow); + continue; + } + // draw deviation float maxPosition = (float) ((measurements[i].pitchOffset + measurements[i].pitchDeviation - min) * vertScaling); float minPosition = (float) ((measurements[i].pitchOffset - measurements[i].pitchDeviation - min) * vertScaling); From 635096fe913bfcdcd355ff9b2f851088c7dd4854 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 13:13:18 +0200 Subject: [PATCH 46/60] docs: add verification steps for the out-of-range markers 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. --- .../plans/2026-09-23-manual-verification.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/superpowers/plans/2026-09-23-manual-verification.md b/docs/superpowers/plans/2026-09-23-manual-verification.md index 6240c55..76bc696 100644 --- a/docs/superpowers/plans/2026-09-23-manual-verification.md +++ b/docs/superpowers/plans/2026-09-23-manual-verification.md @@ -222,3 +222,27 @@ which links no JUCE at all. MIDI note numbers along the bottom of the graph are evenly spaced, not overlapping, and not dropping out at narrow window widths. Resize the window to its minimum and back. + +## Out-of-range markers in the report + +The report plots a fixed +/-15 cents (`ReportDisplayScreen.cpp`). A note whose +reading falls outside that used to draw nothing, so a badly tracking oscillator +went blank at exactly the notes worth looking at - indistinguishable from a note +that was never measured. An arrow now marks the edge the reading ran off. + +The live graph auto-scales to fit every reading, so it can never trigger there; +this is a report-only behaviour. + +- [ ] Produce a report from an oscillator that is off by more than 15 cents + somewhere in its range - detune it deliberately if need be. Confirm those + notes show a solid triangle at the top edge (reading too high) or the + bottom edge (too low), rather than a blank column. +- [ ] Confirm the arrow points the way the reading went, and sits in that note's + own column, lined up with its MIDI number on the axis. +- [ ] Confirm notes still inside +/-15 cents draw normally, with their band and + centre line, and get no arrow. + +Verified during development against a synthetic fixture: readings of +28 and +-31 cents produced correct up and down arrows while in-range notes drew normally. +What remains unverified is the path through real report generation with real +measurements. From c01a461ed6b4d9bc78cdcf07c2da107c7a30d8f6 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Thu, 24 Sep 2026 13:20:54 +0200 Subject: [PATCH 47/60] chore: bump version to 0.4.0 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. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4fd981e..2977717 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ cmake_minimum_required(VERSION 3.22) # `project()` command. `project()` sets up some helpful variables that describe source/binary # directories, and the current project version. This is a standard CMake command. -project(VCO_TUNER VERSION 0.3.0) +project(VCO_TUNER VERSION 0.4.0) # Set c++17 set(CMAKE_CXX_STANDARD 17) From 63f54b9b2233e1a74d39ed6aeca6a11231abfd8f Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Sat, 26 Sep 2026 16:42:28 +0200 Subject: [PATCH 48/60] fix: keep the MIDI channel control on screen in the settings dialog 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'. --- Source/MainComponent.cpp | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index 8fc719b..f33a52b 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -240,7 +240,12 @@ void MainComponent::showAudioSettings() : selectorComponent(m, 1, 1, 0, 0, false, true, false, false) { t = tunerToUse; - + + // Added first so it sits behind the fixed controls below. It used + // to be added last, which put it on top - so when it overflowed + // the space given to it, it covered them. + addAndMakeVisible(&selectorComponent); + channelLabel.setName("MidiChannel Label"); channelLabel.setText("MIDI Channel: ", dontSendNotification); channelLabel.setJustificationType(juce::Justification::centredRight); @@ -260,8 +265,6 @@ void MainComponent::showAudioSettings() close.setButtonText("Close"); close.addListener(this); addAndMakeVisible(&close); - - addAndMakeVisible(&selectorComponent); } void comboBoxChanged (ComboBox* comboBoxThatHasChanged) override @@ -275,12 +278,25 @@ void MainComponent::showAudioSettings() { const int height = selectorComponent.getItemHeight(); const int border = 10; - - selectorComponent.setBounds(0, 0, getWidth(), getHeight() - 4*border - 2*height); - // selectorComponent overwrites its height in its resized() function. But it doesnt seem to work - channelEdit.setBounds(proportionOfWidth (0.35f), selectorComponent.getBottom() + border, proportionOfWidth (0.6f), height); - channelLabel.setBounds(0, selectorComponent.getBottom() + border, proportionOfWidth (0.35f), height); - close.setBounds(border, getHeight() - border - height, getWidth() - 2*border, height); + + // Lay the fixed-height controls out from the bottom upward, and + // give the device selector whatever is left. + // + // These used to be positioned off selectorComponent.getBottom(). + // AudioDeviceSelectorComponent overrides its own height in its + // resized() to fit however many devices and channels are attached, + // so its bottom edge is not where we put it - and when it grew, it + // pushed the MIDI channel control clean off the bottom of the + // dialog. That made the channel unreachable exactly when a device + // was connected, which is the only time you need to set it. + const int closeTop = getHeight() - border - height; + const int channelTop = closeTop - border - height; + + close.setBounds(border, closeTop, getWidth() - 2 * border, height); + channelLabel.setBounds(0, channelTop, proportionOfWidth (0.35f), height); + channelEdit.setBounds(proportionOfWidth (0.35f), channelTop, proportionOfWidth (0.6f), height); + + selectorComponent.setBounds(0, 0, getWidth(), jmax(0, channelTop - border)); } void buttonClicked (Button* bttn) override From e141db542673c9555313de819aa96f2ff80faa62 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Sat, 26 Sep 2026 16:44:08 +0200 Subject: [PATCH 49/60] docs: point the README at this fork, and describe the error bars as they 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. --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 757275b..5781c14 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # VCOTuner A JUCE based tuner application for VCOs, VCFs and other analog gear. It runs on Windows, Mac and Linux. +This is a fork of [TheSlowGrowth/VCOTuner](https://github.com/TheSlowGrowth/VCOTuner) with the measurement code substantially reworked — see [What's new](#whats-new). + ## Overview **How tuning usually works** - Tuning is usually a tedious ping-pong game between adjusting a fine tune pot and adjusting one or multiple tuning trimmers. Whenever a trimmer has been adjusted, the fine tune pot has to be adjusted as well to bring the pitch back to a specific note. @@ -19,7 +21,9 @@ alt="Youtube tutorial video" width="400" border="0" /> **Measurements are much more accurate.** The app finds each zero crossing more precisely than the sample rate alone allows, by interpolating between the two samples either side of it. That calculation was wrong — it mirrored the result within the sample interval, which added roughly *twice* as much timing jitter as doing no interpolation at all. With it fixed, the jitter at 440 Hz drops from about a third of a sample to essentially nothing. -You'll notice this as **far narrower error bars**. On a perfectly good oscillator they used to be tens of cents wide; on a clean signal they're now a fraction of a cent. Raising the resolution setting narrows them further, which it previously did not. +You'll notice this as **error bars that mostly aren't there any more**. The old build drew bands tens of cents wide on a perfectly good oscillator. The uncertainty is now so much smaller than that it falls below a single pixel on a steady signal, so no band is drawn at all. + +The band reappears the moment the pitch genuinely moves — turn a tuning trimmer while a sweep is running and you'll watch it grow, then collapse again when you let go. So read it this way: **no band means the reading is settled and trustworthy; a visible band means the pitch is still moving.** **The error bars were also measuring the wrong thing.** They showed the spread of the individual period readings rather than the uncertainty of the averaged result that's actually plotted. They now show the uncertainty of the number on screen. @@ -39,7 +43,7 @@ One thing to expect from that: a note whose capture contains an audible click or ## Download -[Head over to the "release" section of this repository to download the latest release.](https://github.com/TheSlowGrowth/VCOTuner/releases/latest) +[Head over to the "release" section of this repository to download the latest release.](https://github.com/TimoRozendal/VCOTuner/releases/latest) macOS downloads are not signed by Apple, so Gatekeeper will refuse to open them and claim the app is damaged. It isn't — right-click the app and choose Open, or clear the quarantine flag: @@ -52,7 +56,7 @@ xattr -dr com.apple.quarantine VCOTuner.app Requires CMake 3.22 or later and a C++17 compiler. ``` -git clone --recursive https://github.com/TheSlowGrowth/VCOTuner.git +git clone --recursive https://github.com/TimoRozendal/VCOTuner.git cd VCOTuner cmake -B build cmake --build build --config Release @@ -76,8 +80,8 @@ ctest --test-dir build ## Help to improve it -[If you find bugs, please raise an issue here!](https://github.com/TheSlowGrowth/VCOTuner/issues) +[If you find bugs, please raise an issue here!](https://github.com/TimoRozendal/VCOTuner/issues) -## Are you on Muff's? +## Are you on ModWiggler? -[Here's a thread on MuffWiggler. Post your tuning reports here, if you like](https://www.muffwiggler.com/forum/viewtopic.php?p=2276045) +[Here's a thread on ModWiggler. Post your tuning reports here, if you like](https://www.modwiggler.com/forum/viewtopic.php?p=2276045) From a9755b049019ca0735c826b85077fdd78757ac12 Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Sat, 26 Sep 2026 16:45:08 +0200 Subject: [PATCH 50/60] ci: publish a release when a v* tag is pushed 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. --- .github/workflows/CI.yaml | 50 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 084646e..d394fb8 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -63,11 +63,18 @@ jobs: run: | cmake --build build --config Release + # An .app is a directory containing symlinks. Zipping it on the Linux + # release job would mangle them, so it is packaged here, on macOS. + - name: Package the app bundle + run: | + cd build/VCOTuner_artefacts/Release + ditto -c -k --keepParent VCOTuner.app VCOTuner-macOS.zip + - name: Upload executable uses: actions/upload-artifact@v4 with: name: VCOTuner_MacOS - path: build/VCOTuner_artefacts/Release/** + path: build/VCOTuner_artefacts/Release/VCOTuner-macOS.zip ############################################################################### # builds the application on ubuntu @@ -136,3 +143,44 @@ jobs: - name: Run tests run: | ctest --test-dir build --output-on-failure + + ############################################################################### + # publishes a release when a v* tag is pushed. Does nothing on ordinary pushes. + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [testBuildWindows, testBuildMacOS, testBuildUbuntu, unitTests] + runs-on: ubuntu-latest + permissions: + # GITHUB_TOKEN is read-only by default; creating a release needs write. + contents: write + + steps: + - name: Download every build artifact + uses: actions/download-artifact@v4 + + - name: Publish the release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + body: | + ## Before you download + + **Only the macOS build has been tested on real hardware.** The Windows + and Linux binaries are produced by CI and have never been run against + an actual oscillator, or run at all. They compile; that is all that is + known about them. Treat them as untested. + + **macOS builds are not signed by Apple.** Gatekeeper will refuse to + open the app and claim it is damaged. It isn't. Either right-click the + app and choose Open, or clear the quarantine flag: + + ``` + xattr -dr com.apple.quarantine VCOTuner.app + ``` + + Signing properly needs a paid Apple Developer ID, which this project + does not have. + files: | + VCOTuner_Windows/VCOTuner.exe + VCOTuner_MacOS/VCOTuner-macOS.zip + VCOTuner_Linux/VCOTuner From e450335503ef71725a5014c3f188793dc3021bcb Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Sat, 26 Sep 2026 16:51:48 +0200 Subject: [PATCH 51/60] ci: allow a build to be triggered by hand 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. --- .github/workflows/CI.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index d394fb8..f2a79f5 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -4,6 +4,8 @@ on: # Run for all commits on feature branches push: pull_request: + # lets a build be started by hand, without needing a commit or a tag + workflow_dispatch: jobs: ############################################################################### # builds the application on windows From 712af63a891d230ce5e76e0684570754d20762ef Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Sat, 26 Sep 2026 16:55:06 +0200 Subject: [PATCH 52/60] ci: fix the Windows generator and the Linux package install 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. --- .github/workflows/CI.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index f2a79f5..bd5ea2e 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -25,7 +25,7 @@ jobs: - name: Configure run: | - cmake.exe -G "Visual Studio 17 2022" -A x64 -B build + cmake.exe -A x64 -B build - name: Build run: | @@ -91,6 +91,7 @@ jobs: - name: Setup dependencies run: | + sudo apt-get update sudo apt-get -y install g++ libfreetype6-dev libfontconfig1-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev - name: Checkout @@ -127,6 +128,7 @@ jobs: # pulls in JUCE and bootstraps juceaide, which needs JUCE's build deps. - name: Setup dependencies run: | + sudo apt-get update sudo apt-get -y install g++ libfreetype6-dev libfontconfig1-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev - name: Checkout From 8015ad0af9a9ba210fd4638f51ee0931d8da712f Mon Sep 17 00:00:00 2001 From: TimoRozendal Date: Sat, 26 Sep 2026 17:03:25 +0200 Subject: [PATCH 53/60] ci: let Windows use the runner image's own CMake 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. --- .github/workflows/CI.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index bd5ea2e..495ea02 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -13,10 +13,11 @@ jobs: runs-on: windows-latest steps: - - name: Setup cmake - uses: jwlawson/actions-setup-cmake@v2 - with: - cmake-version: '3.28.x' + # No setup-cmake here on purpose. windows-latest is now + # windows-2025-vs2026, and a pinned CMake 3.28 predates Visual Studio + # 2026 - it finds no usable generator and fails with + # 'CMAKE_C_COMPILER not set'. The image's own CMake always knows the + # Visual Studio the image ships, so use that. - name: Checkout uses: actions/checkout@v4 From 75f54887c0e259eb8834712c24b84be84b162a12 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 13:16:17 +0200 Subject: [PATCH 54/60] Fix two pre-existing UI bugs and add a debug harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/build.yml | 9 ++ .gitignore | 4 + Source/ModernLookAndFeel.h | 9 +- Source/TunerDisplay.cpp | 6 +- tools/check_literals.py | 32 ++++++ tools/debug_harness.sh | 209 ++++++++++++++++++++++++++++++++++++ tools/harness_selftest.py | 65 +++++++++++ tools/ui_capture.py | 82 ++++++++++++++ 8 files changed, 413 insertions(+), 3 deletions(-) create mode 100755 tools/check_literals.py create mode 100755 tools/debug_harness.sh create mode 100755 tools/harness_selftest.py create mode 100755 tools/ui_capture.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2674934..f07e6f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -193,6 +193,15 @@ jobs: sudo apt-get update sudo apt-get install -y g++ libfreetype6-dev libfontconfig1-dev libx11-dev libxinerama-dev libxrandr-dev libxcursor-dev mesa-common-dev libasound2-dev freeglut3-dev libxcomposite-dev libcurl4-openssl-dev libwebkit2gtk-4.1-dev + # Conflict markers, raw non-ASCII literals, JUCE-7 APIs, and the two + # regression guards (tab overlay, CV before the audio callback's early + # returns). See tools/debug_harness.sh. + - name: Static gates + run: ./tools/debug_harness.sh --static-only + + - name: Prove the guards can fail + run: python3 tools/harness_selftest.py + - name: Configure CMake run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DVCOTUNER_BUILD_TESTS=ON diff --git a/.gitignore b/.gitignore index 3c6c2d9..607fbbc 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ *.app .DS_Store build + +# harness output +build-*/ +.harness-*.log diff --git a/Source/ModernLookAndFeel.h b/Source/ModernLookAndFeel.h index e79494d..d8c549c 100644 --- a/Source/ModernLookAndFeel.h +++ b/Source/ModernLookAndFeel.h @@ -261,8 +261,13 @@ class ModernLookAndFeel : public LookAndFeel_V4 void drawTabAreaBehindFrontButton(TabbedButtonBar& bar, Graphics& g, int w, int h) override { - g.setColour(Colors::background); - g.fillRect(0, 0, w, h); + ignoreUnused(bar); + + // JUCE paints this into a full-size child of the tab bar that sits + // directly behind the FRONT tab button and therefore in front of every + // other one (TabbedButtonBar::BehindFrontTabComp, toBehind(frontTab)). + // Filling it opaquely painted over every inactive tab, leaving only the + // selected tab visible. Draw the separator and nothing else. g.setColour(Colors::panelLight.withAlpha(0.3f)); g.drawLine(0, (float)h - 1, (float)w, (float)h - 1); } diff --git a/Source/TunerDisplay.cpp b/Source/TunerDisplay.cpp index f0047da..1321073 100644 --- a/Source/TunerDisplay.cpp +++ b/Source/TunerDisplay.cpp @@ -158,9 +158,13 @@ void TunerDisplay::paint(Graphics& g) hasSignal ? ((semitones >= 0 ? "+" : "") + String(semitones, 4)) : "+-.----"); // Deviation (if available from measurement) + // A bare "±" in a narrow literal reaches JUCE as two Latin-1 bytes and + // renders as "±". Build the glyph from UTF-8 explicitly. + const String plusMinus (CharPointer_UTF8 ("\xc2\xb1")); auto dataCol3 = dataSection.removeFromLeft(dataColWidth); drawSmallDataBox(g, dataCol3.reduced(3), "DEVIATION", - hasSignal ? ("±" + String(currentDeviation * 100.0f, 2) + "c") : "±--.--c"); + hasSignal ? (plusMinus + String(currentDeviation * 100.0f, 2) + "c") + : (plusMinus + "--.--c")); // Period (1/f) auto dataCol4 = dataSection; diff --git a/tools/check_literals.py b/tools/check_literals.py new file mode 100755 index 0000000..600940d --- /dev/null +++ b/tools/check_literals.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Flag raw non-ASCII bytes inside narrow string literals. + +JUCE takes a const char* as UTF-8 in most paths but not all, and a literal like +"±" ends up rendered as "±". Anything non-ASCII that has to reach the UI +should be built with CharPointer_UTF8. Comments are left alone -- they never +reach a string. +""" +import pathlib, re, sys + +root = pathlib.Path(sys.argv[1]) +bad = [] + +for path in sorted(root.rglob('*')): + if path.suffix not in ('.cpp', '.h', '.mm'): + continue + for n, raw in enumerate(path.read_bytes().split(b'\n'), 1): + if all(b < 128 for b in raw): + continue + line = raw.decode('utf-8', 'replace') + stripped = line.lstrip() + if stripped.startswith('//') or stripped.startswith('*'): + continue # a comment, not a literal + if 'CharPointer_UTF8' in line: + continue # already built explicitly + if '"' not in line: + continue # non-ASCII outside any literal + bad.append(f"{path.relative_to(root)}:{n}: {line.strip()[:100]}") + +for b in bad: + print(b) +sys.exit(1 if bad else 0) diff --git a/tools/debug_harness.sh b/tools/debug_harness.sh new file mode 100755 index 0000000..6075923 --- /dev/null +++ b/tools/debug_harness.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# +# Full debug harness for VCOTuner. +# +# Gates, in order: static checks -> configure -> build -> unit tests -> UI smoke. +# Fail-closed: any gate failing fails the run. Nothing is skipped silently; a +# gate that cannot run reports SKIP and still counts as not-passed. +# +# Usage: tools/debug_harness.sh [--quick] [--no-ui] +# --quick reuse an existing build directory instead of configuring fresh +# --no-ui skip the launch/screenshot gate (for CI or a headless session) +# --static-only run only the static gates (used by tools/harness_selftest.sh) + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD="$ROOT/build-harness" +ARTIFACTS="$ROOT/build-harness/artifacts" +QUICK=0 +RUN_UI=1 +STATIC_ONLY=0 + +for arg in "$@"; do + case "$arg" in + --quick) QUICK=1 ;; + --no-ui) RUN_UI=0 ;; + --static-only) STATIC_ONLY=1 ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +PASS=0; FAIL=0; SKIP=0 +declare -a RESULTS + +gate() { # gate [detail] + local name="$1" status="$2" detail="${3:-}" + case "$status" in + PASS) PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s %s\n' "$name" "$detail" ;; + FAIL) FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s %s\n' "$name" "$detail" ;; + SKIP) SKIP=$((SKIP+1)); printf ' \033[33mSKIP\033[0m %s %s\n' "$name" "$detail" ;; + esac + RESULTS+=("$status|$name|$detail") +} + +hdr() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } + +############################################################################### +hdr "Static checks" + +# Unresolved merge conflicts anywhere in the tracked tree. +if git -C "$ROOT" grep -qIn -e '^<<<<<<< ' -e '^>>>>>>> ' -- Source tests CMakeLists.txt 2>/dev/null; then + gate "no-conflict-markers" FAIL "$(git -C "$ROOT" grep -lIn -e '^<<<<<<< ' -- Source tests | tr '\n' ' ')" +else + gate "no-conflict-markers" PASS +fi + +# Raw non-ASCII in narrow string literals reaches JUCE as Latin-1 bytes and +# renders as mojibake. Comments are fine; literals are not. +# (BSD grep has no \xNN escapes, so this is a byte scan in python instead.) +if BAD_UTF8=$(python3 "$ROOT/tools/check_literals.py" "$ROOT/Source" 2>&1); then + gate "no-raw-utf8-literals" PASS +else + gate "no-raw-utf8-literals" FAIL "$(echo "$BAD_UTF8" | head -2 | cut -c1-110)" +fi + +# Font::getStringWidth was removed in JUCE 8; GlyphArrangement replaces it. +if grep -rn 'getCurrentFont()\.getStringWidth\|Font([^)]*)\.getStringWidth' "$ROOT/Source" >/dev/null 2>&1; then + gate "no-juce7-font-api" FAIL "Font::getStringWidth is gone in JUCE 8" +else + gate "no-juce7-font-api" PASS +fi + +# Regression guard: drawTabAreaBehindFrontButton paints into a full-size child +# that sits in front of every inactive tab, so an opaque fill there hides them. +if awk '/drawTabAreaBehindFrontButton/,/^ \}/' "$ROOT/Source/ModernLookAndFeel.h" | grep -q 'fillRect(0, 0, w, h)'; then + gate "tabbar-overlay-not-opaque" FAIL "opaque fill hides inactive tabs" +else + gate "tabbar-overlay-not-opaque" PASS +fi + +# Regression guard: CV output must be serviced before the audio callback's +# early returns, or the CV drops to 0 V between notes and after a sweep. +CB=$(awk '/void VCOTuner::audioDeviceIOCallbackWithContext/,/^\}/' "$ROOT/Source/VCOTuner.cpp") +CV_LINE=$(echo "$CB" | grep -n 'fillOutputBuffer' | head -1 | cut -d: -f1) +RET_LINE=$(echo "$CB" | grep -n 'return;' | head -1 | cut -d: -f1) +if [ -n "$CV_LINE" ] && [ -n "$RET_LINE" ] && [ "$CV_LINE" -lt "$RET_LINE" ]; then + gate "cv-before-early-returns" PASS "fill@$CV_LINE < return@$RET_LINE" +elif [ -z "$CV_LINE" ]; then + gate "cv-before-early-returns" FAIL "no CV fill in the audio callback" +else + gate "cv-before-early-returns" FAIL "CV fill@$CV_LINE is after return@$RET_LINE" +fi + +# The JUCE submodule must match what the tree pins, or the build is not the +# build the gates think they are testing. +PINNED=$(git -C "$ROOT" ls-files -s deps/JUCE | awk '{print $2}') +ACTUAL=$(git -C "$ROOT/deps/JUCE" rev-parse HEAD 2>/dev/null || echo none) +if [ "$PINNED" = "$ACTUAL" ]; then + gate "juce-submodule-in-sync" PASS "$(git -C "$ROOT/deps/JUCE" describe --tags 2>/dev/null || echo "$ACTUAL" | cut -c1-8)" +else + gate "juce-submodule-in-sync" FAIL "pinned ${PINNED:0:8} != checked out ${ACTUAL:0:8}" +fi + +if [ "$STATIC_ONLY" = "1" ]; then + hdr "Summary" + printf ' %d passed, %d failed, %d skipped (static only)\n' "$PASS" "$FAIL" "$SKIP" + [ "$FAIL" -gt 0 ] && exit 1 || exit 0 +fi + +############################################################################### +hdr "Configure" + +# Homebrew's arm-none-eabi-gcc wins CMake's compiler search on this machine and +# fails the ABI test with "unrecognized command-line option '-arch'". Pin Apple +# clang explicitly rather than depending on PATH order. +CMAKE_ARGS=( + -S "$ROOT" -B "$BUILD" + -DCMAKE_BUILD_TYPE=Release + -DVCOTUNER_BUILD_TESTS=ON + -DCMAKE_C_COMPILER=/usr/bin/clang + -DCMAKE_CXX_COMPILER=/usr/bin/clang++ + -DCMAKE_AR=/usr/bin/ar + -DCMAKE_RANLIB=/usr/bin/ranlib +) + +if [ "$QUICK" = "1" ] && [ -f "$BUILD/CMakeCache.txt" ]; then + gate "cmake-configure" PASS "reused $BUILD (--quick)" +else + rm -rf "$BUILD" + if cmake "${CMAKE_ARGS[@]}" > "$ROOT/.harness-configure.log" 2>&1; then + gate "cmake-configure" PASS + else + gate "cmake-configure" FAIL "see .harness-configure.log" + tail -15 "$ROOT/.harness-configure.log" + fi +fi + +############################################################################### +hdr "Build" + +if [ -f "$BUILD/CMakeCache.txt" ]; then + if cmake --build "$BUILD" --config Release -j "$(sysctl -n hw.ncpu)" > "$ROOT/.harness-build.log" 2>&1; then + NWARN=$(grep -c 'warning:' "$ROOT/.harness-build.log" || true) + gate "build-app-and-tests" PASS "$NWARN compiler warnings" + else + gate "build-app-and-tests" FAIL "see .harness-build.log" + grep -i 'error:' "$ROOT/.harness-build.log" | head -10 + fi +else + gate "build-app-and-tests" SKIP "no configured build dir" +fi + +APP="$BUILD/VCOTuner_artefacts/Release/VCOTuner.app" +[ -d "$APP" ] || APP="$BUILD/VCOTuner_artefacts/VCOTuner.app" + +if [ -d "$APP" ]; then + gate "app-bundle-produced" PASS "$(basename "$APP")" +else + gate "app-bundle-produced" FAIL "no .app under $BUILD" +fi + +############################################################################### +hdr "Unit tests" + +if [ -x "$BUILD/tests/VCOTunerTests" ]; then + if ctest --test-dir "$BUILD" --output-on-failure > "$ROOT/.harness-ctest.log" 2>&1; then + SUMMARY=$(grep -E '^[0-9]+% tests passed' "$ROOT/.harness-ctest.log" | head -1) + ASSERTS=$("$BUILD/tests/VCOTunerTests" 2>/dev/null | grep -Eo '[0-9]+ assertions' | head -1) + gate "unit-tests" PASS "${SUMMARY:-all passed} (${ASSERTS:-?})" + else + gate "unit-tests" FAIL "$(grep -E 'tests passed|Failed' "$ROOT/.harness-ctest.log" | head -3 | tr '\n' ' ')" + fi +else + gate "unit-tests" SKIP "tests were not built" +fi + +############################################################################### +hdr "UI smoke" + +if [ "$RUN_UI" = "0" ]; then + gate "ui-smoke" SKIP "--no-ui" +elif [ ! -d "$APP" ]; then + gate "ui-smoke" SKIP "no app bundle" +else + # Ad-hoc sign so the entitlements (microphone) are attached, same as release. + codesign --force --deep --sign - --entitlements "$ROOT/macOS/entitlements.plist" "$APP" >/dev/null 2>&1 + UI_OUT=$(python3 "$ROOT/tools/ui_capture.py" "$APP" "$ARTIFACTS" 2>&1) + RC=$? + if [ $RC -eq 0 ]; then + gate "ui-smoke" PASS "$(echo "$UI_OUT" | head -1)" + echo "$UI_OUT" | sed 's/^/ /' + elif [ $RC -eq 77 ]; then + gate "ui-smoke" SKIP "pyobjc not installed" + else + gate "ui-smoke" FAIL "$(echo "$UI_OUT" | tail -2 | tr '\n' ' ')" + fi +fi + +############################################################################### +hdr "Summary" +printf ' %d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP" +[ -d "$ARTIFACTS" ] && printf ' screenshots: %s\n' "$ARTIFACTS" +if [ "$FAIL" -gt 0 ]; then + printf '\n\033[31mHARNESS FAILED\033[0m\n'; exit 1 +elif [ "$SKIP" -gt 0 ]; then + printf '\n\033[33mHARNESS PASSED WITH SKIPS\033[0m\n'; exit 0 +else + printf '\n\033[32mHARNESS PASSED\033[0m\n'; exit 0 +fi diff --git a/tools/harness_selftest.py b/tools/harness_selftest.py new file mode 100755 index 0000000..7060ac1 --- /dev/null +++ b/tools/harness_selftest.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Prove each static guard actually trips. + +Breaks one thing at a time, asserts the named gate fails, then restores from an +in-memory copy. A guard that cannot fail is not a guard, so this runs before +trusting a green harness. +""" +import pathlib, subprocess, sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +HARNESS = [str(ROOT / 'tools/debug_harness.sh'), '--static-only'] + +PM = 'const String plusMinus (CharPointer_UTF8 ("\\xc2\\xb1"));' +TAB_OK = (' g.setColour(Colors::panelLight.withAlpha(0.3f));\n' + ' g.drawLine(0, (float)h - 1, (float)w, (float)h - 1);\n }') +FONT_OK = 'GlyphArrangement::getStringWidth(g.getCurrentFont(), String(measurements[i].midiPitch))' +CV_OK = ' cvOutputManager->fillOutputBuffer(outputChannelData[0], numSamples);' + +CASES = [ + ('no-raw-utf8-literals', 'Source/TunerDisplay.cpp', + PM, 'const String plusMinus ("±");'), + ('tabbar-overlay-not-opaque', 'Source/ModernLookAndFeel.h', + TAB_OK, ' g.fillRect(0, 0, w, h);\n' + TAB_OK), + ('no-juce7-font-api', 'Source/Visualizer.cpp', + FONT_OK, 'g.getCurrentFont().getStringWidth(String(measurements[i].midiPitch))'), + ('cv-before-early-returns', 'Source/VCOTuner.cpp', + CV_OK, ' /* moved below the early returns */'), +] + + +def harness_output(): + return subprocess.run(HARNESS, capture_output=True, text=True, cwd=ROOT).stdout + + +failed = False +print('harness selftest:') + +for gate, relpath, anchor, broken in CASES: + path = ROOT / relpath + original = path.read_text() + if anchor not in original: + print(f' BROKEN {gate}: anchor not found in {relpath}') + failed = True + continue + path.write_text(original.replace(anchor, broken, 1)) + try: + out = harness_output() + if any('FAIL' in line and gate in line for line in out.splitlines()): + print(f' ok {gate} trips when broken') + else: + print(f' BROKEN {gate} did NOT trip') + failed = True + finally: + path.write_text(original) + +out = harness_output() +if any('FAIL' in line for line in out.splitlines()): + print(' BROKEN baseline is not clean after restore') + print('\n'.join(' ' + l for l in out.splitlines() if 'FAIL' in l)) + failed = True +else: + print(' ok baseline clean after restore') + +print('SELFTEST FAILED' if failed else 'SELFTEST PASSED') +sys.exit(1 if failed else 0) diff --git a/tools/ui_capture.py b/tools/ui_capture.py new file mode 100755 index 0000000..6b027df --- /dev/null +++ b/tools/ui_capture.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Launch the built app, capture each tab, and report what was seen. + +The app is a normal windowed macOS build, so the only reliable handle on it is +its CGWindowID -- AppleScript's "frontmost" loses to whatever terminal is +running the harness. Capture by window id instead. +""" +import subprocess, sys, time, pathlib + +try: + import Quartz +except ImportError: + print("SKIP: pyobjc (Quartz) not available", file=sys.stderr) + sys.exit(77) + +APP = sys.argv[1] +OUT = pathlib.Path(sys.argv[2]) +OUT.mkdir(parents=True, exist_ok=True) + + +def windows(): + wl = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements, + Quartz.kCGNullWindowID) + return [w for w in wl if 'VCOTuner' in str(w.get('kCGWindowOwnerName', ''))] + + +def main_window(timeout=20): + deadline = time.time() + timeout + while time.time() < deadline: + ws = windows() + if ws: + return max(ws, key=lambda w: w['kCGWindowBounds']['Height']) + time.sleep(0.5) + return None + + +def click(win, lx, ly): + b = win['kCGWindowBounds'] + pt = (b['X'] + lx, b['Y'] + ly) + for kind in (Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp): + Quartz.CGEventPost(Quartz.kCGHIDEventTap, + Quartz.CGEventCreateMouseEvent(None, kind, pt, Quartz.kCGMouseButtonLeft)) + time.sleep(0.12) + time.sleep(0.7) + + +def shoot(win, name): + path = OUT / f"{name}.png" + subprocess.run(["screencapture", f"-l{win['kCGWindowNumber']}", "-o", str(path)], check=True) + return path + + +subprocess.run(["osascript", "-e", 'tell application "VCOTuner" to quit'], + capture_output=True) +time.sleep(2) +subprocess.run(["open", APP], check=True) + +win = main_window() +if win is None: + print("FAIL: app window never appeared") + sys.exit(1) + +b = win['kCGWindowBounds'] +print(f"window {int(b['Width'])}x{int(b['Height'])} (id {win['kCGWindowNumber']})") + +shots = [shoot(win, "01_tuner_tab")] +click(win, 150, 125) # the Chart tab sits right of Tuner +shots.append(shoot(win, "02_chart_tab")) +click(win, 60, 125) # back to Tuner +shots.append(shoot(win, "03_back_to_tuner")) + +subprocess.run(["osascript", "-e", 'tell application "VCOTuner" to quit'], + capture_output=True) + +for s in shots: + size = s.stat().st_size + print(f" captured {s.name} ({size} bytes)") + if size < 10000: + print(f"FAIL: {s.name} looks empty") + sys.exit(1) +print("OK: app launched, both tabs reachable, screenshots captured") From ce54212723ae3e51ffe75a60834ade01c0c76646 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 13:26:27 +0200 Subject: [PATCH 55/60] Repair dropouts in the period fit instead of failing the note 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) --- Source/VCOTuner.cpp | 1 + Source/VCOTuner.h | 4 + Source/Visualizer.cpp | 11 +++ Source/dsp/MeasurementStatistics.cpp | 122 ++++++++++++++++++++++----- Source/dsp/MeasurementStatistics.h | 38 ++++++++- Source/dsp/PeriodDetector.cpp | 54 +++++++++++- Source/dsp/PeriodDetector.h | 26 ++++++ tests/MeasurementStatisticsTests.cpp | 92 ++++++++++++++++++++ tests/PeriodDetectorTests.cpp | 82 +++++++++++++++--- 9 files changed, 388 insertions(+), 42 deletions(-) diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 282dd9e..8ec3212 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -322,6 +322,7 @@ void VCOTuner::timerCallback() m.freqDeviation = result.frequencyDeviation; m.pitchDeviation = result.pitchDeviation; m.numMeasurements = detector.numValidPeriods(); + m.rejectedCrossings = result.rejectedCrossings; listeners.call(&Listener::newMeasurementReady, m); // prepare next measurement diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index a4aa48a..46282d4 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -68,6 +68,10 @@ class VCOTuner: public ChangeListener, double freqDeviation; double pitchDeviation; int numMeasurements; + // Crossings the fit had to discard for this note -- a dropout, or a + // trigger that fired on something that was not a cycle boundary. The + // reading is still correct; this says it needed repairing to get there. + int rejectedCrossings; Time timestamp; } measurement_t; diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index 9e225f4..666f876 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -254,6 +254,17 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl g.setColour(barColor.withAlpha(0.3f)); g.fillRoundedRectangle(barCenter - barWidth / 2 - 2, yFlip(barTop + barHeight) - 2, barWidth + 4, barHeight + 4, 3.0f); + + // A note whose fit had to discard a crossing reads correctly but came + // from a capture with a dropout in it. Mark it, so a column that + // needed repairing is not presented as indistinguishable from a clean + // one -- if these cluster, the audio path is the thing to look at. + if (measurements[i].rejectedCrossings > 0) + { + const float dot = jmin (5.0f, barWidth * 0.4f); + g.setColour(ModernLookAndFeel::Colors::warning); + g.fillEllipse(barCenter - dot / 2.0f, chartTop + 3.0f, dot, dot); + } } // Draw the X-Axis label diff --git a/Source/dsp/MeasurementStatistics.cpp b/Source/dsp/MeasurementStatistics.cpp index e202aec..d255be9 100644 --- a/Source/dsp/MeasurementStatistics.cpp +++ b/Source/dsp/MeasurementStatistics.cpp @@ -1,13 +1,37 @@ // Source/dsp/MeasurementStatistics.cpp #include "MeasurementStatistics.h" +#include #include #include namespace vcotuner { -PeriodFit fitPeriod (const double* periods, int numPeriods) +namespace +{ + /** Median of a copy. Taken by value because nth_element reorders. */ + double medianOf (std::vector v) + { + if (v.empty()) + return 0.0; + + const size_t mid = v.size() / 2; + std::nth_element (v.begin(), v.begin() + (long) mid, v.end()); + const double upper = v[mid]; + + if (v.size() % 2 != 0) + return upper; + + // Even count: the median is the mean of the two middle values. The + // nth_element above already placed everything below `mid` before it, + // so the second one is just the largest of that lower part. + const double lower = *std::max_element (v.begin(), v.begin() + (long) mid); + return 0.5 * (lower + upper); + } +} + +PeriodFit fitPeriod (const double* periods, int numPeriods, double outlierTolerance) { PeriodFit fit; @@ -15,51 +39,105 @@ PeriodFit fitPeriod (const double* periods, int numPeriods) return fit; // Cumulate periods back into crossing times: n+1 points for n periods. - const int n = numPeriods + 1; - std::vector times ((size_t) n); + const int numCrossings = numPeriods + 1; + std::vector times ((size_t) numCrossings); times[0] = 0.0; for (int i = 0; i < numPeriods; ++i) times[(size_t) (i + 1)] = times[(size_t) i] + periods[i]; + const double medianPeriod = medianOf (std::vector (periods, periods + numPeriods)); + + if (! (medianPeriod > 0.0)) + return fit; + + // Number the crossings by counting cycles rather than by position. See the + // header: an extra or missing crossing renumbers everything after it, and + // that is what wrecks a plain index fit. + std::vector cycleNumber, crossingTime; + cycleNumber.reserve ((size_t) numCrossings); + crossingTime.reserve ((size_t) numCrossings); + + // The anchor is only ever moved to a crossing that landed on the grid, so + // a rejected crossing's untrustworthy time never becomes the reference for + // the ones that follow it. + double anchorTime = times[0]; + double anchorCycle = 0.0; + cycleNumber.push_back (0.0); + crossingTime.push_back (times[0]); + + int rejected = 0; + + for (int i = 1; i < numCrossings; ++i) + { + const double gap = times[(size_t) i] - anchorTime; + const double cycles = std::floor (gap / medianPeriod + 0.5); + + // Closer to the anchor than half a period: this crossing cannot be a + // new cycle, so it is the spurious one a click inserted. + if (cycles < 1.0) + { + ++rejected; + continue; + } + + if (std::abs (gap - cycles * medianPeriod) > outlierTolerance * medianPeriod) + { + ++rejected; + continue; + } + + anchorCycle += cycles; + anchorTime = times[(size_t) i]; + cycleNumber.push_back (anchorCycle); + crossingTime.push_back (anchorTime); + } + + const int n = (int) cycleNumber.size(); + + fit.rejectedCrossings = rejected; + fit.usedCrossings = n; + + // Three points are the minimum that leaves a degree of freedom for the + // standard error. + if (n < 3) + return fit; + double meanX = 0.0, meanY = 0.0; - for (int i = 0; i < n; ++i) { meanX += i; meanY += times[(size_t) i]; } + for (int i = 0; i < n; ++i) { meanX += cycleNumber[(size_t) i]; meanY += crossingTime[(size_t) i]; } meanX /= n; meanY /= n; double sxx = 0.0, sxy = 0.0; for (int i = 0; i < n; ++i) { - const double dx = i - meanX; + const double dx = cycleNumber[(size_t) i] - meanX; sxx += dx * dx; - sxy += dx * (times[(size_t) i] - meanY); + sxy += dx * (crossingTime[(size_t) i] - meanY); } - // Unreachable by design, not load-bearing: sxx depends only on the - // crossing indices 0..n-1, never on the period values, and the - // numPeriods < 2 guard above already ensures n >= 3, for which sxx is - // always strictly positive. Kept as a defensive guard against future - // changes to how x-values are chosen. + // Guards against every kept crossing sharing one cycle number, which no + // real capture produces but which would divide by zero here. if (sxx <= 0.0) return fit; const double slope = sxy / sxx; const double intercept = meanY - slope * meanX; + if (! (slope > 0.0)) + return fit; + fit.periodSamples = slope; - // Standard error of the slope needs at least one degree of freedom. - if (n > 2) + double sse = 0.0; + for (int i = 0; i < n; ++i) { - double sse = 0.0; - for (int i = 0; i < n; ++i) - { - const double residual = times[(size_t) i] - (intercept + slope * i); - sse += residual * residual; - } - fit.periodStdError = std::sqrt (sse / ((n - 2) * sxx)); - fit.valid = true; + const double residual = crossingTime[(size_t) i] - (intercept + slope * cycleNumber[(size_t) i]); + sse += residual * residual; } + fit.periodStdError = std::sqrt (sse / ((n - 2) * sxx)); + fit.valid = true; + return fit; } @@ -71,6 +149,8 @@ MeasurementResult computeMeasurement (const double* periods, int numPeriods, MeasurementResult result; const auto fit = fitPeriod (periods, numPeriods); + result.rejectedCrossings = fit.rejectedCrossings; + if (! fit.valid || fit.periodSamples <= 0.0 || referenceFrequency <= 0.0 || sampleRate <= 0.0) return result; diff --git a/Source/dsp/MeasurementStatistics.h b/Source/dsp/MeasurementStatistics.h index a72241b..cefd247 100644 --- a/Source/dsp/MeasurementStatistics.h +++ b/Source/dsp/MeasurementStatistics.h @@ -9,6 +9,18 @@ struct PeriodFit bool valid = false; double periodSamples = 0.0; double periodStdError = 0.0; + + /** Crossings left out of the fit because they did not sit on the cycle + grid -- a click's spurious crossing, or one the trigger missed. Zero + on a clean capture. Reported so a repaired measurement can be shown as + repaired rather than passed off as pristine. + */ + int rejectedCrossings = 0; + + /** Crossings actually used. rejectedCrossings + used == the number of + crossings handed in, except where re-indexing collapsed a pair. + */ + int usedCrossings = 0; }; struct MeasurementResult @@ -18,12 +30,13 @@ struct MeasurementResult double frequencyDeviation = 0.0; double pitch = 0.0; double pitchDeviation = 0.0; + int rejectedCrossings = 0; }; -/** Least-squares fit of crossing time against crossing index. +/** Robust least-squares fit of crossing time against cycle number. - The periods are cumulated back into crossing times, then fitted with a - straight line. The slope is the period estimate and the standard error of + The periods are cumulated back into crossing times and fitted with a + straight line whose slope is the period estimate; the standard error of that slope is its uncertainty. This replaces taking the standard deviation of the individual periods, @@ -31,14 +44,31 @@ struct MeasurementResult time, so their errors are negatively correlated and the spread of the periods badly overstates the uncertainty of their mean. + Crossings are numbered by counting cycles rather than by assuming one + cycle per crossing. That distinction is what makes the fit robust. A click + that forces an extra crossing does not merely contribute one outlying + period: it shifts the cycle number of every crossing after it, which an + index-based fit reads as a step in the line and answers with a badly wrong + slope. Rounding each gap to the nearest whole number of median periods + recovers the true cycle number across such a break, and any crossing that + still does not land on the grid is dropped from the fit and counted in + rejectedCrossings. + + The median period is the scale used for that rounding precisely because it + survives a minority of bad crossings; the mean does not. + Requires at least 2 periods (3 crossing times) to return a fit; fewer returns valid == false with finite zeroed fields. Exactly 2 periods leaves a single degree of freedom (n - 2 == 1), which is still a well-defined standard error but a deliberately wide one -- a large error bar is precisely how low confidence should be communicated, rather than discarding the measurement outright. + + outlierTolerance is the permitted distance from the cycle grid, as a + fraction of the median period. */ -PeriodFit fitPeriod (const double* periods, int numPeriods); +PeriodFit fitPeriod (const double* periods, int numPeriods, + double outlierTolerance = 0.25); /** Converts a period sequence into frequency, pitch and their uncertainties. */ MeasurementResult computeMeasurement (const double* periods, int numPeriods, diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index 3c40e48..a2364f5 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -1,5 +1,7 @@ // Source/dsp/PeriodDetector.cpp #include "PeriodDetector.h" + +#include #include namespace vcotuner @@ -37,7 +39,10 @@ namespace void PeriodDetector::prepare (int maxPeriods) { if (maxPeriods > 0) + { periods.reserve ((size_t) maxPeriods); + scratch.reserve ((size_t) maxPeriods); + } } void PeriodDetector::reset (const PeriodDetectorConfig& config) @@ -54,6 +59,8 @@ void PeriodDetector::reset (const PeriodDetectorConfig& config) periods.clear(); periods.reserve ((size_t) config.maxPeriods); + scratch.reserve ((size_t) config.maxPeriods); + outlierCount = 0; lastCrossing = -1.0; lastSample = 0.0; armed = false; @@ -163,6 +170,35 @@ const double* PeriodDetector::validPeriods() const noexcept return periods.data() + firstValidIndex; } +int PeriodDetector::countOutliers (const double* values, int count) +{ + if (values == nullptr || count <= 0) + return 0; + + // assign() over a vector whose capacity was reserved in reset() reuses the + // existing storage, so this does not allocate on the audio thread. + scratch.assign (values, values + count); + + const size_t mid = scratch.size() / 2; + std::nth_element (scratch.begin(), scratch.begin() + (long) mid, scratch.end()); + double median = scratch[mid]; + + if (scratch.size() % 2 == 0) + median = 0.5 * (*std::max_element (scratch.begin(), scratch.begin() + (long) mid) + median); + + if (! (median > 0.0)) + return count; + + const double boundary = median * cfg.stabilityTolerance; + + int outliers = 0; + for (int i = 0; i < count; ++i) + if (std::abs (values[i] - median) >= boundary) + ++outliers; + + return outliers; +} + void PeriodDetector::updateStability() { const int n = (int) periods.size(); @@ -181,9 +217,21 @@ void PeriodDetector::updateStability() // Re-check the whole collected set before declaring success: a drifting // oscillator can satisfy a single window and then wander far outside // tolerance, which is exactly what failedUnstable is for. - currentStatus = isWithinTolerance (validPeriods(), - numValidPeriods(), - cfg.stabilityTolerance) + // + // The check counts how many periods sit off the grid rather than + // demanding that none do. A dropout inserts or drops a single crossing + // and so spoils at most two periods out of however many were collected, + // and failing the note for that discards a measurement the fit can + // repair exactly. Sustained jitter spoils a large share of them and + // still fails here. The comparison is against the median, not the mean: + // an outlier drags the mean towards itself and can hide behind it. + const int valid = numValidPeriods(); + outlierCount = countOutliers (validPeriods(), valid); + + const int allowed = std::max (cfg.minOutliersAllowed, + (int) (cfg.maxOutlierFraction * valid)); + + currentStatus = (outlierCount <= allowed) ? DetectorStatus::stable : DetectorStatus::failedUnstable; return; diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h index 58efc80..99b0a95 100644 --- a/Source/dsp/PeriodDetector.h +++ b/Source/dsp/PeriodDetector.h @@ -16,6 +16,21 @@ struct PeriodDetectorConfig int warmupSamples = 2048; // level-tracking window double silenceFloor = 1e-4; // amplitude below this => silent int requiredPeriods = 10; // valid periods needed for 'stable' + + /** Share of the collected periods allowed to sit off the cycle grid before + the note is called unstable. A dropout inserts or removes one crossing + and so disturbs at most two periods; a genuinely jittery oscillator + disturbs a large fraction of them. Tolerating a bounded few is what + separates "one click" from "this is not a steady pitch", and the fit + in MeasurementStatistics then excludes them rather than absorbing them. + */ + double maxOutlierFraction = 0.02; + + /** Outliers always tolerated regardless of fraction. One dropout costs two + periods, so a lower allowance than this would fail short captures for + the single glitch this is meant to survive. + */ + int minOutliersAllowed = 2; }; enum class DetectorStatus @@ -54,11 +69,17 @@ class PeriodDetector int numValidPeriods() const noexcept; const double* validPeriods() const noexcept; + /** Periods judged off-grid by the last stability check. Zero until the + check has run. + */ + int numOutliers() const noexcept { return outlierCount; } + private: void finishWarmup(); void processCrossing (double s); void recordCrossing (double position); void updateStability(); + int countOutliers (const double* values, int count); PeriodDetectorConfig cfg {}; DetectorStatus currentStatus = DetectorStatus::collecting; @@ -72,6 +93,11 @@ class PeriodDetector bool haveLevel = false; std::vector periods; + + // Scratch for the median in countOutliers(). Sized with periods so the + // audio thread never allocates: nth_element needs to reorder a copy. + std::vector scratch; + int outlierCount = 0; double lastCrossing = -1.0; double lastSample = 0.0; bool armed = false; diff --git a/tests/MeasurementStatisticsTests.cpp b/tests/MeasurementStatisticsTests.cpp index 4968801..666e918 100644 --- a/tests/MeasurementStatisticsTests.cpp +++ b/tests/MeasurementStatisticsTests.cpp @@ -159,3 +159,95 @@ TEST_CASE ("measurement propagates a nonzero deviation using the exact formulas" // pitchDeviation = 12 * relative: REQUIRE (result.pitchDeviation != Approx (12.0 * (std::sqrt (2.0 / 15.0) / 10.0))); } + +//============================================================================== +// Robustness of the fit to bad crossings. +// +// These use exact synthetic period sequences rather than synthesised audio, so +// what is being tested is the estimator itself and not the trigger in front of +// it. + +TEST_CASE ("a spurious crossing is dropped and the period is unchanged") +{ + // A click splits one period into two halves: the crossing between them is + // not a real cycle boundary. Numbering crossings by position would put + // every later crossing one cycle ahead of the truth and bend the line; + // counting cycles recovers it. + std::vector periods (40, 100.0); + periods[20] = 30.0; + periods.insert (periods.begin() + 21, 70.0); // 30 + 70 == one period + + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.periodSamples == Approx (100.0).epsilon (1e-9)); + REQUIRE (fit.rejectedCrossings == 1); +} + +TEST_CASE ("a missed crossing is spanned rather than rejected") +{ + // A trigger that misses one crossing leaves a gap of exactly two periods. + // That is a whole number of cycles, so the crossing after it is still on + // the grid: nothing needs rejecting, the cycle count simply advances by + // two. Treating it as an outlier instead would throw away a good crossing. + std::vector periods (40, 100.0); + periods[20] = 200.0; + periods.erase (periods.begin() + 21); + + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.periodSamples == Approx (100.0).epsilon (1e-9)); + REQUIRE (fit.rejectedCrossings == 0); +} + +TEST_CASE ("the unrepaired fit would have been badly wrong") +{ + // Guards the claim the repair rests on. With a spurious crossing present, + // numbering crossings by position -- one cycle each -- pulls the slope far + // enough off that the reading would be wrong by well over a cent while + // still carrying a modest-looking error bar. The check here is that the + // repaired slope and that naive slope really do differ. + std::vector periods (40, 100.0); + periods[20] = 30.0; + periods.insert (periods.begin() + 21, 70.0); + + double sum = 0.0; + for (double p : periods) sum += p; + const double naiveSlope = sum / (double) periods.size(); // mean period + + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.periodSamples == Approx (100.0).epsilon (1e-9)); + REQUIRE (std::abs (naiveSlope - 100.0) > 1.0); + + // 1.2 samples at a 100-sample period is ~20 cents: not a rounding detail. + const double centsOff = 1200.0 * std::log2 (100.0 / naiveSlope); + REQUIRE (std::abs (centsOff) > 10.0); +} + +TEST_CASE ("a clean sequence rejects nothing and reports what it used") +{ + const std::vector periods (30, 250.0); + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.rejectedCrossings == 0); + REQUIRE (fit.usedCrossings == (int) periods.size() + 1); +} + +TEST_CASE ("rejection does not destroy the uncertainty estimate") +{ + // A repaired fit must still report an honest error bar: dropping a + // crossing removes a point but must not leave the standard error at zero, + // which would claim more confidence than the data supports. + std::vector periods { 100.0, 101.0, 99.0, 100.0, 30.0, 70.0, + 101.0, 99.0, 100.0, 100.0, 101.0 }; + const auto fit = fitPeriod (periods.data(), (int) periods.size()); + + REQUIRE (fit.valid); + REQUIRE (fit.rejectedCrossings == 1); + REQUIRE (fit.periodSamples == Approx (100.0).epsilon (0.01)); + REQUIRE (fit.periodStdError > 0.0); + REQUIRE (std::isfinite (fit.periodStdError)); +} diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index 205dc35..bcb1582 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -1,6 +1,7 @@ // tests/PeriodDetectorTests.cpp #include #include "dsp/PeriodDetector.h" +#include "dsp/MeasurementStatistics.h" using namespace vcotuner; @@ -395,15 +396,17 @@ TEST_CASE ("every shipped pitch and resolution reaches stable") #include "dsp/MeasurementError.h" -TEST_CASE ("one click fails the note instead of being absorbed into the fit") +TEST_CASE ("one click is repaired rather than failing the note") { - // This is a deliberate consequence of re-validating steadiness over the - // whole valid set rather than a single window, written down here so it is - // a specification rather than a surprise: a capture containing one glitch - // is failed and marked, not silently folded into the fit's error bars, - // where it would produce a wrong frequency carrying a plausible-looking - // uncertainty. The note is reported through the non-fatal highJitter - // error, so the sweep carries on past it (asserted at the bottom). + // This used to be specified the other way round: any capture containing a + // glitch was failed, on the grounds that folding it into the fit would + // produce a wrong frequency carrying a plausible-looking uncertainty. + // That reasoning holds only for a fit that absorbs the bad crossing. + // fitPeriod() now numbers crossings by counting cycles, so the crossing a + // click inserts is identified and dropped instead of shifting the cycle + // number of everything after it. The frequency that comes out is the + // correct one, and the repair is reported rather than hidden -- so the + // note is now measured, and the sweep no longer loses it. PeriodDetectorConfig cfg; cfg.warmupSamples = 480; cfg.requiredPeriods = 400; @@ -411,13 +414,17 @@ TEST_CASE ("one click fails the note instead of being absorbed into the fit") const double freq = 440.0, sampleRate = 48000.0; const int numSamples = 60000; // ~545 periods; maxPeriods (600) is not hit - // Control: the same signal without the click is stable. + // Control: the same signal without the click is stable and clean. { const auto clean = makeSine (freq, sampleRate, numSamples, 0.9, 0.0); PeriodDetector detector; detector.reset (cfg); detector.processBlock (clean.data(), numSamples); REQUIRE (detector.status() == DetectorStatus::stable); + + const auto fit = fitPeriod (detector.validPeriods(), detector.numValidPeriods()); + REQUIRE (fit.valid); + REQUIRE (fit.rejectedCrossings == 0); } auto samples = makeSine (freq, sampleRate, numSamples, 0.9, 0.0); @@ -435,10 +442,57 @@ TEST_CASE ("one click fails the note instead of being absorbed into the fit") detector.reset (cfg); detector.processBlock (samples.data(), numSamples); - INFO ("clickAt=" << clickAt << " numPeriods=" << detector.numPeriods()); - REQUIRE (detector.status() == DetectorStatus::failedUnstable); + INFO ("clickAt=" << clickAt << " numPeriods=" << detector.numPeriods() + << " outliers=" << detector.numOutliers()); + REQUIRE (detector.status() == DetectorStatus::stable); + + const auto result = computeMeasurement (detector.validPeriods(), + detector.numValidPeriods(), + sampleRate, freq, 69); + REQUIRE (result.valid); + + // The whole point: the surviving measurement is right, not merely present. + // A tenth of a cent is far tighter than the tens of cents the unrepaired + // fit would have been off by. + REQUIRE (result.frequency == Approx (freq).epsilon (1e-4)); + REQUIRE (std::abs (result.pitch - 69.0) < 0.001); + + // And the repair is visible to the caller rather than silent. + REQUIRE (result.rejectedCrossings > 0); +} + +TEST_CASE ("sustained jitter still fails, and is not mistaken for a glitch") +{ + // The allowance added for dropouts must not quietly accept a signal that + // is genuinely not holding a pitch. A dropout costs one or two periods out + // of hundreds; a wobbling oscillator puts most of them off the median, so + // the count lands far above the allowance and the note still fails. + PeriodDetectorConfig cfg; + cfg.warmupSamples = 480; + cfg.requiredPeriods = 100; + + const double sampleRate = 48000.0; + const int numSamples = 40000; + + // A sine whose frequency swings between 400 and 480 Hz every 200 samples. + // Integrating the frequency keeps the phase continuous, so the only thing + // wrong with the signal is the period length -- there are no edges or + // discontinuities for the trigger to catch instead. + std::vector samples ((size_t) numSamples); + double phase = 0.0; + for (int i = 0; i < numSamples; ++i) + { + const double freq = ((i / 200) % 2 == 0) ? 400.0 : 480.0; + samples[(size_t) i] = (float) (0.9 * std::sin (phase)); + phase += 2.0 * kPi * freq / sampleRate; + } + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), numSamples); - // VCOTuner maps failedUnstable onto highJitter, which is not fatal: the - // note is recorded and the sweep moves on to the next one. - REQUIRE_FALSE (isFatal (MeasurementError::highJitter)); + INFO ("status=" << (int) detector.status() + << " periods=" << detector.numPeriods() + << " outliers=" << detector.numOutliers()); + REQUIRE (detector.status() != DetectorStatus::stable); } From 10bd892b45d5e9b2b233a9ff6d5393ba31147be3 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 13:34:21 +0200 Subject: [PATCH 56/60] Measure the converter's real sample rate instead of trusting the nominal 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) --- CMakeLists.txt | 1 + Source/VCOTuner.cpp | 31 ++++++- Source/VCOTuner.h | 31 +++++++ Source/Visualizer.cpp | 13 ++- Source/dsp/ClockCalibrator.cpp | 141 +++++++++++++++++++++++++++++ Source/dsp/ClockCalibrator.h | 113 +++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/ClockCalibratorTests.cpp | 161 +++++++++++++++++++++++++++++++++ tools/debug_harness.sh | 11 +++ tools/harness_selftest.py | 3 + 10 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 Source/dsp/ClockCalibrator.cpp create mode 100644 Source/dsp/ClockCalibrator.h create mode 100644 tests/ClockCalibratorTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5abda3f..f3baa33 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,7 @@ include_directories(Source) # Pure DSP logic, no JUCE dependency. Linked by both the app and the tests. add_library(vcotuner_dsp STATIC + Source/dsp/ClockCalibrator.cpp Source/dsp/PeriodDetector.cpp Source/dsp/MeasurementStatistics.cpp Source/dsp/MeasurementError.cpp diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 8ec3212..7f10a03 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -214,7 +214,7 @@ void VCOTuner::timerCallback() } double frequency = 0.0, deviation = 0.0; - if (!fitFrequency(detector, sampleRate, frequency, deviation)) + if (!fitFrequency(detector, effectiveSampleRate(), frequency, deviation)) { errors.add(Errors::highJitter); switchState(stopped); @@ -285,7 +285,7 @@ void VCOTuner::timerCallback() const auto result = vcotuner::computeMeasurement(detector.validPeriods(), detector.numValidPeriods(), - sampleRate, + effectiveSampleRate(), referenceFrequency, referencePitch); if (!result.valid) @@ -373,7 +373,7 @@ void VCOTuner::timerCallback() // mode runs until the user stops it, so there is nobody to tell. double frequency = 0.0, deviation = 0.0; if (lastDetectorStatus() == vcotuner::DetectorStatus::stable - && fitFrequency(detector, sampleRate, frequency, deviation)) + && fitFrequency(detector, effectiveSampleRate(), frequency, deviation)) { continuousFreqMeasurementResult = frequency; continuousFreqMeasurementDeviation = deviation; @@ -428,7 +428,7 @@ void VCOTuner::timerCallback() } double frequency = 0.0, deviation = 0.0; - if (!fitFrequency(detector, sampleRate, frequency, deviation)) + if (!fitFrequency(detector, effectiveSampleRate(), frequency, deviation)) { errors.add(Errors::highJitter); switchState(stopped); @@ -648,7 +648,7 @@ void VCOTuner::audioDeviceIOCallbackWithContext (const float* const* inputChanne float* const* outputChannelData, int numOutputChannels, int numSamples, - const AudioIODeviceCallbackContext&) + const AudioIODeviceCallbackContext& context) { // CV output has to be serviced on every callback, not only while a // measurement is running: the voltage is what holds the oscillator at @@ -671,6 +671,19 @@ void VCOTuner::audioDeviceIOCallbackWithContext (const float* const* inputChanne FloatVectorOperations::clear(outputChannelData[channel], numSamples); } + // Fed on every callback, ahead of the early returns: the converter's clock + // runs whether or not a note is being measured, and the estimate is only + // as good as the baseline it was taken over. + clockCalibrator.addBlock (numSamples, context.hostTimeNs); + + if (++clockPublishCounter >= 64) + { + clockPublishCounter = 0; + const auto e = clockCalibrator.estimate(); + measuredSampleRate.store (e.valid ? e.sampleRateHz : 0.0, std::memory_order_relaxed); + clockPpm.store (e.valid ? e.ppmOffset : 0.0, std::memory_order_relaxed); + } + if (stopMeasurement) { startMeasurement = false; @@ -740,6 +753,14 @@ void VCOTuner::switchState(VCOTuner::State newState) void VCOTuner::audioDeviceAboutToStart (AudioIODevice* device) { sampleRate = device->getCurrentSampleRate(); + + // A new device is a new clock: nothing measured about the old one carries + // over, and applying its correction here would be worse than not + // correcting at all. + clockCalibrator.reset (sampleRate); + measuredSampleRate.store (0.0, std::memory_order_relaxed); + clockPpm.store (0.0, std::memory_order_relaxed); + clockPublishCounter = 0; } /** inherited from AudioIODeviceCallback */ diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index 46282d4..d1692bf 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -15,6 +15,7 @@ #include "dsp/MeasurementError.h" #include "dsp/MeasurementStatistics.h" +#include "dsp/ClockCalibrator.h" #include "dsp/PeriodDetector.h" #include @@ -126,11 +127,41 @@ class VCOTuner: public ChangeListener, void removeListener(Listener* l); // CV Output integration + /** The converter's measured rate where one is available, the rate the + device reports otherwise. Every conversion from a period in samples to + a frequency in Hz goes through this rather than the nominal rate: a + converter out by 100 ppm puts every absolute reading out by 0.17 + cents, and that error is systematic, so a longer measurement does not + reduce it. See ClockCalibrator. + */ + double effectiveSampleRate() const noexcept + { + const double measured = measuredSampleRate.load (std::memory_order_relaxed); + return (measured > 0.0) ? measured : sampleRate; + } + + /** Offset of the measured rate from nominal in ppm, or 0 when no estimate + is available. For display: a reading is worth qualifying if the clock + it came from had to be corrected. + */ + double clockOffsetPpm() const noexcept + { + return clockPpm.load (std::memory_order_relaxed); + } + void setCVOutputManager(CVOutputManager* manager) { cvOutputManager = manager; } CVOutputManager* getCVOutputManager() { return cvOutputManager; } private: CVOutputManager* cvOutputManager = nullptr; + + // Written on the audio thread, read on the message thread. The calibrator's + // running sums stay private to the audio thread and only the finished + // estimate is published, so the reader never sees them mid-update. + vcotuner::ClockCalibrator clockCalibrator; + std::atomic measuredSampleRate { 0.0 }; // 0 == no estimate yet + std::atomic clockPpm { 0.0 }; + int clockPublishCounter = 0; // states for the state machine enum State { diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index 666f876..7eda3d3 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -390,7 +390,18 @@ void Visualizer::drawTopInfoPanel(Graphics& g, int width, int panelHeight) g.setColour(ModernLookAndFeel::Colors::textSecondary); g.setFont(Font(12.0f)); - g.drawText(String(current.frequency, 2) + " Hz", startX, topY + 42, sectionWidth, 16, Justification::left); + + // Qualify the absolute frequency with the clock correction that produced + // it. This is the one number the correction moves -- the cents offsets are + // ratios against a reference measured through the same clock, so they were + // never affected by it -- and a reading is worth qualifying if the clock + // behind it had to be corrected. + String freqText = String(current.frequency, 2) + " Hz"; + const double ppm = (tuner != nullptr) ? tuner->clockOffsetPpm() : 0.0; + if (std::abs(ppm) >= 0.5) + freqText += (ppm > 0 ? " clk +" : " clk ") + String(ppm, 0) + "ppm"; + + g.drawText(freqText, startX, topY + 42, sectionWidth, 16, Justification::left); // Section 2: Current Error (cents) startX += sectionWidth; diff --git a/Source/dsp/ClockCalibrator.cpp b/Source/dsp/ClockCalibrator.cpp new file mode 100644 index 0000000..20705f0 --- /dev/null +++ b/Source/dsp/ClockCalibrator.cpp @@ -0,0 +1,141 @@ +// Source/dsp/ClockCalibrator.cpp +#include "ClockCalibrator.h" + +#include + +namespace vcotuner +{ + +void ClockCalibrator::reset (double nominalSampleRate) noexcept +{ + nominal = (nominalSampleRate > 0.0) ? nominalSampleRate : 48000.0; + haveOrigin = false; + sawHostTime = false; + originX = originY = lastX = lastY = 0.0; + sampleIndex = 0; + n = 0; + sx = sy = sxx = sxy = syy = 0.0; +} + +void ClockCalibrator::restart (double x, double y) noexcept +{ + originX = x; + originY = y; + lastX = 0.0; + lastY = 0.0; + n = 1; + sx = sy = sxx = sxy = syy = 0.0; // the origin itself contributes zeros + haveOrigin = true; +} + +void ClockCalibrator::addBlock (int numSamples, const uint64_t* hostTimeNs) noexcept +{ + if (numSamples <= 0) + return; + + if (hostTimeNs == nullptr) + return; // no usable time source; see the header + + sawHostTime = true; + + const double x = (double) sampleIndex; + const double y = (double) *hostTimeNs * 1.0e-9; + + sampleIndex += numSamples; + + if (! haveOrigin) + { + restart (x, y); + return; + } + + const double dx = x - originX; + const double dy = y - originY; + + // Elapsed time and elapsed samples should agree to within the block + // period. Where they do not, the stream was interrupted: samples went + // missing, or the device restarted. Fitting across that joins two + // unrelated segments and reads the gap as a rate error, so start again + // from here rather than carry the damage. + const double expectedStep = (dx - lastX) / nominal; + const double actualStep = dy - lastY; + + if (expectedStep > 0.0 + && std::abs (actualStep - expectedStep) > cfg.discontinuityTolerance * expectedStep) + { + restart (x, y); + return; + } + + lastX = dx; + lastY = dy; + + ++n; + sx += dx; + sy += dy; + sxx += dx * dx; + sxy += dx * dy; + syy += dy * dy; +} + +ClockEstimate ClockCalibrator::estimate() const noexcept +{ + ClockEstimate e; + e.numBlocks = n; + e.spanSeconds = lastY; + + if (n < cfg.minBlocks || lastY < cfg.minSpanSeconds) + return e; + + const double denom = n * sxx - sx * sx; + if (! (denom > 0.0)) + return e; + + // slope is seconds per sample, so the rate is its reciprocal. + const double slope = (n * sxy - sx * sy) / denom; + if (! (slope > 0.0)) + return e; + + const double rate = 1.0 / slope; + const double ppm = (rate / nominal - 1.0) * 1.0e6; + + if (! std::isfinite (ppm) || std::abs (ppm) > cfg.maxPlausiblePpm) + return e; + + e.sampleRateHz = rate; + e.ppmOffset = ppm; + + // Standard error of the slope, then carried to the rate through + // d(1/s)/ds = -1/s^2. + if (n > 2) + { + const double intercept = (sy - slope * sx) / n; + + // Residual sum of squares from the accumulated sums, which avoids + // keeping the points themselves: + // SSE = Syy - 2b*Sxy - 2a*Sy + b^2*Sxx + 2ab*Sx + n*a^2 + const double sse = syy + - 2.0 * slope * sxy + - 2.0 * intercept * sy + + slope * slope * sxx + + 2.0 * slope * intercept * sx + + n * intercept * intercept; + + if (sse >= 0.0) + { + const double slopeStdError = std::sqrt (sse / ((n - 2) * (sxx - sx * sx / n))); + e.stdErrorHz = slopeStdError / (slope * slope); + } + } + + e.valid = true; + return e; +} + +double ClockCalibrator::correctedSampleRate() const noexcept +{ + const auto e = estimate(); + return e.valid ? e.sampleRateHz : nominal; +} + +} // namespace vcotuner diff --git a/Source/dsp/ClockCalibrator.h b/Source/dsp/ClockCalibrator.h new file mode 100644 index 0000000..b6a013e --- /dev/null +++ b/Source/dsp/ClockCalibrator.h @@ -0,0 +1,113 @@ +// Source/dsp/ClockCalibrator.h +#pragma once + +#include + +namespace vcotuner +{ + +struct ClockEstimate +{ + bool valid = false; + double sampleRateHz = 0.0; // the converter's measured rate + double stdErrorHz = 0.0; // uncertainty of that rate + double ppmOffset = 0.0; // measured vs nominal, in parts per million + double spanSeconds = 0.0; // baseline the estimate was taken over + int numBlocks = 0; +}; + +struct ClockCalibratorConfig +{ + /** Blocks needed before an estimate is offered. */ + int minBlocks = 200; + + /** Baseline needed before an estimate is offered. Timing jitter averages + down over the span, so a short baseline is the thing that limits + precision, not the block count. + */ + double minSpanSeconds = 2.0; + + /** Largest correction that will ever be believed. A converter is out by + tens to a few hundred ppm; anything past this is a broken timestamp + source or a device that changed rate underneath us, and applying it + would do more damage than the error it claims to fix. + */ + double maxPlausiblePpm = 2000.0; + + /** A block whose duration disagrees with its sample count by more than + this fraction did not arrive contiguously -- a dropout, or the stream + being restarted. Accumulating across it would fit a line to two + unrelated segments, so the run is restarted instead. + */ + double discontinuityTolerance = 0.5; +}; + +/** Measures the audio device's true sample rate against the host clock. + + The app converts a period in samples to a frequency in Hz by dividing by + the sample rate, and uses the rate the device reports -- a nominal 48000, + not what the converter's crystal actually runs at. Real interfaces are out + by tens to hundreds of ppm, and 100 ppm is 0.17 cents. That error is + systematic, so unlike jitter it does not average away with a longer + measurement. + + It cancels in the pitch offsets, which are ratios against a reference pitch + measured through the same clock, so live tuning was never affected by it. + It does not cancel in any absolute reading: the frequency readout, the + error-in-Hz display, and the frequencies recorded in a report. + + Each audio block gives a (sample count, host time) pair. Fitting a line + through them gives seconds per sample, and its reciprocal is the true rate. + This is the same estimator the period fit uses, for the same reason: the + slope of many points is far better conditioned than any single difference. + + The sums are accumulated online, so the audio thread stores nothing per + block and never allocates, and the baseline can grow without bound. +*/ +class ClockCalibrator +{ +public: + void reset (double nominalSampleRate) noexcept; + void setConfig (const ClockCalibratorConfig& c) noexcept { cfg = c; } + + /** Call once per audio block, from the audio thread. + + hostTimeNs is the device timestamp JUCE passes in + AudioIODeviceCallbackContext. It is null when the host does not supply + one, in which case no estimate is ever produced -- deliberately, since + the alternative is reading a clock on the audio thread and calling the + scheduling noise on it a measurement. + */ + void addBlock (int numSamples, const uint64_t* hostTimeNs) noexcept; + + ClockEstimate estimate() const noexcept; + + bool hostTimestampsAvailable() const noexcept { return sawHostTime; } + + /** nominal * (1 + ppm/1e6) when an estimate is available and plausible, + and the nominal rate otherwise. This is the divisor to use when + converting a period in samples to a frequency. + */ + double correctedSampleRate() const noexcept; + +private: + void restart (double x, double y) noexcept; + + ClockCalibratorConfig cfg {}; + + double nominal = 48000.0; + + bool haveOrigin = false; + bool sawHostTime = false; + + // Sums are kept relative to the first accepted point so they stay small + // and well conditioned however long the run lasts. + double originX = 0.0, originY = 0.0; + double lastX = 0.0, lastY = 0.0; + + long long sampleIndex = 0; + int n = 0; + double sx = 0.0, sy = 0.0, sxx = 0.0, sxy = 0.0, syy = 0.0; +}; + +} // namespace vcotuner diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 98ba854..86c9afe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ FetchContent_Declare( FetchContent_MakeAvailable(Catch2) add_executable(VCOTunerTests + ClockCalibratorTests.cpp PeriodDetectorTests.cpp MeasurementStatisticsTests.cpp MeasurementErrorTests.cpp diff --git a/tests/ClockCalibratorTests.cpp b/tests/ClockCalibratorTests.cpp new file mode 100644 index 0000000..5d510d5 --- /dev/null +++ b/tests/ClockCalibratorTests.cpp @@ -0,0 +1,161 @@ +// tests/ClockCalibratorTests.cpp +#include +#include +#include +#include +#include +#include + +#include "dsp/ClockCalibrator.h" + +using namespace vcotuner; +using Catch::Approx; + +namespace +{ + /** Feeds blocks whose host timestamps advance at `trueRate` while the + device claims `nominal`, optionally with timing jitter on each stamp. + */ + ClockCalibrator runBlocks (double nominal, double trueRate, int numBlocks, + int blockSize = 256, double jitterNs = 0.0, + unsigned seed = 1234) + { + ClockCalibrator cal; + cal.reset (nominal); + + std::mt19937 rng (seed); + std::normal_distribution jitter (0.0, jitterNs); + + double seconds = 0.0; + for (int i = 0; i < numBlocks; ++i) + { + const double noise = (jitterNs > 0.0) ? jitter (rng) : 0.0; + const uint64_t ns = (uint64_t) std::llround (seconds * 1.0e9 + noise); + cal.addBlock (blockSize, &ns); + seconds += blockSize / trueRate; + } + return cal; + } +} + +TEST_CASE ("with no host timestamps there is no estimate at all") +{ + // Reading a clock on the audio thread would measure scheduling noise and + // call it a rate, so the absence of a time source has to stay visible + // rather than be papered over. + ClockCalibrator cal; + cal.reset (48000.0); + for (int i = 0; i < 1000; ++i) + cal.addBlock (256, nullptr); + + REQUIRE_FALSE (cal.hostTimestampsAvailable()); + REQUIRE_FALSE (cal.estimate().valid); + REQUIRE (cal.correctedSampleRate() == Approx (48000.0)); +} + +TEST_CASE ("a converter running fast is measured to within a fraction of a ppm") +{ + // 48000 nominal, actually running 100 ppm fast. + const double nominal = 48000.0; + const double trueRate = nominal * (1.0 + 100.0e-6); + + auto cal = runBlocks (nominal, trueRate, 2000); + const auto e = cal.estimate(); + + REQUIRE (e.valid); + REQUIRE (e.sampleRateHz == Approx (trueRate).epsilon (1e-9)); + REQUIRE (e.ppmOffset == Approx (100.0).margin (0.1)); + REQUIRE (cal.correctedSampleRate() == Approx (trueRate).epsilon (1e-9)); +} + +TEST_CASE ("a hundred ppm is worth correcting: it is a sixth of a cent") +{ + // Pins the motivation. 100 ppm on the rate is 100 ppm on every frequency + // derived from it, which is 0.173 cents -- larger than the uncertainty the + // period fit now reports on a clean note, so it is the dominant error. + const double ppm = 100.0e-6; + const double cents = 1200.0 * std::log2 (1.0 + ppm); + REQUIRE (cents == Approx (0.1732).margin (0.001)); +} + +TEST_CASE ("timestamp jitter averages down instead of biasing the rate") +{ + // Real host timestamps are not exact. Jitter is zero-mean, so the slope + // through many points converges; this asserts it does, rather than the + // noise leaking into the answer. + const double nominal = 48000.0; + const double trueRate = nominal * (1.0 - 50.0e-6); + + auto cal = runBlocks (nominal, trueRate, 4000, 256, 20000.0 /* 20 us */); + const auto e = cal.estimate(); + + REQUIRE (e.valid); + REQUIRE (e.ppmOffset == Approx (-50.0).margin (1.0)); + REQUIRE (e.stdErrorHz > 0.0); + REQUIRE (std::isfinite (e.stdErrorHz)); +} + +TEST_CASE ("no estimate is offered before the baseline is long enough") +{ + // 200 blocks of 256 samples is about a second: past minBlocks but short of + // minSpanSeconds, and a short baseline is exactly what limits precision. + auto cal = runBlocks (48000.0, 48000.0, 210); + const auto e = cal.estimate(); + + REQUIRE_FALSE (e.valid); + REQUIRE (cal.correctedSampleRate() == Approx (48000.0)); +} + +TEST_CASE ("an implausible correction is refused rather than applied") +{ + // A timestamp source that is wrong, or a device that changed rate, can + // imply a huge correction. Applying it would be far worse than the error + // it claims to fix, so the nominal rate stands. + auto cal = runBlocks (48000.0, 48000.0 * 1.05, 2000); // 50000 ppm + const auto e = cal.estimate(); + + REQUIRE_FALSE (e.valid); + REQUIRE (cal.correctedSampleRate() == Approx (48000.0)); +} + +TEST_CASE ("a dropout restarts the run instead of being read as a rate error") +{ + // Samples going missing puts a step between two otherwise good segments. + // Fitting across it would read the gap as a sustained rate error; the + // calibrator starts again from the discontinuity. + const double nominal = 48000.0; + const double trueRate = nominal * (1.0 + 30.0e-6); + const int blockSize = 256; + + ClockCalibrator cal; + cal.reset (nominal); + + double seconds = 0.0; + for (int i = 0; i < 3000; ++i) + { + if (i == 500) + seconds += 0.75; // a three-quarter-second hole in the stream + + const uint64_t ns = (uint64_t) std::llround (seconds * 1.0e9); + cal.addBlock (blockSize, &ns); + seconds += blockSize / trueRate; + } + + const auto e = cal.estimate(); + REQUIRE (e.valid); + + // Without the restart the hole would dominate: 0.75 s spread over the run + // is thousands of ppm, which would have been refused as implausible and + // cost the estimate entirely. + REQUIRE (e.ppmOffset == Approx (30.0).margin (0.5)); +} + +TEST_CASE ("the estimate reports the baseline it was taken over") +{ + auto cal = runBlocks (48000.0, 48000.0, 2000); + const auto e = cal.estimate(); + + REQUIRE (e.valid); + REQUIRE (e.numBlocks == 2000); + REQUIRE (e.spanSeconds == Approx (1999.0 * 256.0 / 48000.0).epsilon (1e-6)); +} diff --git a/tools/debug_harness.sh b/tools/debug_harness.sh index 6075923..03f1c07 100755 --- a/tools/debug_harness.sh +++ b/tools/debug_harness.sh @@ -91,6 +91,17 @@ else gate "cv-before-early-returns" FAIL "CV fill@$CV_LINE is after return@$RET_LINE" fi +# Every conversion from a period in samples to a frequency in Hz must go +# through the measured clock, not the rate the device claims. Reverting one of +# these reintroduces a systematic error no amount of averaging removes. +NOMINAL_USE=$(grep -n 'fitFrequency(detector, sampleRate' "$ROOT/Source/VCOTuner.cpp" || true) +NOMINAL_CM=$(awk '/computeMeasurement\(/,/\);/' "$ROOT/Source/VCOTuner.cpp" | grep -c '^ *sampleRate,' || true) +if [ -n "$NOMINAL_USE" ] || [ "${NOMINAL_CM:-0}" -gt 0 ]; then + gate "frequency-uses-corrected-clock" FAIL "a conversion still divides by the nominal rate" +else + gate "frequency-uses-corrected-clock" PASS +fi + # The JUCE submodule must match what the tree pins, or the build is not the # build the gates think they are testing. PINNED=$(git -C "$ROOT" ls-files -s deps/JUCE | awk '{print $2}') diff --git a/tools/harness_selftest.py b/tools/harness_selftest.py index 7060ac1..14b572e 100755 --- a/tools/harness_selftest.py +++ b/tools/harness_selftest.py @@ -15,6 +15,7 @@ ' g.drawLine(0, (float)h - 1, (float)w, (float)h - 1);\n }') FONT_OK = 'GlyphArrangement::getStringWidth(g.getCurrentFont(), String(measurements[i].midiPitch))' CV_OK = ' cvOutputManager->fillOutputBuffer(outputChannelData[0], numSamples);' +CLOCK_OK = 'fitFrequency(detector, effectiveSampleRate(),' CASES = [ ('no-raw-utf8-literals', 'Source/TunerDisplay.cpp', @@ -25,6 +26,8 @@ FONT_OK, 'g.getCurrentFont().getStringWidth(String(measurements[i].midiPitch))'), ('cv-before-early-returns', 'Source/VCOTuner.cpp', CV_OK, ' /* moved below the early returns */'), + ('frequency-uses-corrected-clock', 'Source/VCOTuner.cpp', + CLOCK_OK, 'fitFrequency(detector, sampleRate,'), ] From d231861aa1ce303a28f2731b85f57d893682bb28 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 14:10:02 +0200 Subject: [PATCH 57/60] Drive the sweep from CV, track the trigger level, and clear the warning 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 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) --- CMakeLists.txt | 1 + Source/CVCalibrationWindow.cpp | 26 +-- Source/CVCalibrationWindow.h | 2 - Source/CVOutput/CVOutputManager.cpp | 88 +++------ Source/CVOutput/CVOutputManager.h | 1 + Source/Calibration/CalibrationEngine.h | 10 +- Source/Calibration/CalibrationTable.cpp | 35 ++-- Source/Calibration/CalibrationTable.h | 3 +- Source/Export/OrnamentCrimeExporter.cpp | 10 +- Source/MainComponent.cpp | 50 ++++- Source/MainComponent.h | 192 +++++++++---------- Source/ModernLookAndFeel.h | 30 +-- Source/ReportPrepScreen.cpp | 234 ++++++++++++------------ Source/Startup.cpp | 169 +++++++++-------- Source/TunerDisplay.cpp | 48 ++--- Source/TunerDisplay.h | 1 - Source/VCOTuner.cpp | 86 +++++++-- Source/VCOTuner.h | 42 ++++- Source/Visualizer.cpp | 96 +++++----- Source/Visualizer.h | 6 +- Source/dsp/CvScaling.cpp | 111 +++++++++++ Source/dsp/CvScaling.h | 60 ++++++ Source/dsp/PeriodDetector.cpp | 48 +++++ Source/dsp/PeriodDetector.h | 28 ++- tests/CMakeLists.txt | 1 + tests/CvScalingTests.cpp | 135 ++++++++++++++ tests/PeriodDetectorTests.cpp | 104 +++++++++++ tools/debug_harness.sh | 9 + tools/harness_selftest.py | 3 + 29 files changed, 1110 insertions(+), 519 deletions(-) create mode 100644 Source/dsp/CvScaling.cpp create mode 100644 Source/dsp/CvScaling.h create mode 100644 tests/CvScalingTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f3baa33..c284830 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,7 @@ include_directories(Source) # Pure DSP logic, no JUCE dependency. Linked by both the app and the tests. add_library(vcotuner_dsp STATIC + Source/dsp/CvScaling.cpp Source/dsp/ClockCalibrator.cpp Source/dsp/PeriodDetector.cpp Source/dsp/MeasurementStatistics.cpp diff --git a/Source/CVCalibrationWindow.cpp b/Source/CVCalibrationWindow.cpp index a48c4af..7aa6eb6 100644 --- a/Source/CVCalibrationWindow.cpp +++ b/Source/CVCalibrationWindow.cpp @@ -17,12 +17,12 @@ // CVSetupScreen Implementation //============================================================================== -CVSetupScreen::CVSetupScreen(CVCalibrationWindow* p, CVOutputManager* cv) - : parent(p), cvOutput(cv) +CVSetupScreen::CVSetupScreen(CVCalibrationWindow* p, CVOutputManager*) + : parent(p) { // Title titleLabel.setText("CV Calibration Setup", dontSendNotification); - titleLabel.setFont(Font(24.0f, Font::bold)); + titleLabel.setFont(Font(FontOptions(24.0f, Font::bold))); titleLabel.setJustificationType(Justification::centred); addAndMakeVisible(titleLabel); @@ -200,7 +200,7 @@ CVRunningScreen::CVRunningScreen(CVCalibrationWindow* p, CalibrationEngine* e) : parent(p), engine(e), progressBar(progress) { titleLabel.setText("Calibration in Progress", dontSendNotification); - titleLabel.setFont(Font(24.0f, Font::bold)); + titleLabel.setFont(Font(FontOptions(24.0f, Font::bold))); titleLabel.setJustificationType(Justification::centred); addAndMakeVisible(titleLabel); @@ -315,10 +315,10 @@ void CVRunningScreen::paint(Graphics& g) // Labels g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(10.0f)); - g.drawText("+50c", historyArea.getX() - 40, historyArea.getY() - 10, 35, 20, Justification::right); - g.drawText("-50c", historyArea.getX() - 40, historyArea.getBottom() - 10, 35, 20, Justification::right); - g.drawText("0", historyArea.getX() - 20, centerY - 10, 15, 20, Justification::right); + g.setFont(Font(FontOptions(10.0f))); + g.drawText("+50c", juce::Rectangle(historyArea.getX() - 40, historyArea.getY() - 10, 35, 20), Justification::right); + g.drawText("-50c", juce::Rectangle(historyArea.getX() - 40, historyArea.getBottom() - 10, 35, 20), Justification::right); + g.drawText("0", juce::Rectangle(historyArea.getX() - 20, centerY - 10, 15, 20), Justification::right); } } @@ -406,7 +406,7 @@ CVResultsScreen::CVResultsScreen(CVCalibrationWindow* p, const CalibrationTable& : parent(p), calibrationTable(table) { titleLabel.setText("Calibration Results", dontSendNotification); - titleLabel.setFont(Font(24.0f, Font::bold)); + titleLabel.setFont(Font(FontOptions(24.0f, Font::bold))); titleLabel.setJustificationType(Justification::centred); addAndMakeVisible(titleLabel); @@ -532,7 +532,7 @@ void CVResultsScreen::paintRowBackground(Graphics& g, int rowNumber, int /*width void CVResultsScreen::paintCell(Graphics& g, int rowNumber, int columnId, int width, int height, bool /*rowIsSelected*/) { g.setColour(ModernLookAndFeel::Colors::textPrimary); - g.setFont(Font(12.0f)); + g.setFont(Font(FontOptions(12.0f))); if (rowNumber >= 0 && rowNumber < calibrationTable.getEntryCount()) { @@ -556,7 +556,7 @@ void CVResultsScreen::paintCell(Graphics& g, int rowNumber, int columnId, int wi break; } - g.drawText(text, 4, 0, width - 8, height, Justification::centredLeft); + g.drawText(text, juce::Rectangle(4, 0, width - 8, height), Justification::centredLeft); } } @@ -564,8 +564,8 @@ void CVResultsScreen::paintCell(Graphics& g, int rowNumber, int columnId, int wi // CVCalibrationWindow Implementation //============================================================================== -CVCalibrationWindow::CVCalibrationWindow(VCOTuner* t, CVOutputManager* cv, Visualizer* v) - : tuner(t), cvOutput(cv), visualizer(v) +CVCalibrationWindow::CVCalibrationWindow(VCOTuner* t, CVOutputManager* cv, Visualizer*) + : tuner(t), cvOutput(cv) { engine = std::make_unique(tuner, cvOutput); showSetupScreen(); diff --git a/Source/CVCalibrationWindow.h b/Source/CVCalibrationWindow.h index e8d0d6f..a4ab5c5 100644 --- a/Source/CVCalibrationWindow.h +++ b/Source/CVCalibrationWindow.h @@ -37,7 +37,6 @@ class CVSetupScreen : public Component, private: CVCalibrationWindow* parent; - CVOutputManager* cvOutput; Label titleLabel; @@ -169,7 +168,6 @@ class CVCalibrationWindow : public Component VCOTuner* tuner; CVOutputManager* cvOutput; - Visualizer* visualizer; std::unique_ptr engine; std::unique_ptr currentScreen; diff --git a/Source/CVOutput/CVOutputManager.cpp b/Source/CVOutput/CVOutputManager.cpp index 459fc88..c15330c 100644 --- a/Source/CVOutput/CVOutputManager.cpp +++ b/Source/CVOutput/CVOutputManager.cpp @@ -9,6 +9,16 @@ #include "CVOutputManager.h" +namespace +{ + vcotuner::CvStandard toCvStandard (CVOutputManager::VoltageStandard s) + { + return (s == CVOutputManager::VoltageStandard::HzPerVolt) + ? vcotuner::CvStandard::hzPerVolt + : vcotuner::CvStandard::oneVoltPerOctave; + } +} + CVOutputManager::CVOutputManager() { } @@ -56,16 +66,12 @@ void CVOutputManager::setCustomVoltageRange(float minVolts, float maxVolts) void CVOutputManager::outputVoltage(float volts) { - // Clamp to interface range - volts = juce::jlimit(interfaceMinVolts, interfaceMaxVolts, volts); - - // Apply interface calibration if available - if (interfaceCalibration.isCalibrated) - { - volts = applyInterfaceCalibration(volts); - } - - currentOutputVoltage.store(volts); + currentOutputVoltage.store( + vcotuner::conditionOutputVoltage(volts, + { interfaceMinVolts, interfaceMaxVolts }, + { interfaceCalibration.isCalibrated, + interfaceCalibration.gain, + interfaceCalibration.offset })); } void CVOutputManager::outputPitch(int midiNote) @@ -113,79 +119,35 @@ float CVOutputManager::midiToVoltage(int midiNote) const float CVOutputManager::midiToVoltage(float midiPitch) const { - switch (currentStandard) - { - case VoltageStandard::OneVoltPerOctave: - // MIDI 60 (C4) = 0V, each semitone = 1/12 volt - return (midiPitch - 60.0f) / 12.0f; - - case VoltageStandard::HzPerVolt: - { - // Convert MIDI to frequency, then to voltage - float freq = 440.0f * std::pow(2.0f, (midiPitch - 69.0f) / 12.0f); - return freq / hzPerVoltScaling; - } - } - - return 0.0f; + return vcotuner::midiToVoltage(midiPitch, toCvStandard(currentStandard), hzPerVoltScaling); } float CVOutputManager::frequencyToVoltage(float hz) const { - switch (currentStandard) - { - case VoltageStandard::OneVoltPerOctave: - { - // Convert frequency to MIDI pitch, then to voltage - float midiPitch = 69.0f + 12.0f * std::log2(hz / 440.0f); - return (midiPitch - 60.0f) / 12.0f; - } - - case VoltageStandard::HzPerVolt: - return hz / hzPerVoltScaling; - } - - return 0.0f; + return vcotuner::frequencyToVoltage(hz, toCvStandard(currentStandard), hzPerVoltScaling); } float CVOutputManager::voltageToMidi(float voltage) const { - switch (currentStandard) - { - case VoltageStandard::OneVoltPerOctave: - return 60.0f + voltage * 12.0f; - - case VoltageStandard::HzPerVolt: - { - float freq = voltage * hzPerVoltScaling; - return 69.0f + 12.0f * std::log2(freq / 440.0f); - } - } - - return 60.0f; + return vcotuner::voltageToMidi(voltage, toCvStandard(currentStandard), hzPerVoltScaling); } float CVOutputManager::voltageToSample(float volts) const { - // Map voltage range to -1.0 to +1.0 sample range - // For Expert Sleepers: -10V to +10V maps to -1.0 to +1.0 - float range = interfaceMaxVolts - interfaceMinVolts; - float normalized = (volts - interfaceMinVolts) / range; // 0 to 1 - return normalized * 2.0f - 1.0f; // -1 to +1 + return vcotuner::voltageToSample(volts, { interfaceMinVolts, interfaceMaxVolts }); } float CVOutputManager::sampleToVoltage(float sample) const { - // Map -1.0 to +1.0 sample range to voltage range - float normalized = (sample + 1.0f) / 2.0f; // 0 to 1 - float range = interfaceMaxVolts - interfaceMinVolts; - return interfaceMinVolts + normalized * range; + return vcotuner::sampleToVoltage(sample, { interfaceMinVolts, interfaceMaxVolts }); } float CVOutputManager::applyInterfaceCalibration(float voltage) const { - // Apply linear correction: corrected = gain * voltage + offset - return interfaceCalibration.gain * voltage + interfaceCalibration.offset; + return vcotuner::applyCalibration(voltage, + { interfaceCalibration.isCalibrated, + interfaceCalibration.gain, + interfaceCalibration.offset }); } void CVOutputManager::setInterfaceCalibration(const InterfaceCalibration& cal) diff --git a/Source/CVOutput/CVOutputManager.h b/Source/CVOutput/CVOutputManager.h index caf9257..c1543e2 100644 --- a/Source/CVOutput/CVOutputManager.h +++ b/Source/CVOutput/CVOutputManager.h @@ -10,6 +10,7 @@ #pragma once #include +#include "../dsp/CvScaling.h" #include #include diff --git a/Source/Calibration/CalibrationEngine.h b/Source/Calibration/CalibrationEngine.h index f8db3cb..70bd20b 100644 --- a/Source/Calibration/CalibrationEngine.h +++ b/Source/Calibration/CalibrationEngine.h @@ -48,10 +48,10 @@ class CalibrationEngine : public VCOTuner::Listener, public: virtual ~Listener() = default; virtual void calibrationStarted() {} - virtual void calibrationPointCompleted(const CalibrationPoint& point) {} - virtual void calibrationProgress(float percent, const String& status) {} - virtual void calibrationCompleted(const CalibrationTable& table) {} - virtual void calibrationError(const String& error) {} + virtual void calibrationPointCompleted(const CalibrationPoint& /*point*/) {} + virtual void calibrationProgress(float /*percent*/, const String& /*status*/) {} + virtual void calibrationCompleted(const CalibrationTable& /*table*/) {} + virtual void calibrationError(const String& /*error*/) {} virtual void calibrationCancelled() {} }; @@ -90,7 +90,7 @@ class CalibrationEngine : public VCOTuner::Listener, void tunerStarted() override {} void tunerStopped() override; void tunerFinished() override {} - void tunerStatusChanged(String statusString) override {} + void tunerStatusChanged(String /*statusString*/) override {} private: enum class State diff --git a/Source/Calibration/CalibrationTable.cpp b/Source/Calibration/CalibrationTable.cpp index 1316d76..d3058a1 100644 --- a/Source/Calibration/CalibrationTable.cpp +++ b/Source/Calibration/CalibrationTable.cpp @@ -16,10 +16,6 @@ CalibrationTable::CalibrationTable() calibrationDate = Time::getCurrentTime(); } -CalibrationTable::~CalibrationTable() -{ -} - void CalibrationTable::addEntry(const Entry& entry) { entries.push_back(entry); @@ -189,7 +185,11 @@ std::vector CalibrationTable::getPolynomialCoefficients(int degree) cons // Build normal equations matrix // We're fitting: correction = a0 + a1*x + a2*x^2 + ... where x = midiNote - int m = degree + 1; + // size_t throughout: these are container indices, and mixing them with + // int is what -Wsign-conversion was flagging on every subscript. The back + // substitution below uses the standard unsigned reverse-loop idiom rather + // than a signed counter that has to end at -1. + const size_t m = (size_t) (degree + 1); std::vector> A(m, std::vector(m, 0.0)); std::vector b(m, 0.0); @@ -198,13 +198,12 @@ std::vector CalibrationTable::getPolynomialCoefficients(int degree) cons double x = entry.midiNote; double y = entry.correctionOffset; - for (int i = 0; i < m; ++i) + for (size_t i = 0; i < m; ++i) { - for (int j = 0; j < m; ++j) - { - A[i][j] += std::pow(x, i + j); - } - b[i] += y * std::pow(x, i); + for (size_t j = 0; j < m; ++j) + A[i][j] += std::pow(x, (double) (i + j)); + + b[i] += y * std::pow(x, (double) i); } } @@ -212,11 +211,11 @@ std::vector CalibrationTable::getPolynomialCoefficients(int degree) cons std::vector coefficients(m, 0.0); // Forward elimination - for (int k = 0; k < m; ++k) + for (size_t k = 0; k < m; ++k) { // Find pivot - int maxRow = k; - for (int i = k + 1; i < m; ++i) + size_t maxRow = k; + for (size_t i = k + 1; i < m; ++i) { if (std::abs(A[i][k]) > std::abs(A[maxRow][k])) maxRow = i; @@ -228,20 +227,20 @@ std::vector CalibrationTable::getPolynomialCoefficients(int degree) cons return {}; // Singular matrix // Eliminate column - for (int i = k + 1; i < m; ++i) + for (size_t i = k + 1; i < m; ++i) { double factor = A[i][k] / A[k][k]; - for (int j = k; j < m; ++j) + for (size_t j = k; j < m; ++j) A[i][j] -= factor * A[k][j]; b[i] -= factor * b[k]; } } // Back substitution - for (int i = m - 1; i >= 0; --i) + for (size_t i = m; i-- > 0; ) { coefficients[i] = b[i]; - for (int j = i + 1; j < m; ++j) + for (size_t j = i + 1; j < m; ++j) coefficients[i] -= A[i][j] * coefficients[j]; coefficients[i] /= A[i][i]; } diff --git a/Source/Calibration/CalibrationTable.h b/Source/Calibration/CalibrationTable.h index 37ef09f..3a94c7e 100644 --- a/Source/Calibration/CalibrationTable.h +++ b/Source/Calibration/CalibrationTable.h @@ -29,7 +29,6 @@ class CalibrationTable }; CalibrationTable(); - ~CalibrationTable(); // Building the table void addEntry(const Entry& entry); @@ -38,7 +37,7 @@ class CalibrationTable // Access int getEntryCount() const { return static_cast(entries.size()); } - const Entry& getEntry(int index) const { return entries[index]; } + const Entry& getEntry(int index) const { return entries[(size_t) index]; } Entry* findEntryForNote(int midiNote); const Entry* findEntryForNote(int midiNote) const; const std::vector& getAllEntries() const { return entries; } diff --git a/Source/Export/OrnamentCrimeExporter.cpp b/Source/Export/OrnamentCrimeExporter.cpp index 8827204..9b33578 100644 --- a/Source/Export/OrnamentCrimeExporter.cpp +++ b/Source/Export/OrnamentCrimeExporter.cpp @@ -58,7 +58,7 @@ String OrnamentCrimeExporter::generateCHeaderString(const CalibrationTable& tabl float voltage = OC_MIN_VOLTAGE + i; if (i == 10) voltage = OC_MAX_VOLTAGE; // Last point is +6V - header += " " + String(ocData.dacValues[i]); + header += " " + String(ocData.dacValues[(size_t) i]); if (i < 10) header += ","; header += " // " + String(voltage >= 0 ? "+" : "") + String(static_cast(voltage)) + "V\n"; } @@ -99,10 +99,10 @@ String OrnamentCrimeExporter::generateReadableString(const CalibrationTable& tab if (i == 10) voltage = OC_MAX_VOLTAGE; String voltStr = String(voltage >= 0 ? "+" : "") + String(static_cast(voltage)) + "V"; - float actualV = dacValueToVoltage(ocData.dacValues[i]); + float actualV = dacValueToVoltage(ocData.dacValues[(size_t) i]); text += String(voltStr).paddedRight(' ', 5); - text += " DAC: " + String(ocData.dacValues[i]).paddedLeft(' ', 5); + text += " DAC: " + String(ocData.dacValues[(size_t) i]).paddedLeft(' ', 5); text += " (actual: " + String(actualV, 4) + "V)\n"; } @@ -130,7 +130,7 @@ OrnamentCrimeExporter::OCCalibrationData OrnamentCrimeExporter::convertToOCForma float correctedVoltage = table.getCorrectedVoltage(midiPitch); // Convert to DAC value - ocData.dacValues[i] = voltageToDACValue(correctedVoltage); + ocData.dacValues[(size_t) i] = voltageToDACValue(correctedVoltage); } return ocData; @@ -145,7 +145,7 @@ CalibrationTable OrnamentCrimeExporter::importFromOCData(const OCCalibrationData float targetVoltage = OC_MIN_VOLTAGE + i; if (i == 10) targetVoltage = OC_MAX_VOLTAGE; - float actualVoltage = dacValueToVoltage(ocData.dacValues[i]); + float actualVoltage = dacValueToVoltage(ocData.dacValues[(size_t) i]); // Convert to MIDI note int midiNote = 60 + static_cast(targetVoltage * 12.0f); diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index a1f89e1..b239254 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -65,6 +65,23 @@ MainComponent::MainComponent() : tuner(&deviceManager), tunerDisplay(&tuner), di failureLabel.setJustificationType(juce::Justification::centredLeft); addAndMakeVisible(&failureLabel); + pitchSourceLabel.setName("Pitch Source Label"); + pitchSourceLabel.setText("Pitch source: ", dontSendNotification); + pitchSourceLabel.setJustificationType(juce::Justification::centredRight); + addAndMakeVisible(&pitchSourceLabel); + + pitchSource.setName("PitchSourceSelector"); + pitchSource.addItem("MIDI out", 1); + pitchSource.addItem("CV output", 2); + pitchSource.addListener(this); + pitchSource.setSelectedId( + getAppProperties().getUserSettings()->getIntValue("PitchSourceID", 1), + dontSendNotification); + tuner.setPitchSource(pitchSource.getSelectedId() == 2 + ? VCOTuner::PitchSource::cvOutput + : VCOTuner::PitchSource::midiOut); + addAndMakeVisible(&pitchSource); + regimeLabel.setName("Regime Label"); regimeLabel.setText("Pitch range: ", dontSendNotification); regimeLabel.setJustificationType(juce::Justification::centredRight); @@ -129,6 +146,7 @@ MainComponent::~MainComponent() tuner.removeListener(&display); getAppProperties().getUserSettings()->setValue("RegimeID", regime.getSelectedId()); getAppProperties().getUserSettings()->setValue("ResolutionID", resolution.getSelectedId()); + getAppProperties().getUserSettings()->setValue("PitchSourceID", pitchSource.getSelectedId()); } @@ -152,6 +170,12 @@ void MainComponent::resized() resolution.setBounds(regimeLabel.getX() - 120 - borderWidth, audioSettings.getBottom() + borderWidth, 120, buttonHeight); resolutionLabel.setBounds(resolution.getX() - 80 - borderWidth, audioSettings.getBottom() + borderWidth, 80, buttonHeight); + // Shares the settings row rather than adding a second one: another row + // would push the tabbed area down, and TunerDisplay lays its readouts out + // at fixed offsets that then overflow the panel. + pitchSourceLabel.setBounds(borderWidth, audioSettings.getBottom() + borderWidth, 90, buttonHeight); + pitchSource.setBounds(pitchSourceLabel.getRight(), audioSettings.getBottom() + borderWidth, 120, buttonHeight); + // failureLabel gets a fixed-height row at the very bottom, beneath the // tabbed area. jmax guards a window shrunk past MainWindow's resize limits // (or that limit changing later) from handing the tabs a negative height. @@ -248,6 +272,30 @@ void MainComponent::comboBoxChanged (ComboBox* comboBoxThatHasChanged) display.clearCache(); + if (wasRunning) + { + tuner.toggleState(); + cycle = wasCycling; + } + } + else if (comboBoxThatHasChanged == &pitchSource) + { + // Switching where the pitch comes from mid-sweep would leave the + // oscillator held by one source and driven by the other, so stop and + // restart around the change the way the other settings do. + bool wasRunning = false; + bool wasCycling = cycle; + if (tuner.isRunning()) + { + wasRunning = true; + tuner.toggleState(); + } + + tuner.setPitchSource(comboBoxThatHasChanged->getSelectedId() == 2 + ? VCOTuner::PitchSource::cvOutput + : VCOTuner::PitchSource::midiOut); + display.clearCache(); + if (wasRunning) { tuner.toggleState(); @@ -434,7 +482,7 @@ void MainComponent::tunerFinished() tuner.toggleState(); } -void MainComponent::measurementFailed (int /*midiPitch*/, vcotuner::MeasurementError reason) +void MainComponent::measurementFailed (int /*midiPitch*/, vcotuner::MeasurementError /*reason*/) { // Per-note failures are never fatal - only the reasons that abort the // whole sweep (routed through tunerStopped instead) are. diff --git a/Source/MainComponent.h b/Source/MainComponent.h index be82d4e..4deb28c 100644 --- a/Source/MainComponent.h +++ b/Source/MainComponent.h @@ -1,95 +1,97 @@ -/* - ============================================================================== - - MainComponent.h - Created: 17 May 2016 8:21:04pm - Author: Johannes Neumann - - ============================================================================== -*/ - -#ifndef MAINCOMPONENT_H_INCLUDED -#define MAINCOMPONENT_H_INCLUDED - -#include "VCOTuner.h" -#include "Visualizer.h" -#include "TunerDisplay.h" -#include "CVOutput/CVOutputManager.h" - -//============================================================================== -ApplicationProperties& getAppProperties(); - -//============================================================================== -/** - */ -class MainComponent: public Component, - public VCOTuner::Listener, - public Button::Listener, - public ComboBox::Listener -{ -public: - MainComponent(); - virtual ~MainComponent() override; - - void resized() override; - void paint(Graphics& g) override; - - virtual void buttonClicked (Button* bttn) override; - virtual void comboBoxChanged (ComboBox* comboBoxThatHasChanged) override; - - virtual void tunerStarted() override; - virtual void tunerStopped() override; - virtual void tunerFinished() override; - virtual void tunerStatusChanged(String statusString) override; - virtual void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override; - -private: - //============================================================================== - AudioDeviceManager deviceManager; - std::unique_ptr cvOutput; - VCOTuner tuner; - - void showAudioSettings(); - - TextButton audioSettings; - TextButton startStop; - TextButton report; - TextButton cvCalibration; - - // Tabbed display with tuner and chart views - std::unique_ptr tabs; - TunerDisplay tunerDisplay; - Visualizer display; - Label statusLabel; - /** names the notes that are currently failing to measure. Cleared at the - start of every run (tunerStarted) and rebuilt from tuner.getFailures() - as failures come in, so it always reflects the current sweep only. */ - Label failureLabel; - Label regimeLabel; - ComboBox regime; - Label resolutionLabel; - ComboBox resolution; - - typedef struct - { - int startNote; - int endNote; - int interval; - } regime_t; - static const int numRegimes = 12; - static const regime_t regimes[numRegimes]; - static const char* regimeTexts[numRegimes]; - static const int numResolutions = 5; - static const int resolutions[numResolutions]; - static const char* resolutionsTexts[numResolutions]; - - bool cycle; - - static const String welcomeText; - - JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent) -}; - - - -#endif // MAINCOMPONENT_H_INCLUDED +/* + ============================================================================== + + MainComponent.h + Created: 17 May 2016 8:21:04pm + Author: Johannes Neumann + + ============================================================================== +*/ + +#ifndef MAINCOMPONENT_H_INCLUDED +#define MAINCOMPONENT_H_INCLUDED + +#include "VCOTuner.h" +#include "Visualizer.h" +#include "TunerDisplay.h" +#include "CVOutput/CVOutputManager.h" + +//============================================================================== +ApplicationProperties& getAppProperties(); + +//============================================================================== +/** + */ +class MainComponent: public Component, + public VCOTuner::Listener, + public Button::Listener, + public ComboBox::Listener +{ +public: + MainComponent(); + virtual ~MainComponent() override; + + void resized() override; + void paint(Graphics& g) override; + + virtual void buttonClicked (Button* bttn) override; + virtual void comboBoxChanged (ComboBox* comboBoxThatHasChanged) override; + + virtual void tunerStarted() override; + virtual void tunerStopped() override; + virtual void tunerFinished() override; + virtual void tunerStatusChanged(String statusString) override; + virtual void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override; + +private: + //============================================================================== + AudioDeviceManager deviceManager; + std::unique_ptr cvOutput; + VCOTuner tuner; + + void showAudioSettings(); + + TextButton audioSettings; + TextButton startStop; + TextButton report; + TextButton cvCalibration; + + // Tabbed display with tuner and chart views + std::unique_ptr tabs; + TunerDisplay tunerDisplay; + Visualizer display; + Label statusLabel; + /** names the notes that are currently failing to measure. Cleared at the + start of every run (tunerStarted) and rebuilt from tuner.getFailures() + as failures come in, so it always reflects the current sweep only. */ + Label failureLabel; + Label pitchSourceLabel; + ComboBox pitchSource; + Label regimeLabel; + ComboBox regime; + Label resolutionLabel; + ComboBox resolution; + + typedef struct + { + int startNote; + int endNote; + int interval; + } regime_t; + static const int numRegimes = 12; + static const regime_t regimes[numRegimes]; + static const char* regimeTexts[numRegimes]; + static const int numResolutions = 5; + static const int resolutions[numResolutions]; + static const char* resolutionsTexts[numResolutions]; + + bool cycle; + + static const String welcomeText; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent) +}; + + + +#endif // MAINCOMPONENT_H_INCLUDED diff --git a/Source/ModernLookAndFeel.h b/Source/ModernLookAndFeel.h index d8c549c..90e0cc7 100644 --- a/Source/ModernLookAndFeel.h +++ b/Source/ModernLookAndFeel.h @@ -102,7 +102,7 @@ class ModernLookAndFeel : public LookAndFeel_V4 g.drawRoundedRectangle(bounds, cornerSize, 1.0f); } - void drawComboBox(Graphics& g, int width, int height, bool isButtonDown, + void drawComboBox(Graphics& g, int width, int height, bool /*isButtonDown*/, int buttonX, int buttonY, int buttonW, int buttonH, ComboBox& box) override { auto bounds = Rectangle(0, 0, width, height).toFloat().reduced(1.0f); @@ -126,7 +126,7 @@ class ModernLookAndFeel : public LookAndFeel_V4 g.fillPath(arrow); } - void drawProgressBar(Graphics& g, ProgressBar& progressBar, int width, int height, + void drawProgressBar(Graphics& g, ProgressBar& /*progressBar*/, int width, int height, double progress, const String& textToShow) override { auto bounds = Rectangle(0, 0, (float)width, (float)height).reduced(1.0f); @@ -155,14 +155,14 @@ class ModernLookAndFeel : public LookAndFeel_V4 if (textToShow.isNotEmpty()) { g.setColour(Colors::textPrimary); - g.setFont(Font(12.0f)); + g.setFont(Font(FontOptions(12.0f))); g.drawText(textToShow, bounds, Justification::centred, false); } } void drawLinearSlider(Graphics& g, int x, int y, int width, int height, - float sliderPos, float minSliderPos, float maxSliderPos, - const Slider::SliderStyle style, Slider& slider) override + float sliderPos, float /*minSliderPos*/, float /*maxSliderPos*/, + const Slider::SliderStyle /*style*/, Slider& /*slider*/) override { auto trackWidth = 4.0f; auto bounds = Rectangle(x, y, width, height).toFloat(); @@ -198,9 +198,9 @@ class ModernLookAndFeel : public LookAndFeel_V4 g.fillRect(bounds); } - void drawTableHeaderColumn(Graphics& g, TableHeaderComponent& header, const String& columnName, - int columnId, int width, int height, bool isMouseOver, - bool isMouseDown, int columnFlags) override + void drawTableHeaderColumn(Graphics& g, TableHeaderComponent& /*header*/, const String& columnName, + int /*columnId*/, int width, int height, bool isMouseOver, + bool /*isMouseDown*/, int /*columnFlags*/) override { auto bounds = Rectangle(0, 0, width, height); @@ -214,27 +214,27 @@ class ModernLookAndFeel : public LookAndFeel_V4 g.drawLine((float)width - 1, 2, (float)width - 1, (float)height - 4); g.setColour(Colors::textPrimary); - g.setFont(Font(13.0f, Font::bold)); + g.setFont(Font(FontOptions(13.0f, Font::bold))); g.drawText(columnName, bounds.reduced(4, 0), Justification::centredLeft, true); } Font getTextButtonFont(TextButton&, int buttonHeight) override { - return Font(jmin(14.0f, buttonHeight * 0.6f)); + return Font(FontOptions(jmin(14.0f, buttonHeight * 0.6f))); } - Font getLabelFont(Label& label) override + Font getLabelFont(Label& /*label*/) override { - return Font(14.0f); + return Font(FontOptions(14.0f)); } // Tab button styling - int getTabButtonBestWidth(TabBarButton& button, int tabDepth) override + int getTabButtonBestWidth(TabBarButton& /*button*/, int /*tabDepth*/) override { return 100; } - void drawTabButton(TabBarButton& button, Graphics& g, bool isMouseOver, bool isMouseDown) override + void drawTabButton(TabBarButton& button, Graphics& g, bool isMouseOver, bool /*isMouseDown*/) override { auto bounds = button.getLocalBounds().toFloat(); bool isFrontTab = button.isFrontTab(); @@ -255,7 +255,7 @@ class ModernLookAndFeel : public LookAndFeel_V4 // Text g.setColour(isFrontTab ? Colors::textPrimary : Colors::textSecondary); - g.setFont(Font(14.0f, isFrontTab ? Font::bold : Font::plain)); + g.setFont(Font(FontOptions(14.0f, isFrontTab ? Font::bold : Font::plain))); g.drawText(button.getButtonText(), bounds.reduced(4), Justification::centred); } diff --git a/Source/ReportPrepScreen.cpp b/Source/ReportPrepScreen.cpp index 4bcf710..f41e79d 100644 --- a/Source/ReportPrepScreen.cpp +++ b/Source/ReportPrepScreen.cpp @@ -1,118 +1,118 @@ -/* - ============================================================================== - - ReportPrepScreen.cpp - Created: 2 Jul 2016 4:12:50pm - Author: Johannes Neumann - - ============================================================================== -*/ - -#include "../JuceLibraryCode/JuceHeader.h" -#include "ReportPrepScreen.h" -#include "ReportProperties.h" - -ReportPrepScreen::ReportPrepScreen(VCOTuner* t, Visualizer* v, ReportCreatorWindow* p) -{ - tuner = t; - visualizer = v; - parent = p; - - t->addListener(this); - t->startContinuousMeasurement(ReportProperties::adjustmentPitch); - startTimer(100); - millisecCounter = 0; -} - -ReportPrepScreen::~ReportPrepScreen() -{ - tuner->removeListener(this); - stopTimer(); - Timer::callPendingTimersSynchronously(); - - if (tuner->isRunning()) - tuner->toggleState(); -} - -void ReportPrepScreen::timerCallback() -{ - currentFreq = tuner->getContinuousMesurementResult(); - - if (currentFreq < ReportProperties::desiredAdjustmentFrequency + ReportProperties::allowedDeviation - && currentFreq > ReportProperties::desiredAdjustmentFrequency - ReportProperties::allowedDeviation) - { - millisecCounter += getTimerInterval(); - if (millisecCounter >= ReportProperties::requiredHoldTimeInMs) - parent->next(); - } - else - { - millisecCounter = 0; - } - - repaint(); -} - -void ReportPrepScreen::paint(Graphics& g) -{ - Rectangle box(0, 0, 300, 50); - box.setCentre(getBounds().getCentre()); - - g.setColour(Colours::white); - g.fillRect(box); - - - double lineSpacing = box.getWidth() / 12; - g.setColour(Colours::lightgreen.withAlpha(0.5f)); - g.fillRect(Rectangle(box.getCentreX() - (int)lineSpacing, box.getY(), (int)(2.0*lineSpacing), box.getHeight())); - - for (int i = -6; i < 7; i++) - { - int spacing; - if (i == -1 || i == 1) - { - g.setColour(Colours::black.withAlpha(0.5f)); - spacing = 0; - } - else - { - g.setColour(Colours::black.withAlpha(0.25f)); - spacing = 6; - } - g.drawLine(box.getCentreX() + (int)(i*lineSpacing), box.getY() + spacing, box.getCentreX() + (int)(i*lineSpacing), box.getBottom() - spacing); - } - - double frequencyOffset = currentFreq - ReportProperties::desiredAdjustmentFrequency; - double halfBoxRange = ReportProperties::allowedDeviation * 6; - float positionInBox = (float) (frequencyOffset / halfBoxRange); - if (positionInBox > 1.0) - { - g.setColour(Colours::red); - g.drawText(">>", box.getRight() + 5, box.getY(), 40, box.getHeight(), juce::Justification::centredLeft); - } - else if (positionInBox < -1.0) - { - g.setColour(Colours::red); - g.drawText("<<", box.getX() - 45, box.getY(), 40, box.getHeight(), juce::Justification::centredRight); - } - else - { - g.setColour(Colours::red); - g.drawLine(box.getCentreX() + (int) (positionInBox * box.getWidth()/2), box.getY(), box.getCentreX() + (int) (positionInBox * box.getWidth()/2), box.getBottom()); - } - - g.setColour(Colours::black); - g.drawText("Use coarse and fine tune controls to adjust the", box.translated(0, -120), juce::Justification::centred); - g.drawText("frequency to " + String(ReportProperties::desiredAdjustmentFrequency) + " Hz. This is done to make reports", box.translated(0, -105), juce::Justification::centred); - g.drawText("more comparable by using the same pitch ranges.", box.translated(0, -90), juce::Justification::centred); - g.drawText(String(currentFreq) + " Hz", box.translated(0, -box.getHeight()), juce::Justification::centred); - - g.drawText("Measurements will start when the frequency error is below", 0, getHeight() - 80, getWidth(), 15, juce::Justification::centred); - g.drawText("+-" + String(ReportProperties::allowedDeviation) + " Hz for at least " + String((float) ReportProperties::requiredHoldTimeInMs / 1000.0f) + " seconds", 0, getHeight() - 65, getWidth(), 15, juce::Justification::centred); -} - -void ReportPrepScreen::tunerStopped() -{ - if (DialogWindow* dw = findParentComponentOfClass()) - dw->exitModalState (1); +/* + ============================================================================== + + ReportPrepScreen.cpp + Created: 2 Jul 2016 4:12:50pm + Author: Johannes Neumann + + ============================================================================== +*/ + +#include "../JuceLibraryCode/JuceHeader.h" +#include "ReportPrepScreen.h" +#include "ReportProperties.h" + +ReportPrepScreen::ReportPrepScreen(VCOTuner* t, Visualizer* v, ReportCreatorWindow* p) +{ + tuner = t; + visualizer = v; + parent = p; + + t->addListener(this); + t->startContinuousMeasurement(ReportProperties::adjustmentPitch); + startTimer(100); + millisecCounter = 0; +} + +ReportPrepScreen::~ReportPrepScreen() +{ + tuner->removeListener(this); + stopTimer(); + Timer::callPendingTimersSynchronously(); + + if (tuner->isRunning()) + tuner->toggleState(); +} + +void ReportPrepScreen::timerCallback() +{ + currentFreq = tuner->getContinuousMesurementResult(); + + if (currentFreq < ReportProperties::desiredAdjustmentFrequency + ReportProperties::allowedDeviation + && currentFreq > ReportProperties::desiredAdjustmentFrequency - ReportProperties::allowedDeviation) + { + millisecCounter += getTimerInterval(); + if (millisecCounter >= ReportProperties::requiredHoldTimeInMs) + parent->next(); + } + else + { + millisecCounter = 0; + } + + repaint(); +} + +void ReportPrepScreen::paint(Graphics& g) +{ + Rectangle box(0, 0, 300, 50); + box.setCentre(getBounds().getCentre()); + + g.setColour(Colours::white); + g.fillRect(box); + + + double lineSpacing = box.getWidth() / 12; + g.setColour(Colours::lightgreen.withAlpha(0.5f)); + g.fillRect(Rectangle(box.getCentreX() - (int)lineSpacing, box.getY(), (int)(2.0*lineSpacing), box.getHeight())); + + for (int i = -6; i < 7; i++) + { + int spacing; + if (i == -1 || i == 1) + { + g.setColour(Colours::black.withAlpha(0.5f)); + spacing = 0; + } + else + { + g.setColour(Colours::black.withAlpha(0.25f)); + spacing = 6; + } + g.drawLine(box.getCentreX() + (int)(i*lineSpacing), box.getY() + spacing, box.getCentreX() + (int)(i*lineSpacing), box.getBottom() - spacing); + } + + double frequencyOffset = currentFreq - ReportProperties::desiredAdjustmentFrequency; + double halfBoxRange = ReportProperties::allowedDeviation * 6; + float positionInBox = (float) (frequencyOffset / halfBoxRange); + if (positionInBox > 1.0) + { + g.setColour(Colours::red); + g.drawText(">>", juce::Rectangle(box.getRight() + 5, box.getY(), 40, box.getHeight()), juce::Justification::centredLeft); + } + else if (positionInBox < -1.0) + { + g.setColour(Colours::red); + g.drawText("<<", juce::Rectangle(box.getX() - 45, box.getY(), 40, box.getHeight()), juce::Justification::centredRight); + } + else + { + g.setColour(Colours::red); + g.drawLine(box.getCentreX() + (int) (positionInBox * box.getWidth()/2), box.getY(), box.getCentreX() + (int) (positionInBox * box.getWidth()/2), box.getBottom()); + } + + g.setColour(Colours::black); + g.drawText("Use coarse and fine tune controls to adjust the", box.translated(0, -120), juce::Justification::centred); + g.drawText("frequency to " + String(ReportProperties::desiredAdjustmentFrequency) + " Hz. This is done to make reports", box.translated(0, -105), juce::Justification::centred); + g.drawText("more comparable by using the same pitch ranges.", box.translated(0, -90), juce::Justification::centred); + g.drawText(String(currentFreq) + " Hz", box.translated(0, -box.getHeight()), juce::Justification::centred); + + g.drawText("Measurements will start when the frequency error is below", juce::Rectangle(0, getHeight() - 80, getWidth(), 15), juce::Justification::centred); + g.drawText("+-" + String(ReportProperties::allowedDeviation) + " Hz for at least " + String((float) ReportProperties::requiredHoldTimeInMs / 1000.0f) + " seconds", juce::Rectangle(0, getHeight() - 65, getWidth(), 15), juce::Justification::centred); +} + +void ReportPrepScreen::tunerStopped() +{ + if (DialogWindow* dw = findParentComponentOfClass()) + dw->exitModalState (1); } \ No newline at end of file diff --git a/Source/Startup.cpp b/Source/Startup.cpp index 7b71868..a2a5e01 100644 --- a/Source/Startup.cpp +++ b/Source/Startup.cpp @@ -1,85 +1,84 @@ -/* - ============================================================================== - - This file is part of the JUCE library. - Copyright (c) 2015 - ROLI Ltd. - - Permission is granted to use this software under the terms of either: - a) the GPL v2 (or any later version) - b) the Affero GPL v3 - - Details of these licenses can be found at: www.gnu.org/licenses - - JUCE is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - ------------------------------------------------------------------------------ - - To release a closed-source product which uses JUCE, commercial licenses are - available: visit www.juce.com for more information. - - ============================================================================== -*/ - -#include "../JuceLibraryCode/JuceHeader.h" -#include "MainWindow.h" - -//============================================================================== -class VCOTunerApp : public JUCEApplication -{ -public: - VCOTunerApp() {} - - void initialise (const String&) override - { - // initialise our settings file.. - - PropertiesFile::Options options; - options.applicationName = "VCO Tuner Application"; - options.filenameSuffix = "settings"; - options.osxLibrarySubFolder = "Application Support"; - - appProperties.reset(new ApplicationProperties()); - appProperties->setStorageParameters (options); - - LookAndFeel::setDefaultLookAndFeel (&lookAndFeel); - - mainWindow.reset(new MainWindow()); - mainWindow->setUsingNativeTitleBar (true); - } - - void shutdown() override - { - mainWindow.reset(); - appProperties.reset(); - LookAndFeel::setDefaultLookAndFeel (nullptr); - } - - void systemRequestedQuit() override - { - if (mainWindow != nullptr) - mainWindow->tryToQuitApplication(); - else - JUCEApplicationBase::quit(); - } - - const String getApplicationName() override { return "VCO Tuner"; } - const String getApplicationVersion() override { return ProjectInfo::versionString; } - bool moreThanOneInstanceAllowed() override { return true; } - - ApplicationCommandManager commandManager; - std::unique_ptr appProperties; - LookAndFeel_V3 lookAndFeel; - -private: - std::unique_ptr mainWindow; -}; - -static VCOTunerApp& getApp() { return *dynamic_cast(JUCEApplication::getInstance()); } -ApplicationCommandManager& getCommandManager() { return getApp().commandManager; } -ApplicationProperties& getAppProperties() { return *getApp().appProperties; } - - -// This kicks the whole thing off.. -START_JUCE_APPLICATION (VCOTunerApp) +/* + ============================================================================== + + This file is part of the JUCE library. + Copyright (c) 2015 - ROLI Ltd. + + Permission is granted to use this software under the terms of either: + a) the GPL v2 (or any later version) + b) the Affero GPL v3 + + Details of these licenses can be found at: www.gnu.org/licenses + + JUCE is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + ------------------------------------------------------------------------------ + + To release a closed-source product which uses JUCE, commercial licenses are + available: visit www.juce.com for more information. + + ============================================================================== +*/ + +#include "../JuceLibraryCode/JuceHeader.h" +#include "MainWindow.h" + +//============================================================================== +class VCOTunerApp : public JUCEApplication +{ +public: + VCOTunerApp() {} + + void initialise (const String&) override + { + // initialise our settings file.. + + PropertiesFile::Options options; + options.applicationName = "VCO Tuner Application"; + options.filenameSuffix = "settings"; + options.osxLibrarySubFolder = "Application Support"; + + appProperties.reset(new ApplicationProperties()); + appProperties->setStorageParameters (options); + + LookAndFeel::setDefaultLookAndFeel (&lookAndFeel); + + mainWindow.reset(new MainWindow()); + mainWindow->setUsingNativeTitleBar (true); + } + + void shutdown() override + { + mainWindow.reset(); + appProperties.reset(); + LookAndFeel::setDefaultLookAndFeel (nullptr); + } + + void systemRequestedQuit() override + { + if (mainWindow != nullptr) + mainWindow->tryToQuitApplication(); + else + JUCEApplicationBase::quit(); + } + + const String getApplicationName() override { return "VCO Tuner"; } + const String getApplicationVersion() override { return ProjectInfo::versionString; } + bool moreThanOneInstanceAllowed() override { return true; } + + ApplicationCommandManager commandManager; + std::unique_ptr appProperties; + LookAndFeel_V3 lookAndFeel; + +private: + std::unique_ptr mainWindow; +}; + +static VCOTunerApp& getApp() { return *dynamic_cast(JUCEApplication::getInstance()); } +ApplicationProperties& getAppProperties() { return *getApp().appProperties; } + + +// This kicks the whole thing off.. +START_JUCE_APPLICATION (VCOTunerApp) diff --git a/Source/TunerDisplay.cpp b/Source/TunerDisplay.cpp index 1321073..6e1f65c 100644 --- a/Source/TunerDisplay.cpp +++ b/Source/TunerDisplay.cpp @@ -9,7 +9,7 @@ #include "TunerDisplay.h" -TunerDisplay::TunerDisplay(VCOTuner* t) : tuner(t) +TunerDisplay::TunerDisplay(VCOTuner*) { } @@ -41,16 +41,16 @@ void TunerDisplay::paint(Graphics& g) g.setColour(isActive ? ModernLookAndFeel::Colors::meter : ModernLookAndFeel::Colors::meterBad.withAlpha(0.5f)); g.fillEllipse(headerArea.getX(), headerArea.getCentreY() - 6, 12, 12); g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(12.0f)); - g.drawText(isActive ? "MEASURING" : "STANDBY", headerArea.getX() + 18, headerArea.getY(), 100, 40, Justification::centredLeft); + g.setFont(Font(FontOptions(12.0f))); + g.drawText(isActive ? "MEASURING" : "STANDBY", juce::Rectangle(headerArea.getX() + 18, headerArea.getY(), 100, 40), Justification::centredLeft); // MIDI Note number (right) g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(11.0f)); - g.drawText("MIDI", headerArea.getRight() - 80, headerArea.getY() + 2, 40, 16, Justification::right); + g.setFont(Font(FontOptions(11.0f))); + g.drawText("MIDI", juce::Rectangle(headerArea.getRight() - 80, headerArea.getY() + 2, 40, 16), Justification::right); g.setColour(hasSignal ? ModernLookAndFeel::Colors::textPrimary : ModernLookAndFeel::Colors::textDim); - g.setFont(Font(20.0f, Font::bold)); - g.drawText(hasSignal ? String(currentMidiNote) : "--", headerArea.getRight() - 40, headerArea.getY(), 40, 40, Justification::centred); + g.setFont(Font(FontOptions(20.0f, Font::bold))); + g.drawText(hasSignal ? String(currentMidiNote) : "--", juce::Rectangle(headerArea.getRight() - 40, headerArea.getY(), 40, 40), Justification::centred); contentBounds.removeFromTop(10); @@ -64,7 +64,7 @@ void TunerDisplay::paint(Graphics& g) String noteName = hasSignal ? MidiMessage::getMidiNoteName(currentMidiNote, true, true, 4) : "--"; g.setColour(hasSignal ? ModernLookAndFeel::Colors::accent : ModernLookAndFeel::Colors::textDim); - g.setFont(Font(64.0f, Font::bold)); + g.setFont(Font(FontOptions(64.0f, Font::bold))); g.drawText(noteName, noteBg, Justification::centred); contentBounds.removeFromTop(15); @@ -119,19 +119,19 @@ void TunerDisplay::paint(Graphics& g) // Label g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(11.0f)); - g.drawText("PITCH ERROR", centsSection.getX(), centsSection.getY(), centsSection.getWidth(), 16, Justification::centred); + g.setFont(Font(FontOptions(11.0f))); + g.drawText("PITCH ERROR", juce::Rectangle(centsSection.getX(), centsSection.getY(), centsSection.getWidth(), 16), Justification::centred); // Large cents value g.setColour(centsColor); - g.setFont(Font(48.0f, Font::bold)); + g.setFont(Font(FontOptions(48.0f, Font::bold))); String centsText = hasSignal ? ((currentCents >= 0 ? "+" : "") + String(currentCents, 2)) : "+---.--"; - g.drawText(centsText, centsSection.getX(), centsSection.getY() + 18, centsSection.getWidth(), 55, Justification::centred); + g.drawText(centsText, juce::Rectangle(centsSection.getX(), centsSection.getY() + 18, centsSection.getWidth(), 55), Justification::centred); // Unit label g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(16.0f)); - g.drawText("cents", centsSection.getX(), centsSection.getY() + 70, centsSection.getWidth(), 20, Justification::centred); + g.setFont(Font(FontOptions(16.0f))); + g.drawText("cents", juce::Rectangle(centsSection.getX(), centsSection.getY() + 70, centsSection.getWidth(), 20), Justification::centred); contentBounds.removeFromTop(10); @@ -183,26 +183,26 @@ void TunerDisplay::drawMeasurementBox(Graphics& g, Rectangle bounds, cons // Label g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(10.0f)); - g.drawText(label, bounds.getX(), bounds.getY() + 8, bounds.getWidth(), 14, Justification::centred); + g.setFont(Font(FontOptions(10.0f))); + g.drawText(label, juce::Rectangle(bounds.getX(), bounds.getY() + 8, bounds.getWidth(), 14), Justification::centred); // Value g.setColour(valueColor); - g.setFont(Font(18.0f, Font::bold)); - g.drawText(value, bounds.getX(), bounds.getY() + 28, bounds.getWidth(), 50, Justification::centred); + g.setFont(Font(FontOptions(18.0f, Font::bold))); + g.drawText(value, juce::Rectangle(bounds.getX(), bounds.getY() + 28, bounds.getWidth(), 50), Justification::centred); } void TunerDisplay::drawSmallDataBox(Graphics& g, Rectangle bounds, const String& label, const String& value) { // Label g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(9.0f)); - g.drawText(label, bounds.getX(), bounds.getY(), bounds.getWidth(), 14, Justification::centred); + g.setFont(Font(FontOptions(9.0f))); + g.drawText(label, juce::Rectangle(bounds.getX(), bounds.getY(), bounds.getWidth(), 14), Justification::centred); // Value g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(14.0f, Font::bold)); - g.drawText(value, bounds.getX(), bounds.getY() + 16, bounds.getWidth(), 40, Justification::centred); + g.setFont(Font(FontOptions(14.0f, Font::bold))); + g.drawText(value, juce::Rectangle(bounds.getX(), bounds.getY() + 16, bounds.getWidth(), 40), Justification::centred); } void TunerDisplay::drawPrecisionMeter(Graphics& g, Rectangle bounds) @@ -218,7 +218,7 @@ void TunerDisplay::drawPrecisionMeter(Graphics& g, Rectangle bounds) float meterWidth = bounds.getWidth(); // Tick marks and labels - g.setFont(Font(9.0f)); + g.setFont(Font(FontOptions(9.0f))); for (int i = -5; i <= 5; ++i) { float x = centerX + (i / 5.0f) * (meterWidth / 2.0f - 10); @@ -234,7 +234,7 @@ void TunerDisplay::drawPrecisionMeter(Graphics& g, Rectangle bounds) { g.setColour(ModernLookAndFeel::Colors::textDim); String labelText = (i == 0) ? "0" : String(i * 10); - g.drawText(labelText, x - 15, bounds.getBottom() + 2, 30, 12, Justification::centred); + g.drawText(labelText, juce::Rectangle(x - 15, bounds.getBottom() + 2, 30, 12), Justification::centred); } } diff --git a/Source/TunerDisplay.h b/Source/TunerDisplay.h index dea1b1e..1ad01e0 100644 --- a/Source/TunerDisplay.h +++ b/Source/TunerDisplay.h @@ -37,7 +37,6 @@ class TunerDisplay : public Component, void drawSmallDataBox(Graphics& g, Rectangle bounds, const String& label, const String& value); void drawPrecisionMeter(Graphics& g, Rectangle bounds); - VCOTuner* tuner; // Current state int currentMidiNote = 60; diff --git a/Source/VCOTuner.cpp b/Source/VCOTuner.cpp index 7f10a03..97c1849 100644 --- a/Source/VCOTuner.cpp +++ b/Source/VCOTuner.cpp @@ -92,7 +92,7 @@ VCOTuner::~VCOTuner() stopTimer(); if (currentlyPlayingMidiNote >= 0) - trySendMidiNoteOff(currentlyPlayingMidiNote); + releasePitch(currentlyPlayingMidiNote); deviceManager->removeAudioCallback(this); } @@ -129,6 +129,12 @@ void VCOTuner::toggleState() void VCOTuner::start() { + if (cvOutputUnavailable()) + { + errors.add(Errors::cvOutputUnavailable); + return; + } + if (!isRunning()) switchState(prepRefMeasurement); } @@ -177,7 +183,7 @@ void VCOTuner::timerCallback() // send reference midi note referencePitch = (highestPitch + lowestPitch) / 2; currentPitch = referencePitch; - trySendMidiNoteOn(currentPitch); + playPitch(currentPitch); } else { @@ -199,7 +205,7 @@ void VCOTuner::timerCallback() if (!startMeasurement) { // send note off - trySendMidiNoteOff(currentPitch); + releasePitch(currentPitch); // A failed reference measurement is always fatal: every other // note's pitch is expressed relative to this frequency, so @@ -251,7 +257,7 @@ void VCOTuner::timerCallback() if (cycleCounter == 0) { // send midi note - trySendMidiNoteOn(currentPitch); + playPitch(currentPitch); } else { @@ -295,7 +301,7 @@ void VCOTuner::timerCallback() } // send note off - trySendMidiNoteOff(currentPitch); + releasePitch(currentPitch); // check if the frequency has changed compared to the reference frequency // if not, it is likely that the MIDI output is not working. This check runs @@ -359,7 +365,7 @@ void VCOTuner::timerCallback() break; // send midi note and start measuring - trySendMidiNoteOn(continuousFrequencyMeasurementPitch); + playPitch(continuousFrequencyMeasurementPitch); startDetectorRun(continuousFrequencyMeasurementPitch); switchState(continuousFrequencyMeasurement); cycleCounter++; @@ -394,7 +400,7 @@ void VCOTuner::timerCallback() if (cycleCounter == 0) { // send midi note - trySendMidiNoteOn(singleMeasurementPitch); + playPitch(singleMeasurementPitch); } else { @@ -416,7 +422,7 @@ void VCOTuner::timerCallback() if (!startMeasurement) { // send note off - trySendMidiNoteOff(singleMeasurementPitch); + releasePitch(singleMeasurementPitch); const vcotuner::DetectorStatus status = lastDetectorStatus(); @@ -468,8 +474,43 @@ void VCOTuner::startContinuousMeasurement(int pitch) state = prepareContinuousFrequencyMeasurement; } -void VCOTuner::trySendMidiNoteOn(int pitch) +void VCOTuner::setPitchSource (PitchSource source) +{ + if (source == pitchSource) + return; + + // Leaving CV driving after switching to MIDI would have two things setting + // the pitch at once, so hand the oscillator back before changing over. + if (pitchSource == PitchSource::cvOutput && cvOutputManager != nullptr) + cvOutputManager->setActive(false); + + pitchSource = source; +} + +bool VCOTuner::cvOutputUnavailable() const noexcept +{ + return pitchSource == PitchSource::cvOutput + && (cvOutputManager == nullptr + || availableOutputChannels.load(std::memory_order_relaxed) <= 0); +} + +void VCOTuner::playPitch(int pitch) { + if (pitchSource == PitchSource::cvOutput) + { + if (cvOutputManager == nullptr) + { + errors.add(Errors::cvOutputUnavailable); + switchState(stopped); + return; + } + + cvOutputManager->setActive(true); + cvOutputManager->outputVoltage(cvOutputManager->midiToVoltage(pitch)); + currentlyPlayingMidiNote = pitch; + return; + } + MidiOutput* midiOut = deviceManager->getDefaultMidiOutput(); if (midiOut == nullptr) { @@ -479,14 +520,24 @@ void VCOTuner::trySendMidiNoteOn(int pitch) } if (currentlyPlayingMidiNote != -1) - trySendMidiNoteOff(currentlyPlayingMidiNote); + releasePitch(currentlyPlayingMidiNote); midiOut->sendMessageNow(MidiMessage::noteOn(midiChannel, pitch, (uint8_t) 100)); currentlyPlayingMidiNote = pitch; } -void VCOTuner::trySendMidiNoteOff(int pitch) +void VCOTuner::releasePitch(int pitch) { + if (pitchSource == PitchSource::cvOutput) + { + // A pitch CV has no note-off: the voltage is the note. Holding it + // leaves the oscillator where it was, so the next pitch settles from + // a neighbouring voltage rather than from 0 V -- and an oscillator + // left sounding is what lets a trimmer be adjusted between sweeps. + currentlyPlayingMidiNote = -1; + return; + } + MidiOutput* midiOut = deviceManager->getDefaultMidiOutput(); if (midiOut == nullptr) { @@ -539,7 +590,7 @@ bool VCOTuner::awaitingStopRequest() void VCOTuner::failCurrentNote(vcotuner::MeasurementError reason) { - trySendMidiNoteOff(currentPitch); + releasePitch(currentPitch); // Only cancel a run that is actually in flight - the timeout path. When the // detector finished on its own the audio thread has already cleared its own @@ -548,7 +599,7 @@ void VCOTuner::failCurrentNote(vcotuner::MeasurementError reason) if (startMeasurement) stopMeasurement = true; - // trySendMidiNoteOff() stops the tuner when the MIDI device has gone away. + // releasePitch() stops the tuner when the MIDI device has gone away. // That is fatal, so do not resume the sweep on top of it. if (state == stopped) return; @@ -656,6 +707,9 @@ void VCOTuner::audioDeviceIOCallbackWithContext (const float* const* inputChanne // between notes and after a sweep. // Channels that were not enabled when the device was opened are null, and // AudioBuffer::clear() would memset straight through them. + availableOutputChannels.store(outputChannelData != nullptr ? numOutputChannels : 0, + std::memory_order_relaxed); + if (outputChannelData != nullptr) { const bool cvActive = cvOutputManager != nullptr && cvOutputManager->isActive(); @@ -703,7 +757,6 @@ void VCOTuner::audioDeviceIOCallbackWithContext (const float* const* inputChanne if (!initialized) { vcotuner::PeriodDetectorConfig cfg; - cfg.sampleRate = sampleRate; cfg.requiredPeriods = numPeriodSamples; cfg.warmupSamples = currentWarmupSamples; // cfg.maxPeriods must stay at the default the constructor reserved, @@ -733,7 +786,7 @@ void VCOTuner::switchState(VCOTuner::State newState) if (state == stopped) { if (currentlyPlayingMidiNote >= 0 && currentlyPlayingMidiNote < 128) - trySendMidiNoteOff(currentlyPlayingMidiNote); + releasePitch(currentlyPlayingMidiNote); stopMeasurement = true; listeners.call(&Listener::tunerStopped); } @@ -819,7 +872,8 @@ const String VCOTuner::Errors::bufferFull = "The signal did settle into a steady const String VCOTuner::Errors::stableTimeout = "The measurement did not finish in time. Either the incoming zero-crossings never settled into a steady rate, or the signal was too weak or intermittent for enough of them to arrive in the first place. Are you recording from the right oscillator, on the right channel, and is its level high enough?"; -const String VCOTuner::Errors::noFrequencyChangeBetweenMeasurements = "Apparently the frequency of the oscillator is not changing between measurements. Please check if your MIDI-to-CV interface is set to the correct MIDI channel and make sure that it is selected as the default midi output device in the audio and midi settings."; +const String VCOTuner::Errors::noFrequencyChangeBetweenMeasurements = "Apparently the frequency of the oscillator is not changing between measurements. If the pitch source is MIDI, check that your MIDI-to-CV interface is set to the correct MIDI channel and is selected as the default MIDI output device in the audio and midi settings. If the pitch source is CV output, check that the audio interface output is DC-coupled and patched to the oscillator's pitch input."; +const String VCOTuner::Errors::cvOutputUnavailable = "The pitch source is set to CV output, but there is no audio output to send it to. Open the audio settings and select an output device with at least one channel. The output must be DC-coupled: an AC-coupled output cannot carry a pitch voltage."; const String VCOTuner::Errors::noMidiDeviceAvailable = "You don't have a MIDI output device selected or the selected device is not available."; diff --git a/Source/VCOTuner.h b/Source/VCOTuner.h index d1692bf..9d1bdb6 100644 --- a/Source/VCOTuner.h +++ b/Source/VCOTuner.h @@ -29,7 +29,7 @@ class VCOTuner: public ChangeListener, { public: VCOTuner(AudioDeviceManager* deviceManager); - ~VCOTuner(); + ~VCOTuner() override; void toggleState(); void start(); @@ -98,13 +98,13 @@ class VCOTuner: public ChangeListener, const AudioIODeviceCallbackContext& context) override; /** inherited from AudioIODeviceCallback */ - virtual void audioDeviceAboutToStart (AudioIODevice* device); + virtual void audioDeviceAboutToStart (AudioIODevice* device) override; /** inherited from AudioIODeviceCallback */ - virtual void audioDeviceStopped(); + virtual void audioDeviceStopped() override; /** inherited from ChangeListener */ - virtual void changeListenerCallback (ChangeBroadcaster* source); + virtual void changeListenerCallback (ChangeBroadcaster* source) override; class Listener { @@ -149,11 +149,36 @@ class VCOTuner: public ChangeListener, return clockPpm.load (std::memory_order_relaxed); } + /** Where the pitch the oscillator is asked to play comes from. + + midiOut sends MIDI notes for an external MIDI-to-CV interface to turn + into a voltage. cvOutput skips that interface and drives the + oscillator straight from a DC-coupled audio output, which takes a + second converter -- with its own scaling error -- out of the + measurement chain, and closes the loop: the same app sets the voltage + and measures what came back. + */ + enum class PitchSource { midiOut, cvOutput }; + + void setPitchSource (PitchSource source); + PitchSource getPitchSource() const noexcept { return pitchSource; } + + /** True when cvOutput is selected but nothing can carry it -- no manager, + or an audio device opened with no output channels. Checked before a + run rather than letting the sweep fail note by note. + */ + bool cvOutputUnavailable() const noexcept; + void setCVOutputManager(CVOutputManager* manager) { cvOutputManager = manager; } CVOutputManager* getCVOutputManager() { return cvOutputManager; } private: CVOutputManager* cvOutputManager = nullptr; + PitchSource pitchSource = PitchSource::midiOut; + + // Recorded by the audio callback so the pre-run check can tell whether a + // CV-driven sweep has anywhere to send its voltage. + std::atomic availableOutputChannels { 0 }; // Written on the audio thread, read on the message thread. The calibrator's // running sums stay private to the audio thread and only the finished @@ -180,10 +205,12 @@ class VCOTuner: public ChangeListener, ListenerList listeners; // processes the state machine - virtual void timerCallback(); + virtual void timerCallback() override; void switchState(State newState); - void trySendMidiNoteOn(int pitch); - void trySendMidiNoteOff(int pitch); + // Named for what they do, not for how: with cvOutput selected these set a + // voltage and no MIDI is sent at all. + void playPitch(int pitch); + void releasePitch(int pitch); /** hands the detector to the audio thread for a measurement at this pitch */ void startDetectorRun(int pitch); /** records the failure, tells the listeners and moves on to the next note */ @@ -283,6 +310,7 @@ class VCOTuner: public ChangeListener, static const String bufferFull; static const String stableTimeout; static const String noFrequencyChangeBetweenMeasurements; + static const String cvOutputUnavailable; static const String noMidiDeviceAvailable; static const String audioDeviceStoppedDuringMeasurement; }; diff --git a/Source/Visualizer.cpp b/Source/Visualizer.cpp index 7eda3d3..5732bf1 100644 --- a/Source/Visualizer.cpp +++ b/Source/Visualizer.cpp @@ -31,10 +31,10 @@ void Visualizer::paint(juce::Graphics &g, int width, int height) { // Draw empty state message g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(18.0f)); - g.drawText("No measurement data yet", 0, height / 2 - 30, width, 30, juce::Justification::centred); - g.setFont(Font(14.0f)); - g.drawText("Press Start to begin measuring", 0, height / 2, width, 30, juce::Justification::centred); + g.setFont(Font(FontOptions(18.0f))); + g.drawText("No measurement data yet", juce::Rectangle(0, height / 2 - 30, width, 30), juce::Justification::centred); + g.setFont(Font(FontOptions(14.0f))); + g.drawText("Press Start to begin measuring", juce::Rectangle(0, height / 2, width, 30), juce::Justification::centred); return; } @@ -68,8 +68,8 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl if (measurements.size() == 0) { g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(18.0f)); - g.drawText("No Data", 0, 0, width, height, juce::Justification::centred); + g.setFont(Font(FontOptions(18.0f))); + g.drawText("No Data", juce::Rectangle(0, 0, width, height), juce::Justification::centred); return; } @@ -151,11 +151,16 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl lineText += "c"; g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(11.0f)); + g.setFont(Font(FontOptions(11.0f))); g.drawText(lineText, juce::Rectangle(0, yFlip(float(linePos) + 7), sidebarWidth - 6, 14), Justification::centredRight); - // don't overwrite maximum "in-tune" lines - if (y * lineInterval == allowedPitchOffset || y * lineInterval == -allowedPitchOffset) + // don't overwrite maximum "in-tune" lines. Both sides are computed, + // so an exact comparison is a coin toss on the last bit and the skip + // silently fails to fire; compare against a fraction of the spacing + // between lines instead. + const double sameLine = lineInterval * 1.0e-6; + if (std::abs(y * lineInterval - allowedPitchOffset) < sameLine + || std::abs(y * lineInterval + allowedPitchOffset) < sameLine) continue; g.setColour(ModernLookAndFeel::Colors::panelLight.withAlpha(0.3f)); @@ -269,8 +274,8 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl // Draw the X-Axis label g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(12.0f)); - g.drawText("MIDI Note", 0, height - bottomBarHeight, (int)sidebarWidth - 10, bottomBarHeight, Justification::centredRight); + g.setFont(Font(FontOptions(12.0f))); + g.drawText("MIDI Note", juce::Rectangle(0, height - bottomBarHeight, (int)sidebarWidth - 10, bottomBarHeight), Justification::centredRight); // Draw note labels on X axis const int numPitchTextIntervals = 5; @@ -301,7 +306,7 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl for (int i = startLine; i <= endLine; i += pitchTextInterval) { g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(11.0f)); + g.setFont(Font(FontOptions(11.0f))); float textWidth = GlyphArrangement::getStringWidth(g.getCurrentFont(), String(measurements[i].midiPitch)); float xLeft = sidebarWidth + i * float(columnWidth); float x = xLeft + float(columnWidth) / 2.0f - textWidth / 2.0f; @@ -342,8 +347,8 @@ void Visualizer::paintWithFixedScaling(Graphics& g, int width, int height, doubl // Draw reference label g.setColour(ModernLookAndFeel::Colors::accentAlt); - g.setFont(Font(10.0f, Font::bold)); - g.drawText("REF", xLeft, height - bottomBarHeight + 22, float(columnWidth), 12, Justification::centred); + g.setFont(Font(FontOptions(10.0f, Font::bold))); + g.drawText("REF", juce::Rectangle(xLeft, height - bottomBarHeight + 22, float(columnWidth), 12), Justification::centred); } } } @@ -380,16 +385,16 @@ void Visualizer::drawTopInfoPanel(Graphics& g, int width, int panelHeight) // Section 1: Current Note & Frequency g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(10.0f)); - g.drawText("CURRENT NOTE", startX, topY, sectionWidth, 12, Justification::left); + g.setFont(Font(FontOptions(10.0f))); + g.drawText("CURRENT NOTE", juce::Rectangle(startX, topY, sectionWidth, 12), Justification::left); g.setColour(ModernLookAndFeel::Colors::textPrimary); - g.setFont(Font(24.0f, Font::bold)); + g.setFont(Font(FontOptions(24.0f, Font::bold))); String noteName = MidiMessage::getMidiNoteName(current.midiPitch, true, true, 4); - g.drawText(noteName, startX, topY + 14, sectionWidth, 28, Justification::left); + g.drawText(noteName, juce::Rectangle(startX, topY + 14, sectionWidth, 28), Justification::left); g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(12.0f)); + g.setFont(Font(FontOptions(12.0f))); // Qualify the absolute frequency with the clock correction that produced // it. This is the one number the correction moves -- the cents offsets are @@ -401,13 +406,13 @@ void Visualizer::drawTopInfoPanel(Graphics& g, int width, int panelHeight) if (std::abs(ppm) >= 0.5) freqText += (ppm > 0 ? " clk +" : " clk ") + String(ppm, 0) + "ppm"; - g.drawText(freqText, startX, topY + 42, sectionWidth, 16, Justification::left); + g.drawText(freqText, juce::Rectangle(startX, topY + 42, sectionWidth, 16), Justification::left); // Section 2: Current Error (cents) startX += sectionWidth; g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(10.0f)); - g.drawText("PITCH ERROR", startX, topY, sectionWidth, 12, Justification::left); + g.setFont(Font(FontOptions(10.0f))); + g.drawText("PITCH ERROR", juce::Rectangle(startX, topY, sectionWidth, 12), Justification::left); float currentCents = (float)(current.pitchOffset * 100.0); Colour errorColor; @@ -419,9 +424,9 @@ void Visualizer::drawTopInfoPanel(Graphics& g, int width, int panelHeight) errorColor = ModernLookAndFeel::Colors::meterBad; g.setColour(errorColor); - g.setFont(Font(24.0f, Font::bold)); + g.setFont(Font(FontOptions(24.0f, Font::bold))); String errorStr = (currentCents >= 0 ? "+" : "") + String(currentCents, 1) + "c"; - g.drawText(errorStr, startX, topY + 14, sectionWidth, 28, Justification::left); + g.drawText(errorStr, juce::Rectangle(startX, topY + 14, sectionWidth, 28), Justification::left); // Mini tuning meter Rectangle meterBounds(startX, topY + 46, sectionWidth - 20, 12); @@ -430,29 +435,28 @@ void Visualizer::drawTopInfoPanel(Graphics& g, int width, int panelHeight) // Section 3: Statistics startX += sectionWidth; g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(10.0f)); - g.drawText("STATISTICS", startX, topY, sectionWidth, 12, Justification::left); + g.setFont(Font(FontOptions(10.0f))); + g.drawText("STATISTICS", juce::Rectangle(startX, topY, sectionWidth, 12), Justification::left); g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(11.0f)); - g.drawText("Max: " + String(maxOffset * 100, 1) + "c", startX, topY + 16, sectionWidth, 14, Justification::left); - g.drawText("Min: " + String(minOffset * 100, 1) + "c", startX, topY + 30, sectionWidth, 14, Justification::left); - g.drawText("Avg: " + String(avgOffset * 100, 1) + "c", startX, topY + 44, sectionWidth, 14, Justification::left); + g.setFont(Font(FontOptions(11.0f))); + g.drawText("Max: " + String(maxOffset * 100, 1) + "c", juce::Rectangle(startX, topY + 16, sectionWidth, 14), Justification::left); + g.drawText("Min: " + String(minOffset * 100, 1) + "c", juce::Rectangle(startX, topY + 30, sectionWidth, 14), Justification::left); + g.drawText("Avg: " + String(avgOffset * 100, 1) + "c", juce::Rectangle(startX, topY + 44, sectionWidth, 14), Justification::left); // Section 4: Progress startX += sectionWidth; g.setColour(ModernLookAndFeel::Colors::textDim); - g.setFont(Font(10.0f)); - g.drawText("PROGRESS", startX, topY, sectionWidth, 12, Justification::left); + g.setFont(Font(FontOptions(10.0f))); + g.drawText("PROGRESS", juce::Rectangle(startX, topY, sectionWidth, 12), Justification::left); g.setColour(ModernLookAndFeel::Colors::textPrimary); - g.setFont(Font(18.0f, Font::bold)); - g.drawText(String(measurements.size()) + " pts", startX, topY + 14, sectionWidth, 24, Justification::left); + g.setFont(Font(FontOptions(18.0f, Font::bold))); + g.drawText(String(measurements.size()) + " pts", juce::Rectangle(startX, topY + 14, sectionWidth, 24), Justification::left); g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(11.0f)); - g.drawText("Range: " + String(measurements[0].midiPitch) + "-" + String(measurements.getLast().midiPitch), - startX, topY + 42, sectionWidth, 14, Justification::left); + g.setFont(Font(FontOptions(11.0f))); + g.drawText("Range: " + String(measurements[0].midiPitch) + "-" + String(measurements.getLast().midiPitch), juce::Rectangle(startX, topY + 42, sectionWidth, 14), Justification::left); } void Visualizer::paint(Graphics& g) @@ -520,13 +524,13 @@ void Visualizer::drawTunerDial(Graphics& g, Rectangle bounds, float cents // Draw note name - large and prominent String noteName = MidiMessage::getMidiNoteName(midiNote, true, true, 4); g.setColour(ModernLookAndFeel::Colors::textPrimary); - g.setFont(Font(48.0f, Font::bold)); - g.drawText(noteName, bounds.getX(), bounds.getY() + 15, bounds.getWidth(), 50, Justification::centred); + g.setFont(Font(FontOptions(48.0f, Font::bold))); + g.drawText(noteName, juce::Rectangle(bounds.getX(), bounds.getY() + 15, bounds.getWidth(), 50), Justification::centred); // Draw frequency g.setColour(ModernLookAndFeel::Colors::textSecondary); - g.setFont(Font(16.0f)); - g.drawText(String(frequency, 2) + " Hz", bounds.getX(), bounds.getY() + 60, bounds.getWidth(), 20, Justification::centred); + g.setFont(Font(FontOptions(16.0f))); + g.drawText(String(frequency, 2) + " Hz", juce::Rectangle(bounds.getX(), bounds.getY() + 60, bounds.getWidth(), 20), Justification::centred); // Draw cents value below the dial Colour centsColor; @@ -538,17 +542,17 @@ void Visualizer::drawTunerDial(Graphics& g, Rectangle bounds, float cents centsColor = ModernLookAndFeel::Colors::meterBad; g.setColour(centsColor); - g.setFont(Font(28.0f, Font::bold)); + g.setFont(Font(FontOptions(28.0f, Font::bold))); String centsStr = (cents >= 0 ? "+" : "") + String(cents, 1) + " cents"; - g.drawText(centsStr, bounds.getX(), bounds.getBottom() - 50, bounds.getWidth(), 30, Justification::centred); + g.drawText(centsStr, juce::Rectangle(bounds.getX(), bounds.getBottom() - 50, bounds.getWidth(), 30), Justification::centred); // Draw flat/sharp indicators - g.setFont(Font(18.0f)); + g.setFont(Font(FontOptions(18.0f))); g.setColour(cents < -5 ? ModernLookAndFeel::Colors::meterWarn : ModernLookAndFeel::Colors::textDim); - g.drawText("FLAT", bounds.getX() + 20, centerY - 10, 60, 20, Justification::left); + g.drawText("FLAT", juce::Rectangle(bounds.getX() + 20, centerY - 10, 60, 20), Justification::left); g.setColour(cents > 5 ? ModernLookAndFeel::Colors::meterWarn : ModernLookAndFeel::Colors::textDim); - g.drawText("SHARP", bounds.getRight() - 80, centerY - 10, 60, 20, Justification::right); + g.drawText("SHARP", juce::Rectangle(bounds.getRight() - 80, centerY - 10, 60, 20), Justification::right); } void Visualizer::drawTunerArc(Graphics& g, float centerX, float centerY, float radius, float cents) diff --git a/Source/Visualizer.h b/Source/Visualizer.h index 55996e4..6d9c37a 100644 --- a/Source/Visualizer.h +++ b/Source/Visualizer.h @@ -18,13 +18,13 @@ class Visualizer: public Component, { public: Visualizer(VCOTuner* t); - ~Visualizer(); + ~Visualizer() override; void paintWithFixedScaling(Graphics& g, int width, int height, double min, double max); void paint(Graphics& g, int width, int height); - virtual void paint(Graphics& g); + void paint(Graphics& g) override; - virtual void newMeasurementReady(const VCOTuner::measurement_t& m); + virtual void newMeasurementReady(const VCOTuner::measurement_t& m) override; void measurementFailed (int midiPitch, vcotuner::MeasurementError reason) override; void clearCache() { measurements.clear(); failedPitches.clear(); } diff --git a/Source/dsp/CvScaling.cpp b/Source/dsp/CvScaling.cpp new file mode 100644 index 0000000..5951d40 --- /dev/null +++ b/Source/dsp/CvScaling.cpp @@ -0,0 +1,111 @@ +// Source/dsp/CvScaling.cpp +#include "CvScaling.h" + +#include +#include + +namespace vcotuner +{ + +namespace +{ + constexpr float kA440 = 440.0f; + constexpr float kA440Midi = 69.0f; + constexpr float kZeroVoltMidi = 60.0f; // C4 sits at 0 V in 1V/oct here + + float clampTo (float v, const CvRange& r) + { + return std::min (std::max (v, r.minVolts), r.maxVolts); + } +} + +float midiToVoltage (float midiPitch, CvStandard standard, float hzPerVoltScaling) +{ + switch (standard) + { + case CvStandard::oneVoltPerOctave: + return (midiPitch - kZeroVoltMidi) / 12.0f; + + case CvStandard::hzPerVolt: + { + if (! (hzPerVoltScaling > 0.0f)) + return 0.0f; + + const float freq = kA440 * std::pow (2.0f, (midiPitch - kA440Midi) / 12.0f); + return freq / hzPerVoltScaling; + } + } + + return 0.0f; +} + +float voltageToMidi (float voltage, CvStandard standard, float hzPerVoltScaling) +{ + switch (standard) + { + case CvStandard::oneVoltPerOctave: + return kZeroVoltMidi + voltage * 12.0f; + + case CvStandard::hzPerVolt: + { + const float freq = voltage * hzPerVoltScaling; + + // Hz/V cannot express zero or negative frequency, so there is no + // pitch to return below 0 V. Answering with the 0 V pitch keeps + // the result finite instead of handing back -inf from the log. + if (! (freq > 0.0f)) + return kZeroVoltMidi; + + return kA440Midi + 12.0f * std::log2 (freq / kA440); + } + } + + return kZeroVoltMidi; +} + +float frequencyToVoltage (float hz, CvStandard standard, float hzPerVoltScaling) +{ + switch (standard) + { + case CvStandard::oneVoltPerOctave: + { + if (! (hz > 0.0f)) + return 0.0f; + + const float midiPitch = kA440Midi + 12.0f * std::log2 (hz / kA440); + return (midiPitch - kZeroVoltMidi) / 12.0f; + } + + case CvStandard::hzPerVolt: + return (hzPerVoltScaling > 0.0f) ? hz / hzPerVoltScaling : 0.0f; + } + + return 0.0f; +} + +float voltageToSample (float volts, const CvRange& range) +{ + const float span = range.maxVolts - range.minVolts; + if (! (span > 0.0f)) + return 0.0f; + + return ((volts - range.minVolts) / span) * 2.0f - 1.0f; +} + +float sampleToVoltage (float sample, const CvRange& range) +{ + const float span = range.maxVolts - range.minVolts; + return range.minVolts + ((sample + 1.0f) * 0.5f) * span; +} + +float applyCalibration (float voltage, const CvCalibration& cal) +{ + return cal.isCalibrated ? (cal.gain * voltage + cal.offset) : voltage; +} + +float conditionOutputVoltage (float volts, const CvRange& range, const CvCalibration& cal) +{ + return clampTo (applyCalibration (clampTo (volts, range), cal), range); +} + +} // namespace vcotuner diff --git a/Source/dsp/CvScaling.h b/Source/dsp/CvScaling.h new file mode 100644 index 0000000..300a958 --- /dev/null +++ b/Source/dsp/CvScaling.h @@ -0,0 +1,60 @@ +// Source/dsp/CvScaling.h +#pragma once + +namespace vcotuner +{ + +enum class CvStandard +{ + oneVoltPerOctave, + hzPerVolt +}; + +/** How a voltage maps onto the audio sample the interface converts back to it. + + A DC-coupled output is just a DAC: full negative scale is minVolts and full + positive scale is maxVolts, so the mapping is linear and the range is a + property of the interface, not of the pitch. +*/ +struct CvRange +{ + float minVolts = -10.0f; + float maxVolts = +10.0f; +}; + +/** Linear correction measured for a particular interface. */ +struct CvCalibration +{ + bool isCalibrated = false; + float gain = 1.0f; + float offset = 0.0f; +}; + +/** Pure conversions between pitch, voltage and sample value. + + Kept free of JUCE so it can be linked into the tests, which is the whole + point: these are the numbers that decide what note a VCO is asked to play, + and an error here is indistinguishable from an oscillator that will not + track. The manager in Source/CVOutput owns the device and the state; this + owns only the arithmetic. +*/ +float midiToVoltage (float midiPitch, CvStandard standard, float hzPerVoltScaling); +float voltageToMidi (float voltage, CvStandard standard, float hzPerVoltScaling); +float frequencyToVoltage (float hz, CvStandard standard, float hzPerVoltScaling); + +float voltageToSample (float volts, const CvRange& range); +float sampleToVoltage (float sample, const CvRange& range); + +float applyCalibration (float voltage, const CvCalibration& cal); + +/** Clamp, correct, then clamp again. + + The second clamp is the point. Correction is gain * v + offset, so a + voltage that was inside the range before it can land outside it after, and + voltageToSample() would then return a magnitude past +/-1 for the interface + to clip silently -- losing accuracy at exactly the extremes the calibration + was measured to fix. +*/ +float conditionOutputVoltage (float volts, const CvRange& range, const CvCalibration& cal); + +} // namespace vcotuner diff --git a/Source/dsp/PeriodDetector.cpp b/Source/dsp/PeriodDetector.cpp index a2364f5..f31c359 100644 --- a/Source/dsp/PeriodDetector.cpp +++ b/Source/dsp/PeriodDetector.cpp @@ -56,6 +56,9 @@ void PeriodDetector::reset (const PeriodDetectorConfig& config) levelMidpoint = 0.0; levelAmplitude = 0.0; haveLevel = false; + cycleMin = 1e30; + cycleMax = -1e30; + haveCycleExtent = false; periods.clear(); periods.reserve ((size_t) config.maxPeriods); @@ -92,7 +95,18 @@ void PeriodDetector::processBlock (const float* samples, int numSamples) } if (haveLevel && currentStatus == DetectorStatus::collecting) + { + // Extent of the cycle in progress. Two compares per sample, and + // only once warm-up has produced a level to track from. + if (cfg.midpointTrackingRate > 0.0) + { + if (s < cycleMin) cycleMin = s; + if (s > cycleMax) cycleMax = s; + haveCycleExtent = true; + } + processCrossing (s); + } ++sampleCounter; } @@ -111,6 +125,38 @@ void PeriodDetector::finishWarmup() } haveLevel = true; + + // The warm-up window is the first cycle the tracker sees; start it from + // what warm-up already measured rather than from nothing. + cycleMin = runningMin; + cycleMax = runningMax; + haveCycleExtent = true; +} + +void PeriodDetector::trackLevel() noexcept +{ + // Called at a crossing, so the level only ever changes between cycles: + // the threshold a period was measured against is the same at both ends of + // that period, and the trailing period is unaffected by the update. + if (cfg.midpointTrackingRate > 0.0 && haveCycleExtent && cycleMax > cycleMin) + { + const double cycleMid = (cycleMax + cycleMin) * 0.5; + const double cycleAmp = (cycleMax - cycleMin) * 0.5; + + // A cycle that collapsed into the noise floor says nothing useful + // about where the middle of the waveform is; leave the level alone + // rather than dragging it towards an artefact. + if (cycleAmp >= cfg.silenceFloor) + { + const double rate = cfg.midpointTrackingRate; + levelMidpoint += rate * (cycleMid - levelMidpoint); + levelAmplitude += rate * (cycleAmp - levelAmplitude); + } + } + + cycleMin = 1e30; + cycleMax = -1e30; + haveCycleExtent = false; } void PeriodDetector::processCrossing (double s) @@ -155,6 +201,8 @@ void PeriodDetector::recordCrossing (double position) lastCrossing = position; + trackLevel(); + updateStability(); } diff --git a/Source/dsp/PeriodDetector.h b/Source/dsp/PeriodDetector.h index 99b0a95..58d3c74 100644 --- a/Source/dsp/PeriodDetector.h +++ b/Source/dsp/PeriodDetector.h @@ -8,7 +8,6 @@ namespace vcotuner struct PeriodDetectorConfig { - double sampleRate = 48000.0; double hysteresisFraction = 0.1; // of measured amplitude int stabilityWindow = 5; // consecutive periods compared double stabilityTolerance = 0.1; // 10% spread allowed @@ -17,6 +16,25 @@ struct PeriodDetectorConfig double silenceFloor = 1e-4; // amplitude below this => silent int requiredPeriods = 10; // valid periods needed for 'stable' + /** How fast the trigger level follows the signal, as a blend coefficient + applied once per cycle. 0 disables tracking and latches the level for + the whole measurement. + + The level is latched at the end of warm-up, and an oscillator whose DC + offset drifts during a long capture then crosses a threshold that no + longer sits at the middle of its waveform. That does not merely add + noise: the crossing happens at a progressively different phase each + cycle, so the crossing times acquire a ramp and the fitted period comes + out biased. Following the drift keeps the crossing at a fixed phase. + + Updated only between cycles, never within one, so the threshold a + period is measured against is the same at both of its ends. The rate is + slow for the reason the level was latched in the first place: a + threshold that chases noise would put that noise straight into the + crossing times. + */ + double midpointTrackingRate = 0.05; + /** Share of the collected periods allowed to sit off the cycle grid before the note is called unstable. A dropout inserts or removes one crossing and so disturbs at most two periods; a genuinely jittery oscillator @@ -76,6 +94,7 @@ class PeriodDetector private: void finishWarmup(); + void trackLevel() noexcept; void processCrossing (double s); void recordCrossing (double position); void updateStability(); @@ -92,6 +111,13 @@ class PeriodDetector double levelAmplitude = 0.0; bool haveLevel = false; + // Extent of the cycle currently being collected, folded into the trigger + // level at each crossing. Separate from runningMin/Max, which belong to + // warm-up and stop being updated once it ends. + double cycleMin = 1e30; + double cycleMax = -1e30; + bool haveCycleExtent = false; + std::vector periods; // Scratch for the median in countOutliers(). Sized with periods so the diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 86c9afe..3d8f840 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ FetchContent_Declare( FetchContent_MakeAvailable(Catch2) add_executable(VCOTunerTests + CvScalingTests.cpp ClockCalibratorTests.cpp PeriodDetectorTests.cpp MeasurementStatisticsTests.cpp diff --git a/tests/CvScalingTests.cpp b/tests/CvScalingTests.cpp new file mode 100644 index 0000000..a796dd3 --- /dev/null +++ b/tests/CvScalingTests.cpp @@ -0,0 +1,135 @@ +// tests/CvScalingTests.cpp +#include +#include +#include + +#include "dsp/CvScaling.h" + +using namespace vcotuner; +using Catch::Approx; + +TEST_CASE ("one volt per octave really is one volt per octave") +{ + // The defining property, stated as such: twelve semitones apart must be + // exactly one volt apart, anywhere in the range. + for (float pitch : { 24.0f, 48.0f, 60.0f, 72.0f, 108.0f }) + { + const float low = midiToVoltage (pitch, CvStandard::oneVoltPerOctave, 1000.0f); + const float high = midiToVoltage (pitch + 12.0f, CvStandard::oneVoltPerOctave, 1000.0f); + REQUIRE (high - low == Approx (1.0f).margin (1e-6)); + } + + // and a semitone is a twelfth of that + const float a = midiToVoltage (60.0f, CvStandard::oneVoltPerOctave, 1000.0f); + const float b = midiToVoltage (61.0f, CvStandard::oneVoltPerOctave, 1000.0f); + REQUIRE (b - a == Approx (1.0f / 12.0f).margin (1e-6)); +} + +TEST_CASE ("pitch survives a round trip through voltage") +{ + // If these two disagree, a note asked for and the note read back differ, + // and the difference is indistinguishable from an oscillator that will + // not track. + for (float pitch = 24.0f; pitch <= 108.0f; pitch += 1.0f) + { + { + const float v = midiToVoltage (pitch, CvStandard::oneVoltPerOctave, 1000.0f); + REQUIRE (voltageToMidi (v, CvStandard::oneVoltPerOctave, 1000.0f) + == Approx (pitch).margin (1e-3)); + } + { + const float v = midiToVoltage (pitch, CvStandard::hzPerVolt, 1000.0f); + REQUIRE (voltageToMidi (v, CvStandard::hzPerVolt, 1000.0f) + == Approx (pitch).margin (1e-3)); + } + } +} + +TEST_CASE ("frequency and pitch routes agree with each other") +{ + // A440 is MIDI 69 by definition, so asking by frequency and asking by + // pitch must produce the same voltage. + for (auto standard : { CvStandard::oneVoltPerOctave, CvStandard::hzPerVolt }) + { + const float byPitch = midiToVoltage (69.0f, standard, 1000.0f); + const float byFreq = frequencyToVoltage (440.0f, standard, 1000.0f); + REQUIRE (byPitch == Approx (byFreq).margin (1e-5)); + } +} + +TEST_CASE ("Hz per volt doubles the voltage for an octave, unlike 1V/oct") +{ + // The two standards are different shapes, not different constants. This + // pins that: Hz/V is proportional to frequency, so an octave is a factor + // of two rather than a fixed step. + const float v1 = midiToVoltage (69.0f, CvStandard::hzPerVolt, 1000.0f); + const float v2 = midiToVoltage (81.0f, CvStandard::hzPerVolt, 1000.0f); + REQUIRE (v2 == Approx (2.0f * v1).margin (1e-5)); + REQUIRE (v1 == Approx (0.44f).margin (1e-4)); // 440 Hz / 1000 Hz per V +} + +TEST_CASE ("the sample mapping hits the rails exactly at the range endpoints") +{ + const CvRange range { -10.0f, +10.0f }; + + REQUIRE (voltageToSample (-10.0f, range) == Approx (-1.0f)); + REQUIRE (voltageToSample ( 0.0f, range) == Approx ( 0.0f).margin (1e-6)); + REQUIRE (voltageToSample (+10.0f, range) == Approx (+1.0f)); + + // and an asymmetric interface maps its own zero, not the midpoint of the + // symmetric case + const CvRange unipolar { 0.0f, 10.0f }; + REQUIRE (voltageToSample (0.0f, unipolar) == Approx (-1.0f)); + REQUIRE (voltageToSample (5.0f, unipolar) == Approx ( 0.0f).margin (1e-6)); +} + +TEST_CASE ("voltage survives a round trip through the sample mapping") +{ + const CvRange range { -10.0f, +10.0f }; + for (float v = -10.0f; v <= 10.0f; v += 0.5f) + REQUIRE (sampleToVoltage (voltageToSample (v, range), range) == Approx (v).margin (1e-4)); +} + +TEST_CASE ("a degenerate range is refused rather than dividing by zero") +{ + const CvRange broken { 5.0f, 5.0f }; + REQUIRE (voltageToSample (5.0f, broken) == Approx (0.0f)); + REQUIRE (std::isfinite (voltageToSample (5.0f, broken))); +} + +TEST_CASE ("calibration cannot push the output past the interface range") +{ + // The bug this pins: clamping before the correction and not after let a + // corrected voltage leave the range, which voltageToSample() then turns + // into a magnitude past +/-1 for the interface to clip silently. + const CvRange range { -10.0f, +10.0f }; + const CvCalibration cal { true, 1.05f, 0.4f }; // 5% gain, 0.4 V offset + + for (float v = -12.0f; v <= 12.0f; v += 0.25f) + { + const float out = conditionOutputVoltage (v, range, cal); + REQUIRE (out >= range.minVolts); + REQUIRE (out <= range.maxVolts); + REQUIRE (std::abs (voltageToSample (out, range)) <= 1.0f + 1e-6f); + } + + // the top of the range is where it used to escape + REQUIRE (conditionOutputVoltage (10.0f, range, cal) == Approx (10.0f)); +} + +TEST_CASE ("an uncalibrated interface is passed through untouched") +{ + const CvCalibration none {}; + REQUIRE (applyCalibration (3.3f, none) == Approx (3.3f)); + + const CvCalibration cal { true, 2.0f, 1.0f }; + REQUIRE (applyCalibration (3.0f, cal) == Approx (7.0f)); +} + +TEST_CASE ("Hz per volt below zero volts returns a finite pitch") +{ + // Hz/V cannot express zero or negative frequency. The log would return + // -inf and carry it into every pitch computed from it. + const float pitch = voltageToMidi (-1.0f, CvStandard::hzPerVolt, 1000.0f); + REQUIRE (std::isfinite (pitch)); +} diff --git a/tests/PeriodDetectorTests.cpp b/tests/PeriodDetectorTests.cpp index bcb1582..697a9e2 100644 --- a/tests/PeriodDetectorTests.cpp +++ b/tests/PeriodDetectorTests.cpp @@ -496,3 +496,107 @@ TEST_CASE ("sustained jitter still fails, and is not mistaken for a glitch") << " outliers=" << detector.numOutliers()); REQUIRE (detector.status() != DetectorStatus::stable); } + +//============================================================================== +// Following the trigger level while the signal drifts. + +namespace +{ + /** A sine sitting on a DC offset that ramps, as an oscillator's does while + it warms up. Returns the measured frequency, or 0 if the note failed. + */ + double measureWithDrift (double trackingRate, double freq, double driftPerSecond) + { + const double sampleRate = 48000.0; + const int numSamples = (int) (sampleRate * 260.0 / freq); // ~260 cycles + + std::vector samples ((size_t) numSamples); + for (int i = 0; i < numSamples; ++i) + { + const double t = i / sampleRate; + samples[(size_t) i] = (float) (driftPerSecond * t + + 0.8 * std::sin (2.0 * kPi * freq * t)); + } + + PeriodDetectorConfig cfg; + cfg.warmupSamples = (int) (2.0 * sampleRate / freq); + cfg.requiredPeriods = 200; + cfg.midpointTrackingRate = trackingRate; + + PeriodDetector detector; + detector.reset (cfg); + detector.processBlock (samples.data(), numSamples); + + if (detector.status() != DetectorStatus::stable) + return 0.0; + + const auto fit = fitPeriod (detector.validPeriods(), detector.numValidPeriods()); + return fit.valid ? sampleRate / fit.periodSamples : 0.0; + } + + double centsFrom (double measured, double truth) + { + return (measured > 0.0) ? 1200.0 * std::log2 (measured / truth) : 0.0; + } +} + +TEST_CASE ("a drifting DC offset biases a latched trigger level") +{ + // The mechanism, pinned before the fix is asserted. A threshold that stays + // put while the waveform moves under it is crossed at a progressively + // different phase each cycle, so the crossing times acquire a ramp and the + // fitted period comes out biased -- not merely noisy. + const double freq = 110.0; + const double latched = measureWithDrift (0.0, freq, 0.2); + + REQUIRE (latched > 0.0); + REQUIRE (std::abs (centsFrom (latched, freq)) > 0.3); +} + +TEST_CASE ("following the level between cycles removes most of that bias") +{ + const double freq = 110.0; + const double latched = measureWithDrift (0.0, freq, 0.2); + const double tracking = measureWithDrift (0.05, freq, 0.2); + + REQUIRE (tracking > 0.0); + + const double latchedErr = std::abs (centsFrom (latched, freq)); + const double trackingErr = std::abs (centsFrom (tracking, freq)); + + INFO ("latched " << latchedErr << "c, tracking " << trackingErr << "c"); + REQUIRE (trackingErr < 0.1); + REQUIRE (trackingErr < latchedErr / 5.0); +} + +TEST_CASE ("tracking costs almost nothing when there is nothing to track") +{ + // A level that chases noise would put that noise into the crossing times, + // which is why the latch existed. Following the signal is not free -- the + // cycle extent moves by a fraction of a sample from cycle to cycle, so the + // level wobbles a little and that reaches the crossing times. The bound + // here is what that actually costs on a clean 440 Hz sine: about 3e-5 + // cents, four orders of magnitude below the drift bias it removes and far + // below anything the rest of the chain can resolve. + const double freq = 440.0; + + const double tracked = std::abs (centsFrom (measureWithDrift (0.05, freq, 0.0), freq)); + const double latched = std::abs (centsFrom (measureWithDrift (0.0, freq, 0.0), freq)); + + INFO ("tracked " << tracked << "c, latched " << latched << "c"); + REQUIRE (latched < 1e-6); // a fixed level on a clean signal is exact + REQUIRE (tracked < 1e-3); // and following one is close enough to it +} + +TEST_CASE ("severe drift at a low pitch fails outright with a latched level") +{ + // Worst case, and the reason this is not only about accuracy: a low note + // has few cycles per second for the drift to be spread over, so the + // waveform walks clear of a fixed threshold and stops crossing it at all. + // The note is lost rather than measured badly. + const double freq = 50.0; + + REQUIRE (measureWithDrift (0.0, freq, 0.2) == 0.0); // never completes + REQUIRE (measureWithDrift (0.05, freq, 0.2) > 0.0); // measured + REQUIRE (std::abs (centsFrom (measureWithDrift (0.05, freq, 0.2), freq)) < 0.2); +} diff --git a/tools/debug_harness.sh b/tools/debug_harness.sh index 03f1c07..39d31c9 100755 --- a/tools/debug_harness.sh +++ b/tools/debug_harness.sh @@ -102,6 +102,15 @@ else gate "frequency-uses-corrected-clock" PASS fi +# The sweep must honour the pitch source. If playPitch() stops dispatching on +# it, CV silently stops driving the oscillator and every note goes out over +# MIDI instead -- which looks like a patching mistake, not a code change. +if awk '/void VCOTuner::playPitch/,/^\}/' "$ROOT/Source/VCOTuner.cpp" | grep -q 'PitchSource::cvOutput'; then + gate "sweep-honours-pitch-source" PASS +else + gate "sweep-honours-pitch-source" FAIL "playPitch no longer dispatches on the pitch source" +fi + # The JUCE submodule must match what the tree pins, or the build is not the # build the gates think they are testing. PINNED=$(git -C "$ROOT" ls-files -s deps/JUCE | awk '{print $2}') diff --git a/tools/harness_selftest.py b/tools/harness_selftest.py index 14b572e..969a272 100755 --- a/tools/harness_selftest.py +++ b/tools/harness_selftest.py @@ -16,6 +16,7 @@ FONT_OK = 'GlyphArrangement::getStringWidth(g.getCurrentFont(), String(measurements[i].midiPitch))' CV_OK = ' cvOutputManager->fillOutputBuffer(outputChannelData[0], numSamples);' CLOCK_OK = 'fitFrequency(detector, effectiveSampleRate(),' +SWEEP_OK = ' if (pitchSource == PitchSource::cvOutput)\n {\n if (cvOutputManager == nullptr)' CASES = [ ('no-raw-utf8-literals', 'Source/TunerDisplay.cpp', @@ -28,6 +29,8 @@ CV_OK, ' /* moved below the early returns */'), ('frequency-uses-corrected-clock', 'Source/VCOTuner.cpp', CLOCK_OK, 'fitFrequency(detector, sampleRate,'), + ('sweep-honours-pitch-source', 'Source/VCOTuner.cpp', + SWEEP_OK, ' if (false)\n {\n if (cvOutputManager == nullptr)'), ] From 5c99d18ee49d2dc2d97847eb14a68f2f56645818 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 14:30:25 +0200 Subject: [PATCH 58/60] Fix what the debug analysis found before pushing 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) --- Source/MainComponent.cpp | 25 +++++++++-- tests/CMakeLists.txt | 6 +++ tests/RealtimeAllocationCheck.cpp | 69 +++++++++++++++++++++++++++++++ tools/debug_harness.sh | 19 +++++++++ tools/ui_capture.py | 16 ++++++- 5 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 tests/RealtimeAllocationCheck.cpp diff --git a/Source/MainComponent.cpp b/Source/MainComponent.cpp index b239254..289f610 100644 --- a/Source/MainComponent.cpp +++ b/Source/MainComponent.cpp @@ -15,13 +15,26 @@ #include "ModernLookAndFeel.h" #include "TunerDisplay.h" -// Global look and feel instance -static ModernLookAndFeel modernLookAndFeel; +/** The one ModernLookAndFeel the app uses. + + Constructed on first call rather than at file scope. A LookAndFeel built + during static initialisation races JUCE's own static Colours, which JUCE + asserts on in a debug build ("you're using a static LookAndFeel object"); + in a release build the assertion is gone but the ordering is still + undefined. A function-local static is constructed after main() has started + and destroyed after shutdown() has already dropped the components that + refer to it. +*/ +static ModernLookAndFeel& getModernLookAndFeel() +{ + static ModernLookAndFeel instance; + return instance; +} MainComponent::MainComponent() : tuner(&deviceManager), tunerDisplay(&tuner), display(&tuner) { // Apply modern look and feel - LookAndFeel::setDefaultLookAndFeel(&modernLookAndFeel); + LookAndFeel::setDefaultLookAndFeel(&getModernLookAndFeel()); std::unique_ptr savedAudioState (getAppProperties().getUserSettings() ->getXmlValue ("audioDeviceState")); @@ -482,8 +495,12 @@ void MainComponent::tunerFinished() tuner.toggleState(); } -void MainComponent::measurementFailed (int /*midiPitch*/, vcotuner::MeasurementError /*reason*/) +void MainComponent::measurementFailed (int /*midiPitch*/, vcotuner::MeasurementError reason) { + // Used only by the assertion below, which compiles away in a release + // build -- hence the explicit ignore rather than a commented-out name. + ignoreUnused (reason); + // Per-note failures are never fatal - only the reasons that abort the // whole sweep (routed through tunerStopped instead) are. jassert (! vcotuner::isFatal (reason)); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3d8f840..53bc61d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,3 +19,9 @@ target_link_libraries(VCOTunerTests PRIVATE vcotuner_dsp Catch2::Catch2WithMain) list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) include(Catch) catch_discover_tests(VCOTunerTests DISCOVERY_MODE PRE_TEST) + +# Built and run separately from the Catch2 suite: it replaces global operator +# new to count allocations, which must not affect the other tests. +add_executable(VCOTunerRealtimeCheck RealtimeAllocationCheck.cpp) +target_link_libraries(VCOTunerRealtimeCheck PRIVATE vcotuner_dsp) +add_test(NAME realtime-allocation COMMAND VCOTunerRealtimeCheck) diff --git a/tests/RealtimeAllocationCheck.cpp b/tests/RealtimeAllocationCheck.cpp new file mode 100644 index 0000000..6c02ae1 --- /dev/null +++ b/tests/RealtimeAllocationCheck.cpp @@ -0,0 +1,69 @@ +// tests/RealtimeAllocationCheck.cpp +// +// Counts heap allocations while the audio-thread code runs. +// +// A separate executable rather than a Catch2 case: it replaces global +// operator new, which is not something to impose on the rest of the suite. +// Exits non-zero if anything on the audio path allocates -- a malloc there can +// block on a lock held by another thread and produce a dropout, and the +// dropout shows up as a failed note rather than as the memory bug it is. +#include "dsp/PeriodDetector.h" +#include "dsp/ClockCalibrator.h" +#include +#include +#include +#include +#include +#include + +static std::atomic allocs{0}; +static std::atomic counting{false}; + +void* operator new(std::size_t n) { + if (counting.load()) allocs.fetch_add(1); + void* p = std::malloc(n ? n : 1); + if (!p) throw std::bad_alloc(); + return p; +} +void operator delete(void* p) noexcept { std::free(p); } +void operator delete(void* p, std::size_t) noexcept { std::free(p); } + +using namespace vcotuner; +static const double kPi = 3.14159265358979323846; + +int main() { + const double sr = 48000.0, freq = 440.0; + const int block = 256, blocks = 4000; + + std::vector buf((size_t) block); + + PeriodDetector det; + det.prepare(PeriodDetectorConfig().maxPeriods); // the non-realtime step + ClockCalibrator cal; + + long phase = 0; + for (int run = 0; run < 12; ++run) { + PeriodDetectorConfig cfg; + cfg.requiredPeriods = 200; + cfg.warmupSamples = 480; + + // everything from here on is what the audio thread does + counting.store(true); + det.reset(cfg); // reset() runs on the audio thread + cal.reset(sr); + for (int b = 0; b < blocks; ++b) { + for (int i = 0; i < block; ++i, ++phase) + buf[(size_t) i] = (float) (0.9 * std::sin(2.0 * kPi * freq * phase / sr)); + det.processBlock(buf.data(), block); + const uint64_t ns = (uint64_t) ((phase / sr) * 1e9); + cal.addBlock(block, &ns); + if ((b % 64) == 0) (void) cal.estimate(); // published from the audio thread + } + counting.store(false); + } + + printf("detector status : %d\n", (int) det.status()); + printf("clock estimate : valid=%d ppm=%+.3f\n", (int) cal.estimate().valid, cal.estimate().ppmOffset); + printf("allocations on the audio path: %ld\n", allocs.load()); + return allocs.load() == 0 ? 0 : 1; +} diff --git a/tools/debug_harness.sh b/tools/debug_harness.sh index 39d31c9..2aa3538 100755 --- a/tools/debug_harness.sh +++ b/tools/debug_harness.sh @@ -13,6 +13,15 @@ set -uo pipefail +# This machine's shell profile exports an ARM cross-toolchain globally -- +# CC/CXX pointing at arm-none-eabi-gcc, and C_INCLUDE_PATH / CPLUS_INCLUDE_PATH +# / LIBRARY_PATH pointing into arm-none-eabi-newlib. Those leak into any native +# build started from that shell: CC/CXX 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 compile. Clear +# them so a harness run means the same thing wherever it is started from. +unset CC CXX CROSS_COMPILE C_INCLUDE_PATH CPLUS_INCLUDE_PATH LIBRARY_PATH LD_LIBRARY_PATH + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD="$ROOT/build-harness" ARTIFACTS="$ROOT/build-harness/artifacts" @@ -194,6 +203,16 @@ else gate "unit-tests" SKIP "tests were not built" fi +if [ -x "$BUILD/tests/VCOTunerRealtimeCheck" ]; then + if RT_OUT=$("$BUILD/tests/VCOTunerRealtimeCheck" 2>&1); then + gate "no-audio-thread-allocation" PASS "$(echo "$RT_OUT" | tail -1)" + else + gate "no-audio-thread-allocation" FAIL "$(echo "$RT_OUT" | tail -1)" + fi +else + gate "no-audio-thread-allocation" SKIP "check was not built" +fi + ############################################################################### hdr "UI smoke" diff --git a/tools/ui_capture.py b/tools/ui_capture.py index 6b027df..2d82645 100755 --- a/tools/ui_capture.py +++ b/tools/ui_capture.py @@ -25,11 +25,23 @@ def windows(): return [w for w in wl if 'VCOTuner' in str(w.get('kCGWindowOwnerName', ''))] -def main_window(timeout=20): - deadline = time.time() + timeout +def main_window(timeout=90): + """Wait for the app's window. + + Generous, and it reports how long it waited. The bundle lives on an + external disk and the harness ad-hoc signs it immediately before this + runs, so the first launch after a full rebuild spends a long time in + Gatekeeper validating every file in it -- long enough that a 20 second + limit failed the gate while the app was still coming up. + """ + start = time.time() + deadline = start + timeout while time.time() < deadline: ws = windows() if ws: + waited = time.time() - start + if waited > 5: + print(f" (window took {waited:.0f}s to appear)") return max(ws, key=lambda w: w['kCGWindowBounds']['Height']) time.sleep(0.5) return None From 9dceed576a61f61a5924725edf0b6b01eb9b54d0 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 14:50:09 +0200 Subject: [PATCH 59/60] Build every test target in CI, not just the Catch2 one 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) --- .github/workflows/build.yml | 2 +- tests/CMakeLists.txt | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f07e6f7..96f3f8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -206,7 +206,7 @@ jobs: run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DVCOTUNER_BUILD_TESTS=ON - name: Build tests - run: cmake --build build --target VCOTunerTests + run: cmake --build build --target vcotuner_all_tests - name: Run tests run: ctest --test-dir build --output-on-failure diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 53bc61d..b4e0234 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,3 +25,10 @@ catch_discover_tests(VCOTunerTests DISCOVERY_MODE PRE_TEST) add_executable(VCOTunerRealtimeCheck RealtimeAllocationCheck.cpp) target_link_libraries(VCOTunerRealtimeCheck PRIVATE vcotuner_dsp) add_test(NAME realtime-allocation COMMAND VCOTunerRealtimeCheck) + +# One target for everything ctest will try to run. CI builds this rather than +# naming each executable: a test registered with add_test() but never built +# reports "Not Run" and fails the suite, and the workflow should not have to be +# edited every time a target is added here. +add_custom_target(vcotuner_all_tests + DEPENDS VCOTunerTests VCOTunerRealtimeCheck) From adf71981f596fcd31a4ff8a6b5f1a3de34a13bb7 Mon Sep 17 00:00:00 2001 From: Ziforge Date: Sun, 27 Sep 2026 15:09:08 +0200 Subject: [PATCH 60/60] Say in the README that the new work is untested on hardware 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) --- README.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b6e2ba5..78ab657 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,54 @@ A JUCE-based precision tuner for VCOs, VCFs and other analog gear. Runs on Windo ![VCOTuner Screenshot](docs/screenshot.png) +## Status: work in progress, not yet validated on hardware + +**None of what this fork adds has been checked against a real oscillator yet.** +The MIDI-driven tuner underneath is the original application and has been used +for years; everything listed below it is new here. It builds on macOS, Windows +and Linux in CI and passes its own test suite, but a passing test is not a tuned +VCO. Treat the new parts as unproven until they have been used on hardware — +including by me. + +| | state | +|---|---| +| CV output driving a sweep | **never driven a real oscillator.** Implemented, and exercised end to end in software only | +| Sample-rate calibration | measured against a real audio interface (−5.8 ppm on the development machine); its effect on tuning accuracy is unverified against a known reference | +| Dropout repair, trigger-level tracking | verified against synthetic signals in the test suite; not yet seen a real drifting VCO | +| macOS build | run on real hardware | +| Windows and Linux builds | compile in CI, never run at all | + +The CV settling allowance is 100 ms, inherited from the MIDI-to-CV path. If CV +tracking reads consistently flat or sharp at the start of each note, that is the +first number to change. + +Reports from anyone who does patch it to hardware are very welcome. + ## Features +- **CV output** - drives the oscillator directly from a DC-coupled audio output, so no MIDI-to-CV interface sits in the measurement chain *(untested on hardware — see Status)* +- **CV calibration** - 1V/oct and Hz/V, Expert Sleepers and MOTU presets, with export to CSV, JSON and Ornament & Crime *(untested on hardware)* +- **Sample-rate calibration** - measures the converter's true rate, so absolute frequency readings are not out by the interface's ppm error - **Scientific Tuner Display** - High-precision frequency measurement with Hz and cents error display - **Tabbed Interface** - Separate Tuner and Chart views - **Pitch Tracking** - Real-time frequency detection with deviation meter - **Tuning Reports** - Export measurements as PNG with device info -This is a fork of [TheSlowGrowth/VCOTuner](https://github.com/TheSlowGrowth/VCOTuner) updated for modern macOS compatibility. - ## How It Works Traditional VCO tuning requires constant back-and-forth between fine tune and trimmer adjustments. VCOTuner eliminates this by: -1. Outputting MIDI notes across a selectable range -2. Measuring the actual frequency for each note -3. Using a center reference pitch so you can focus solely on trimmer adjustments +1. Playing a series of pitches across a selectable range +2. Measuring the actual frequency for each one +3. Using a centre reference pitch so you can focus solely on trimmer adjustments Tuning takes minutes instead of hours. +The pitch can come from either source, selectable in the main window: + +- **MIDI out** — sends MIDI notes for an external MIDI-to-CV interface to convert. This is how the original works, and the path that has actually been used on hardware. +- **CV output** — sets a voltage on a DC-coupled audio output directly. This removes a second converter, with its own scaling error, from the measurement chain. **Not yet tested against a real oscillator.** + ## What's new **Measurements are much more accurate.** The app finds each zero crossing more precisely than the sample rate alone allows, by interpolating between the two samples either side of it. That calculation was wrong — it mirrored the result within the sample interval, which added roughly *twice* as much timing jitter as doing no interpolation at all. With it fixed, the jitter at 440 Hz drops from about a third of a sample to essentially nothing.