Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
cbb1d25
docs: add design spec for measurement accuracy and robustness fixes
TimoRozendal Sep 23, 2026
7dd350c
docs: add implementation plan for measurement accuracy fixes
TimoRozendal Sep 23, 2026
5d23ef7
test: add Catch2 test harness and PeriodDetector skeleton
TimoRozendal Sep 23, 2026
390ba7f
feat: add signal level tracking and silence detection to PeriodDetector
TimoRozendal Sep 23, 2026
000137b
test: cover multi-block level tracking; fix: correct latched-level co…
TimoRozendal Sep 23, 2026
2a8d27b
fix: reject false zero crossings with a hysteresis trigger
TimoRozendal Sep 23, 2026
0f6de90
test: assert amplitude-independence, not a miscalibrated absolute bound
TimoRozendal Sep 23, 2026
0ecaedf
docs: correct latched-level claim and miscalibrated test bound
TimoRozendal Sep 23, 2026
b0ec7ca
fix: correct sub-sample zero-crossing interpolation formula
TimoRozendal Sep 23, 2026
bf7c746
feat: add stability detection and terminal statuses to PeriodDetector
TimoRozendal Sep 23, 2026
e10da7e
fix: re-validate steadiness over the full valid set before declaring …
TimoRozendal Sep 23, 2026
ff94513
docs: require re-validating steadiness before declaring stable
TimoRozendal Sep 23, 2026
9dab41b
feat: estimate period and uncertainty by least-squares fit
TimoRozendal Sep 23, 2026
109943a
test: cover nonzero deviation propagation and the two-period boundary
TimoRozendal Sep 24, 2026
df3b904
feat: classify measurement errors and track per-sweep failures
TimoRozendal Sep 24, 2026
9eb154d
fix: floor the measurement timeout so high pitches are not cut off
TimoRozendal Sep 24, 2026
b83e5ad
perf: let PeriodDetector pre-allocate its period storage
TimoRozendal Sep 24, 2026
10667bc
feat: continue the sweep when a single note fails to measure
TimoRozendal Sep 24, 2026
4737c79
fix: stop a failed note from cancelling the next note's measurement
TimoRozendal Sep 24, 2026
b4fab88
feat: mark failed notes and exclude them from graph auto-scaling
TimoRozendal Sep 24, 2026
58d5ad3
fix: give failed notes a column and a shape-based marker
TimoRozendal Sep 24, 2026
8eb794c
feat: report failed notes without interrupting the sweep
TimoRozendal Sep 24, 2026
ab66e31
fix: show the end-of-report failure summary where the report actually…
TimoRozendal Sep 24, 2026
fb831bf
fix: correct the bufferFull message's causal claim
TimoRozendal Sep 24, 2026
ad07c57
docs: record the plan amendments made during implementation
TimoRozendal Sep 24, 2026
72ad34e
docs: add manual verification checklist for measurement changes
TimoRozendal Sep 24, 2026
2d72900
fix: keep the unresponsive-interface check alive after a failed first…
TimoRozendal Sep 24, 2026
7a5334b
fix: bound every prep state's wait for a pending stop request
TimoRozendal Sep 24, 2026
7cdf1da
refactor: drop describeError's default label
TimoRozendal Sep 24, 2026
ef55c9a
build: don't build the unit tests by default
TimoRozendal Sep 24, 2026
89510ce
test: pin run-in exclusion with the detector's actual counts
TimoRozendal Sep 24, 2026
c7b83ae
test: sweep the shipped pitch and resolution space for a terminal status
TimoRozendal Sep 24, 2026
3695f1f
test: record that one click fails the note rather than skewing the fit
TimoRozendal Sep 24, 2026
7c4197c
test: assert the timeout actually covers the measurement it guards
TimoRozendal Sep 24, 2026
d0b0d05
docs: make a timing test's comment self-contained
TimoRozendal Sep 24, 2026
b188875
refactor: share one steadiness check between both stability tests
TimoRozendal Sep 24, 2026
fa59942
perf: stop tracking min/max once warm-up is over
TimoRozendal Sep 24, 2026
b2c1dbe
docs: correct the bounded-wait comment's claim about the old behaviour
TimoRozendal Sep 24, 2026
fafdc0d
fix: skip the reference pitch in the no-frequency-change gate
TimoRozendal Sep 24, 2026
79bc873
chore: add .gitattributes to lock in existing line endings
TimoRozendal Sep 24, 2026
3095666
build: upgrade JUCE from 6.1.5 to 8.0.15
TimoRozendal Sep 24, 2026
1848aa2
ci: modernise the workflow for JUCE 8
TimoRozendal Sep 24, 2026
90c84cd
docs: record the JUCE 8 upgrade in the verification checklist
TimoRozendal Sep 24, 2026
bf8ec86
docs: describe the measurement changes in the README
TimoRozendal Sep 24, 2026
2e15720
feat: mark measurements that fall outside the plotted range
TimoRozendal Sep 24, 2026
635096f
docs: add verification steps for the out-of-range markers
TimoRozendal Sep 24, 2026
c01a461
chore: bump version to 0.4.0
TimoRozendal Sep 24, 2026
63f54b9
fix: keep the MIDI channel control on screen in the settings dialog
TimoRozendal Sep 26, 2026
e141db5
docs: point the README at this fork, and describe the error bars as t…
TimoRozendal Sep 26, 2026
a9755b0
ci: publish a release when a v* tag is pushed
TimoRozendal Sep 26, 2026
e450335
ci: allow a build to be triggered by hand
TimoRozendal Sep 26, 2026
712af63
ci: fix the Windows generator and the Linux package install
TimoRozendal Sep 26, 2026
8015ad0
ci: let Windows use the runner image's own CMake
TimoRozendal Sep 26, 2026
83acfab
Merge TimoRozendal/VCOTuner measurement rework into the CV fork
Ziforge Sep 27, 2026
75f5488
Fix two pre-existing UI bugs and add a debug harness
Ziforge Sep 27, 2026
ce54212
Repair dropouts in the period fit instead of failing the note
Ziforge Sep 27, 2026
10bd892
Measure the converter's real sample rate instead of trusting the nomi…
Ziforge Sep 27, 2026
d231861
Drive the sweep from CV, track the trigger level, and clear the warni…
Ziforge Sep 27, 2026
5c99d18
Fix what the debug analysis found before pushing
Ziforge Sep 27, 2026
9dceed5
Build every test target in CI, not just the Catch2 one
Ziforge Sep 27, 2026
adf7198
Say in the README that the new work is untested on hardware
Ziforge Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
77 changes: 75 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
submodules: recursive

