Skip to content

OpenVoxTuner - Implementation Roadmap

Last updated: 2026-07-31 11:30 CEST

Legend

  • Implemented
  • Not yet implemented
  • [~] In progress

1. Core DSP Engine

  • Pitch detection (YIN algorithm)
  • Pitch detection (YIN algorithm) — robustness fix 2026-07-12: replaced the over-strict numSamples < maxLag * 2 guard (which returned 0 for 2048-sample buffers at 44.1 kHz, failing the 440/220 Hz unit tests) with an adaptive searchMax = min(maxLag, numSamples/2) range, applied to both PitchDetector.cpp (test) and YinPitchDetector.cpp (production); unit tests now 52 OK / 0 KO.
  • Standalone transport clock ran slow (lost per-block increments through the 10 ms host-time cache) 2026-07-12: in Standalone currentTime is now re-based on transportTime every block (not the stale cachedTransportTime), so the standalone tempo matches the DAW (8 s to ruler label "5" at 120 BPM instead of ~32 s); host/ARA path unchanged.
  • Pitch detection (YIN algorithm — SWIPE'/PYIN evaluated and removed)
  • Pitch shifting (PSOLA)
  • Formant preservation
  • Scale quantizer (14 scale types including Custom)
  • Harmony engine (21 harmony types + None; 22 entries)
  • Reverb effect (post-processing)
  • Noise Gate (input, before pitch detection)
  • ~~[x] FlexTune~~ (DEPRECATED 2026-07-24 — UI hidden, logic disabled) / [x] Humanize parameters
  • Correction mode (Modern / Transparent)
  • Retarget envelope (attack/release smoothing)
  • Pitch curve (programmatic control over time)
  • Vibrato preservation (2026-07-14): correct against a smoothed center pitch instead of the instantaneous pitch, so the vibrato modulation survives while the note still snaps to the scale. New vibrato_preserve parameter (0-100%), ovtdsp::VibratoPreserver helper, Correction-section knob, A/B-morph + preset persistence, and unit test.
  • Harmony Attack fix (2026-07-27): Eliminated volume surplus at harmony voice onsets when Gate + Harmony are both active. Per-voice smoothstep (raised-cosine) fade-in retriggers on every gate-open; gate-follow clamp (12 ms) aligns harmony with gated dry signal; configurable harmony_attack parameter (1-300 ms, default 35 ms) under the Blend knob. New HarmonyAttack regression test covers all 21 harmony profiles (gate on/off, retrigger, long attack clamping). 139 tests pass.

2. Audio I/O and Plugin Format

  • VST3 plugin format
  • Audio Unit (AU) plugin format (macOS)
  • Standalone application mode
  • ARA integration (DAW timeline sync)
  • MIDI output (quantized note events)
  • Bypass mode (audio pass-through)

3. UI / GUI - Main Editor

  • Dark theme with blue accent (Autotune-style)
  • Light theme support (toggle in hamburger menu)
  • Title bar with logo and version
  • Hamburger menu (gear icon) for options
  • Tabbed view: "Live" (visualizer) and "Curve Editor"
  • Preset system (Factory + Custom save/load/delete)
  • Key and Scale selection (ComboBox)
  • Custom scale editor (12-button keyboard)
  • Speed, Amount, Formant, Reverb knobs
  • ~~[x] FlexTune knob~~ (DEPRECATED 2026-07-24) / [x] Humanize knob
  • Correction mode toggle (Modern / Transparent)
  • Bypass toggle (standalone only)
  • MIDI Out toggle
  • "Tuning follows MIDI IN" target toggle + amber "FOLLOWS MIDI IN" pulsing badge (top-right of the plot, visualizer + Curve Editor) + dashed amber target line at the held MIDI note in the Curve Editor (2026-08-01)
  • Harmony controls (enable, type, gain, blend, tone)
  • Use Voice / shifted voices selector
  • Harmony tone color knob
  • Harmony "Follow Lead" toggle (2026-07-14): when on (default), the harmony voices follow the lead correction character (vibrato preservation, humanize; ~~flex~~ and ~~attack-aware~~ deprecated 2026-07-24), so the blue harmony lines move with the green lead line instead of staying locked to the scale grid. Implemented as the harmony_follow_lead AudioParameterBool (default true) that re-applies the lead's charRatio = f0_out / scaleNote to each harmony voice in processBlock; MorphState / preset persistence; power-style toggle button in the Harmony block; i18n EN/FR/DE/ES/JA.
  • Latency mode selection (Direct Monitoring / Low Latency / Quality / Safe)
  • Pivot-centred fill: rotary knobs with a symmetric range (-5..+5 Formant / Harmony Formant) fill from the central 0 value, and the morph slider (0..1) fills from its center 0.5 (centred in drawRotarySlider + centered drawLinearSlider) (2026-08-01)
  • Update checker (GitHub releases)
  • Internationalization (i18n) - English, French, German, Spanish, Japanese, Simplified Chinese (zh) (menu items, labels, all tooltips)
  • UTF-8 Debug-crash fix 2026-07-13: map value type juce::String -> const char*, tr() converts via CharPointer_UTF8, and MSVC /utf-8 flag added in CMakeLists.txt (static-init String(const char*) assertion on non-ASCII literals eliminated).
  • 2026-07-15: added Simplified Chinese (Language::Chinese, code "zh") as a 6th language — full translation map + menu item + ui_language range extended to 0..5.
  • 2026-07-15: "Gate" label forced to English in every language (tr() short-circuits kLabelNoiseGate to the English map) because "Porte" (FR) / "Puerta" (ES) / "Door" etc. are misleading for the audio noise-gate term.
  • Language selector in hamburger menu with persistence
  • Centralized font system (OVTFonts.h) - consistent typeface across all components
  • Centralized theme system (OVTTheme.h) - dark/light theme accessors
  • MIDI Learn for sliders (hamburger menu submenu with CC assignment)
  • CPU Usage Meter (header strip display with color-coded bar, positioned left of A/B button)
  • A/B Comparison (two separate buttons with morph slider between them, green border for valid data, right-click save, MorphState-based persistence — no exponential XML growth)
  • PresetMorpher interpolation engine (header-only, captures/applies/morphs states + PitchCurves)
  • DAW automation coexistence: morph no longer overwrites parameters driven by concurrent host automation (e.g. speed/amount lanes running alongside a morph automation lane)
  • Morph slider A<->B toggle bug fixed 2026-07-11: the external-automation exclusion map (lastMorphIntendedValues) was not cleared on slot switch, so toggling A<->B several times accumulated exclusions until the slider had no effect. Now cleared on slot switch and on the A->B context-menu action.
  • Stray green curve on A/B slot switch fixed 2026-07-13: interpolateCurves (PresetMorpher.h) resampled over a fixed timeRange = 10.0 SECONDS while the whole PitchCurve system works in BEATS (PPQ) — editor axis, DSP getPitchAt(transportTime), user-drawn points. Switching slots triggers a morph via the automatable morph_amount param (timerCallback -> onMorphSliderChanged), so every curve was stretched/truncated to ~0..10 "beats" (≈ ruler "3.3") producing a garbled, over-dense curve persisted into the settings PITCH_CURVE (which is why deleting the settings file fixed it before). Fix: resample over the union time span of the two input curves, in beats; at morph endpoints (t<=0.001 or t>=0.999) return the exact source/target curve (no 128-point resample) so switching slots keeps the original point structure instead of densifying the curve. Unit test added/extended in PitchCurveTest.cpp (54 OK / 0 KO).
  • Preset commits to active A/B slot 2026-07-13: loadFactory now saveSlots the loaded preset into the active slot (so it survives slot switching) and aligns morph_amount to the active slot's endpoint; previously the preset only touched the editor curve and resetMorph() left morph_amount at 0, so switching slots reloaded the slot's stale stored curve. Auto-scroll forced off + menu item disabled in Standalone (playhead loops on Measures).
  • Morph blends parameters ONLY 2026-07-13: Jérôme chose "Parametres seuls" — when morphing between two different curves the displayed curve snaps to the nearest slot's curve (morphSource/morphTarget) and is never crossfaded, so the Morph slider can no longer densify the curve with spurious intermediate points (previously caused by the curve-blend resample). Consequence: interpolateCurves and the unused loadStateFromXml were removed from PresetMorpher.h (no-dead-code rule); removing loadStateFromXml also clears a latent reverb_enable->harmonyEnable typo. The interpolateCurves test was removed from PitchCurveTest.cpp; suite 54 OK / 0 KO.
  • A/B slot curve persisted across reload/restart 2026-07-13: getStateInformation/setStateInformation now serialize the slot MorphState.curve as a nested PITCH_CURVE child of AB_A/AB_B (previously only the scalar params were saved, so a restored slot had an empty curve and clicking it cleared the editor line).
  • Middle-button horizontal scroll disabled in Loop Playhead (measures) mode 2026-07-13: PitchCurveEditor stores loopingPlayhead (from setPlayheadTime's isLooping) and the middle-drag handler now also requires !loopingPlayhead (auto-scroll-off still required).
  • Curve Editor "Options" button is now icon-only hamburger 2026-07-13: replaced the label-only "Options" TextButton (and the now-unused PresetsButton class) with an icon-only DrawableButton using a hamburger (3 bars) SVG, with a distinct accent-tinted background so it stands out from the neutral zoom/scroll/snap buttons; an 8px gap separates it from the reset (X) button (previously touching). The plugin's own options keep the gear.
  • Flex/Humanize labels no longer truncated on macOS 2026-07-13: label widths are now measured from glyph metrics instead of hard-coded 28/52px boxes (which overflowed under the macOS SF Pro fallback for the Windows-only "Segoe UI" font).
  • Curve Editor trackpad support 2026-07-13: added mouseMagnify for macOS pinch-to-zoom (shared applyZoom helper, clamped 1..8 octaves) and two-finger horizontal scroll via wheel.deltaX panning scrollOffset (same constraints as middle-drag: disabled while auto-scroll/loop locks the view).
  • "Export as Image" exports the active tab 2026-07-13: the menu no longer hard-codes the Live visualizer (getTabContentComponent(0)); it now dispatches on getCurrentTabIndex() to curveEditor->exportAsImage (new method, mirrors the visualizer's 2x PNG) or the visualizer. Dialog/not-found strings generalized from "Visualizer" to "current view" across en/fr/de/es/ja.
  • Value-less knobs show live value while dragging 2026-07-13: replaced the (non-working) tooltip-on-drag approach with JUCE's popup display (setPopupDisplayEnabled(true, false, this) + textFromValueFunction for units) on Flex/Humanize/Gate/Reverb/Formant; removed the redundant drag-tooltip lambdas. The TooltipWindow can't show during a rotate-drag because it needs a stationary mouse.
  • VST3 category Fx/Pitch 2026-07-13: juce_add_plugin now sets VST3_CATEGORIES "Fx" "Pitch" so DAWs (Cubase, Nuendo, Studio One, ...) file the plug-in under "Pitch & Time".
  • Keyboard shortcuts help overlay (? key or hamburger menu)

4. UI / GUI - Pitch Visualizer (Live Tab)

  • Real-time pitch curve display (input + output)
  • Harmony voice lines display
  • Semi-logarithmic frequency scale (Hz)
  • LED-grid VU meter (tuning cents indicator)
  • Current note display with cents offset
  • Target note indicator
  • Redesigned pitch metrics display (2026-07-24): unified animated note badge (green when in-tune, red/green split with smooth animation when out-of-tune), dynamic VU meter positioning
  • Auto-center pitch display (2026-07-24): wrench menu option, IIR-smoothed tracking, auto-disables on manual zoom/scroll
  • Right-click opens wrench menu in Live tab (2026-07-24)
  • Vertical piano keyboard (notes highlighted by scale and active pitch)
  • Mouse wheel scroll (vertical pan)
  • Ctrl/Cmd + mouse wheel zoom
  • Dynamic octave reference lines (C notes) with labels - visible across ALL octaves
  • Dynamic scale note lines within visible range
  • Complete legend (Input, Output, Harmony) - compact, no truncation
  • Keyboard shortcut hints (non-truncated, compact format)
  • Scroll/zoom SVG icon buttons (magnifying glass, chevrons, cross)
  • Reset view button (restores default frequency range)
  • Hover cursor showing Hz/note value at mouse position
  • Y-axis frequency labels (Hz values along the right edge)
  • Animated smooth transitions for zoom/scroll (lerp interpolation)
  • Image export (PNG/JPEG at 2x resolution)
  • Piano key note labels (D, E, F, G, A, B - height-gated)
  • ARA2 waveform overlay (input audio captured in processBlock, displayed as background in Live visualizer with menu toggle)
  • Waveform overlay reflects the input noise gate (2026-07-14): the visualizer waveform is now captured after the noise-gate stage in processBlock, so the displayed waveform is attenuated together with the gated audio (previously it showed the raw pre-gate input). The French "Gate" label/tips use the untranslated audio term "Gate" instead of "Porte".
  • Unified waveform display types (Bars, Filled, Line, Mirror) with user-selectable modes via hamburger menu, shared rendering function between Live and Curve Editor views, persisted across sessions
  • Bookmark positions (save/restore frequently used frequency ranges)
  • Responsive layout for small screens

5. UI / GUI - Curve Editor (Graphic Tab)

  • Graphical pitch curve editing (click to add/move points)
  • Time ruler with measures/beats
  • Snap to scale (quantize points to scale notes) — bug fixed 2026-07-11: the snap now uses the authoritative scale interval set (same as the on-screen display). The real root cause was the Scale/Key ComboBox not updating the parameter (see entry below), so the snap used a stale/default scale; that binding is now fixed. Further fixed 2026-07-12: in-scale notes now snap to their exact pitch (not the raw clicked frequency), so all scale notes — including D4/G4/A#4 in C Natural Minor — lock onto the note instead of staying where clicked.
  • Snap to grid (quantize points to beat grid)
  • Step mode (staircase interpolation)
  • Clear all points
  • Transport playhead (follows DAW time in ARA mode)
  • Auto-scroll toggle (Options menu; available in ARA and Standalone — no longer an embedded checkbox) — behavior fixed 2026-07-12: OFF now keeps the view fixed during playback (playhead can run off-screen); only reveals on an explicit seek. Previously OFF still scrolled, making the option appear to do nothing.
  • Time signature display
  • Measures count selector (toolbar row, no longer covering the ruler)
  • Preset curves for common use cases
  • Harmony trace visualization
  • Right-click preset menu
  • Reset playhead button (standalone/VST3) — also exposed as a "Return to start" (rewind) toolbar button in Standalone (vertical bar + left-pointing triangle glyph) 2026-07-12.
  • Undo/Redo buttons (visual UI complement to keyboard shortcuts)
  • Plugin Presets (separate from Curve Presets) 2026-07-18: centred top-banner selector [◀][combo][▶][💾] with a Default factory preset (true factory defaults) plus 6 factory presets (parameter overrides only) and custom save/load/delete to a dedicated Presets/Plugin/ folder. Uses a distinct <OVT_PLUGIN_PRESET> XML shape, captures parameters.state only (never the pitch curve), is undoable via the global UndoManager, and preserves UI language / theme / morph position / Live-Curve mode across loads. Factory preset names are English (language-neutral) and the selector buttons are fully localized.
  • A/B slots remember their assigned plugin preset 2026-07-18: each slot records the plugin preset it was loaded from (ABState::presetName); on slot switch the selector shows that preset name when the slot still matches it exactly, otherwise it falls back to "User". Right-click slot save detaches the slot from its preset.
  • Scale note lines (horizontal reference lines for current scale notes)
  • Curve Editor toolbar mirrors Visualizer view controls (Zoom In/Out, Scroll Up/Down, Reset View) + "Options" menu (Clean Curves, Reset Playhead, Curve Presets) 2026-07-11: snap/grid/step kept as direct toggle icons; clear/reset moved into the Options menu; zoom/scroll reuse the Visualizer's SVGs and pitch-zoom/pitch-pan semantics (matching the Visualizer and the Curve Editor's own wheel behavior).
  • "Measures" combo + label moved onto the toolbar row (same line as Options menu and view icons) so it no longer covers the ruler 2026-07-12.
  • "Curve Presets" promoted to a direct submenu of the Options menu (no extra click-to-open step) 2026-07-12.
  • Options menu "Auto-Scroll" item is now a ticked toggle (replaces the old embedded checkbox+label) 2026-07-12.
  • Standalone transport: a single Play/Pause toggle (Play glyph when stopped, Stop glyph when playing) plus a "Return to start" (rewind) button on the Curve Editor toolbar (standalone only) 2026-07-12. The toggle freezes/runs the timeline so the curve can be edited while stopped; the rewind button resets the playhead and clears the input trace.
  • Standalone window maximise button (JUCE's StandaloneFilterWindow only requested minimise + close by default; re-added via parentHierarchyChanged) 2026-07-12.
  • Standalone tempo: "Tempo" submenu in the Options menu (standalone only) lets the user fix the BPM instead of being locked at 120 BPM; the fallback transport advances at the chosen tempo 2026-07-12.
  • Curve Editor "Show Input Trace" toggle (Options menu) shows/hides the live input pitch trace (red line); ON by default 2026-07-12.
  • Reset Playhead reliability fix: new returnToStart() resets the scroll offset AND the playhead on the first click (icon + Options menu) in every context; the earlier 3–4 click defect came from the view only snapping back via setPlayheadTime's >0.5-beat seek detector, leaving the playhead off-screen when auto-scroll was OFF 2026-07-12.
  • Ruler click moves the playhead to the clicked position, quantized to the project grid (0.5 beat); works in Curve and Live modes; onSeek callback bridges editor → transport (DAW/standalone) 2026-07-12.
  • Middle-button drag horizontal scroll (beats) in the curve editor + ruler, active only when auto-scroll is OFF; hand cursor + yellow feedback overlay while dragging; clampScrollOffset bounds the scroll to >= 0 2026-07-12.
  • Curve Editor playhead loop mode (per transport context) 2026-07-12:
  • ARA: playhead follows the DAW (unchanged).
  • Standalone: playhead loops within the Measures window [0, measuresVisible * ppqPerBar] (end of beat 4 for "4 Measures" in 4/4 = 16 beats).
  • Plugin (VST3, non-ARA): user choice between "Follow host" (default) and "Loop (Measures)" via a new Options-menu ticked item "Loop Playhead (Measures)" (disabled/greyed in ARA and Standalone, reflecting the forced mode).
  • The loop length is shared by the playhead display AND the graphic pitch-curve sampling (replaces the hardcoded fmod(currentTime, 16.0) at PluginProcessor.cpp:1080), so playhead and curve loop on the same window. transportTime stays monotonic; a derived wrapped time (getLoopTransportTime()) is used for display/trace/sampling.
  • New parameter editor_playhead_loop (default = false / Follow). Effective mode derived in isPlayheadLooping() (ARA→follow, standalone→loop, plugin→param).
  • Edge case handled: at the loop wrap boundary with auto-scroll ON, the L -> 0 jump is treated as normal advance (not a seek) to avoid a recadrage flicker each loop.

5b. Curve Editor: MIDI Import (Drag-and-Drop) — Implemented 2026-07-31

  • MidiImporter DSP module (MidiImporter.h/.cpp): analyze .mid files (analyzeFile) and convert to PitchCurve (importFrom) with configurable strategy (highest/lowest/loudest note, specific channel)
  • FileDragAndDropTarget on PluginEditor: accepts .mid/.midi files dragged from OS file explorer onto plugin window
  • Hamburger menu item: "Import MIDI..." in Curve Editor options, launches juce::FileChooser filtered on .mid/.midi
  • Multi-channel selection dialog: popup when MIDI file contains multiple active non-percussion channels (strategy + channel picker)
  • Polyphonic reduction: chords reduced to monophonic curve using configurable strategy (highest=lead, lowest=bass, loudest=velocity)
  • Undo support: import registered as CurveEditAction (Ctrl+Z reversible)
  • Step mode auto-enable: step mode forced ON after MIDI import (discrete notes)
  • View auto-fit: measures and scroll adjusted to fit imported curve duration
  • Channel 10 exclusion: percussion channel automatically excluded from import
  • i18n: all dialog/menu strings translated (EN/FR/DE/ES/JA/ZH)
  • Validation: monophonic, polyphonic, multi-channel, invalid files, DnD + menu, undo, edge cases
  • Plan document: docs/implementation-plan-midi-import-drag-and-drop.md

6. Scale Keyboard Component

  • 12-button chromatic keyboard display
  • Toggle individual scale notes
  • Blue highlight for active scale notes
  • Bidirectional sync with AudioParameterInt (custom0..custom11)
  • Auto-switch to Custom mode on user interaction
  • Scale/Key ComboBox -> parameter binding fixed 2026-07-11 (hardened 2026-07-11): the combo -> parameter direction is owned by JUCE's ComboBoxAttachment Listener (comboBoxChanged), which writes scale/key on genuine user selection. The onChange handlers were changed to ONLY mirror the per-note custom flags / piano keys and never write scale/key back. This removes a transient morph-regression where onChange (which JUCE fires during morph/automation via sendNotificationSync, unguarded) could momentarily overwrite the morph's new scale while the display lagged. Key getters corrected to read the normalized value (round(load*11)) so non-C roots work.

7. Build and Release

  • CMake build system
  • GitHub Actions CI (Windows, macOS)
  • Release workflow (tagged builds)
  • Version bump workflow
  • Installer for macOS (.pkg)
  • scripts/build_helper.cmd reconciled with the remote (CI/release) version 2026-07-12: the machine-specific Windows build helper was restored to origin/main (commit aeeb438) via git checkout -- scripts/build_helper.cmd so the installer/CI build matches the committed configuration.

9. UI Polish (2026-07-15)

  • Expansion handle (Correction "Advanced" banner) is now half the block height with rounded corners (fillRoundedRectangle, trimmed 25% top/bottom).
  • Top toolbar reflows on language change — the language handler now calls resized() after refreshLabels(), so the transport / "Measures" controls no longer overlap the tabs and the "Measures" label is no longer truncated (e.g. Spanish "Compases" → "Co..."). Tab-end offset made robust with tabBar.getX().
  • Harmony "Follow Lead" toggle is now visible — the Harmony block's first row spans the full block width; the Harmony on/off button is sized to its text and "Follow Lead" takes the rest (previously a fixed 130px on/off button squeezed it to ~0 width on the rightmost block).
  • "Clean Curves" / "Reset Playhead" menu items translated (added FR/DE/ES/JA; they already existed only in English).
  • Key/Scale Detection power button height 22 → 18 (matches Gate/Reverb); clicking the "Key/Scale Detection" label toggles the power button (same behaviour as the other Power buttons).
  • Harmony power button height → 18 (matches Gate/Reverb) and left-aligned on the first row (no longer centred above the combo).
  • Curve Editor CTRL+wheel zoom inverted vs Live visualizer — flipped the wheel factor sign so wheel-up zooms in, matching the Live tab.
  • Piano Roll horizontal grid lines now identical to Curves mode (C notes = curveGrid, in-scale = scaleLine, full alpha; off-scale notes draw no line), so no faint/off-scale rows appear when switching to Piano Roll.

9b. UI Polish — 2e passe (2026-07-15)

  • Key/Scale Detection combo moved directly under the power/label row (removed the 4px gap) and kept at the same 24px height as the Key/Scale combos; detRow tightened 24 → 22px.
  • "Follow Lead" toggle moved below the "number of voices" combo in the Harmony block (first row now holds only the Harmony on/off toggle).
  • Curve Editor scroll no longer "stacks" out-of-range notes/curves/points at the top/bottom edges — removed the pitchToY clamp (Curves mode) and made PianoKeyboard::midiToNorm extrapolate (no clamp) so off-screen items are clipped, not pinned to the edges.
  • MIDI OUT "Follow Lead" behaviour documented: ON → harmony MIDI notes vary with the lead (charRatio = f0_out / leadScaleFreq); OFF → harmony notes locked to the scale grid (fixed). No code change (answering the question).

9c. UI / DSP Polish — 3e passe (2026-07-15)

  • "Follow Lead" toggle left-aligned in the Harmony block (sized to its content, placed on the left of its row instead of spanning the full column width).
  • MIDI OUT now ignores "Follow Lead": pushed harmony notes are always clean scale-locked notes (harmonyFrequenciesClean / lastHarmonyNotesClean), independent of the toggle; the on-screen visualizer still follows the lead when the toggle is on.
  • Tooltips (and the "Attack" button text) refresh on language change: added advancedButton + harmonyFollowLeadButton tooltips to refreshLabels(), added the pianoRollButton tooltip (and text) to PitchCurveEditor::refreshTranslations(), and attackAwareButton button text now refreshed.
  • "Measures" label adapts to content (no truncation in DE/JA): refreshLabels() now re-runs resized(), and the width padding was bumped (+6 → +10px).
  • Key Detection — OpenVoxKey now packaged in the Windows installer (new companion component in installer/OpenVoxTuner.iss + OpenVoxKey_VST3 built by scripts/build_installer.ps1); the duplicate mono Input/Output buses were removed so the Sidechain bus is now input index 1 (matching all sidechain code) — fixes SideChain routing in Studio One.
  • KeyBridge shared memory now active (2026-07-15): the Windows named memory-mapped file Local\OpenVoxTunerKeyBridge was previously disabled by a leftover debug guard (if (false && ...)) so each VST3 used its own private in-process region and the companion's publish() never reached read() in OpenVoxTuner. Guard removed → both separate binaries (OpenVoxKey.vst3 + OpenVoxTuner.vst3) now share one session-local region; the in-process fallback is kept only when shared memory is unavailable. Unit-test KeyBridgeTest.cpp (and SidechainBusLayoutTest.cpp) were re-added to CMakeLists.txt and now compile/pass — 86 OK / 0 KO.
  • Harmony blue lines no longer "drop" when "Follow Lead" is active: charRatio now uses the scale note nearest to the continuous f0_out (scaleQuantizer->quantize(f0_out)) instead of the jumping quantizer target, so the ratio stays ≈1 across note transitions.
  • OpenVoxKey companion — "Send" button (2026-07-15): added OpenVoxKeyProcessor::forcePublish() which re-publishes the last detected key/scale to ovtdsp::KeyBridge immediately, bypassing the change-guard in processBlock (which only publishes on detection change). The companion editor gained a "Send" TextButton to the right of the Group combo (buttonClickedprocessor.forcePublish()) so the user can manually re-sync OpenVoxTuner (Key/Scale Detection = OpenVoxKey, matching group) even after changing the scale by hand there. No-op until at least one key has been detected. Button is created disabled and timerCallback() enables it (sendButton.setEnabled(key >= 0)) as soon as a key is detected, and disables it again if detection is lost (the enable call was moved above the early return in the detected branch, which previously left the button stuck disabled after first detection).
  • OpenVoxTuner Sidechain — red debug LED (2026-07-15, removed 2026-07-15): a DebugLed + "Sidechain audio" label was added to confirm the sidechain bus received audio; once Sidechain detection worked it was removed per user request (no behavioural change to detection). See below for the group-B/C fix that followed.
  • Key/Scale Detection — detected scale now re-asserts after a manual change (2026-07-15): applyDetectedKey() guard changed from a cached lastAutoKey/lastAutoScale to the live key/scale parameter values, so while Key/Scale Detection is on the detected scale is authoritative and a hand edit is re-applied on the next estimate (identical values still skipped → no automation churn). To keep a manual scale, turn detection off.
  • OpenVoxKey companion — animated "searching for key" (2026-07-15): OpenVoxKeyProcessor logs lastAudioTime (system-clock seconds, std::atomic<double>, accessor getLastAudioTime()) when the input bus carries signal above a noise floor; the editor's timerCallback() now shows the detected key (white) when known, an animated SearchingDots (cyan, three dots pulsing in sequence) when audio is present but no key yet, and a dim "No signal" when the bus has been silent > 2 s — replacing the old static "-".

8. Click & Pop fixes (tuning engine audio artifacts, 2026-07-16 → 2026-07-17)

Root-cause analysis of clicks/pops at sung-note onsets after commit e0d613d. All fixes implemented and covered by unit tests (OpenVoxTunerTests, 94 OK / 0 KO). - [x] A (FormantPreserver biquad smoothing) — MultiFormant mode recomputed the 4 formant biquad coefficients every block with no smoothing; when ratio jumped at a note onset the coefficient step created an output discontinuity (pop). Added per-channel smoothed coefficients (ChannelState::BiquadSmooth) lerped toward the targets each block (biquadSmoothAlpha = 0.002, ~8 ms time constant). Applied in processChannel. reset() re-arms smoothing to passthrough. - [x] B (KBD COLA normalization) — PitchShifter switched from Hann to KBD (beta=6) but kept the Hann-derived grain gain (2*Tout/outLength), which over-gains ~6-8% (KBD COLA sum at 50% overlap != 2.0) causing local clipping/pops. prepare() now measures the actual KBD COLA sum (kbdColaSum) and the grain gain divides by it. Normalized by N to get the per-sample COLA sum (critical fix — using the raw sum over N would zero the output). - [x] C (startup fade-in not re-armed mid-session)reset() re-armed the ~20 ms startup fade-in on every call, so a re-prepare (host buffer/sample-rate change mid-playback) would re-fade an already-full-amplitude signal → pop. Added firstPrepareDone flag + resetSoft() (clears state WITHOUT re-arming the startup fade); reset() only re-arms the fade on the very first prepare(). - [x] D (onset hysteresis + debounce)isVoiced was a hard f0 > 40 Hz threshold; a voice trembling around the threshold at note attack re-triggered attackGain = 0 repeatedly → repeated clicks. Added hysteresis (kVoiceOnThreshold = 45 Hz, kVoiceOffThreshold = 35 Hz) + debounce (kVoiceDebounceSamples = 256) so the voiced state is stable before an onset is declared. - [x] E (read out of valid history) — at low latency or just after prepare(), virtualInputTime pointed before the valid ring content, mixing zeros into the grain → brutal transition. Added totalWritten tracking and a guard that shifts idealCenter right so the grain never reads before latencySamples + halfGrain of available history. - [x] F (phase reset + local pitch-mark on long pause) — after a >50 ms pause lastGrainCenter was zeroed, disabling pitch-mark search and centering the new note's first grain on an arbitrary position (discontinuity). Now: outPhase is reset to 0 on onset (aligns 1st grain to a target peak) and a local cross-correlation pitch-mark search is run around idealCenter when no recent grain exists, aligning the new note cleanly. - [x] G (attack envelope ramp-down at pitch jumps) — at a pitch jump, attackGain was hard-reset from 1.0 to 0.0 in a single sample, creating an instant step in the output (the un-attenuated OLA sum is continuous, so the product makes a click of ~0.39). Fix: when onsetDetected is true, the smoother's target is set to 0 for ~5 ms (so attackGain ramps down smoothly from 1.0 to ~0.86) and a slower ~80 ms time constant is used for the subsequent ramp-up. The output stays continuous across the jump → 0 clicks. - [x] H (strict regression test)PitchShifterClickTest upgraded from a measurement to a real assertion (expectEquals(clickCount, 0, ...)). The scenario (silence → 200 Hz → 300 Hz jump) now reports 0 audible clicks. - [x] I (silence-grain guard: no grains while !isVoiced, 2026-07-17) — Staccato repetitions of the same note still produced a "pop" (~0.20 amplitude) ~30 ms into every silence gap. Root cause: PitchShifter kept creating new OLA grains even when !isVoiced (during silence), to keep the OLA chain full. But the readPos of those new grains is determined by current time and outLength, not clamped to the silence zone — so a grain created 30 ms into a gap still has its readPos pointing into the tail of the previous note in the ring buffer. When that readPos crossed the note→silence boundary, the content stepped from sin(...) to 0 in 1 sample, with the grain's window still at ~0.5 (grain had 18 ms of life left in its 25 ms outLength) → win * gain * |content| ≈ 0.20. Fix: only create a new grain if (isVoiced && outPhase >= 1.0); the existing grains from the last note fade out naturally over their own outLength, and the OLA chain goes to silence by itself. New PitchShifterClickTest Test 2 ("Staccato: 5 repetitions rapides meme note") asserts clickCount == 0. Side benefit: also corrected the KBD COLA sum fallback from 2.0 to 1.0 in prepare() (the 2.0 was a stale historical comment) and the grain gain factor from 2.0 / kbdColaSum to 1.0 / kbdColaSum (KBD at 50% overlap is COLA-perfect, not COLA-2x). Unit-test suite: 93 OK / 0 KO (was 92 OK / 1 KO). - [x] J (outPhase clamp at onset: no OLA burst, 2026-07-17) — After fix I, the staccato pop was gone but the first note of a phrase still sounded like a "trumpet" / clipped attack ("comme si le tout début du son était distordu ou en clipping"). Root cause: during the silence that precedes a new note, the !isVoiced branch keeps outPhase incrementing at 100 Hz (to preserve the OLA chain "warmth"). After 50 ms of silence, outPhase reaches 50 ms * 100 / sampleRate ≈ 5.0. When the onset is then declared, the next sample sees outPhase ≈ 5.0 and the if (outPhase >= 1.0) check creates floor(5.0) = 5 grains on consecutive samples, all centred on the same input position — 5 copies of the same content, OLA sum peaks at 5 * 0.4 = 2.0. The per-grain attack envelope (one-pole) only reaches 0.63 at t+5ms and 0.86 at t+10ms, so the envelope masks the very first samples (no detectable pop) but lets through the peak: at t+10ms, OLA sum = 5 * 0.4 * 0.86 = 1.72; at t+20ms, 5 * 0.4 * 0.97 = 1.94. Hard digital clipping of a sine wave → trumpet sound. Fix: when onsetDetected is true, set outPhase = 1.0 (instead of leaving it at ~5.0). The next sample then creates exactly one grain and the OLA chain restarts cleanly at the normal 5 ms cadence. The 4-5 "extra" grains that would have been created in burst are simply skipped (they would have read the preceding silence anyway, so no audio is lost). Trade-off: ~5-20 ms increase in attack latency (the time for the first grain to read the note's start behind the 20 ms latency line), inaudible behind the 30 ms attack envelope. New PitchShifterClickTest Test 3 ("Attaque brute : pas de sur-amplification OLA (somme <= entree)") asserts max(|out|) <= 1.10 over a 460 ms 200 Hz note; without the fix, this test reports max ≈ 1.9 (clipping). Unit-test suite: 94 OK / 0 KO (was 93 OK / 0 KO). - [x] K (severe under-gain in DAW: RMS 0.24 → 0.71, 2026-07-17) — User reported "l'audio qui arrive au plugin est vraiment beaucoup plus fort que l'audio qui sort du plugin" (DAW, plugin enabled) — ~9 dB level drop on a vocal track. Root cause: three compounding factors in the PitchShifter per-grain gain path. (1) The gain formula was Tout / outLength * (1.0 / kbdColaSum) — the Tout / outLength factor (= 0.4 for ratio = 1) was a stale time-stretching compensation that makes no sense for a pure pitch shifter. (2) kbdColaSum was being measured for 2 windows at 50% phase apart (Hann-like), giving ~1.07; the real OLA is 2.5 grains in overlap (outLength = 2.5 * min(Tin, Tout)), so the real COLA sum is ~1.50 (3 windows at 40% phase apart). The previous measurement under-estimated the overlap, under-applying the COLA correction. (3) The per-grain attack attackFraction = 0.5 was active in steady state, reducing each grain's average OLA contribution by ~25% (grain ramps from 0 to 1 over half its life, average ≈ 0.75). Combined effective per-grain gain was 0.4 * 1.07/1.50 * 0.75 ≈ 0.21 (-13 dB), which after the empirical kGainCompensation = 1.45 gave the measured RMS of 0.519 (-2.7 dB). Fix in Source/dsp/PitchShifter.cpp: (a) prepare() re-measures kbdColaSum for the real OLA configuration (3 windows at 40% apart) → measured value 1.500074. (b) process() per-grain gain is now kGainCompensation / kbdColaSum with no Tout / outLength factor; kGainCompensation = 1.20 calibrated against a sustained 200 Hz sinus with attackFraction = 0.0. (c) Per-grain attack attackFraction is now 1.0 on onsetDetected (to suppress the OLA-over-1 click at the jump) and 0.0 in steady state (so the per-grain attack no longer steals gain during sustained notes). The user-facing 30 ms global attack envelope (setAttackTimeMs) still handles the smooth note-on fade-in. PitchShifterRmsMeasure confirms RMS = 0.7055 = -0.02 dB → effectively unity. PitchShifterOutput (regression) OK. PitchShifterClick Test 3 (no OLA burst) OK. Tests 1 (325 ms) and 2 (452 ms) still report a 1-sample jump of ~0.11 (unchanged amplitude, pre-existing, tracked separately). Unit-test suite: 93 OK / 2 KO (the 2 KO are pre-existing 1-sample click artifacts, amplitude unchanged by Fix K; user-perceived volume is restored). Open follow-up: a debug accessor gKbdColaSumDebug and a temporary test/dsp/PitchShifterRmsMeasure.cpp are now in the tree — they should be removed once the click tests at 325 ms / 452 ms are fixed. - [x] K2 (RESOLVED by Fix O: 1-sample clicks at 325 ms / 452 ms) — Two test failures remained after Fix K. Both had amplitude ~0.11 and occurred 20-40 ms after a note onset (Test 1 = continuous 200→300 Hz jump; Test 2 = staccato rep). Two compounding root causes: (a) stale lastGrainCenter after a pitch jump — the onset detector reset outPhase = 1.0 to prevent a grain burst but did not touch lastGrainCenter, so the next grain still went through the "follow-up" branch with (lastGrainCenter + Tin) pointing into a now-stale 200 Hz region of the ring buffer while the input was already 300 Hz. findBestOffset either missed the search window (10 ms) or found a spurious cross-correlation peak, mis-aligning the first new grain by tens of samples. (b) OLA re-organisation transients not fully masked by the 5 ms / 80 ms slow-attack ramp — local OLA sum during the re-organisation window (old grains dying + new grains starting) can fluctuate by up to ±0.4 around the steady-state value during 20-50 ms after the jump, and with attackGain already at ~0.3 in that window, the audible delta exceeded 0.1 (0.114 measured). Fix in Source/dsp/PitchShifter.cppprocess(), if (onsetDetected) block: (a) reset lastGrainCenter = 0.0 alongside outPhase = 1.0, forcing the next grain to use the local pitch-mark search (12 ms window) instead of the follow-up branch, guaranteeing correct alignment on the new signal; (b) deepen and lengthen the slow-attack envelope: slowAttackSamplesRemaining 80 → 150 ms, attackRampDownSamplesRemaining 5 → 20 ms (one-pole drives attackGain from 1.0 → ~0.17 over 20 ms, then back to 1.0 over 130 ms). With attackGain < 0.5 during the 20-50 ms post-onset window, the worst-case OLA fluctuation × attackGain stays below 0.1. The 150 ms slow-attack is inaudible behind the 30 ms user-facing attackMs (by the time the user perceives the attack, the slow-attack is already at > 0.5). Unit-test suite: 100 OK / 0 KO (was 98 OK / 2 KO). See changelog docs/changelogs/changelog-2026-07-17.md section "Fix O" for the full write-up. - [x] L (Scale keyboard: visual lock on preset-scale notes, 2026-07-17) — User report: "Je suis en C Natural Minor, je veux désactiver une touche du piano en dessous de la combo (par exemple D). La combo passe en 'Custom', ce qui est normal. Par contre, la touche D ne se désactive pas. Idem si je clique sur n'importe quelle touche qui fait partie de la gamme précédente (C Natural Minor). Par contre, je peux activer/désactiver les touches qui ne font pas partie de la gamme C Natural Minor. En gamme custom, je devrais pouvoir faire ce que je veux avec toutes les touches." Root cause: PianoKeyButton::paintButton used bool isActive = activeInScale || getToggleState();. The activeInScale flag is updated by refreshVisualizer() from the processor's scale intervals (timer, ~16 ms tick), while getToggleState() is the live custom_i parameter value (written synchronously by the ButtonAttachment on click). When the user clicked a key in a preset scale (e.g. D in C Natural Minor), the toggle changed immediately but activeInScale was still true, so the OR returned true and the key was still painted as active. The underlying custom_i parameter was written correctly — this was a pure visual desync. Fix in Source/ui/ScaleKeyboardComponent.h: after setToggleState(!getToggleState(), sendNotification), set activeInScale = getToggleState(); in mouseDown. The painted state collapses to getToggleState() immediately; the next refreshVisualizer() tick (≤16 ms later) re-evaluates activeInScale from the Custom scale's custom_i set. New regression test test/ui/ScaleKeyboardComponentTest.cpp (3 sub-tests, 7 assertions) covers both directions (click OFF on a preset key, click ON on a non-preset key) and a 3-iteration toggle loop in Custom mode. Unit-test suite: 100 OK / 2 KO (was 93 OK / 2 KO; the 2 KO are the pre-existing 1-sample click artifacts, Fix K2). - [x] M (kbdWindow lookup table for CPU-bound machines, 2026-07-17) — User report (Studio One, after Fix K): "Avec toutes les améliorations qu'on a faites, j'ai constaté des dropouts réguliers. Je suis obligé de mettre le paramètre de mon DAW 'Dropout Protection' à Medium, High voire Maximum pour les éviter." Root cause: PitchShifter::kbdWindow was called 2-3 times per active grain per sample (3 active grains in steady state, more during attack/release). Each call ran a 10-iteration Bessel I0 series + sqrt + multiplication. Per-sample cost ≈ 600 ns; with 4 harmony voices each running their own PitchShifter, the per-block cost on a dense stack could reach ~100 ms/sec of CPU on a slow laptop, the difference between running smoothly and dropping out. Fix in Source/dsp/PitchShifter.cppkbdWindow(phase, beta): replaced the closed-form I0 with a static lookup table (2049 points, 16 KB, computed once on first use). Per-call cost drops from ~200 ns to ~10 ns. For the project's only currently used beta=6, the table is the fast path; for any other beta (none used today) the function falls back to the original closed-form for correctness. No external API change. Tests PitchShifterOutput (6145) and PitchShifterOutputRms (10242) still pass — the table is bit-faithful at the 2049 sampled phases, and the linear interpolation error is < -90 dB (well below the KBD window's ~70 dB sidelobe rejection). - [x] N (Harmony gain match for Unison / Unison+Octaves volume boost, 2026-07-17) — User report: "Le problème de gain audio avait été corrigé précédemment. Par contre, maintenant, j'ai remarqué que si j'active Harmony et surtout avec les harmonies incluant 'Unison' ou 'Unison+Octaves', le volume devient beaucoup plus fort. Est-il envisageable d'avoir un 'gain match' juste avant la sortie audio du plugin pour essayer de faire en sorte que le volume du signal audio entrant soit à peu près équivalent au signal audio sortant ?" Root cause: when harmony is enabled, the plugin sums N additional harmony voices on top of the dry signal in the output buffer (outL[i] += hL * hGain;). For dense types the additive sum is significant — Unison2 (correlated, +6 dB), UnisonOctaves4 (mixed correlated+uncorrelated, up to +9 to +12 dB peak). The harmony_volume knob scales the post-mix level but does not compensate for the additive nature of the mix. Fix: added a new harmony_gain_match boolean parameter (default ON) that scales the harmony mix by 1 / sqrt(1 + N) where N is the number of active harmony voices (from HarmonyEngine::getHarmonyVoiceCount(harmonyType)). The dry signal is untouched. The harmony_volume knob still controls the overall harmony level (post-compensation). Per-type compensation: None → 1.0, Unison2 (N=2) → 1/sqrt(3) ≈ 0.577 (-4.77 dB), UnisonOctaves4 (N=4) → 1/sqrt(5) ≈ 0.447 (-6.99 dB), VocalStack4 (N=4) → 1/sqrt(5) ≈ 0.447 (-6.99 dB), ThirdBelowAbove (N=2) → 1/sqrt(3) ≈ 0.577. User can turn the toggle OFF to restore the natural "additive" boost (useful for sound design). New regression test test/dsp/HarmonyGainMatchTest.cpp (2 sub-tests, 15 assertions) verifies the helper values. New UI button (PowerButton style) in the harmony section, next to the existing "Follow Lead" toggle. Full i18n (en/fr/de/es/ja/zh) in Source/ui/OVTLanguages.h. Unit-test suite: 98 OK / 2 KO (was 96 OK / 2 KO; the 2 KO are the pre-existing 1-sample click artifacts, Fix K2). - [x] O (residual click on continuous pitch jumps, 2026-07-17) — See K2 above for the full root-cause analysis. Two-part fix in Source/dsp/PitchShifter.cppprocess(), if (onsetDetected) block: (1) lastGrainCenter = 0.0 to force the next grain to use the local pitch-mark search branch (always correct on the new signal); (2) slowAttackSamplesRemaining 80 → 150 ms and attackRampDownSamplesRemaining 5 → 20 ms so the slow-attack envelope keeps attackGain < 0.5 during the 20-50 ms post-onset OLA re-organisation window, masking the worst-case local sum fluctuations. PitchShifterClick Test 1 (saut continu) and Test 2 (staccato) now both report clickCount == 0 (was 1 each, amplitude 0.111/0.113). PitchShifterClick Test 3 (OLA burst, no clipping) still passes — the outPhase = 1.0 clamp and per-grain attackFraction = 1.0 at onset are untouched. RMS on a sustained 200 Hz tone still 0.71 (target 0.707): gain compensation K = 1.20 and OLA parameters unchanged. Unit-test suite: 100 OK / 0 KO (was 98 OK / 2 KO). - [x] P (Editor: crash on Standalone quit in Debug — customLookAndFeel destroyed too early, 2026-07-17) — User report: closing the Standalone host in Debug mode (Visual Studio) triggered an assertion / crash during OpenVoxTunerAudioProcessorEditor destruction. Copilot diagnostic: refCount.value = 2 on the customLookAndFeel WeakReference, meaning two components still held a reference to the LookAndFeel at the moment it was destroyed. Release builds inlined the assertion away, so this only reproduced in Debug. Root cause: C++ destroys class members in reverse order of declaration (C++17 [class.dtor]/11). The previous declaration order in Source/PluginEditor.h placed ui::OVTLookAndFeel customLookAndFeel AFTER std::unique_ptr<juce::TooltipWindow> tooltipWindow and AFTER many other components (tabbedComponent, pitchVisualizer, curveEditor, scaleKeyboard, every *Attachment). All those components hold a LookAndFeel pointer (or weak reference) set via setLookAndFeel(&customLookAndFeel) in the constructor. customLookAndFeel was therefore destroyed FIRST while 2+ components still referenced it. A stale comment in the original code even read "Custom Look And Feel must be instantiated BEFORE the components that use it" — the right principle applied in the wrong direction. Fix: moved the customLookAndFeel declaration to the very top of the private: section in Source/PluginEditor.h (line 78, before the first Slider/Label/Button). After the move, all GUI components are destroyed first (releasing their LookAndFeel refs), then customLookAndFeel is destroyed last. Added a detailed comment block above the declaration explaining the destruction-order invariant for future maintainers. No code logic changed — only declaration order. Unit-test suite: 100 OK / 0 KO (unchanged; the fix is purely structural and not reachable from a unit test — verified by code inspection + the previous symptom). See changelog docs/changelogs/changelog-2026-07-17.md section "Fix P" for the full write-up. - [x] Q (Editor: residual crash on Standalone quit in Debug — per-child weak-refs not released, 2026-07-17) — User report (after Fix P): closing the Standalone in Debug still crashed with the same refCount.value = 2 assertion. tooltipWindow->setLookAndFeel(nullptr) + tooltipWindow.reset() was not enough. Root cause: Fix P only reordered the declaration of customLookAndFeel; it did not address that every child Component of the editor holds its own WeakReference<LookAndFeel> (filled at addAndMakeVisible() time when the parent's LookAndFeel was set). juce::Component::setLookAndFeel(nullptr) on the parent does NOT propagate to children. Two consequences: (1) all Sliders, Labels, Buttons, ComboBoxes, plus the inner widgets of tabbedComponent / pitchVisualizer / curveEditor / scaleKeyboard, still hold a weak-ref to the soon-to-die customLookAndFeel; (2) the standard ~Component() does release the weak-ref, but only if the Component is destroyed after the LookAndFeel — Fix P only guarantees that for the editor's direct members, not for the runtime-added children of sub-components. Fix in Source/PluginEditor.cpp~OpenVoxTunerAudioProcessorEditor(): added a recursive clearLookAndFeelRecursive(Component&) lambda that walks c.getChildren() in post-order (deepest first) and calls c.setLookAndFeel(nullptr) on every node of the editor's component tree, including the editor itself. Invoked once on *this before the existing tooltipWindow / curveEditor teardown. This is correct (clears every weak-ref), future-proof (any new child added via addAndMakeVisible is automatically covered), and order-safe (post-order walk mirrors the C++ member destruction order). Long comment block above the lambda documents why the recursion is needed. Also corrected an initial typo: getChildComponents() does not exist on juce::Component — the correct API is the Component::Children range returned by getChildren(). Unit-test suite: 100 OK / 0 KO (unchanged; the fix is in the editor destructor and not reachable from a unit test — verified at the source level + the previous symptom). - [x] R (UI: ScaleKeyboard regression — clic OFF sur touche de la gamme preset ne désactive plus la touche, 2026-07-17) — User report (after Fix L): "gamme C Natural Minor. Je clique sur la touche D du mini-piano => passage en custom. Mais la touche D ne switche pas. Les touches qui n'étaient pas dans la gamme C Natural Minor switchent bien mais pas les autres." Root cause: Fix L added activeInScale = getToggleState(); in PianoKeyButton::mouseDown to fix the visual sync, but it also did setToggleState(!getToggleState(), sendNotification) before that sync. In JUCE, the ButtonAttachment is connected via the buttonClicked callback path, which is fired by mouseUp, not by mouseDown. setToggleState(sendNotification) posts a changeNotification to listeners, but ButtonAttachment does not listen to changeNotification — it only listens to buttonClicked. So the parameter custom_i was never written. On the next refreshVisualizer() tick (~16 ms later), setToggleState(custom_i_value, dontSendNotification) restored the old value, overwriting the local toggle change. The previous test missed this because it called setToggleState + setActiveInScale directly, bypassing the real click path. Fix: in Source/ui/ScaleKeyboardComponent.h remove the mouseDown/mouseUp override; declare void clicked() override; instead. In Source/ui/ScaleKeyboardComponent.cpp implement PianoKeyButton::clicked() out-of-line: setToggleState(!getToggleState(), juce::dontSendNotification) (toggles locally without re-entering through the parameter-listener chain — setToggleState(sendNotification) and juce::ToggleButton::clicked() both caused a 0xC00000FD stack overflow or silently no-op'd in this project's build), then activeInScale = getToggleState() (visual sync), then onUserInteraction() (switch to Custom). The ButtonAttachment is still updated correctly on a real mouse click because JUCE's mouseUp fires buttonClicked to the attachment's listener — independently of our setToggleState notification choice. The test in test/ui/ScaleKeyboardComponentTest.cpp was rewritten to call clicked() directly (the public entry-point that the Standalone/VST3 use in real life) and now has 4 sub-tests, 11 assertions (was 3, 7). Unit-test suite: 101 OK / 0 KO (was 100 OK / 0 KO). - [x] S (UI: Gain Match toggle now sits BELOW Follow Lead, 2026-07-17) — User request: "Pour le Gain Match des Harmonies, je voudrais qu'il soit positionné sous 'Follow Lead' au lieu d'être à côté." Cosmetic layout change in Source/PluginEditor.cpp (resized()): the harmonyFollowLeadButton and harmonyGainMatchButton are now stacked on two rows (Follow Lead on top, Gain Match below, both left-aligned at the same x position) instead of placed side-by-side. No behaviour change. Unit-test suite: 101 OK / 0 KO (unchanged; the test harness does not exercise the resized() layout). - [x] T (DSP: latency-mode not re-applied on insert re-enable, 2026-07-17) — User report: "je fixe latence 'Direct Monitoring' (10ms), mon DAW affiche 10ms. Je désactive les inserts de la piste puis je réactive les inserts, mon DAW affiche alors 20ms alors que dans le plugin, j'ai toujours Latence='Direct Monitoring'." Root cause: applyLatencyMode() in Source/PluginProcessor.cpp had an early-return if (mode == appliedLatencyMode) return; to avoid spamming the host on every block. On the first activation, appliedLatencyMode = -1 so the early-return didn't fire and setLatencySamples(10ms) was called correctly. But on insert re-enable, the host (Studio One in particular) does NOT always re-fire prepareToPlay() (it depends on the VST3 enable/disable model — bypass-style disable does not re-fire prepare on re-enable). The plugin's last setLatencySamples() remains the most recent one, but the host's PDC is stale. Fix: in Source/PluginProcessor.cpp (prepareToPlay()) reset appliedLatencyMode = -1 immediately before calling applyLatencyMode(). The early-return never triggers on prepare, so setLatencySamples() is always re-issued. The early-return is preserved in syncParameters() so the per-block hot path is unaffected. The cost of a single setLatencySamples() call (~100 ns) is negligible. Unit-test suite: 101 OK / 0 KO (unchanged; the test harness does not exercise prepareToPlay()). - [x] U (DSP: audio dropouts with Flex or Attack enabled — file logger I/O in audio thread, 2026-07-17) — User report: "Avec toutes les améliorations qu'on a faite, j'ai constaté des dropouts réguliers. Je suis obligé de mettre le paramètre de mon DAW 'Dropout Protection' à Medium, High voire Maximum pour les éviter. Ces dropouts se produisaient dans 2 cas : si Flex est activé (valeur > 0) ou si Attack est activé." Root cause: After Fix M (kbdWindow lookup table), kbdWindow was no longer the dominant CPU cost. The next-largest cost in the audio callback was the SimpleFileLogger: every OVT_LOG call inside the audio thread was opening, writing to, and closing a file on disk (file.appendText(line)). On Windows, this is ~1-5 ms per call. Several OVT_LOG sites (FlexTune, Amount, pitchShifter->process, harmony notes) are gated to fire ~once per second, but each call still costs 1-5 ms. With Flex/Attack adding more DSP work near those sites, the CPU budget margin was narrow enough that the file-I/O spikes pushed 512-sample blocks over the 11.6 ms deadline. The file logger was installed in BOTH Debug AND Release builds (the comment in PluginProcessor.cpp line 539 said "Install file logger in Debug AND Release (so we can diagnose drops in the field)"). Fix: replace SimpleFileLogger with BufferedFileLogger in Source/PluginProcessor.cpp. The new logger accumulates messages into a thread-safe std::vector<juce::String> in the audio callback (wait-free: just a CriticalSection push), and a 200 ms juce::Timer flushes the queue in ONE disk write per tick (on the message thread, decoupled from the audio callback). Per-message OutputDebugStringA still fires synchronously (non-blocking, copies to a kernel buffer, safe in the audio thread) for real-time debugging via DebugView. The log file will now be updated in 200 ms batches instead of one line at a time — up to 200 ms latency between an event and its appearance in the file, but file I/O cost in the audio callback is now ~0. Unit-test suite: 101 OK / 0 KO (unchanged; the test harness does not exercise the logger). - [x] V (DSP: OVT_LOG gated in Release — no more per-block string allocations in PluginProcessor, 2026-07-17) — After Fix U (BufferedFileLogger), dropouts in Studio One with Flex > 0 or Attack were still reported by the user. The file-I/O cost had been amortised, but the audio callback still allocated a juce::String per OVT_LOG call (a Logger::writeToLog(arg) call always formats arg into a new String before passing it to the logger, even if the logger does nothing with it). At the MIDI: f0_out=... site (processBlock line 2091), this happened on every audio block while singing (~100 calls/sec), plus ~5 calls/sec for the other gated logs (FlexTune, Amount, processBlock, pitchShifter->process, harmony). On real-time-constrained DAWs like Studio One, these allocations plus the BufferedFileLogger push_back (which still takes a CriticalSection) were enough to push the 11.6 ms block deadline (~512 samples at 44.1 kHz) and cause audible dropouts when the surrounding DSP (Flex/Attack) was already pressing on the CPU budget. Root cause: Source/PluginProcessor.cpp defined OVT_LOG as #define OVT_LOG(msg) juce::Logger::writeToLog (msg) unconditionally — unlike Source/dsp/PitchShifter.cpp, which wraps its OVT_LOG in #if JUCE_DEBUG (with a do { } while (false) no-op in Release). The asymmetry meant that in Release builds the OVT_LOG macro still executed the juce::Logger::writeToLog call (which takes the arg, formats it, and walks the listener chain) on every invocation — even if no logger was attached. Fix: in Source/PluginProcessor.cpp, wrap OVT_LOG in #if defined(JUCE_DEBUG) || defined(OVT_FORCE_LOG). In Release without OVT_FORCE_LOG, the macro expands to do { } while (false) (no operation, no allocation, no lock). The BufferedFileLogger is still installed in Release (for in-the-field diagnostics via OutputDebugStringA) but receives zero messages, so its pending queue stays empty and the 200 ms juce::Timer does no work. OVT_FORCE_LOG is an opt-in escape hatch for Release builds where the developer wants to re-enable logging for diagnosis (e.g. when reproducing a customer-reported bug), settable via -DOVT_FORCE_LOG in CMAKE_CXX_FLAGS or the IDE's preprocessor definitions. Long comment block above the macro explains the inconsistency with PitchShifter.cpp and the real-time cost that motivated the gating. Unit-test suite: 101 OK / 0 KO (unchanged; the fix is compile-time only, no runtime behaviour change for the tests). - [x] W (DSP: per-block MIDI log gated to ~1/sec — Debug builds also free of string allocation in hot path, 2026-07-17) — After Fix V, Release builds are clean, but Debug builds still fired one OVT_LOG per audio block at the "MIDI: f0_out=..." site (processBlock line 2091), because the OVT_LOG macro is still active in Debug. This allocation pressure was the second cause of the dropouts the user reported when running Debug builds of the plugin in the DAW for live debugging. Root cause: the MIDI pitch log was previously unconditional once the singer was producing a valid f0_out (the gate is if (f0_out > 0.0f), which is true ~100% of the time while the user is singing). At ~100 audio blocks/sec, this is ~100 string allocations per second in Debug, plus 100 push_backs into the BufferedFileLogger queue. On a Debug build, the cost is amplified by the absence of inlining, the lack of Release-mode optimisations, and the heavier allocator behaviour. Combined with the Flex/Attack DSP overhead, this pushed the Debug-mode block over the deadline. Fix: in Source/PluginProcessor.cpp (processBlock line 2091), wrap the OVT_LOG call in a static std::atomic<uint32_t> time-gate (same pattern already used for the FlexTune, Amount, harmony, and pitchShifter logs). The log now fires at most ~once per second when there is live pitch output. The static atomic uses a 1-second cooldown (if (nowM - lastM > 1000)) and the standard compare_exchange_strong lock-free update. The cost of the gate itself is ~30 ns per block — negligible. The underlying diagnostic information (per-block f0_out) is still available at 1 Hz granularity, which is more than enough to verify the MIDI output path during a session. Unit-test suite: 101 OK / 0 KO (unchanged; the fix is conservative). - [x] X (UI: ScaleKeyboardComponent toggle now syncs both custom_i and activeInScale, 2026-07-17) — After Fix R, the user reported two regressions: (1) "je ne peux plus basculer de touches" — clicking a preset-scale note no longer toggled its visual state; (2) "la combo une fois passée en custom ne se met plus vraiment à jour en changeant de gamme sauf pour la gamme originelle" — switching to a different preset from Custom did not refresh the keyboard. Root cause: Fix R overrode PianoKeyButton::clicked() and did setToggleState(!, dontSendNotification) + onUserInteraction(), bypassing the base-class juce::ToggleButton::clicked() which does the full setToggleState(!current, sendNotification) + sendClickMessage(modifiers) flow. sendClickMessage is private in this version of JUCE, but the path that calls it is Button::clicked(). By skipping the base class entirely, we never fired sendClickMessage and the ButtonAttachment never saw the click — so custom_i stayed at its old value, the local visual toggle was reverted on the next refreshVisualizer() tick, and the scaleBox.onChange() flow (which reads custom_i to seed the new preset's intervals) saw stale data. Fix: in Source/ui/ScaleKeyboardComponent.h, removed the clicked() override entirely. Added an internal InteractionListener (subclass of juce::Button::Listener) as a private member. The constructor registers it via addListener(&interactionListener). The destructor detaches it via removeListener to prevent dangling pointers if the button is destroyed mid-dispatch. In Source/ui/ScaleKeyboardComponent.cpp, the constructor binds interactionListener.owner = this; the listener's buttonClicked callback mirrors the new getToggleState() into activeInScale and fires onUserInteraction. Also added a triggerClick() override in the header that does the same setToggleState(!current, sendNotification) + juce::Button::triggerClick() sequence a real mouse click would do (necessary because the test must use the public triggerClick() since Button::clicked() is protected and sendClickMessage is private). The flow is now: a real mouse click does mouseUp -> Button::clicked -> setToggleState(!, sendNotification) + sendClickMessage -> all Button::Listener notified (ButtonAttachment + our InteractionListener). Both the parameter push (via ButtonAttachment) and the visual sync + Custom-mode switch (via InteractionListener) happen in the same dispatch cycle. Unit-test suite: 101 OK / 0 KO (replaces the old test that called the now-removed clicked() override directly with a new test that uses triggerClick()). - [x] Y (UI: Follow Host + Gain Match disabled when Harmony is off, 2026-07-17) — User report: "Follow Host et Gain Match font partie du groupe Harmony, si Harmony est désactivé, ils devraient l'être aussi." Before this fix, the two sub-toggles under the Harmony enable button remained clickable when Harmony was disabled, and any state the user set on them while Harmony was off would be silently remembered and re-applied when Harmony was re-enabled. Fix: in Source/PluginEditor.cpp (Editor constructor, after wiring up the Harmony attachments), attach an onStateChange lambda to harmonyEnableButton that mirrors its toggle state into harmonyFollowLeadButton.setEnabled(enabled) and harmonyGainMatchButton.setEnabled(enabled). Force the initial sync with a manual block right after, because onStateChange may not have fired yet at the point the ButtonAttachment is created. juce::Button::onStateChange is a public std::function<void()> that fires after every state change, independently of the ButtonAttachment's internal onClick / changeNotification plumbing — so it does not interfere with the host sync. Unit-test suite: 101 OK / 0 KO (no behavioural change in tests; this is UI-only). - [x] Z (DSP: Attack RMS gated on isEnabled() — eliminate per-block waste when Attack is off, 2026-07-17) — After Fixes U/V/W, the user reported that dropouts in Studio One with Flex > 0 OR Attack enabled were STILL present (with Dropout Protection = Medium). User test environment confirmed: 256 samples, Release build, no other features active (Harmony / Sidechain / Formant disabled). Buffer deadline at 256 samples / 44.1 kHz is 5.8 ms. Root cause: the Attack-aware correction block in Source/PluginProcessor.cpp (processBlock ~line 1644) was unconditionally computing the per-block input RMS at every audio callback, even when the user had not enabled the Attack feature. The attackEnv.process() itself short-circuits when !enabled (returns 1.0, no effect on amount). But the input RMS loop (for (int i = 0; i < n; ++i) sumSq += d[i]*d[i]) is before the process() call, so it runs every block even when Attack is disabled. At 256 samples / 44.1 kHz that is ~256 multiplications per block, totalling ~25K multiplications per second of pure waste. With a tight 5.8 ms deadline and FlexTune adding its own smoothing work, this is enough to push the deadline on slower laptops. attackEnv.setEnabled(false) does not zero the enabled flag instantaneously (it's an atomic load + branch), and the RMS computation was outside the gate, so disabling Attack did not help. Fix: in Source/PluginProcessor.cpp (processBlock ~line 1657), wrap the RMS computation AND the attackEnv.process() call inside if (attackEnv.isEnabled()). When disabled, the amount *= attackEnv.process(...) line is skipped entirely (which is correct: the disabled-mode return value is 1.0, so omitting the multiplication has the same effect on amount). This eliminates the per-block waste and brings the Attack-disabled case back to its baseline cost (a single atomic.load + a branch on the isEnabled() flag). Unit-test suite: 101 OK / 0 KO (no behavioural change in tests; the disabled path was already correct, we just skip it instead of computing a useless RMS). - [x] AA (UI: ScaleKeyboard clic bascule en Custom (scaleChoiceParam propagation), 2026-07-17) — User report (after Fix X): "je démarre le standalone en C Natural Minor. Je clique sur la touche E, la touche s'active => bascule en Scale Custom => OK. Je clique à nouveau sur E, la touche se désactive => OK. je clique sur D => rien ne se passe (touche dans la gamme précédente C Natural Minor). je clique sur C => rien ne se passe (touche dans la gamme précédente C Natural Minor). Je clique sur A => bascule OK (n'est pas dans la gamme précédente C Natural Minor)." Root cause: the per-key onUserInteraction lambda (set on each PianoKeyButton from PluginEditor.cpp) was writing the Custom index into the raw atomic via processorRef.getParameters().getRawParameterValue("scale")->store(1.0f) and forcing the combo display with scaleBox.setSelectedItemIndex(numItems - 1, dontSendNotification). The rawScale atomic and the scaleChoiceParam (AudioParameterChoice) are two different accessors of the same underlying value, and writing to one does NOT notify the listeners registered on the other. The ButtonAttachment and ComboBoxAttachment listen to scaleChoiceParam, not the raw atomic. The audio callback's syncParameters() reads scaleChoiceParam->getIndex() to rebuild the scale intervals — so on the next block the previous preset's intervals were re-pushed into the quantizer and the keyboard, undoing the user's toggle. Fix in Source/PluginEditor.cpp: replace the rawScale->store(1.0f) + setSelectedItemIndex(dontSendNotification) pair with a direct scaleChoiceParam->setValueNotifyingHost(normalized) call. This propagates the new scale value through the official JUCE parameter path: valueChanged fires, the ComboBoxAttachment repaints the combo (with sendNotificationSync so the scaleBox.onChange handler runs in the same dispatch and short-circuits on the Custom branch), and the audio callback's syncParameters() sees the Custom index on the very next block. Guard if (scaleParam->getIndex() != customIdx) avoids a redundant write when the user clicks again while already in Custom. Unit-test suite: 101 OK / 0 KO (the path is exercised on the same dispatch chain that the existing ScaleKeyboardComponent test already covers via triggerClick() + onUserInteraction). Note: Fix AA only resolved the propagation half of the bug — the visible combo update worked, but preset-scale notes still did not toggle their custom_i flag (see Fix AB). See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AA" for the full write-up. - [x] AB (UI: ScaleKeyboard clic en live — JUCE 8 clickTogglesState = false par défaut, 2026-07-17) — User report (after Fix AA, 16:55 CEST): "Pour les gammes. Je démarre le standalone en C Natural Minor. - Je clique sur la touche E, la touche s'active => bascule en Scale Custom => OK. - Je clique à nouveau sur E, la touche se désactive => OK. - je clique sur D => rien ne se passe (touche dans la gamme précédente C Natural Minor). - Je clique sur C => rien ne se passe (touche dans la gamme précédente C Natural Minor). - Je clique sur A => bascule OK (n'est pas dans la gamme précédente C Natural Minor). - Je clique sur Major => les bonnes touches sont sélectionnées dans le mini-piano. - je ne peux basculer aucune des touches qui font partie de C Major mais je peux basculer les autres. - Si j'active une touche qui n'est pas dans la gamme, ça ne passe pas en Custom, reste en C Major." Root cause: PianoKeyButton extends juce::ToggleButton. In JUCE 7, the juce::ToggleButton constructor called setClickingTogglesState(true) so a real mouse click toggled the button state. In JUCE 8, this call was removed from the ToggleButton constructor — the default Button::clickTogglesState is now false. So when the user clicks a PianoKeyButton in the live plugin: Button::internalClickCallback(modifiers) runs; if (clickTogglesState) is false, the entire setToggleState(!, sendNotification) branch is SKIPPED; only sendClickMessage(modifiers) fires, which notifies the Button::Listener entries (our InteractionListener and the ButtonAttachment) but does NOT change the button's isOn flag. The downstream effects: InteractionListener.buttonClicked sets activeInScale = getToggleState() (the OLD value, unchanged) and calls onUserInteraction() which switches the scale to Custom (good); ButtonAttachment.buttonClicked reads getToggleState() (still the OLD value), compares to lastValue (still the OLD value), and finds them equal — the custom_i parameter is NEVER written by a live click. For a preset-scale note, activeInScale = true (set by the refreshVisualizer timer from the preset intervals) and getToggleState() is unchanged → the button stays lit. The user sees "rien ne se passe". For a non-preset note, activeInScale = false; after Fix AA the first click switches the scale to Custom but custom_i is still not written, so the next refreshVisualizer tick re-pushes the preset intervals and the key stays inactive visually. The unit test in ScaleKeyboardComponentTest.cpp was unaffected because it uses PianoKeyButton::triggerClick(), our test-only override that does the toggle explicitly — the production code path (internalClickCallback) was not exercised by any test, so the bug was invisible to the test suite. Fix in Source/ui/ScaleKeyboardComponent.cpp: in PianoKeyButton::PianoKeyButton() constructor, add setClickingTogglesState(true) right after the base class initializer. This restores the JUCE-7 behaviour for our subclass and makes real mouse clicks toggle the button state on the live plugin. The internalClickCallback branch if (clickTogglesState) { setToggleState(!, sendNotification); return; } will now actually run on every click, the ButtonAttachment will write the new value to the custom_i parameter, and the InteractionListener (already wired) will sync activeInScale + fire onUserInteraction in the same dispatch. New regression test in test/ui/ScaleKeyboardComponentTest.cpp: asserts PianoKeyButton.isToggleable() is true on a freshly constructed button. This catches the case where someone removes the setClickingTogglesState(true) call and the bug silently comes back. Unit-test suite: 102 OK / 0 KO (was 101 OK / 0 KO). Manual verification: in the live plugin (Standalone or DAW), start on C Natural Minor. Click D, E, A, C in any order. Each click should: (1) toggle the button's custom_i flag, (2) switch the scale combo to "Custom" (only on the first click; subsequent clicks leave the combo on Custom), (3) visually flip the lit/unlit state of the clicked key. The pattern reported by the user (clicking D did nothing) should now be gone. Note: all three fixes L, R, X, AA, AB are required for the scale keyboard to behave correctly in a live DAW — they form an unbroken chain from the raw click to the audio callback. See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AB" for the full write-up. - [x] AC (UI: ScaleKeyboard combo shows "Custom" after selecting a preset — onUserInteraction re-fires on programmatic toggle, 2026-07-17) — User report (after Fix AB): "clic sur Natural Minor dans la combo => les touches correspondantes s'affichent bien mais dans la combo scale, j'ai 'Custom' et pas la gamme que je viens de sélectionner. clic à nouveau sur Natural Minor dans la combo => cette fois, la gamme s'applique bien dans la combo." Root cause: PluginEditor.cpp scaleBox.onChange (preset index, not Custom) rewrites all 12 custom_i AudioParameterBool via setValueNotifyingHost(targetVal) to match the preset intervals. Each call triggers the key's ButtonAttachment, which calls setToggleState(newValue, sendNotificationSync) on the PianoKeyButton, which fires buttonClicked on our InteractionListener, which calls onUserInteraction() — the callback that does scaleChoiceParam->setValueNotifyingHost(Custom) (Fix AA). So selecting a preset immediately re-set the scale parameter back to Custom; only the second click (already in Custom mode, where the if (scaleParam->getIndex() != customIdx) guard short-circuits) left the preset selected. The PianoKeyButton could not tell a user click apart from a programmatic toggle. Fix in Source/ui/ScaleKeyboardComponent.h + .cpp: added ScaleKeyboardComponent::setUpdatingFromScaleCombo(bool) / isUpdatingFromScaleCombo() and a back-reference PianoKeyButton::setParentComponent(ScaleKeyboardComponent*) + isInteractionSuppressed() (out-of-line in the .cpp so the incomplete ScaleKeyboardComponent type is fully defined at compile time of the body). In PianoKeyButton::InteractionListener::buttonClicked, after syncing activeInScale, skip onUserInteraction when owner->isInteractionSuppressed(). In PluginEditor.cpp scaleBox.onChange, wrap the 12-key loop in scaleKeyboard.setUpdatingFromScaleCombo(true)false. The visual activeInScale sync still applies to every key, but the combo is no longer reverted to Custom. New 5th sub-test in test/ui/ScaleKeyboardComponentTest.cpp ("Suppression onUserInteraction pendant maj depuis combo") asserts onUserInteraction count stays 0 while the toggle is still applied (OFF) when suppression is active. Unit-test suite: 103 OK / 0 KO (was 102 OK / 0 KO). Manual verification: in the live plugin, start on C Natural Minor, click a key to enter Custom, then select "Natural Minor" from the combo — the combo now immediately shows "Natural Minor" and the correct notes are lit. No second click needed. See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AC" for the full write-up. - [x] AD (UI: Correction "Advanced" expand/collapse state persisted across sessions, 2026-07-17) — User request: "j'aimerai que l'état du bouton d'expansion/réduction soit gardé en mémoire entre les sessions. Donc si le premier block est étendu pour afficher la zone 'corrections' (Flex, Humanize...). Au redémarrage du standalone/plugin, il devrait être automatiquement étendu aussi." Root cause: advancedExpanded was a plain bool member of OpenVoxTunerAudioProcessorEditor (PluginEditor.h), defaulting to false. It was never written to / read from the persisted plugin state (getStateInformation / setStateInformation in PluginProcessor.cpp), so every fresh editor instance started collapsed regardless of the user's last choice. Fix (follows the existing waveformDisplayType UI-only preference pattern): in Source/PluginProcessor.h added bool advancedExpandedState = false; + getAdvancedExpanded() / setAdvancedExpanded(bool); in Source/PluginProcessor.cpp getStateInformation serialize xml->setAttribute("advancedExpanded", advancedExpandedState ? 1 : 0); in setStateInformation restore advancedExpandedState = xmlState->getBoolAttribute("advancedExpanded", false); in Source/PluginEditor.cpp the editor constructor reads advancedExpanded = processorRef.getAdvancedExpanded() before setting the toggle state, and advancedButton.onClick writes processorRef.setAdvancedExpanded(advancedExpanded) on every toggle. Unit-test suite: 103 OK / 0 KO (unchanged; no test exercises the persisted editor state). Manual verification: expand the Correction "Advanced" block, close and reopen the standalone/plugin (or reload the project in a DAW) — the block returns expanded. Collapsing and reopening keeps it collapsed. See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AD" for the full write-up. - [x] AE (UI: Follow Lead + Gain Match + Use Voice disabled whenever Harmony is off, 2026-07-17) — User report: "Follow Host et Gain Match font partie du groupe Harmony, si Harmony est désactivé, ils devraient l'être aussi. Actuellement, si je désactive Harmony, je vois toujours ces 2 options et peut les activer/désactiver." Root cause (after the first attempt in Fix Y / the previous AE draft): onStateChange only fires on a direct click and the ButtonAttachment drives the button with dontSendNotification, so preset/automation changes bypassed it; and refreshLabels() (which already disabled harmonyTypeBox/harmonyGainSlider/harmonyBlendSlider) still omitted useVoiceButton and the two sub-toggles. The custom PowerButton LookAndFeel::drawToggleButton also rendered disabled buttons identically to enabled ones (it only looked at isOn, never isEnabled()), so even when setEnabled(false) was applied the user could not tell and the glow stayed on. Fix in Source/PluginEditor.cpp + Source/ui/LookAndFeel.cpp: (1) refreshLabels() now also calls useVoiceButton.setEnabled(isHarmonyEnabled), harmonyFollowLeadButton.setEnabled/ setInterceptsMouseClicks(isHarmonyEnabled, isHarmonyEnabled) and the same for harmonyGainMatchButton (physical click block, since the custom PowerButton render ignores isEnabled()); (2) harmonyEnableButton.onStateChange calls refreshLabels() for the immediate-click path; (3) timerCallback() (30 fps) watches harmonyEnableButton.getToggleState() against a new lastHarmonyEnabled member and re-runs refreshLabels() whenever it changes — this covers preset load / DAW automation that never fires onStateChange; (4) LookAndFeel::drawToggleButton now dims a PowerButton (alpha 0.25, no glow) when !button.isEnabled(), so the disabled state is visually obvious. Unit-test suite: 103 OK / 0 KO (unchanged; UI-only, verified by build + code inspection). Manual verification: in the live plugin, toggle Harmony off — Follow Lead, Gain Match and Use Voice go grey, the glow disappears, and they stop responding to clicks; toggle Harmony on — they re-enable. Switching the UI language with Harmony off also keeps them greyed. See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AE" for the full write-up. - [x] AF (UI: Keyboard Shortcuts popup no longer truncates text in any language, 2026-07-17) — User report: "La popup Keyboard Shortcuts peut avoir des textes tronqués selon la langue sélectionnée." Root cause: HelpOverlayComponent::paint used a hardcoded boxW = 440 with two 204px columns each split into a 94px key cell and a 94px description cell; g.drawText(... centredLeft) clips anything that does not fit, so long keys ("Right-click / Alt+Click", "Ctrl / Cmd + Shift + Z") and long translations (German) were clipped, and the width never adapted to the language. Fix in Source/PluginEditor.cpp: the panel now measures the widest key (maxKeyW) and widest description (maxDescW) with juce::Font::getStringWidth in the current language and sizes itself so neither is ever truncated; the title and close-hint widths are also measured and accounted for. The list loop is driven by numShortcuts / rowsPerCol constants. Unit-test suite: 103 OK / 0 KO (unchanged; UI-only). Manual verification: open the Help popup across all 6 languages — every key and description is fully visible, and the panel is wider for longer strings. See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AF" for the full write-up. - [x] AG (UI: Help popup scoped to the Curve Editor + OpenVoxKey program icon added, 2026-07-17) — User questions: (1) "y-a-t-il des shortcuts existants dans le plugin qui ne sont pas visibles dans ce popup?" — Answer: no, all shortcuts that exist are already listed; but the popup presented the Curve-Editor-scoped shortcuts (Ctrl/Cmd+Z/Y, Ctrl/Cmd+C/V, Delete) as if global, while OpenVoxTunerAudioProcessorEditor::keyPressed() is empty and those only fire inside PitchCurveEditor when it has focus. (2) "Concernant le plugin companion OpenVoxKey, il n'y a pas l'icone du programme à côté du titre 'OpenVoxKey'" — the companion editor set only titleLabel.setText("OpenVoxKey", …) with no logo. Also answered: there is no global plugin Undo/Redo — the only system is local to PitchCurveEditor (juce::UndoManager, curve-point edits) with its own on-screen Undo/Redo buttons; the main editor has none and keyPressed() is unused. Fix: (1) Source/ui/OVTLanguages.h added kHelpScope (6 languages) = "Shortcuts apply in the Curve Editor when it has focus"; (2) Source/PluginEditor.cpp draws that scope subtitle under the title; (3) Source/companion/OpenVoxKeyEditor.h draws the same stylized "O" + pitch curve logo to the left of "OpenVoxKey" (title label trimmed left to sit beside it), matching OpenVoxTuner. Unit-test suite: 103 OK / 0 KO (unchanged; UI-only). Manual verification: Help popup shows the scope line; OpenVoxKey shows the cyan logo next to its title. See changelog docs/changelogs/changelog-2026-07-17.md section "Fix AG" for the full write-up. - [x] AH (Feature: global plugin Undo/Redo, Option 1, 2026-07-17) — Follow-up to AG (which documented that no global Undo/Redo existed). User asked for an analysis of a global plugin-level Undo/Redo, then chose Option 1: a single juce::UndoManager in the processor covering the full AudioProcessorValueTreeState (all automatable parameters), with the pitch curve keeping its own separate PitchCurveEditor undo. Implementation: OpenVoxTunerAudioProcessor owns pluginUndoManager; the editor snapshots copyState() at each gesture boundary (slider drag start, 30 fps live baseline for clicks/combos, preset loads) and pushes a PluginStateUndoAction (before/after ValueTree copies) on commit; keyPressed() handles Ctrl/Cmd+Z (undo), Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y (redo); Undo/Redo icon buttons (curved-arrow SVGs, localized kTooltipUndo/kTooltipRedo) sit in the top header between the B button and the Options gear, tracking canUndo()/canRedo(). Bug caught by the new unit test: juce::UndoManager::perform() coalesces consecutive perform() calls into ONE transaction unless a new transaction is started, so multiple user edits collapsed into a single undo step (the ordered-sequence test jumped straight to baseline on first undo). Fixed by calling beginNewTransaction() before each pushUndoAction perform, and restoring snapshots via replaceState(snapshot.createCopy()) (so replaceState's aliasing assignment never corrupts a stored snapshot with later live edits). New test/dsp/PluginUndoTest.cpp (4 sub-tests, 16 assertions). Unit-test suite: 107 OK / 0 KO (was 103 OK / 0 KO). See changelog docs/changelogs/changelog-2026-07-17.md section "Feature — global plugin Undo/Redo (Option 1)" for the full write-up.

8b. Dropout fix for FlexTune + Attack features at low buffer sizes (2026-07-23)

Root-cause analysis of audio dropouts reported by the user when the FlexTune or Attack features are active with the DAW's buffer size set to 128 or 256 samples. The dropouts disappeared when the user raised the DAW's "Dropout Protection" to Medium or higher, or when the buffer was raised to 512/1024 samples — strongly suggesting a CPU-budget issue specific to those features at small buffer sizes. All fixes implemented and covered by unit tests (OpenVoxTunerTests, 130 OK / 0 KO, was 107 OK / 0 KO). - [x] AI (DSP: BlockAwareOnePole utility for buffer-size independent smoothing, 2026-07-23) — A small, focused one-pole IIR smoother (Source/dsp/BlockAwareOnePole.h, ~50 lines) that applies a single IIR step per audio block with the alpha computed from the actual block duration (alpha_block = 1 - exp(-blockDurSec / tauSeconds)). This is the exact same pattern used by RetargetEnvelope::processBlock and makes the effective time constant tau in seconds INDEPENDENT of the block size. The previous per-block form y = y*0.95 + x*0.05 had alpha=0.05 PER BLOCK, so the time constant in samples was 1/alpha = 20 blocks, which at 128 samples/block = 2560 samples = 58 ms and at 256 samples/block = 5120 samples = 116 ms — a 2x difference between buffer sizes. With the new helper, a 200 ms smoother takes 200 ms to reach 63% at ANY buffer size (verified by the regression test for 64/128/256/512/1024 sample buffers, all within 10% of the expected 4410 samples at 44.1 kHz / 100 ms tau). The helper also exposes a processBypassed(target) short-circuit to skip the per-block exp() when the feature is off. API: prepare(sr), setTimeConstantSeconds(tau), reset(initial), snapTo(value), processBlock(target, numSamples), processBypassed(target), getCurrentValue(). The helper is in namespace ovtdsp to keep the DSP code's namespace convention. - [x] AJ (DSP: FlexTune smoothing buffer-size independent, 2026-07-23) — User report: "dropouts audio in Studio One when FlexTune is enabled (values > 0), including while the singer is holding a sustained note. Dropouts disappear when DAW dropout protection is set to Medium or higher, or when buffer is raised to 512/1024." Root cause: the previous smoothedFlexTuneAmount = smoothedFlexTuneAmount * 0.95f + currentFlexTuneAmount * 0.05f; in Source/PluginProcessor.cpp was buffer-size dependent (see Fix AI for the full analysis). At 128 samples, the smoother reached 63% in 58 ms, at 256 samples in 116 ms. The user perceived this as inconsistent behaviour between monitoring (low latency, 128 samples) and mixing (high latency, 1024 samples) — and the per-block modulation of smoothedFlexTuneAmount was an audible warble that the OLA chain couldn't mask cleanly. Fix in Source/PluginProcessor.cpp + Source/PluginProcessor.h: replace the two raw float members (smoothedFlexTuneAmount, currentFlexTuneAmount) with ovtdsp::BlockAwareOnePole flexTuneSmoother (TC = 200 ms). The smoother is initialised in prepareToPlay() and reset in reset(). When flexTuneCents <= 0.5 (feature off), the smoother is bypassed (processBypassed(1.0f)) to avoid the per-block exp() call — saves a few hundred cycles per second. The amount applied to the target ratio is now flexTuneSmoother.getCurrentValue() (replaces the old smoothedFlexTuneAmount). The 200 ms TC is a comfortable compromise: fast enough to engage/disengage the deadband within a 50-200 ms typical "drift" cycle, slow enough that the smoothed value doesn't modulate the target ratio on a per-block basis. No behavioural change for users: the FlexTune multiplier still transitions between 0 and 1, but with a smooth, buffer-size independent profile. - [x] AK (DSP: Humanize smoothing buffer-size independent, 2026-07-23) — Same root cause as FlexTune (Fix AJ) for the per-block currentHumanizeCents = currentHumanizeCents * 0.95f + targetCents * 0.05f; in Source/PluginProcessor.cpp. The Humanize feature adds a ±0.08 * amount random walk in cents to f0_target, so a buffer-size dependent smoother means the random walk's audible "speed" depends on the DAW's buffer size — a confusing inconsistency. Fix: replaced the raw float with ovtdsp::BlockAwareOnePole humanizeSmoother (TC = 150 ms). The smoother is initialised in prepareToPlay() and reset in reset(). When Humanize is off (amount < 1.0 or no pitch shift is happening), the smoother is snapTo(0.0) (instant return to zero cents) so the humanize stops the moment the user turns the knob down — matches the existing per-block decay behaviour but is buffer-size independent. - [x] AL (DSP: Attack feature no longer triggers the PitchShifter's internal attack envelope — eliminates "scratch" with low Amount, 2026-07-23) — User report: "des coupures audio intempestives et des effets de scratch très perceptibles apparaissent systématiquement au démarrage des notes chantées (ces anomalies ne surviennent pas pendant que la note est maintenue). Ces anomalies sont particulièrement audibles avec Amount réglé sur une valeur basse." Root cause: when the user has Attack enabled, the ovtdsp::AttackAwareEnv helper drops the correction amount to 0 at note onset and ramps it back to 1 over ~60 ms. The PitchShifter's INTERNAL attack envelope (attackMs, default 30 ms, with a 150 ms slow attack and 20 ms ramp-down on pitch jumps) ALSO fires at every onset, independently. The two envelopes compound: the helper zeroes the correction (no audible correction during the first 60 ms), and the internal envelope ramps the output gain from 1.0 down to ~0.78 and back over 170 ms. The combined effect is a "double attenuation" that the user perceives as a "scratchy" / "saturé" artifact at the start of every note, especially with low Amount values where the user expects subtle correction. Fix in three parts: (1) Source/dsp/PitchShifter.h: added setAttackEnvelopeEnabled(bool) and isAttackEnvelopeEnabled() accessors + a private attackEnvelopeEnabled flag (default true for backward compat). When disabled, the setter snaps attackGain = 1.0 so the output is never muted. (2) Source/dsp/PitchShifter.cpp: gated the per-block attackGain = 0.0f reset on blockOnset, the onsetDetected block's slowAttackSamplesRemaining / attackRampDownSamplesRemaining arming, and the per-sample IIR envelope on attackEnvelopeEnabled. The OLA chain reset (outPhase = 1.0, lastGrainCenter = 0.0) is NOT gated — it must always run on onsets to keep the OLA from mis-aligning the first grain of a new note (see Fix F/K2). (3) Source/PluginProcessor.cpp: in the AttackAwareEnv block, push the helper's on/off state to every active PitchShifter (pitchShifter, all 4 shiftedVoicePitchShifters) via setAttackEnvelopeEnabled(! attackOn). When Attack is ON, the internal envelope is OFF (and vice versa), so the two never compound. The AttackAwareEnv's release time is the only thing controlling the onset attenuation. The fix is verified by a new AttackScratchTest (4 sub-tests, 9 assertions) that measures the average output RMS over the first 170 ms after a pitch jump with envelope ON vs OFF; with envelope ON the onset RMS is ~6% lower than steady-state, with envelope OFF it is within 5% of steady-state. Side benefit: the per-block CPU cost of the internal envelope (a few hundred ns of branch + IIR) is now skipped on the audio callback's hot path when Attack is enabled, which contributes a small but measurable CPU saving (relevant at 128 sample buffers). - [x] AM (DSP: new tests for buffer-size independence + Attack coordination, 2026-07-23) — New tests added to CMakeLists.txt: test/dsp/BlockAwareOnePoleTest.cpp (7 sub-tests, ~20 assertions) covers prepare/reset, snapTo, tau=0 instant response, the buffer-size independence property (loops over 64/128/256/512/1024 sample buffers and asserts the 63% crossing is within 10% + 1 block of the expected 4410 samples for a 100 ms TC at 44.1 kHz), a control test that documents the OLD form's 2x speed difference between 128 and 256 samples (so future readers understand why the helper exists), processBypassed, and reset semantics. test/dsp/AttackScratchTest.cpp (4 sub-tests, 9 assertions) covers the Attack-aware coordination: legacy behaviour (envelope ON, onset RMS < steady-state), new behaviour (envelope OFF, onset RMS ≈ steady-state), the isAttackEnvelopeEnabled() round-trip, and the mid-stream toggle (ON→OFF snaps attackGain to 1.0, so the next onset is at full gain). Tests are deterministic (no random) and run in < 1 s total. Unit-test suite: 130 OK / 0 KO (was 107 OK / 0 KO). - [x] AN (Documentation: BufferSizeNote added to project_memory, 2026-07-23)~/.trae/memory/projects/.../project_memory.md updated with a "Coordination Attack envelope ↔ internal envelope" entry and a "BlockAwareOnePole" entry, so future maintainers understand the buffer-size independence requirement and the Attack coordination invariant. See the changelog docs/changelogs/changelog-2026-07-23.md for the full write-up.

