diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 00000000..b7c2a49e --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Canonical Tap House SessionStart hook for Claude Code on the web. +# +# Fresh web-session containers clone the repo bare: no submodules, no +# pre-commit hook installed — so `git commit` runs unformatted and the +# clang-format CI gate fails on code a local clone would have fixed at commit +# time. This hook closes that gap at session start: +# +# 1. init submodules recursively (kernels, SDKs, vendored test harnesses) +# 2. install pre-commit and register the repo's canonical hook +# (.pre-commit-config.yaml — the Tap-wide pinned clang-format) +# 3. warm the pinned clang-format binary so the first commit doesn't +# pay the download (the container snapshot caches it) +# +# Canonical copy: taphouse (distributed by scripts/sync.sh alongside +# .clang-format / .clang-tidy / .pre-commit-config.yaml; drift-guarded where +# present). Web-only; local clones are untouched. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" + +echo "session-start: initializing submodules ..." +git submodule update --init --recursive + +echo "session-start: installing pre-commit ..." +if ! python3 -m pre_commit --version >/dev/null 2>&1; then + python3 -m pip install --quiet pre-commit +fi +python3 -m pre_commit install +python3 -m pre_commit install-hooks + +echo "session-start: done." diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..e06b0338 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index d2bac24a..eeec541b 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -12,9 +12,9 @@ on: [push, pull_request] jobs: drift: # Fails if this repo's .clang-format/.clang-tidy/STYLE.md diverge from TapHouse. - uses: tap/taphouse/.github/workflows/drift-check.yml@v4 + uses: tap/taphouse/.github/workflows/drift-check.yml@v5 with: - ref: v4 + ref: v5 clang-format: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 5aea5042..2e7fff3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,10 +26,11 @@ modern package; the legacy Jamoma-era tree has been pruned. What matters here: `submodules/taptools/include/taptools/`, in the `tap::tools` namespace, plus the kernel's own Catch2 tests, render tools, C ABI, notebooks, and benchmarks. It is a standalone CMake project with its own CI; develop the DSP there (or against a sibling checkout via `TAPTOOLS_KERNEL_DIR`). - See `submodules/taptools/README.md`. Most externals consume only the kernel headers, but the - spectral trio (`tap.convolve~`/`tap.nr~`/`tap.spectra~`) also link `tap::dsp` — the kernel's shared - real FFT, pinned at the kernel's own nested `submodules/dsptap` — so the root `CMakeLists.txt` - exposes that target and those three objects (and their min-tests) link it. The root also forces + See `submodules/taptools/README.md`. Most externals consume only the kernel headers, but some + also link `tap::dsp` — the shared DspTap primitives (real FFT, YIN, PSOLA, phase vocoder), + pinned at the kernel's own nested `submodules/dsptap`: the spectral trio + (`tap.convolve~`/`tap.nr~`/`tap.spectra~`) and `tap.tune~`. The root `CMakeLists.txt` exposes + that target and those objects (and their min-tests) link it. The root also forces C++20 on every object and test target centrally (Min otherwise pins C++17). - **`source/projects//`** — the Min-based externals (one folder per object: a `.cpp` + a `CMakeLists.txt`), thin wrappers over the kernel headers where the DSP has been extracted. @@ -54,7 +55,8 @@ The historical material lives on branches, not in the working tree: with **no dependency on `min-lib`** (min-lib is the under-maintained piece; keeping DSP portable makes the wrapper a small swappable shim). Substantial DSP belongs in a **kernel header** in the kernel repo (`submodules/taptools/include/taptools/.h`, namespace `tap::tools`, C++ stdlib - only, C++17-clean) with the wrapper `.cpp` doing only Min glue — six objects follow this today. + only) with the wrapper `.cpp` doing only Min glue — a growing set of objects follow this today + (see `REVIVAL.md` for the current list). New/changed DSP is committed in the kernel repo, then the submodule pin is bumped here. Within `source/projects/`, no shared global lookup tables — each object is self-contained; sharing between kernels is allowed (and encouraged) inside the kernel repo. @@ -105,7 +107,14 @@ cmake --build build --config Release CI (`.github/workflows/build.yml`) checks out submodules recursively, builds both platforms on every push, and **fails the macOS job if any `.mxo` is not universal** (checked with `lipo`). DSP -correctness is gated by the kernel repo's own CI, not here. +correctness is gated by the kernel repo's own CI, not here. A style workflow additionally gates +**clang-format and clang-tidy** on `source/projects/` (plus a drift check that `.clang-format` / +`.clang-tidy` / `.pre-commit-config.yaml` match the canonical taphouse copies — never hand-edit +those). Run `pre-commit install` once per clone so commits format themselves; on Claude Code web +the checked-in SessionStart hook (`.claude/hooks/session-start.sh`) initializes submodules and +installs the hook automatically. Note clang-tidy compiles with a *clang* front end: wrapper code +that GCC's mock-kernel test build accepts can still fail there (e.g. ambiguous Min `attribute` +conversions), so treat tidy as a second compiler. ## Adding / porting an object diff --git a/REVIVAL.md b/REVIVAL.md index 5c3833eb..e6a74831 100644 --- a/REVIVAL.md +++ b/REVIVAL.md @@ -1253,6 +1253,46 @@ square shaper resolved via Open303's measured constants, and the author-approved gate-hold). Remaining: **runtime validation in Max** for all three objects (help patchers, maxtests, the render WAVs, and the notebook as the evaluation material). +**12. Net-new object — `tap.tune~` (2026-07-22).** ✅ **v1 shipped** — real-time monophonic +pitch correction (the classic retune / hard-snap effect). Cross-repo vertical slice: the YIN +detector landed in **DspTap** as the shared `tap::dsp::yin` primitive (full-rate, cumulative-mean +normalized, parabolic sub-sample lag; double golden model + float32 profile, the `fft.h` +backend pattern — promoted from the decimated follower inside `grm_pitchaccum.h`); the corrector +kernel landed in **`tap/taptools`** as `taptools/tune.h` (`tune::corrector`: scale/key mapper +with per-note enables + MIDI-target mode, retune-speed glide, and the two-tap `tap.shift~` +transposer engine with its window locked to an **even multiple of the detected period** — the +lock measurably makes the average retune ratio exact); this repo adds the Min wrapper, wrapper +tests, reference page, and help patcher. IP framing (per the pitch-correction handoff doc): the +implemented pipeline is the public-domain expired-patent design; polyphonic per-note editing +(Celemony DNA) deliberately out of scope; "tune", not the trademarked product name. +**Backends shipped (2026-07-22, same day):** resynthesis is now selectable via `@backend` — +`grain` (the validated low-latency two-tap default), `psola` (true TD-PSOLA, new DspTap +primitive `tap::dsp::psola`; formant-preserving on voice, and honestly documented as a +spectral-envelope resampler — pure tones far from a new harmonic thin out, pinned by its +tests), and `pvoc` (peak-locked phase vocoder, new DspTap primitive `tap::dsp::pvoc` on the +shared real FFT; Laroche–Dolson-style rigid peak-region shifting, exact waveform identity at +ratio 1). Detector, mapper, and retune glide are shared; only the last stage swaps, and +switching live is click-safe (the incoming engine starts from silence). +**Formant preservation shipped (2026-07-22, same day):** `@formant` enables the LPC +source-filter method on the pvoc backend (DspTap: autocorrelation LPC + Levinson–Durbin, +order 48, `envelope(target)/envelope(source)` per relocated bin — from the published +literature only, per the IP policy); psola preserves formants inherently. **Verification +notebooks shipped** in both upstream repos, driving the shipping C++ through each repo's +C ABI: DspTap `notebooks/pitchshift.ipynb` (YIN accuracy; Finding 1 — PSOLA is a +spectral-envelope resampler; Finding 2 — naive phase-vocoder remapping measurably loses +half its level vs the peak-locked design; the formant demo) and TapTools +`notebooks/tune.ipynb` (retune-speed family, the three backends compared on the same +vibrato voice, MIDI-mode formant demo; `Tune`/`Yin` added to the kernel C ABI). +**Deferred list closed (2026-07-22, same day):** auto-key detection shipped (kernel: +Krumhansl–Kessler profile scoring over a slowly-forgetting pitch-class histogram — +learning-only, `@autokey` + `getkey`/`applykey` in the wrapper); the **detected-pitch +outlet** shipped (right outlet, timer-driven `pitch ` reports every `@interval` +ms with an atomic audio→scheduler handoff, plus the `key ...` replies); and the **runtime +maxtest** shipped (`runtime-tests/patchers/tap.tune~.maxtest.maxpat`, generated via +`make_maxtest.py` — unpitched DC passes the grain engine at exactly unity). Remaining for +this object: the in-Max validation pass itself (help patcher + maxtest under a licensed +Max), like the rest of the DSP set. + Remaining (ongoing, now cross-repo — DSP lands in `tap/taptools`, then bump the submodule pin here): lift the remaining simple inline-DSP objects' math into kernel headers opportunistically as they're touched. Control/utility and Jitter objects never move — they are Max message-logic, not diff --git a/docs/tap.tune~.maxref.xml b/docs/tap.tune~.maxref.xml new file mode 100644 index 00000000..e120b6c2 --- /dev/null +++ b/docs/tap.tune~.maxref.xml @@ -0,0 +1,145 @@ + + + + + + + Real-time monophonic pitch correction + + + + The tap.tune~ object is the classic retune effect: it detects the pitch of a + monophonic input (voice or instrument), snaps it to the nearest allowed note, and glides + the correction in at a settable retune speed. Slow speeds correct transparently while + letting vibrato through; a speed of zero snaps instantly — the well-known hard-quantize + vocal effect. Allowed notes come from a key and scale (with individual per-note enables via + the notes message), or in midi mode from whatever MIDI notes are currently held + (sent as note messages). The detector is a YIN-family pitch tracker. Resynthesis + is selectable via backend: a period-locked granular transposer sharing its + engine with tap.shift~ (the low-latency default), formant-preserving TD-PSOLA, + or a peak-locked phase vocoder. The right outlet reports the detected pitch + (pitch messages, every interval milliseconds while the input is voiced) + and answers getkey/applykey with the auto-key estimate learned when + autokey is on. The object is monophonic by design — pitch detection needs a + single voice. For multichannel signals, wrap it in an mc. operator. + + + + + + 74 Objects + TapTools + Audio + Effect + + + + + + + + + + + Set the allowed notes directly, as absolute pitch classes, replacing the key/scale + derivation. Send either twelve 0/1 toggles (C through B) or a list of the enabled + pitch classes (0 = C .. 11 = B). + + + + + + + + + MIDI note-on/off for midi mode: a pitch (0-127) and a velocity. Velocity 0 releases + the note; anything else holds it as a correction target. + + + + + Release all held MIDI notes. + + + + Report the auto-key estimate from the right outlet: 'key root + major|minor confidence', or 'key none' while there is not yet enough voiced + material. Enable learning with the autokey attribute. + + + + Adopt the auto-key estimate as the key and scale attributes, then + report it from the right outlet. + + + + Reset the audio state (buffers and glides). + + + + + + + + Key root for the scale (c, c#, d .. b). Setting it re-derives the allowed + notes from key + scale, replacing any custom notes mask. + + + Scale the corrector snaps to, relative to the key: chromatic, major, + minor, harmonic, melodic, pentatonic, or minorpentatonic. + + + Target selection: scale snaps to the key/scale notes; midi snaps to the + nearest held MIDI note and leaves the signal untouched when none are held. + + + Resynthesis engine: grain (low-latency two-tap default), psola + (formant-preserving TD-PSOLA — best on voice, needs harmonic-rich input), or pvoc + (peak-locked phase vocoder, one FFT frame of latency). + + + Learn the key from the incoming pitches (Krumhansl-Kessler profile + scoring over a slowly-forgetting histogram, about a minute of memory). Learning + only — query with getkey, adopt with applykey. + + + Pitch-report period in milliseconds for the right outlet. 0 disables + reporting. + + + Formant preservation for the pvoc backend (LPC source-filter): the + correction shifts the pitch while the spectral envelope stays put. Matters for large + corrections (midi mode). The psola backend preserves formants inherently. + + + Retune speed in milliseconds — the time constant of the glide onto the + target note. 0 snaps instantly (the hard quantize effect). + + + Correction depth in percent (0-100). 100 lands on the target; 50 corrects + half the distance. + + + Lowest input frequency (Hz) treated as pitched. + + + Highest input frequency (Hz) treated as pitched. + + + Pass the input through unprocessed. + + + Silence the output. + + + + + + + + + + + + + diff --git a/help/tap.tune~.maxhelp b/help/tap.tune~.maxhelp new file mode 100644 index 00000000..c310781b --- /dev/null +++ b/help/tap.tune~.maxhelp @@ -0,0 +1,657 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 7, + "minor" : 0, + "revision" : 4, + "architecture" : "x64", + "modernui" : 1 + } +, + "rect" : [ 63.0, 104.0, 1050.0, 760.0 ], + "bglocked" : 0, + "openinpresentation" : 0, + "default_fontsize" : 12.0, + "default_fontface" : 0, + "default_fontname" : "Lato Light", + "gridonopen" : 1, + "gridsize" : [ 5.0, 5.0 ], + "gridsnaponopen" : 2, + "objectsnaponopen" : 0, + "statusbarvisible" : 2, + "toolbarvisible" : 1, + "lefttoolbarpinned" : 2, + "toptoolbarpinned" : 2, + "righttoolbarpinned" : 2, + "bottomtoolbarpinned" : 2, + "toolbars_unpinned_last_save" : 15, + "tallnewobj" : 0, + "boxanimatetime" : 200, + "enablehscroll" : 1, + "enablevscroll" : 1, + "devicewidth" : 0.0, + "description" : "", + "digest" : "", + "tags" : "", + "style" : "tap", + "subpatcher_template" : "tap.template", + "boxes" : [ { + "box" : { + "id" : "obj-1", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 30.0, 20.0, 500.0, 33.0 ], + "fontsize" : 20.0, + "style" : "", + "text" : "tap.tune~ — real-time monophonic pitch correction" + } + + } +, { + "box" : { + "id" : "obj-2", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 30.0, 55.0, 640.0, 47.0 ], + "style" : "", + "text" : "Detects the input's pitch, snaps it to the nearest allowed note, and glides the correction in at the retune speed. Speed 0 is the hard-snap quantize effect; slow speeds correct transparently. Try the deliberately sharp test tones below, then experiment with key, scale, and speed." + } + + } +, { + "box" : { + "id" : "obj-3", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 30.0, 120.0, 50.0, 23.0 ], + "style" : "", + "text" : "452." + } + + } +, { + "box" : { + "id" : "obj-4", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 85.0, 120.0, 50.0, 23.0 ], + "style" : "", + "text" : "460." + } + + } +, { + "box" : { + "id" : "obj-5", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 140.0, 120.0, 50.0, 23.0 ], + "style" : "", + "text" : "336." + } + + } +, { + "box" : { + "id" : "obj-6", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 195.0, 120.0, 260.0, 20.0 ], + "style" : "", + "text" : "out-of-tune test tones (Hz)" + } + + } +, { + "box" : { + "id" : "obj-7", + "maxclass" : "newobj", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 30.0, 155.0, 63.0, 23.0 ], + "style" : "", + "text" : "saw~ 452." + } + + } +, { + "box" : { + "id" : "obj-8", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 2, + "outlettype" : [ "signal", "" ], + "patching_rect" : [ 30.0, 260.0, 176.0, 23.0 ], + "style" : "", + "text" : "tap.tune~ @key c @scale major" + } + + } +, { + "box" : { + "id" : "obj-9", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 155.0, 180.0, 23.0 ], + "attr" : "key", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-10", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 185.0, 180.0, 23.0 ], + "attr" : "scale", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-11", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 215.0, 180.0, 23.0 ], + "attr" : "speed", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-12", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 245.0, 180.0, 23.0 ], + "attr" : "amount", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-13", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 275.0, 180.0, 23.0 ], + "attr" : "mode", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-24", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 305.0, 180.0, 23.0 ], + "attr" : "backend", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-25", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 650.0, 305.0, 340.0, 33.0 ], + "style" : "", + "text" : "grain = low latency; psola = formant-preserving (voice); pvoc = phase vocoder (one frame of latency)" + } + + } +, { + "box" : { + "id" : "obj-26", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 335.0, 180.0, 23.0 ], + "attr" : "formant", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-27", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 650.0, 338.0, 340.0, 20.0 ], + "style" : "", + "text" : "keep the vocal character on big shifts (pvoc backend)" + } + + } +, { + "box" : { + "id" : "obj-28", + "maxclass" : "attrui", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 365.0, 180.0, 23.0 ], + "attr" : "autokey", + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-29", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 460.0, 395.0, 55.0, 23.0 ], + "style" : "", + "text" : "getkey" + } + + } +, { + "box" : { + "id" : "obj-30", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 520.0, 395.0, 65.0, 23.0 ], + "style" : "", + "text" : "applykey" + } + + } +, { + "box" : { + "id" : "obj-31", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 240.0, 300.0, 95.0, 23.0 ], + "style" : "", + "text" : "print tap.tune~" + } + + } +, { + "box" : { + "id" : "obj-32", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 650.0, 368.0, 340.0, 47.0 ], + "style" : "", + "text" : "learn the key from what you play, then ask for it (getkey) or adopt it (applykey). The right outlet also streams 'pitch ' reports every interval ms." + } + + } +, { + "box" : { + "id" : "obj-14", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 155.0, 190.0, 23.0 ], + "style" : "", + "text" : "notes 1 0 0 0 1 0 0 1 0 0 0 0" + } + + } +, { + "box" : { + "id" : "obj-15", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 240.0, 135.0, 220.0, 20.0 ], + "style" : "", + "text" : "snap only to a C major triad:" + } + + } +, { + "box" : { + "id" : "obj-16", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 185.0, 90.0, 23.0 ], + "style" : "", + "text" : "note 64 100" + } + + } +, { + "box" : { + "id" : "obj-17", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 335.0, 185.0, 75.0, 23.0 ], + "style" : "", + "text" : "note 64 0" + } + + } +, { + "box" : { + "id" : "obj-18", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 240.0, 208.0, 220.0, 20.0 ], + "style" : "", + "text" : "midi mode: hold / release E4" + } + + } +, { + "box" : { + "id" : "obj-19", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 240.0, 230.0, 45.0, 23.0 ], + "style" : "", + "text" : "flush" + } + + } +, { + "box" : { + "id" : "obj-20", + "maxclass" : "message", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 290.0, 230.0, 45.0, 23.0 ], + "style" : "", + "text" : "clear" + } + + } +, { + "box" : { + "id" : "obj-21", + "maxclass" : "newobj", + "numinlets" : 2, + "numoutlets" : 1, + "outlettype" : [ "signal" ], + "patching_rect" : [ 30.0, 300.0, 50.0, 23.0 ], + "style" : "", + "text" : "*~ 0.25" + } + + } +, { + "box" : { + "id" : "obj-22", + "maxclass" : "ezdac~", + "numinlets" : 2, + "numoutlets" : 0, + "patching_rect" : [ 30.0, 340.0, 45.0, 45.0 ], + "style" : "" + } + + } +, { + "box" : { + "id" : "obj-23", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 30.0, 420.0, 640.0, 33.0 ], + "style" : "", + "text" : "The object is monophonic by design — pitch detection needs a single voice. For multichannel signals, wrap it in an mc. operator. See also tap.shift~ (fixed-ratio shifting) and tap.pitchaccum~ (dual-voice transposition with delay and feedback)." + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-7", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-3", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-7", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-4", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-7", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-5", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-7", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-9", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-10", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-11", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-12", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-13", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-24", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-26", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-28", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-29", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-30", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-31", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-8", 1 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-14", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-16", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-17", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-19", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-8", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-20", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-21", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-8", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-22", 0 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-21", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-22", 1 ], + "disabled" : 0, + "hidden" : 0, + "source" : [ "obj-21", 0 ] + } + + } + ], + "dependency_cache" : [ ], + "autosave" : 0 + } + +} diff --git a/runtime-tests/make_maxtest.py b/runtime-tests/make_maxtest.py index 5abf6f90..6301d69e 100644 --- a/runtime-tests/make_maxtest.py +++ b/runtime-tests/make_maxtest.py @@ -188,3 +188,17 @@ def control_test(filename, object_text, trigger, expected, assert_name, numinlets=2, numoutlets=2, ) + # tap.tune~: DC is unpitched — the detector reports unvoiced, the correction + # relaxes to zero, and the period-locked two-tap grain engine passes the + # signal at exactly unity (its complementary envelopes sum to 1 and the + # frozen taps read a constant). Pins the unpitched-passthrough contract in + # a real Max instance. (Outlet 2 is the pitch/key report outlet.) + audio_test( + "tap.tune~.maxtest.maxpat", + object_text="tap.tune~", + input_value="1.", + expected="1.", + assert_name="tap.tune~-unpitched-dc-unity", + description="tap.tune~: unpitched DC passes the grain engine at unity (no correction applies).", + numoutlets=2, + ) diff --git a/runtime-tests/patchers/tap.tune~.maxtest.maxpat b/runtime-tests/patchers/tap.tune~.maxtest.maxpat new file mode 100644 index 00000000..6d3ec08c --- /dev/null +++ b/runtime-tests/patchers/tap.tune~.maxtest.maxpat @@ -0,0 +1,372 @@ +{ + "patcher": { + "fileversion": 1, + "appversion": { + "major": 7, + "minor": 0, + "revision": 4, + "architecture": "x64", + "modernui": 1 + }, + "rect": [ + 66.0, + 79.0, + 1070.0, + 480.0 + ], + "bglocked": 0, + "openinpresentation": 0, + "default_fontsize": 12.0, + "default_fontface": 0, + "default_fontname": "Helvetica Neue Light", + "gridonopen": 1, + "gridsize": [ + 5.0, + 5.0 + ], + "gridsnaponopen": 1, + "statusbarvisible": 2, + "toolbarvisible": 1, + "description": "tap.tune~: unpitched DC passes the grain engine at unity (no correction applies).", + "boxes": [ + { + "box": { + "id": "obj-1", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 40, + 40, + 70, + 22.0 + ], + "text": "loadbang" + } + }, + { + "box": { + "id": "obj-2", + "maxclass": "button", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "bang" + ], + "patching_rect": [ + 40, + 90, + 24, + 24 + ] + } + }, + { + "box": { + "id": "obj-3", + "maxclass": "message", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 40, + 140, + 30, + 22.0 + ], + "text": "1" + } + }, + { + "box": { + "id": "obj-4", + "maxclass": "newobj", + "numinlets": 2, + "numoutlets": 0, + "outlettype": [], + "patching_rect": [ + 40, + 190, + 45, + 22.0 + ], + "text": "dac~" + } + }, + { + "box": { + "id": "obj-5", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 120, + 140, + 110, + 22.0 + ], + "text": "test.terminate" + } + }, + { + "box": { + "id": "obj-6", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 40, + 110, + 22.0 + ], + "text": "sig~ 1." + } + }, + { + "box": { + "id": "obj-7", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 2, + "outlettype": [ + "signal", + "signal" + ], + "patching_rect": [ + 300, + 100, + 160, + 22.0 + ], + "text": "tap.tune~" + } + }, + { + "box": { + "id": "obj-8", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "signal" + ], + "patching_rect": [ + 300, + 160, + 110, + 22.0 + ], + "text": "round~ 1e-06" + } + }, + { + "box": { + "id": "obj-9", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 220, + 90, + 22.0 + ], + "text": "test.sample~" + } + }, + { + "box": { + "id": "obj-10", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 280, + 150, + 22.0 + ], + "text": "test.equals 1." + } + }, + { + "box": { + "id": "obj-11", + "maxclass": "newobj", + "numinlets": 1, + "numoutlets": 1, + "outlettype": [ + "" + ], + "patching_rect": [ + 300, + 340, + 180, + 22.0 + ], + "text": "test.assert tap.tune~-unpitched-dc-unity" + } + } + ], + "lines": [ + { + "patchline": { + "source": [ + "obj-1", + 0 + ], + "destination": [ + "obj-2", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 0 + ], + "destination": [ + "obj-3", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-3", + 0 + ], + "destination": [ + "obj-4", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-2", + 0 + ], + "destination": [ + "obj-5", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-6", + 0 + ], + "destination": [ + "obj-7", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-7", + 0 + ], + "destination": [ + "obj-8", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-8", + 0 + ], + "destination": [ + "obj-9", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-2", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-9", + 0 + ], + "destination": [ + "obj-10", + 0 + ], + "hidden": 0, + "disabled": 0 + } + }, + { + "patchline": { + "source": [ + "obj-10", + 0 + ], + "destination": [ + "obj-11", + 0 + ], + "hidden": 0, + "disabled": 0 + } + } + ] + } +} diff --git a/source/projects/tap.tune_tilde/CMakeLists.txt b/source/projects/tap.tune_tilde/CMakeLists.txt new file mode 100644 index 00000000..84f2ab96 --- /dev/null +++ b/source/projects/tap.tune_tilde/CMakeLists.txt @@ -0,0 +1,38 @@ +# Copyright 1999-2026 Timothy Place. Distributed under the New BSD License. + +cmake_minimum_required(VERSION 3.19) + +set(C74_MIN_API_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../min-api) +include(${C74_MIN_API_DIR}/script/min-pretarget.cmake) + +############################################################# +# MAX EXTERNAL +############################################################# + +include_directories("${C74_INCLUDES}") +# The portable DSP kernel (header-only), pinned as the submodules/taptools submodule. +include_directories("${TAPTOOLS_KERNEL_DIR}/include") + +set(SOURCES + ${PROJECT_NAME}.cpp + ${TAPTOOLS_KERNEL_DIR}/include/taptools/tune.h +) + +add_library( + ${PROJECT_NAME} + MODULE + ${SOURCES} +) + +include(${C74_MIN_API_DIR}/script/min-posttarget.cmake) + +# Catch-based unit tests (built when the _test.cpp is present; run via ctest). +include(${C74_MIN_API_DIR}/test/min-object-unittest.cmake) + +# The tune kernel pulls its YIN detector from the shared DspTap library (tap::dsp): link it into the +# external, and into the min-test target when present. (These need C++20 for DspTap's headers; the +# root sets C++20 on every object + test target centrally.) +target_link_libraries(${PROJECT_NAME} PRIVATE tap::dsp) +if (TARGET ${PROJECT_NAME}_test) + target_link_libraries(${PROJECT_NAME}_test PUBLIC tap::dsp) +endif () diff --git a/source/projects/tap.tune_tilde/tap.tune_tilde.cpp b/source/projects/tap.tune_tilde/tap.tune_tilde.cpp new file mode 100644 index 00000000..3c75a6da --- /dev/null +++ b/source/projects/tap.tune_tilde/tap.tune_tilde.cpp @@ -0,0 +1,349 @@ +/// @file +/// @brief tap.tune~ — real-time monophonic pitch correction. +/// @details The classic retune effect: detect the input's pitch, snap it to the nearest allowed +/// note, and glide the correction in at a settable retune speed — transparent at slow +/// speeds, the hard-quantize effect at zero. Targets come from a key + scale (with +/// per-note enables) or, in midi mode, from whatever MIDI notes are currently held. +/// +/// All DSP lives in the portable kernel (taptools/tune.h — a YIN detector from the +/// shared DspTap library, a scale/MIDI target mapper, and a period-locked two-tap +/// delay-line transposer); this file is only the Max plumbing. Monophonic by design — +/// pitch detection needs a single voice. For multichannel use, wrap the object in an +/// mc. operator. +/// @author Timothy Place +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min.h" +#include "taptools/tune.h" + +using namespace c74::min; + +class tune : public object, public sample_operator<1, 1> { + private: + static constexpr const char* k_note_names[12] = {"c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"}; + + // Constructed before the attributes below so their defaults can forward into it. + tap::tools::tune::corrector m_engine; + + // Shadows for the detection range: the kernel takes both bounds together. + double m_min_hz{tap::tools::tune::k_default_min_hz}; + double m_max_hz{tap::tools::tune::k_default_max_hz}; + + // Detected pitch, handed from the audio thread to the report timer (relaxed + // single-value handoff — the newest value wins, the tap.303.seq~ pattern). + std::atomic m_hz_now{0.0}; + double m_hz_sent{-1.0}; + + static int pitch_class_of(const symbol& s) { + const std::string name = s; + for (int i = 0; i < 12; ++i) { + if (name == k_note_names[i]) { + return i; + } + } + return 0; + } + + static unsigned scale_mask_of(const symbol& s) { + using namespace tap::tools::tune; + if (s == "major") { + return k_scale_major; + } + if (s == "minor") { + return k_scale_minor; + } + if (s == "harmonic") { + return k_scale_harmonic_minor; + } + if (s == "melodic") { + return k_scale_melodic_minor; + } + if (s == "pentatonic") { + return k_scale_major_pentatonic; + } + if (s == "minorpentatonic") { + return k_scale_minor_pentatonic; + } + return k_scale_chromatic; + } + + public: + MIN_DESCRIPTION{"Real-time monophonic pitch correction. Detects the input's pitch, snaps it to " + "the nearest note of a key/scale (or the nearest held MIDI note), and retunes " + "with a settable retune speed — transparent correction at slow speeds, the " + "hard-snap quantize effect at zero. For multichannel signals, wrap this object " + "in an mc. operator."}; + MIN_TAGS{"pitch"}; + MIN_AUTHOR{"Timothy Place"}; + MIN_RELATED{"tap.shift~, tap.pitchaccum~, retune~, pitchshift~, fzero~"}; + + inlet<> m_in{this, "(signal) audio input"}; + outlet<> m_out{this, "(signal) pitch-corrected output", "signal"}; + outlet<> m_info{this, "(list) reports: 'pitch ' while voiced, 'key ...' on getkey/applykey"}; + + // Fires on the scheduler thread; declared before the attributes so the interval + // attribute's constructor-time setter finds it fully built. Reports the detected + // pitch when it changes (or when voicing stops), then re-arms itself. + timer<> m_report{this, + MIN_FUNCTION{ + const double hz = m_hz_now.load(std::memory_order_relaxed); + const bool voiced_now = hz > 0.0; + const bool voiced_sent = m_hz_sent > 0.0; + if (voiced_now && (!voiced_sent || std::abs(hz - m_hz_sent) > 0.01)) { + const double midi = 69.0 + 12.0 * std::log2(hz / 440.0); + m_info.send("pitch", midi, hz); + m_hz_sent = hz; + } + else if (!voiced_now && voiced_sent) { + m_info.send("pitch", -1, 0.0); // voicing ended + m_hz_sent = 0.0; + } + if (static_cast(interval) > 0.0) { + m_report.delay(interval); + } + return {}; + }}; + + tune(const atoms& args = {}) { m_engine.prepare(samplerate()); } + + attribute key{this, + "key", + "c", + range{"c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"}, + setter{MIN_FUNCTION{ + m_engine.set_key(pitch_class_of(args[0])); + return {args[0]}; + }}, + description{"Key root for the scale. Setting it re-derives the allowed notes from key + " + "scale, replacing any custom 'notes' mask."}}; + + attribute scale{ + this, + "scale", + "chromatic", + range{"chromatic", "major", "minor", "harmonic", "melodic", "pentatonic", "minorpentatonic"}, + setter{MIN_FUNCTION{ + m_engine.set_scale(scale_mask_of(args[0])); + return {args[0]}; + }}, + description{"Scale the corrector snaps to, relative to the key. Setting it re-derives " + "the allowed notes, replacing any custom 'notes' mask."}}; + + attribute mode{this, + "mode", + "scale", + range{"scale", "midi"}, + setter{MIN_FUNCTION{ + m_engine.set_mode(args[0] == "midi" ? tap::tools::tune::mode::midi + : tap::tools::tune::mode::scale); + return {args[0]}; + }}, + description{"Target selection: 'scale' snaps to the key/scale notes; 'midi' snaps to the " + "nearest currently-held MIDI note (send 'note' messages) and leaves the " + "signal untouched when none are held."}}; + + attribute backend{this, + "backend", + "grain", + range{"grain", "psola", "pvoc"}, + setter{MIN_FUNCTION{ + if (args[0] == "psola") { + m_engine.set_backend(tap::tools::tune::backend::psola); + } + else if (args[0] == "pvoc") { + m_engine.set_backend(tap::tools::tune::backend::pvoc); + } + else { + m_engine.set_backend(tap::tools::tune::backend::grain); + } + return {args[0]}; + }}, + description{"Resynthesis engine. 'grain' is the low-latency two-tap default; 'psola' " + "is formant-preserving TD-PSOLA, best on voice (needs harmonic-rich " + "input); 'pvoc' is a peak-locked phase vocoder with one FFT frame of " + "latency. Switching starts the new engine from silence."}}; + + attribute speed{this, + "speed", + tap::tools::tune::k_default_speed_ms, + range{0.0, 5000.0}, + setter{MIN_FUNCTION{ + m_engine.set_speed(args[0]); + return {args[0]}; + }}, + description{"Retune speed in milliseconds — the time constant of the glide onto the " + "target note. 0 snaps instantly (the hard quantize effect); slow values " + "correct transparently while letting vibrato through."}}; + + attribute amount{this, + "amount", + 100.0, + range{0.0, 100.0}, + setter{MIN_FUNCTION{ + m_engine.set_amount(args[0]); + return {args[0]}; + }}, + description{"Correction depth in percent. 100 lands on the target; 50 corrects half " + "the distance; 0 leaves the pitch untouched."}}; + + attribute minfreq{this, "minfreq", tap::tools::tune::k_default_min_hz, + setter{MIN_FUNCTION{ + m_min_hz = args[0]; + m_engine.set_range(m_min_hz, m_max_hz); + return {args[0]}; + }}, + description{"Lowest input frequency (Hz) treated as pitched. Estimates below it are " + "ignored (no correction)."}}; + + attribute maxfreq{this, "maxfreq", tap::tools::tune::k_default_max_hz, + setter{MIN_FUNCTION{ + m_max_hz = args[0]; + m_engine.set_range(m_min_hz, m_max_hz); + return {args[0]}; + }}, + description{"Highest input frequency (Hz) treated as pitched. Estimates above it are " + "ignored (no correction)."}}; + + attribute autokey{this, "autokey", false, setter{MIN_FUNCTION{ + m_engine.set_autokey(args[0]); + return {args[0]}; + }}, + description{"Learn the key from the incoming pitches (Krumhansl-Kessler profile " + "scoring over a slowly-forgetting histogram). Learning only — query the " + "estimate with 'getkey', adopt it as key + scale with 'applykey'."}}; + + attribute interval{this, + "interval", + 50.0, + range{0.0, 1000.0}, + setter{MIN_FUNCTION{ + if (static_cast(args[0]) > 0.0) { + m_report.delay(args[0]); + } + return {args[0]}; + }}, + description{"Pitch-report period in milliseconds for the right outlet ('pitch " + " ' while the input is voiced). 0 disables reporting."}}; + + attribute formant{this, "formant", false, setter{MIN_FUNCTION{ + m_engine.set_formant(args[0]); + return {args[0]}; + }}, + description{"Formant preservation for the pvoc backend (LPC source-filter): the " + "correction shifts the pitch while the spectral envelope stays put — " + "matters for large corrections (midi mode). The psola backend preserves " + "formants inherently; the grain engine is unaffected."}}; + + attribute bypass{this, "bypass", false, description{"Pass the input through unprocessed."}}; + + attribute mute{this, "mute", false, description{"Silence the output."}}; + + message<> notes{this, "notes", + "Set the allowed notes directly, as absolute pitch classes (replacing the key/scale " + "derivation). Send either twelve 0/1 toggles (C through B, Auto-panel style) or a " + "list of the enabled pitch classes (0 = C .. 11 = B).", + MIN_FUNCTION{ + if (args.size() == 12) { + unsigned mask = 0u; + for (int i = 0; i < 12; ++i) { + if (static_cast(args[static_cast(i)]) != 0) { + mask |= 1u << i; + } + } + m_engine.set_notes(mask); + } + else { + unsigned mask = 0u; + for (const auto& a : args) { + const int pc = static_cast(a); + mask |= 1u << (((pc % 12) + 12) % 12); + } + m_engine.set_notes(mask); + } + return {}; + }}; + + message<> note{this, "note", + "MIDI note-on/off for midi mode: a pitch (0-127) and a velocity. Velocity 0 " + "releases the note; anything else holds it as a correction target.", + MIN_FUNCTION{ + const int pitch = args[0]; + const int velocity = (args.size() > 1) ? static_cast(args[1]) : 0; + if (velocity > 0) { + m_engine.note_on(pitch); + } + else { + m_engine.note_off(pitch); + } + return {}; + }}; + + message<> flush{this, "flush", "Release all held MIDI notes.", + MIN_FUNCTION{ + m_engine.notes_off(); + return {}; + }}; + + message<> getkey{this, "getkey", + "Report the auto-key estimate from the right outlet: 'key " + "', or 'key none' while there is not yet enough voiced material.", + MIN_FUNCTION{ + send_key_estimate(); + return {}; + }}; + + message<> applykey{this, "applykey", + "Adopt the auto-key estimate as the key and scale attributes, then report it " + "from the right outlet. Does nothing (reports 'key none') without an estimate.", + MIN_FUNCTION{ + const auto e = m_engine.autokey_estimate(); + if (e.valid()) { + key = k_note_names[e.key]; + scale = e.minor ? "minor" : "major"; + } + send_key_estimate(); + return {}; + }}; + + message<> clear{this, "clear", "Reset the audio state (buffers and glides).", + MIN_FUNCTION{ + m_engine.clear(); + return {}; + }}; + + message<> dspsetup{this, "dspsetup", "Re-prepare for the current sample rate when the DSP chain starts.", + MIN_FUNCTION{ + m_engine.prepare(samplerate()); + return {}; + }}; + + sample operator()(sample x) { + if (bypass) { + return x; + } + const sample y = m_engine.process(x); + m_hz_now.store(m_engine.detected_hz(), std::memory_order_relaxed); + return mute ? 0.0 : y; + } + + /// Kernel access for the unit tests. + const tap::tools::tune::corrector& engine() const { return m_engine; } + + private: + // The estimate reads the learner's histogram while audio may be writing it; a torn + // double there skews one report harmlessly and the next call heals it. + void send_key_estimate() { + const auto e = m_engine.autokey_estimate(); + if (e.valid()) { + m_info.send("key", symbol(k_note_names[e.key]), symbol(e.minor ? "minor" : "major"), e.confidence); + } + else { + m_info.send("key", symbol("none")); + } + } +}; + +MIN_EXTERNAL(tune); diff --git a/source/projects/tap.tune_tilde/tap.tune_tilde_test.cpp b/source/projects/tap.tune_tilde/tap.tune_tilde_test.cpp new file mode 100644 index 00000000..877cd2c0 --- /dev/null +++ b/source/projects/tap.tune_tilde/tap.tune_tilde_test.cpp @@ -0,0 +1,197 @@ +/// @file +/// @brief Unit tests for tap.tune~ (Min-level behavior; the DSP is tested in the kernel repo). +// SPDX-License-Identifier: BSD-3-Clause +// Copyright 2026 Timothy Place. + +#include + +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "tap.tune_tilde.cpp" // include the object source so we can instantiate it + +SCENARIO("tap.tune~ instantiates with the expected defaults") { + ext_main(nullptr); // configure the class (required once per test executable) + + GIVEN("a default instance") { + test_wrapper an_instance; + tune& my_object = an_instance; + + THEN("the attributes have the documented defaults") { + REQUIRE(my_object.key == symbol("c")); + REQUIRE(my_object.scale == symbol("chromatic")); + REQUIRE(my_object.mode == symbol("scale")); + REQUIRE(static_cast(my_object.speed) == tap::tools::tune::k_default_speed_ms); + REQUIRE(static_cast(my_object.amount) == 100.0); + REQUIRE(static_cast(my_object.minfreq) == tap::tools::tune::k_default_min_hz); + REQUIRE(static_cast(my_object.maxfreq) == tap::tools::tune::k_default_max_hz); + REQUIRE(static_cast(my_object.bypass) == false); + REQUIRE(static_cast(my_object.mute) == false); + } + THEN("the engine is prepared and starts fully chromatic") { + REQUIRE(my_object.engine().prepared()); + REQUIRE(my_object.engine().notes() == tap::tools::tune::k_scale_chromatic); + } + } +} + +SCENARIO("attributes forward into the kernel") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + tune& my_object = an_instance; + + WHEN("the key and scale are set") { + my_object.key = "d"; + my_object.scale = "major"; + + THEN("the kernel note mask is D major, rotated to absolute pitch classes") { + REQUIRE(my_object.engine().key() == 2); + REQUIRE(my_object.engine().scale() == tap::tools::tune::k_scale_major); + unsigned expected = 0u; + for (const int degree : {0, 2, 4, 5, 7, 9, 11}) { + expected |= 1u << ((degree + 2) % 12); + } + REQUIRE(my_object.engine().notes() == expected); + } + } + + WHEN("speed and amount are set") { + my_object.speed = 250.0; + my_object.amount = 50.0; + + THEN("the kernel follows") { + REQUIRE(my_object.engine().speed() == 250.0); + REQUIRE(my_object.engine().amount() == 50.0); + } + } + + WHEN("mode is set to midi") { + my_object.mode = "midi"; + + THEN("the kernel switches target mode") { + REQUIRE(my_object.engine().target_mode() == tap::tools::tune::mode::midi); + } + } + + WHEN("the backend is switched") { + THEN("it defaults to the grain engine") { + REQUIRE(my_object.backend == symbol("grain")); + REQUIRE(my_object.engine().resynth_backend() == tap::tools::tune::backend::grain); + } + + my_object.backend = "psola"; + THEN("psola forwards into the kernel") { + REQUIRE(my_object.engine().resynth_backend() == tap::tools::tune::backend::psola); + } + + my_object.backend = "pvoc"; + THEN("pvoc forwards into the kernel") { + REQUIRE(my_object.engine().resynth_backend() == tap::tools::tune::backend::pvoc); + } + } + + WHEN("auto-key learning is toggled") { + THEN("it defaults off, with reporting at 50 ms") { + REQUIRE(static_cast(my_object.autokey) == false); + REQUIRE(my_object.engine().autokey() == false); + REQUIRE(static_cast(my_object.interval) == 50.0); + } + + my_object.autokey = true; + THEN("the flag forwards into the kernel") { + REQUIRE(my_object.engine().autokey() == true); + } + } + + WHEN("the key estimate is queried before anything was heard") { + my_object.autokey = true; + my_object.getkey(); + + THEN("no estimate exists yet (the message must not crash or fabricate one)") { + REQUIRE_FALSE(my_object.engine().autokey_estimate().valid()); + } + } + + WHEN("applykey runs without an estimate") { + my_object.applykey(); + + THEN("the key and scale attributes stay untouched") { + REQUIRE(my_object.key == symbol("c")); + REQUIRE(my_object.scale == symbol("chromatic")); + } + } + + WHEN("formant preservation is enabled") { + THEN("it defaults off") { + REQUIRE(static_cast(my_object.formant) == false); + REQUIRE(my_object.engine().formant() == false); + } + + my_object.formant = true; + THEN("the flag forwards into the kernel") { + REQUIRE(my_object.engine().formant() == true); + } + } + } +} + +SCENARIO("the notes message sets the mask in both accepted forms") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + tune& my_object = an_instance; + + WHEN("twelve 0/1 toggles are sent") { + my_object.notes(atoms{1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0}); // C, E, G + + THEN("the mask holds exactly those pitch classes") { + REQUIRE(my_object.engine().notes() == ((1u << 0) | (1u << 4) | (1u << 7))); + } + } + + WHEN("a shorter list of pitch classes is sent") { + my_object.notes(atoms{9, 11, 2}); // A, B, D + + THEN("the mask holds exactly those pitch classes") { + REQUIRE(my_object.engine().notes() == ((1u << 9) | (1u << 11) | (1u << 2))); + } + } + } +} + +SCENARIO("processing produces finite output and bypass passes through") { + ext_main(nullptr); + + GIVEN("an instance") { + test_wrapper an_instance; + tune& my_object = an_instance; + + WHEN("a burst of samples runs through") { + bool all_finite = true; + for (int t = 0; t < 4096; ++t) { + const auto y = my_object(std::sin(t * 0.05)); + if (!std::isfinite(static_cast(y))) { + all_finite = false; + } + } + THEN("the output stays finite") { + REQUIRE(all_finite); + } + } + + WHEN("bypass is on") { + my_object.bypass = true; + THEN("the input passes through untouched") { + REQUIRE(static_cast(my_object(0.5)) == 0.5); + } + } + + WHEN("mute is on") { + my_object.mute = true; + THEN("the output is silent") { + REQUIRE(static_cast(my_object(0.5)) == 0.0); + } + } + } +} diff --git a/submodules/taptools b/submodules/taptools index 15b35ab9..d5d3dd4c 160000 --- a/submodules/taptools +++ b/submodules/taptools @@ -1 +1 @@ -Subproject commit 15b35ab9d38f8ad3935b7a7f12c7f890dcbc4566 +Subproject commit d5d3dd4ccd74e9736ffb7851c52f2c1687af9e63