- name: Configure CMake
run: cmake -B build -DCMAKE_BUILD_TYPE=Release
run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"

- name: Build
run: cmake --build build --config Release
Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:
submodules: recursive

- name: Configure CMake
run: cmake -B build -DCMAKE_BUILD_TYPE=Release
run: cmake -A x64 -B build -DCMAKE_BUILD_TYPE=Release

- name: Build
run: cmake --build build --config Release
Expand Down Expand Up @@ -177,3 +177,76 @@ jobs:
path: build/VCOTuner-Linux.tar.gz
if-no-files-found: warn
retention-days: 90

# builds and runs the measurement unit tests
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

# The tests themselves link no JUCE, but configuring the project still
# pulls in JUCE and bootstraps juceaide, which needs JUCE's build deps.
- name: Install Dependencies
run: |
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

- name: Build tests
run: cmake --build build --target vcotuner_all_tests

- 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: [build-macos, build-windows, build-linux, unit-tests]
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. They compile; that is all that is known about
them. Treat them as untested.

**macOS builds are not signed by Apple.** They are ad-hoc signed with
the entitlements the app needs, but Gatekeeper will still refuse to
open them and claim the app is damaged. It isn't. Either right-click
the app and choose Open, or clear the quarantine flag:

```
xattr -cr VCOTuner.app
```

Signing properly needs a paid Apple Developer ID, which this project
does not have.
files: |
VCOTuner-macOS/VCOTuner-macOS.zip
VCOTuner-Windows/VCOTuner-Windows.zip
VCOTuner-Linux/VCOTuner-Linux.tar.gz
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@
*.app
.DS_Store
build

# harness output
build-*/
.harness-*.log
26 changes: 24 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
# 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
# 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)
Expand Down Expand Up @@ -56,6 +56,27 @@ 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/CvScaling.cpp
Source/dsp/ClockCalibrator.cpp
Source/dsp/PeriodDetector.cpp
Source/dsp/MeasurementStatistics.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)

# 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)
endif()

target_sources(VCOTuner
PRIVATE
Source/MainComponent.cpp
Expand Down Expand Up @@ -142,6 +163,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
Expand Down
90 changes: 83 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,99 @@

[![Build](https://github.com/Ziforge/VCOTuner/actions/workflows/build.yml/badge.svg)](https://github.com/Ziforge/VCOTuner/actions/workflows/build.yml)

A fork of [TheSlowGrowth/VCOTuner](https://github.com/TheSlowGrowth/VCOTuner) that adds direct CV output and calibration, and merges the reworked measurement code from [TimoRozendal/VCOTuner](https://github.com/TimoRozendal/VCOTuner) — see [What's new](#whats-new).

A JUCE-based precision tuner for VCOs, VCFs and other analog gear. Runs on Windows, macOS (including Sequoia 15.x), and Linux.

![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.

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.

**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/Ziforge/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

### Prerequisites

- CMake 3.12 or later
- CMake 3.22 or later
- C++17 compatible compiler
- Platform-specific dependencies (see below)

Expand Down Expand Up @@ -76,10 +137,25 @@ cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
```

## Tests

The measurement code has an automated test suite. It is not built by default:

```bash
cmake -B build -DVCOTUNER_BUILD_TESTS=ON
cmake --build build
ctest --test-dir build
```

## Issues

[Report bugs or request features here](https://github.com/Ziforge/VCOTuner/issues)

## Are you on ModWiggler?

[Here's a thread on ModWiggler. Post your tuning reports here, if you like](https://www.modwiggler.com/forum/viewtopic.php?p=2276045)

## Credits

Original application by [TheSlowGrowth](https://github.com/TheSlowGrowth/VCOTuner)
Original application by [TheSlowGrowth](https://github.com/TheSlowGrowth/VCOTuner).
Measurement accuracy and robustness rework by [TimoRozendal](https://github.com/TimoRozendal/VCOTuner).
Loading
Loading