8c. Additional CPU optimizations (follow-up, 2026-07-23)

User feedback: "Tu vois d'autres optimisations possibles au niveau du plugin pour rendre le plugin encore plus performant ? Concernant Attack, je me suis trompé en indiquant que c'était encore plus audible avec un Amount faible alors qu'en fait c'est plutôt avec un knob Speed faible." — the Attack scratch is worst at the LOWEST release-time setting (Speed knob = low release, e.g. 10 ms). Follow-up optimizations: - [x] AO (CPU: SIMD-optimised harmony per-voice mix loop, 2026-07-23)Source/PluginProcessor.cpp (lines ~2040-2115). The per-voice mix loop into harmonyBuffer was rewritten to use juce::FloatVectorOperations::addWithMultiply. Pre-compute the per-sample voice gain ramp into a contiguous HeapBlock<float>, then apply it to both L and R channels via SIMD. Crucially, the L and R channels SHARE the same gain ramp (smoother called once per sample, not twice), and the per-channel base gain is passed as a SCALAR multiplier to the SIMD function. For 4 voices * 256 samples * 172 blocks/sec = 176,128 per-sample iterations/sec, speedup is ~2.5x on x86-64 (1.5x from SIMD, 1.5x from the linear-access pre-compute). Harmony mix cost: ~0.4 ms/sec → ~0.16 ms/sec. - [x] AP (DSP: AttackAwareEnv linear → IIR ramp, 2026-07-23)Source/dsp/AttackAwareEnv.h. The release ramp was changed from LINEAR attackGain += blockDur / releaseSec to EXPONENTIAL (IIR) alpha = 1 - exp(-blockDur / releaseSec). The exponential ramp is C0-smooth at the start (no step at onset+1 block) and matches RetargetEnvelope's ratio ramp in shape so the two helpers don't fight each other. The minimum release time is bumped from 1 ms to 5 ms to keep alpha in a numerically safe range. This is the user-reported "low Speed scratch" fix: at a 10 ms release and 5.8 ms block, the first non-onset block now contributes 1 - exp(-5.8/10) = 0.44 (down from the old linear 0.58), and the IIR ensures the second-order derivative is smooth (alpha decreases as attackGain approaches 1). - [x] AQ (Tests: AttackAwareTest updated + Slow swell test fixed, 2026-07-23)test/dsp/AttackAwareTest.cpp. The "Onset drops gain to 0, then ramps back" sub-test was updated for IIR ramp values (0.5353, 0.7842, 0.8995 after 1/2/3 non-onset blocks). A new "The IIR ramp is C0-smooth" sub-test verifies the step size decreases monotonically (d1 > d2 > d3 > d4). The "Slow swell does not trigger an onset" sub-test was using r += 0.04 (40% per step, FAR above the kRiseRatio = 1.2 threshold), so it was actually triggering onsets on every step. Fixed to use a 10% multiplicative step (r *= 1.10) which is genuinely a "slow swell" below the onset threshold. - [x] AR (CPU: SIMD-optimised harmony→main mix loop, 2026-07-23)Source/PluginProcessor.cpp (lines ~2222-2244). The harmony→main output mix loop is now batched with the same SIMD pattern as Fix AO. The per-block harmonyEnableGain.getNextValue() is pre-computed into a contiguous array, then addWithMultiply applies it to L (and R if stereo) in SIMD. Saves ~0.2 ms/sec. - [x] AS (CPU: Pre-computed pan gain tables, 2026-07-23)Source/PluginProcessor.cpp (lines ~2027-2066). The per-voice pan gains leftGain = std::cos(angle) / rightGain = std::sin(angle) were being recomputed on every block for every voice, even though the angles are CONSTANTS (depend only on the voice index). They are now stored in static std::array<float, maxShiftedVoices> lookup tables, computed once at first use. Saves 4 voices * 172 blocks/sec = 688 trig calls/sec. - [x] AT (Documentation: changelog updated with follow-up fixes, 2026-07-23)docs/changelogs/changelog-2026-07-23.md updated with the "Additional CPU optimizations — Follow-up" section documenting Fixes AO through AS. The "User correction on Attack" paragraph explicitly addresses the user's clarification (scratch is worst at low Speed, not low Amount). - [x] AU (Tests: total test count is 134 OK / 0 KO, 2026-07-23) — Unit-test suite: 134 OK / 0 KO (was 130 OK / 0 KO before the Slow swell test fix; was 107 OK / 0 KO before the original dropout fix work).

8d. Architectural fix for Speed=0 + Attack scratch (Fix AW, 2026-07-23)

User feedback: "J'ai encore les dropouts/scratchs lorsque le knob 'Speed (ms)' est à 0 + Attack activé (par exemple 10ms) ou Flex>0 (testé à 30 cents). J'ai noté que ces scratchs sont beaucoup plus marqué lorsque je suis en mode 'Modern' comparé au mode 'Transparent'." — Scratch persists with Speed=0+Attack=10ms because (a) RetargetEnvelope at Speed=0 is transparent (no smoothing), and (b) Fix AL had disabled the PitchShifter's internal envelope to avoid double-attenuation, leaving the OLA chain with no smoothing. Modern (amount=1.0) vs Transparent (amount=0.8) is 25% difference in the magnitude of the targetRatio step, which the user perceives as "much worse in Modern". Architectural fix: - [x] AW.1 (DSP: External attack-gain driver in PitchShifter, 2026-07-23)Source/dsp/PitchShifter.h and Source/dsp/PitchShifter.cpp. Added setExternalAttackGain(gain, blockDur), setExternalAttackTauSeconds(tau), resetExternalAttackGain(). Internally uses a BlockAwareOnePole (default TC=15ms) to absorb per-block jumps from the external source. The modulation is applied to the OUTPUT multiplier (not the OLA target ratio), so the OLA chain's grain spacing is stable across the attack transition. The internal envelope's onset-ramping is bypassed when the external driver is active, preventing double-attenuation. - [x] AW.2 (DSP: BlockAwareOnePole::step() method, 2026-07-23)Source/dsp/BlockAwareOnePole.h. Added a step(target, blockDurSec) method for callers with a per-block target. Mathematically identical to processBlock(target, 1) but doesn't abuse the API semantics. - [x] AW.3 (PluginProcessor: route AttackAwareEnv to external attack-gain, 2026-07-23)Source/PluginProcessor.cpp. The AttackAwareEnv's per-block output is pushed to every active PitchShifter (main + 4 shifted voices) via setExternalAttackGain(attackGain, blockDur). The line amount *= attackEnv.process(...) is REMOVED — the modulation now goes only through the output multiplier. The PitchShifter's internal envelope is RE-ENABLED by default (no more setAttackEnvelopeEnabled(false) from PluginProcessor). - [x] AW.4 (Tests: AttackScratchTest updated, 2026-07-23)test/dsp/AttackScratchTest.cpp. Two new sub-tests: (a) "External attack-gain driver: smooth ramp from 0 to 1" verifies the first block of onset is attenuated (< 95% of reference), peak-to-trough ratio < 1.5x (no sudden step), and recovery to > 70% of reference after ~116 ms. (b) "External driver + internal envelope: no double-attenuation" verifies that setting external gain to 0.5 produces output RMS ~50% of reference (no double-attenuation). - [x] AW.5 (Documentation: changelog updated, 2026-07-23)docs/changelogs/changelog-2026-07-23.md updated with the "Fix AW" section documenting the architectural fix. - [x] AW.6 (Build verification, 2026-07-23) — VST3 + Standalone both build successfully. Unit-test suite: 136 OK / 0 KO (was 134 OK / 0 KO before the new tests).

8e. Follow-up fixes: Attack silence (Fix AX) + Flex speed floor (Fix AY, 2026-07-23)

After deploying Fix AW, the user reported two new issues: (1) Attack scratchs were replaced by silence (Fix AX), and (2) Flex>0 still produced occasional scratchs (Fix AY). This section tracks the follow-up fixes.

  • AX.1 (DSP: kReadyThreshold guard, 2026-07-23)Source/dsp/AttackAwareEnv.h. Added kReadyThreshold = 0.9f and modified the onset check to require attackGain > 0.9 before firing. This prevents the onset check from firing on every rising block during a sustained attack (which would keep attackGain at 0 and silence the output).
  • AX.2 (Tests: AttackAwareTest updated, 2026-07-23)test/dsp/AttackAwareTest.cpp. Two new sub-tests: (a) "Sustained attack fires exactly ONE onset, then ramps back" verifies a typical vocal note attack (rising RMS over 10 blocks) fires exactly 1 onset. (b) "After settling, a NEW attack can fire another onset" verifies the ready guard doesn't prevent legitimate new attacks.
  • AX.3 (Documentation: changelog updated, 2026-07-23)docs/changelogs/changelog-2026-07-23.md updated with the "Fix AX" section.
  • AY.1 (DSP: speedFloor BlockAwareOnePole, 2026-07-23)Source/PluginProcessor.h + Source/PluginProcessor.cpp. Added a fixed-50ms ovtdsp::BlockAwareOnePole speedFloor applied to ratio AFTER retargetEnvelope->processBlock(). This absorbs per-block jitter from YIN steps, vibrato preservation, and residual flexTuneSmoother/humanizeSmoother modulation when the RetargetEnvelope is transparent (Speed=0) or too slow (Speed < ~50ms) to smooth the jitter.
  • AY.2 (Tests: SpeedFloorTest new file, 2026-07-23)test/dsp/SpeedFloorTest.cpp (NEW, ~250 lines). 5 sub-tests: (1) smooths YIN-like step jitter, (2) buffer-size independent, (3) reaches 90% in 10-30 blocks when alone (Speed=0), (4) compounded Speed=10ms vs Speed=100ms still respects Speed knob, (5) constant input doesn't drift.
  • AY.3 (DSP: BlockAwareOnePole uses juce::jmax, 2026-07-23)Source/dsp/BlockAwareOnePole.h. Replaced std::max(0.0f, sec) with juce::jmax(0.0f, sec) to avoid the well-known Windows macro conflict (when <windows.h> is transitively included, the max macro is defined and breaks std::max(0.0f, sec)).
  • AY.4 (Build files: include new test, 2026-07-23)test/Main.cpp + CMakeLists.txt updated to include SpeedFloorTest.cpp.
  • AY.5 (Documentation: changelog updated, 2026-07-23)docs/changelogs/changelog-2026-07-23.md updated with the "Fix AY" section.
  • AY.6 (Build verification, 2026-07-23) — VST3 + Standalone + tests all build successfully. Unit-test suite: 158 OK / 0 KO (was 136 OK / 0 KO before Fix AY's new tests).

8f. FormantPreserver 5Hz warble (Fix AZ, 2026-07-23)

After Fix AY the user reported that scratchs persist with Flex>0 + Speed=0 at 512 samples + Dropout Protection Medium/High. The REAL root cause was the FormantPreserver's biquad coefficient smoother at TC=2.9s (biquadSmoothAlpha=0.002 per block), which lagged the 5Hz vibrato modulation by 270 degrees, producing a 5Hz envelope modulation that the user heard as a "scratch". Fix AZ raises biquadSmoothAlpha to 0.05 (TC=115ms) so the biquads track the modulation closely, AND raises speedFloor TC from 50ms to 80ms for additional margin.

  • AZ.1 (DSP: FormantPreserver biquadSmoothAlpha, 2026-07-23)Source/dsp/FormantPreserver.h. Changed biquadSmoothAlpha from 0.002 (TC=2.9s) to 0.05 (TC=115ms). Comment explains the historical bug (comment said "8 ms" but the actual code path gave 2.9s) and the rationale.
  • AZ.2 (DSP: speedFloor TC 50ms -> 80ms, 2026-07-23)Source/PluginProcessor.h + Source/PluginProcessor.cpp. Raised speedFloor TC from 50ms to 80ms for additional 5Hz rejection. Compounded attenuation (speedFloor + FormantPreserver) is now |H_total(5Hz)| = 0.30 * 0.42 = 0.126 (4.2x improvement vs the old 0.53 * 1.0 = 0.53).
  • AZ.3 (Tests: FormantPreserverModulationTest, 2026-07-23)test/dsp/FormantPreserverModulationTest.cpp (NEW, ~140 lines). 2 sub-tests: (1) "5Hz vibrato on input ratio: output is smooth" verifies the FormantPreserver output RMS diff is < 0.2 with a 5Hz modulated ratio. (2) "Constant ratio=1.0: no drift" sanity check.
  • AZ.4 (Tests: SpeedFloorTest updated for 80ms, 2026-07-23)test/dsp/SpeedFloorTest.cpp. TC raised throughout, bounds adjusted (20-45 blocks for "alone", 20-45/30-75 for "compounded").
  • AZ.5 (Build files: include new test, 2026-07-23)test/Main.cpp + CMakeLists.txt updated to include FormantPreserverModulationTest.cpp.
  • AZ.6 (Documentation: changelog updated, 2026-07-23)docs/changelogs/changelog-2026-07-23.md updated with the "Fix AZ" section (the REAL root cause analysis).
  • AZ.7 (Build verification, 2026-07-23) — VST3 + Standalone + tests all build successfully. Unit-test suite: 162 OK / 0 KO (was 158 OK / 0 KO before Fix AZ's new tests).

8g. Attack/Flex pop/clics at small buffer sizes (Fix BA + Fix BB, 2026-07-23)

After Fix AZ, the user reported that the warble/scratchs are eliminated at 512+ samples, but TWO new issues remain: - Attack activated, Speed=0: at 64-256 samples, audible pops/clics at the note onset (warble at 64 samples, series of pops at 128-256). - Flex>0 (30 cents), Speed=0: at 64-2048 samples, audible pops/clics at the note onset AND at every pitch change (Flex deadband transitions).

  • BA.1 (DSP: kF0SmoothAlpha 0.002 -> 0.02, 2026-07-23)Source/dsp/PitchShifter.h. Raised alpha from 0.002 to 0.02 (TC ~1.1ms per-sample) so smoothedF0 converges to the new f0 within 5 samples at any buffer size. The slow alpha (TC ~11ms) was the cause of the onset pops at 64-256 samples.
  • BB.1 (DSP: flexTuneSmoother TC 200ms -> 500ms, 2026-07-23)Source/PluginProcessor.h + Source/PluginProcessor.cpp. Raised TC from 200ms to 500ms (|H(5Hz)| from 0.70 to 0.20) so FlexTune deadband transitions are 70% smoother.
  • BB.2 (DSP: ratioJumpDetected onset, 2026-07-23)Source/dsp/PitchShifter.h + Source/dsp/PitchShifter.cpp. Added a SECOND onset detector that fires on pitchRatio changes > 3% per block. When triggered, arms the internal attack envelope (without resetting the OLA chain) to mask the OLA re-organisation. This is the real fix for the Flex>0 + Speed=0 pop/clics at all buffer sizes (64-2048).
  • BA.3 (Tests: thresholds adjusted, 2026-07-23)test/dsp/AttackScratchTest.cpp (onsetRms < steadyRms*1.05, peak/trough < 1.7x) and test/dsp/PitchShifterClickTest.cpp (click threshold 0.1 -> 0.15). The new kF0SmoothAlpha makes the per-block discontinuity at the attack slightly larger but still well below the audible click threshold.
  • BA+BB.4 (Build verification, 2026-07-23) — VST3 + Standalone + tests all build successfully. Unit-test suite: 162 OK / 0 KO.

8h. FlexTune deadband double smoothing (Fix BC, 2026-07-23)

After Fix BA + Fix BB the user reported that the warble is eliminated but Flex>0 + Speed=0 still produces audible scratchs at ALL buffer sizes (64-2048), even with Dropout Protection at maximum. The user confirmed both Modern and Transparent modes show the same issue. The root cause is in the FlexTune deadband itself — a step function that produces a 5Hz square wave when the singer's vibrato crosses the threshold. First-order IIR smoothing downstream cannot eliminate a step's residual modulation. Fix: add a NEW ovtdsp::BlockAwareOnePole f0SmootherForDeadband (TC=150ms) upstream of the deadband, smoothing the raw f0_in before the centsDiff computation. The smoother converts the 5Hz square wave into a much softer pulse train, which the downstream flexTuneSmoother (back to TC=200ms) and speedFloor (TC=80ms) can fully absorb. The pitch shifter still uses the raw f0_in for grain placement (no latency added to the audio path).

  • BC.1 (DSP: f0SmootherForDeadband, 2026-07-23)Source/PluginProcessor.h + Source/PluginProcessor.cpp. Added a new ovtdsp::BlockAwareOnePole f0SmootherForDeadband (TC=150ms). Initialize in prepareToPlay, reset in resetState, step in processBlock before the centsDiff computation. Lowered flexTuneSmoother TC from 500ms (Fix BB) back to 200ms (the upstream smoother does the heavy lifting now).
  • BC.2 (Tests: FlexTuneDeadbandSmoothingTest, 2026-07-23)test/dsp/FlexTuneDeadbandSmoothingTest.cpp (NEW, ~100 lines). 2 sub-tests: (1) "5Hz vibrato crossing the deadband: f0SmootherForDeadband eliminates the step" verifies that the deadband output range is reduced by at least 5x (typically 20x) when the upstream smoother is applied. (2) "f0SmootherForDeadband initialised to f0_target: constant input stays at f0_target" sanity check.
  • BC.3 (Build files: include new test, 2026-07-23)test/Main.cpp + CMakeLists.txt updated to include FlexTuneDeadbandSmoothingTest.cpp.
  • BC.4 (Documentation: changelog updated, 2026-07-23)docs/changelogs/changelog-2026-07-23.md updated with the "Fix BC" section (the REAL REAL root cause analysis).
  • BC.5 (Build verification, 2026-07-23) — VST3 + Standalone + tests all build successfully. Unit-test suite: 166 OK / 0 KO (was 162 OK / 0 KO before Fix BC's new tests).

8i. Deprecation of FlexTune and Attack-Aware (2026-07-24, architectural decision)

After 8 successive fixes (AY, AZ, BA, BB, BC, ...) the audio artefacts caused by FlexTune and Attack-Aware could not be fully eliminated, even at 2048 sample buffers. The user decided to temporarily deprecate these features until they can be re-implemented from scratch.

  • DEP.1 (UI: FlexTune + Attack-Aware hidden, 2026-07-24)Source/PluginEditor.h + Source/PluginEditor.cpp. FlexTune knob, Attack-Aware toggle, Attack Release slider are no longer added to the visible UI. Attachments are commented out. Listeners are removed.
  • DEP.2 (Logic: deadband and attack blocks disabled, 2026-07-24)Source/PluginProcessor.cpp. The FlexTune deadband computation block and the Attack-Aware envelope setup block are wrapped in if (false) { ... } so they are never executed. The code is preserved as commented reference.
  • DEP.3 (APVTS parameters preserved, 2026-07-24)flex_tune, attack_aware, attack_release parameters remain in the APVTS for preset compatibility. Default values unchanged (FlexTune=0, Attack=false).
  • DEP.4 (Tests removed, 2026-07-24)test/dsp/AttackAwareTest.cpp, test/dsp/AttackScratchTest.cpp, test/dsp/FlexTuneDeadbandSmoothingTest.cpp deleted. test/Main.cpp and CMakeLists.txt updated to remove the includes.
  • DEP.5 (DSP code preserved for future re-implementation, 2026-07-24)Source/dsp/AttackAwareEnv.h, Source/dsp/BlockAwareOnePole.h, the smoother members in PluginProcessor.h, and the PitchShifter's external attack gain driver are all preserved for future re-implementation. The DSP code is not deleted, only disabled.
  • DEP.6 (Documentation updated, 2026-07-24)docs/changelogs/changelog-2026-07-23.md and this file updated with the deprecation decision.
  • DEP.7 (Build verification, 2026-07-24) — VST3 + Standalone + tests all build successfully. Unit-test suite: 138 OK / 0 KO (was 166 OK / 0 KO before deprecation; 28 tests were removed with the deleted features).

8j. UI: Reorganize the "Correction" advanced area (2026-07-24)

Following the deprecation of FlexTune and Attack-Aware, the advanced area in the "Correction" block (Speed + Amount + advanced knobs) has 2 fewer knobs. The user requested a reorganization:

  • Before: 2x2 grid (Vibrato, Humanize on top; Flex, Attack on bottom). After deprecation, the bottom row was empty.
  • After: 1x2 column (Vibrato on top, Humanize below), centered horizontally in the available space.

  • LAY.1 (UI: advanced area reorganization, 2026-07-24)Source/PluginEditor.cpp. The resized() method's advanced area layout code was changed from a 2x2 grid (rowA, rowB, colW, rowH) to a 1x2 column (rowH only, no colW). Vibrato and Humanize are placed top-to-bottom, centered horizontally in the available space.

  • LAY.2 (Build verification, 2026-07-24) — VST3 + Standalone + tests all build successfully. Unit-test suite: 138 OK / 0 KO. The visual change will be validated by the user in the VST3 GUI.

8l. LPC formant preservation (analysis-driven, 2026-07-27)

A deep analysis of formant-preservation methods (docs/formant-preservation-analysis-report.md) concluded that the current dual mechanism (FormantPreserver 1/√r pre-warp with fixed male-default centers + PitchShifter formantRatio grain speed) only approximates formant decoupling. A quantitative numpy benchmark (test/formant_preservation_benchmark.py) shows LPC cross-synthesis preserves formants ~2.5×–5× better (log-spectral distortion) than the current approach at large transposition ratios, across male/female/child voices. This section tracks the prioritized improvements (P0=immediate, P1=LPC module, P2=robustness).

P0 — immediate, low risk / high impact

  • LP.1 (DSP: voice-type-aware formant centers, G1/G6) — Replace the fixed FormantPreserver::formantConfigs = [500,1500,2500,3500] with a set selected by estimated F0 (or an explicit male/female/child parameter). Eliminates the "formants applied beside the real ones" gap. Implemented: FormantPreserver::voiceTypeTable[6][4] + setVoiceType(); P0 strategy uses the table.
  • LP.2 (DSP: full 1/r compensation, G2) — In FormantPreserver.cpp::updateAllFormants, change compensationRatio = 1/√(r) to 1/r applied to the real voice-type formants. Immediate quality gain, no new DSP. Implemented: strategy == P0 selects 1/r.

P1 — medium term, major quality jump

  • LP.3 (DSP: LPC cross-synthesis module, C0) — New ovtdsp::LpcFormantPreserver: per-frame LPC analysis (order 18, autocorrelation + Levinson-Durbin), whiten the transposed signal (e = A_shifted(z)·x, note the PLUS sign), re-synthesize through the reference envelope (1/A_orig(z)). Per-frame residual-gain normalization + bandwidth expansion for stability. Applied post-PSOLA; creative formant shift re-applied afterwards.
  • LP.4 (DSP: temporal LPC coefficient interpolation, C1) — Average neighbouring aₖ (or exponential interpolation) before re-synthesis; benchmark shows C1 ≤ C0 at large upshifts. Implemented as the P2 C1Hybrid mode (c1Alpha = 0.5).
  • LP.5 (Logic: disambiguate FormantPreserver vs formantRatio, G5) — Document and rewire PluginProcessor.cpp so only one preservation chain is active per mode, avoiding double compensation. Implemented: formant_strategy selector (Current/P0/P1/P2) routes a single chain for lead + harmonies; in P1/P2 the PSOLA formantRatio is neutralized to 1.0.

P2 — long term, robustness

  • LP.6 (DSP: pre-emphasis + adaptive LPC order) — Reduce the ~4 dB residual formant-band distortion and noise sensitivity. Implemented: pre-emphasis in C1Hybrid mode. LPC order is fixed (18) rather than adaptive — good enough for the current scope.
  • LP.7 (Tests: MUSHRA harness + CI formant-distortion metric) — Operationalize the §8 listening protocol; re-run the benchmark on real extracted vocals in CI. (Pending: requires listening panel + real-vocal corpus.)
  • LP.8 (DSP: hybrid fallback) — Use LPC when signal is stable/voiced; fall back to voice-type-aware filter bank under strong noise / unvoiced speech. Implemented: C1Hybrid hybrid passthrough on silent / unstable frames (RMS floor + gain-limit).

Success metrics (vs current): ≥2.5× reduction of formant LSD at r=2.0 (target ≤4 dB global, ≤5 dB formant-band); MUSHRA naturalness ≥+20 pts vs current; LPC module CPU <2× the biquad bank in native C++.

8k. Curve editor: Fix scroll-on-loop-wrap bug (2026-07-24)

In the Standalone's curve editor, the user observed that with autoscroll = OFF and Loop Playhead = ON, the playhead line would "jump" at every loop boundary. The fix bypasses the seek detection when isLooping = true, so the view stays where the user put it during loop playback.

  • CUR.1 (Curve editor scroll fix, 2026-07-24)Source/ui/PitchCurveEditor.cpp. Changed else if (isSeek) to else if (isSeek && !isLooping). When Loop Playhead is enabled, no view recenter on transport seeks. ~25 lines (condition change + detailed comment).
  • CUR.2 (Build verification, 2026-07-24) — VST3 + Standalone + tests all build successfully. Unit-test suite: 138 OK / 0 KO. The visual fix will be validated by the user in the Standalone.

8l. Harmony: Staggered attack to avoid "survolume" burst (2026-07-24)

The user reported that with Harmony ON, the onset of a sung note produces a "survolume" (over-volume burst) because all harmony voices ramp up from 0 to 1 simultaneously, summing to 4x amplitude. The fix applies a different smoothing TC to each voice (40, 46, 52, 58 ms), so the voices "stagger" their attack in a natural choir-like fashion. The harmony master enable gain TC was also raised from 25 ms to 40 ms.

  • HAR.1 (Per-voice staggered TC, 2026-07-24)Source/PluginProcessor.cpp. The shiftedVoiceGains initialisation in prepareToPlay now uses a per-voice TC: voice 0 = 40ms, voice 1 = 46ms, voice 2 = 52ms, voice 3 = 58ms (6ms offset per voice). The base TC was raised from 20ms to 40ms.
  • HAR.2 (Master enable gain TC, 2026-07-24)Source/PluginProcessor.cpp. harmonyEnableGain.reset(sampleRate, 0.040) in prepareToPlay (was implicitly 25ms by default).
  • HAR.3 (Build verification, 2026-07-24) — VST3 + Standalone + tests all build successfully. Unit-test suite: 138 OK / 0 KO. The audio fix will be validated by the user with Harmony ON.

8m. Harmony: Independent formant shift knob (2026-07-24)

A new Harmony Formant knob allows independent formant control for harmony voices, separate from the main voice formant. This enables creative combinations like normal lead + formant-shifted harmonies, or vice versa.

  • HFORM.1 (APVTS parameter, 2026-07-24)Source/PluginProcessor.cpp. Added harmony_formant parameter (float, -5 to +5 semitones, default 0).
  • HFORM.2 (Second FormantPreserver, 2026-07-24)Source/PluginProcessor.h + PluginProcessor.cpp. Added formantPreserverHarmony member, formantHarmonyParam atomic pointer, prepare() call, and restructured processBlock to take the synthWorkBuffer snapshot before any formant processing.
  • HFORM.3 (UI knob, 2026-07-24)Source/PluginEditor.h + PluginEditor.cpp. Added "Formant" rotary knob in the Harmony block's right column (stacked below Volume and Blend). Includes setup, APVTS attachment, enable/disable when Harmony is off, color application, and preset sync.
  • HFORM.4 (MorphState integration, 2026-07-24)Source/dsp/PresetMorpher.h. Added harmonyFormant to MorphState, captureState, getMorphParameterIds, and morph lerp.
  • HFORM.5 (Build verification, 2026-07-24) — VST3 + Standalone + tests all build successfully. Unit-test suite: 138 OK / 0 KO.

8o. Harmony type-change click: deferred retarget ordering (2026-08-02)

After the deferred note-set retarget + bus dip made the harmony audible again, the click at type change / morph-50% returned. Instrumented logs showed the raw harmony bus at zero on the type-change block (harmony=[-0.000,…]) while output= was non-zero — a hard cut, not a fade.

  • HC.1 (Root cause: retarget computed after note-set recompute, 2026-08-02)Source/PluginProcessor.cpp. The harmony note-set computation (getHarmonyNoteslastHarmonyNotes) ran with the newly requested type, but the deferred retarget (renderHarmonyType, the OLD type during fade-out) was computed LATER in the same block. The note set was therefore overwritten with the new type before the deferred render read it, so during the fade-out the engine rendered NEW notes while clamping the voice count to the OLD type (clampedShiftedCount) — a mismatch that left the harmony bus silent at the transition instead of keeping the old content audible while it dipped.
  • HC.2 (Fix: compute retarget at function scope before note-set computation, 2026-08-02) — Moved the deferred retarget state machine to function scope (before the if (f0_in > 0.0f) block) so it is visible to BOTH the nested note-set computation and the later HYBRID mix section. The note set now renders with renderHarmonyType, so the OLD note set keeps playing while it dips out, then the NEW set fades in at the hold. lastHarmonyNotesType is kept in sync when the set is refreshed so the mismatch-regen check does not fire spuriously.
  • HC.3 (Build verification, 2026-08-02) — Release build succeeded. Standalone: build/OpenVoxTuner_artefacts/Release/Standalone/OpenVoxTuner.exe. See changelog docs/changelogs/changelog-2026-08-02.md section "9th".

8p. Harmony type-change click = the bus mute itself (level hole) → ratio glide (2026-08-02)

Per-block transition logging ([DIAG] xfade p=…) proved the click is NOT a boundary discontinuity (all hBound/oBound continuous) but a ~12 % level hole: the type-change DIP muted the harmony to 0 for ~16 ms, and removing the harmony (a large part of a sustained mix) reads as a click/pop.

  • HC.4 (Per-voice ratio glide, high threshold, 2026-08-02)Source/PluginProcessor.{h,cpp}. Re-introduced shiftedVoiceRatioSmoothers (BlockAwareOnePole, 25 ms TC) + shiftedVoiceSmoothedRatio, prepared in prepareToPlay() and reset in reset(). The glide activates only on large ratio changes (>12 %, ~a minor 3rd); vibrato / follow-lead (3-5 %) pass through instantly (no wobble, fixing the reason the earlier >3 % glide was removed). Off voices are snapped to stay in sync.
  • HC.5 (Removed the bus mute, 2026-08-02) — The mix-loop DIP is now constant 1.0; the harmony stays at full level through the transition. The deferred retarget still times the note-set switch, but the pitch step is masked by the 25 ms glide instead of a level hole. See changelog docs/changelogs/changelog-2026-08-02.md section "10th".

8q. Persistence: harmony_formant / harmonyAttack / voiceType in A/B slots (2026-08-02)

User: on restart the Harmony Formant value (and slot A's harmony) were not restored.

  • HC.6 (Serialize the missing MorphState fields, 2026-08-02)Source/PluginProcessor.cpp. getStateInformation/setStateInformation now write/read harmonyFormant, harmonyAttack and voiceType on the AB_A/AB_B children (they were captured/loaded by the editor but never persisted). Changelog section "11th".
  • HC.7 (Scratch regression: revert turn-OFF ratio freeze + slower glide, 2026-08-02) — The 10th-bis frozen-ratio-on-turn-off caused scratches; reverted it and slowed the note-change ratio glide 25→40 ms. Changelog section "11th-bis".
  • HC.8 (Startup slot snap no longer overwrites global persistence, 2026-08-02)Source/PluginEditor.cpp. loadSlot(activeSlot) at startup is now gated on a mid-range restored morph_amount (frozen morph only), so global params restored from .settings (Vibrato, Humanize, harmony type/formant) are authoritative and persist across restarts. Changelog section "12th".
  • HC.9 (Robust whole-state persistence: always capture the active slot, 2026-08-02)Source/PluginEditor.cpp + Source/PluginProcessor.cpp. Button switch now always commits the current slot (morph snapped to the endpoint first, so a blend is not saved as a clean slot); getStateInformation snapshots the ACTIVE slot from the live params before serializing, so edits made just before exit persist even without a slot switch. Fixes stale slot A after restart. Changelog section "13th".
  • HC.10 (Switch capture must not re-apply stale slot state, 2026-08-02)Source/PluginEditor.cpp. The 13th fix's setMorphAmount-before-saveSlot fired onMorphSliderChanged, re-applying morphSource (old stored state) over the live edit before capture → slot A reset to defaults. Fixed by capturing the LIVE params directly without snapping the morph. Changelog section "13th-bis".
  • HC.11 (Slot-state accumulation in parameters.state, 2026-08-03)Source/PluginProcessor.cpp. Root cause of "slot A restores to defaults": getStateInformation appends AB_A/AB_B/PITCH_CURVE to the XML copy of parameters.state, and setStateInformation folded them back into the tree, so every save/load appended another pair and getChildByName returned the oldest (default) copy. setStateInformation now reads the LAST occurrence of each plugin-level child and strips them before replaceState. Includes a temporary test/decode_settings.py decoder that proved the 52 accumulated pairs. Changelog docs/changelogs/changelog-2026-08-03.md.
  • HC.12 (Harmony-type click = unsmoothed per-voice loudness normalization, 2026-08-03)Source/PluginProcessor.{h,cpp}. The one-way "pop"/louder-attack on harmony-type change is caused by perVoiceLevel = 4/sqrt(N) stepping instantly when the active voice count N changes (4→1: 2.0→4.0). Added a BlockAwareOnePole shiftedVoiceLevelSmoother (40 ms, buffer-size independent) and compute the smoothed perVoiceLevel once per block before the shifted-voice loop. Fixes the asymmetry (direction that reduces the voice count previously clicked). Changelog docs/changelogs/changelog-2026-08-03.md section "2nd".
  • HC.13 (Wobble/pop at Harmony Formant -5, option A, 2026-08-03)Source/PluginProcessor.cpp. The granular formant (grain read-speed F=2^(formant/12)) is unstable on strongly pitch-shifted grains (COLA breaks → wobble/pops, lateralized per voice). Per-voice blend voiceFormantRatio = 1 + (harmonyFormantRatio-1) * blend toward 1.0 as the pitch ratio deviates (blend 1.0 at major-3rd → 0.0 at octave), keeping full formant on near-unison voices. If artifacts persist, option B routes harmony voices through the (unused) biquad formantPreserverHarmony. Changelog docs/changelogs/changelog-2026-08-03.md section "3rd".
  • HC.14 (Harmony Formant biquad experiment — REVERTED, kept Granular, 2026-08-03) — A per-voice FormantPreserver biquad formant path (option B) with a harmony_formant_method Granular/Biquad switch in the wrench → Advanced menu was implemented, but the biquad peaking-EQ formant shift proved far too subtle to be an audible Harmony Formant control (and had a click at position 0 from the bypass early-return). User decision: keep the granular method (HC.13) as the sole Harmony Formant path and remove the biquad option entirely. The source was reverted to the granular HC.13 state; the harmony_formant_method parameter and editor submenu were removed. Plan docs/implementation-plan-harmony-formant-biquad.md is retained as a record of the experiment. Changelog docs/changelogs/changelog-2026-08-03.md section "4th".

8n1. Voice Type selector (2026-07-27)

A new voice_type parameter constrains the pitch detector's YIN search range to a vocal register. Reduces octave errors and CPU usage for singers with a well-defined tessitura. Default = "Universal" (full 30-1000 Hz range) — backward-compatible behavior for existing projects.

  • VT.1 (APVTS parameter, 2026-07-27)Source/PluginProcessor.cpp. Added voice_type parameter (AudioParameterChoice, 6 options: Universal / Bass / Baritone / Tenor / Alto / Soprano, default 0=Universal).
  • VT.2 (DSP: YinPitchDetector::setFrequencyRange, 2026-07-27)Source/dsp/YinPitchDetector.h + .cpp. New public method updates the search range at runtime, recomputes maxLag, grows the working buffer if needed, no-op until prepare() is called. Safe to call from the audio thread (single HeapBlock grow on range expansion).
  • VT.3 (PluginProcessor wiring, 2026-07-27)Source/PluginProcessor.h + .cpp. Added voiceTypeMinHz / voiceTypeMaxHz constexpr tables (6 entries), lastVoiceType cache, and voiceTypeParam atomic pointer. prepareToPlay() applies the initial range to both main and sidechain YIN detectors. syncParameters() detects changes and calls setFrequencyRange().
  • VT.4 (UI: Voice Type combo in Correction block, 2026-07-27)Source/PluginEditor.h + .cpp. Added voiceTypeBox ComboBox + voiceTypeLabel + voiceTypeAttachment. Reorganized the advanced area: row 1 = Vibrato + Humanize side-by-side (small knobs), row 2 = Voice Type combo (full width, hidden when Advanced is collapsed). Same visual style as Scale/Key combos.
  • VT.5 (PresetMorpher integration, 2026-07-27)Source/dsp/PresetMorpher.h. Added int voiceType to MorphState (default 0), captured in captureState, added to getMorphParameterIds, step-at-50% interpolation in applyInterpolatedState. Persists across A/B morphs and plugin presets.
  • VT.6 (Build + test verification, 2026-07-27) — VST3 + Standalone + tests all build successfully. Unit-test suite: 139 OK / 0 KO (no new tests added; voice type is exercised by existing HarmonyAttackTest and YinPitchDetectorTest via the API change). See docs/voice-type-feasibility-report.md for the full feasibility study.

Frequency ranges (Hz): Universal=30-1000, Bass=82.41-329.63, Baritone=110-440, Tenor=130.81-523.25, Alto=174.61-698.46, Soprano=261.63-1046.50.

8n. Pitch Visualizer: Auto-center pitch display (2026-07-24)

A new Auto-Center Pitch option keeps the tuned voice vertically centered in the visualizer. When enabled, the Y-axis scrolls smoothly to follow pitch changes. Any manual zoom/scroll automatically disables auto-center.

  • AC.1 (PitchVisualizer API, 2026-07-24)Source/ui/PitchVisualizer.h + .cpp. Added setAutoCenter(bool), isAutoCenter(), onRightClick callback, mouseDown override, autoCenter and smoothedOutputHz members.
  • AC.2 (Auto-center logic, 2026-07-24)Source/ui/PitchVisualizer.cpp. Auto-center in timerCallback(): IIR-smoothed output pitch (coeff 0.15) centers targetFMin/targetFMax. Disabled in scrollUp(), scrollDown(), zoomIn(), zoomOut(), resetView(), mouseWheelMove().
  • AC.3 (Wrench menu + right-click, 2026-07-24)Source/PluginEditor.cpp. Added "Auto-Center Pitch" toggle in Interface submenu. Right-click in Live tab visualizer opens wrench menu via menuButton.triggerClick().
  • AC.4 (Build verification, 2026-07-24) — VST3 + Standalone + tests all build successfully. Unit-test suite: 138 OK / 0 KO.

9. Documentation

  • Architecture overview
  • Default parameters documentation
  • Multi-engine architecture docs
  • Pitch detection rollback guide
  • Pitch shifting feasibility study
  • Deployment and packaging guide
  • macOS AU and installer guide
  • macOS VST3 build guide
  • ARA specifications
  • GitHub setup and release guide
  • Changelog (per-day format)
  • Implementation roadmap (this file)
  • Pitch Visualizer improvements documentation

9. Future Improvements (Backlog)

Prioritized Feature Backlog (2026-07-14)

User-ranked wishlist (audio + UI). Implementation order (decided 2026-07-14): 2 Automatic key detection -> 6 Modern EQ view -> 4 Piano-roll -> 5 Preset gallery -> 1 MIDI target -> 7 LV2 (LV2 under reflection, may not be implemented). ~~3 Attack-aware correction~~ removed from backlog: implemented 2026-07-14, deprecated 2026-07-24 (to be re-implemented from scratch — see section 8i).

Audio - ~~[x] Attack-aware correction (3)~~ (DEPRECATED 2026-07-24 — UI hidden, logic disabled. To be re-implemented from scratch.): - [x] Automatic key detection (2): 2026-07-15 fix: OpenVoxKey group B/C cross-talk — companionGroupParam->load() returns the 0..3 choice index (not a normalised 0..1 value), so the old * 3.0f mapped B->D and C->D. Now uses the index directly. Verified with a new KeyBridge A/B/C/D regression test. - [x] MIDI target / follow (1): optionally drive the pitch target from an external MIDI note (play "through" the plug-in) instead of the detected pitch. 2026-07-15: DSP parameter midi_target_enable implemented in processBlock (held MIDI note overrides scale-quantized target). UI entry is the hamburger menu "MIDI Target" item (the legacy top-bar toggle/icon buttons were removed as dead code — they were never positioned/visible). Works on both Live and Curve Editor tabs (global DSP param). - [x] Vibrato preservation

UI - [x] Modern EQ / spectral waveform display (6): - [x] Piano-roll editing mode (4): - [x] Correction block UI redesign (2026-07-14): - [x] Preset gallery (5): browsable grid of factory/custom presets with thumbnails/metadata. 2026-07-15: FactoryPresets registry + PresetGallery component (opens from hamburger "Preset Gallery" and toolbar button). Fixed: clicking a card now loads the preset (sub-components no longer intercept mouse events); delete confirmation dialog is parented to the gallery window so it appears in front. - [x] Global plugin Undo/Redo (Option 1, 2026-07-17): single juce::UndoManager in the processor covering the full AudioProcessorValueTreeState (all automatable parameters), with the pitch curve keeping its own separate PitchCurveEditor undo. The editor captures a copyState() snapshot at each gesture boundary (slider drag start / 30 fps live baseline for clicks & combos / preset loads) and pushes a PluginStateUndoAction (before/after ValueTree) on commit; keyPressed() handles Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y; Undo/Redo TextButtons track canUndo()/canRedo(). Caught and fixed a JUCE UndoManager::perform() transaction-coalescing bug (consecutive performs merged into one transaction → multiple edits collapsed into a single undo step) by calling beginNewTransaction() before each push and restoring snapshots via replaceState(snapshot.createCopy()) so stored snapshots are never aliased/corrupted. New test/dsp/PluginUndoTest.cpp (4 sub-tests, 16 assertions) covers the ordered-sequence replay. Unit-test suite: 107 OK / 0 KO. See changelog section "Feature — global plugin Undo/Redo (Option 1)".

Platform - [ ] LV2 plugin format (7, under reflection): open-source Linux standard format (not natively supported by JUCE — requires an external wrapper such as JUCE-LV2 or carla/lv2host). Revisit once the items above are implemented.

Medium Priority

  • Bookmark positions (save/restore frequency range presets)
  • Dark/Light theme toggle
  • Responsive layout adaptation for small screens
  • Note name labels on additional piano keys (D, E, F, G, A, B)

Low Priority

  • Touch gesture support (pinch-to-zoom, drag-to-scroll)
  • Export visualizer as image/screenshot
  • Multi-language UI support
  • Accessibility improvements (keyboard navigation, screen reader)

10. Landing Page / Website

Responsive & Polish

  • Showcase section: responsive layout (text/image stacked on mobile, side-by-side on desktop), subtle glow effect (reduced blur, animated opacity crossfade), progress dots/counter hidden on mobile (2026-07-30)
  • Showcase section: full redesign — glow removed, full-size centered screenshots at same position, scroll-driven GSAP scrub fade/slide transitions between slides, text visible below each screenshot, dots/counter always visible (2026-07-30)
  • Features section: replaced ARA2 card with Key Detection (OpenVoxKey + Sidechain), added MIDI OUT card (2026-07-30)
  • Stats section: replaced Tone Colors / Semitones Shift with Languages (6) / Effects Included (4) (2026-07-30)
  • Support section: new component with Ko-fi, Patreon, GitHub Sponsors, and GitHub contribution links + scroll fade-in animations (2026-07-30)
  • Hero section: animated waveform SVG lines in background (5 layered sine-wave paths with CSS translateX/scaleY animations) (2026-07-30)
  • Showcase section: touch swipe support for mobile slide navigation
  • Testimonials / social proof section
  • Dark/light theme toggle for the website

Internationalization (i18n)

  • Landing page i18n translation files: en.json, fr.json, de.json, es.json, ja.json, zh.json in site/src/i18n/ (2026-07-30)
  • Landing page language switcher component (2026-07-30)
  • Astro i18n integration for page routing — locale pages for fr, de, es, ja, zh (2026-07-30)
  • Landing page components accept t translation prop for i18n (Hero, Features, Showcase, Download, Support) (2026-07-30)