diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73d5f7c..287577d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64" - name: Build - run: cmake --build build --target voice_allocator_test render_test render_cli ui_test plugin_host_test Drosera_VST3 + run: cmake --build build --target voice_allocator_test render_test render_cli ui_test plugin_host_test Drosera_VST3 Drosera_AU - name: Test the release check run: python3 tools/test_release_check.py @@ -65,6 +65,15 @@ jobs: unzip -q pluginval.zip -d pluginval-bin pluginval-bin/pluginval.app/Contents/MacOS/pluginval --strictness-level 5 --validate-in-process --validate "$VST3" + # Audio Units are found only in the plugin folders, so install the built one for the runner's user. + - name: auval and pluginval on the AU + run: | + mkdir -p ~/Library/Audio/Plug-Ins/Components + ditto build/Drosera_artefacts/Release/AU/Drosera.component ~/Library/Audio/Plug-Ins/Components/Drosera.component + killall -9 AudioComponentRegistrar 2>/dev/null || true + auval -v aumu Dros Cdsk + pluginval-bin/pluginval.app/Contents/MacOS/pluginval --strictness-level 5 --validate-in-process --validate ~/Library/Audio/Plug-Ins/Components/Drosera.component + - name: Check reference renders run: tools/hardware-comparison/check_reference.sh build/render_cli diff --git a/CHANGELOG.md b/CHANGELOG.md index ab163f8..d0e40ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Ver - Echo Pong: a second echo, off by default, whose repeats bounce between left and right and saturate, with PONG, Drive, Tone, Width and Level in its own PONG tab. - The Ember Pong preset. +### Fixed + +- The echo's first repeats after loading, or after the engine recovers from a bad sample, came late and warped: + its delay line started at 5 s and glided towards the knob for seconds, settling up to 3 ms short of it. It now + starts at the knob. The three reference renders with echo change throughout, the seven without are byte-identical, + so `tools/hardware-comparison/reference/SHA256SUMS` was regenerated. +- Bypass delays the input by the declared latency, so hosts at rates other than 48 kHz keep the bypassed audio aligned. +- A host that prepares with a sample rate of 0 no longer makes the resampling buffers unbounded. +- The noise generator multiplied a signed integer past its range, which is undefined behaviour: it now wraps + as unsigned. Same sound. +- The parameter registry no longer depends on the standard library's hash-map order: its handlers run in a + fixed order, the one the reference renders were made with. +- The Audio Unit is built and validated with `auval` and `pluginval` in CI. +- JUCE is fetched by commit, not by tag. +- A restored project takes its parameters at once instead of gliding from the defaults, so it settles on the + same drone every time it loads. Presets chosen in the window still glide. +- The manual says how far Reverb Mix detunes low MIDI notes, from measurement. + ## [0.1.1] - 2026-11-01 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f5f779..a4c5b3c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,7 +47,7 @@ if(DROSERA_BUILD_PLUGIN) include(FetchContent) FetchContent_Declare(JUCE GIT_REPOSITORY https://github.com/juce-framework/JUCE.git - GIT_TAG 8.0.9 + GIT_TAG f72bad64d29715216226685810c5196bd0d79d77 # 8.0.9, pinned to the commit: tags can move GIT_SHALLOW ON) FetchContent_MakeAvailable(JUCE) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 486036e..38de4f0 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -103,6 +103,9 @@ accepted. While a note is in control: - Body is snapped to a whole number of the note's periods, so the Body knob selects a multiple. - A band-pass on the note keeps the loop on the fundamental. +Keep Reverb Mix low while playing notes below C3 (note 48): the reverb sits in the loop and pulls low notes out +of tune, up to about half a semitone at 35 % (see the known limits). + Moving Frequency returns control to the knob once no key is held. A knob moved while a key is down does not take pitch back after release: move it again. @@ -169,7 +172,9 @@ others turns POLY off. "Ember Pong" uses PONG and sustains itself, and choosing the free drone finds another resonance instead. - With MIDI Pitch or LOCK on and Body at about 0.7 or more, a few chord notes sit up to about 60 cents off, at any Coupling, on a neighbouring Body resonance. -- Reverb Mix detunes MIDI notes, because the reverb sits in the loop and its phase is not compensated. With POLY on, +- Reverb Mix detunes MIDI notes, because the reverb sits in the loop and its phase is not compensated. Measured + dry, notes 18 to 84 stay within 17 cents at every Body; at 35 % Mix, notes below 48 read up to 46 cents off + (and one case of +105 cents at Body 0.9), while notes from 48 up stay within about 20 cents. With POLY on, chords can go sour: with "Chord Keys" otherwise as it is, at Reverb Mix 35 % a C major chord read +20, -28 and -8 cents. - The values of "Open Fifths", "Sympathetic", "Chord Keys" and "Ember Pong", and the defaults of Echo Drive, Echo Tone and Echo Level, were chosen by measurement and have not been judged by ear. diff --git a/review/2026-11-03-code-review.md b/review/2026-11-03-code-review.md new file mode 100644 index 0000000..69f1ef1 --- /dev/null +++ b/review/2026-11-03-code-review.md @@ -0,0 +1,527 @@ +# Drosera – Revisione tecnica (DSP, real-time, VST3/AU, build) + +Data: 2026-11-03. Commit analizzato: `9447f73` (branch `claude/audio-plugin-vst3-au-review-9b137d`). +Revisione eseguita su una copia isolata del progetto; il repository originale non è stato modificato. +Gli esperimenti e le patch di prova sono in `review/experiments/`. I log di build e test citati sono stati tenuti fuori dal repository. + +--- + +## 1. Executive summary + +Drosera è un drone synth a feedback (porting del firmware Audrey II) in JUCE 8.0.9, con formati VST3, AU e +Standalone. Il progetto è in buono stato: la separazione fra motore DSP (senza JUCE) e wrapper plugin è +netta, il thread audio non alloca né blocca, la suite di test è ampia (471 verifiche offline, test del VST3 +caricato in un host, test dell'interfaccia, checksum di riferimento del motore) e tutto è passato nella +sandbox. Il manuale documenta onestamente i limiti sonori. + +Nessun problema Critical o High. I punti più rilevanti: + +| ID | Severità | Sintesi | +|---|---|---| +| D-01 | Medium | Overflow intero con segno (undefined behaviour) nel generatore di rumore DaisySP, eseguito a ogni campione sul thread audio. Rilevato da UBSan. Fix di una riga, bit-identico. | +| D-02 | Medium | L'echo parte da 5 s e scivola al valore della manopola in ~3 s dopo ogni `prepareToPlay` (e dopo ogni recupero da NaN): le prime ripetizioni sono in ritardo e intonate male. Ereditato dal firmware. | +| D-03 | Medium | L'ordine dei gestori dei parametri dipende dall'iterazione di `std::unordered_map`: i render di riferimento (checksum) dipendono dalla libreria standard, e la compensazione di fase MIDI legge valori del tick corrente o precedente a seconda di quell'ordine. | +| D-04 | Low | Latenza dichiarata all'host ma nessun `processBlockBypassed`: in bypass l'audio passa senza ritardo (compensazione di latenza dell'host disallineata). | +| D-05 | Low | L'Audio Unit viene costruito ma mai validato (`auval`) né rilasciato; la CI valida solo il VST3. | +| D-06 | Low | `prepareToPlay` con sample rate 0 produce un `ratio` infinito convertito a `size_t`. | + +CPU misurata: da 0,5 % (mono) a 2,2 % (tre voci MIDI) di un core Apple Silicon. Memoria: circa 3,5 MB di +stato DSP per istanza. + +Valutazione complessiva: qualità alta per un progetto di questa dimensione; gli interventi suggeriti sono +piccoli e a basso rischio. Vedi §12 per il piano. + +--- + +## 2. Architettura analizzata + +### 2.1 Componenti + +| Area | File | Ruolo | +|---|---|---| +| Motore (senza JUCE) | `src/dsp/FeedbackSynthEngine.*` | Tre `FeedbackVoice`, rumore bianco per voce, echo stereo, accoppiamento fra voci, rampa di polifonia | +| | `src/dsp/FeedbackVoice.*` | Un anello di feedback: linea di ritardo stereo ("Body"), due corde Karplus-Strong, overdrive, LPF/HPF biquad, riverbero `ReverbSc`, band-pass di tracking opzionale | +| | `src/dsp/KarplusString.*`, `BiquadFilters.*`, `EchoDelay.h`, `StereoEcho.h` | Blocchi DSP | +| | `src/dsp/DroseraCore.h` | Facciata a 48 kHz fissi: mappa parametri normalizzati, smoothing a 12 kHz (blocco di 4 campioni), MIDI pitch/gate, polifonia, PONG, limiter, recupero da NaN | +| | `src/dsp/ParameterRegistry.h`, `SmoothedValue.h` | Registro parametri con smoothing one-pole e handler `std::function` | +| | `src/dsp/SincResampler.h` | Convertitore windowed-sinc (96 tap, Kaiser β=9, 512 fasi) | +| | `src/dsp/VoiceAllocator.h` | Assegnazione note MIDI a tre voci, array fissi | +| Plugin | `src/plugin/PluginProcessor.cpp` | `AudioProcessor`: APVTS (25 parametri), MIDI sample-accurate, ponte di sample rate, stato, programmi | +| | `src/plugin/PluginEditor.*`, `src/plugin/ui/*` | Finestra 760×500, pianta/spettro, manopole, tab | +| | `src/plugin/SpectrumTap.h` | FIFO SPSC lock-free audio → UI | +| | `src/plugin/EditorBridge.h` | Interfaccia editor → processor oltre ai parametri | +| | `src/plugin/Presets.h` | 16 preset di fabbrica | +| Terze parti | `third_party/DaisySP` (commit 7f67768, MIT) | Delay line, ReverbSc, Limiter, Overdrive, WhiteNoise, Tone, DcBlock, CrossFade | +| Test | `tests/render_test.cpp` (1930 righe), `plugin_host_test.cpp`, `ui_test.cpp`, `voice_allocator_test.cpp`, `render_cli.cpp`, `editor_snapshot.cpp` | | + +### 2.2 Flusso del segnale + +``` +host in (L+R)/2 · inputgain ──► [SincResampler host→48k] ──┐ + ▼ + per voce v: noise_v + in + coupling_v ──► DelayLine(Body, R = L−4) ──► KarplusString ×2 ──► Overdrive + ──► LPF12 ──► HPF12 ──► ReverbSc (mix) ──► samp_v ──► ×fb_gain ──► [BPF24 tracking] ──► DelayLine (write) + mix voci (×ramp, ×1/√3) ──► StereoEcho (send; PONG: linee incrociate, drive, tone, width, level) ──► 0.5·(dry+wet) + ──► ×volume ──► Limiter (pre-gain 0.7) ──► controllo NaN/inf (Recover) ──► [SincResampler 48k→host] + ──► hard clip ±1 ──► SpectrumTap (UI) ──► host out +``` + +Il controllo gira a 12 kHz: ogni 4 campioni `ParameterRegistry::Process` applica lo smoothing e chiama gli +handler, che scrivono nel motore. In modo MIDI/LOCK `applyPitchAndBody` calcola la risposta in fase dei +filtri dell'anello alla nota (numeri complessi) e corregge i ritardi perché l'anello oscilli sulla +fondamentale. + +### 2.3 Threading + +- Thread audio: `processBlock` → `updateParams` (legge gli atomici APVTS) → `renderChunk` → `DroseraCore::Render`. +- Thread messaggi: editor con timer a 30 Hz; legge solo atomici (`published_note_`, `midiPitchActive_`, `uiTab`…) e la FIFO `SpectrumTap`. +- `prepareToPlay` alloca (vettori, tabelle del resampler, `Engine::Init` alla prima chiamata); mai dal thread audio. +- Stato: XML tramite `copyXmlToBinary`; `replaceState` è thread-safe (documentato in JUCE). + +--- + +## 3. Ambiente di analisi + +Sandbox: `…/scratchpad/sandbox/` con `src/` (copia da `git archive HEAD`), `tools/venv/` (strumenti), `build/release`, `build/asan`, `experiments/`. Nessuna installazione globale sull'host. + +| Strumento | Versione | Provenienza | +|---|---|---| +| macOS | Darwin 27.2.0, arm64 | host | +| Compilatore | AppleClang 21.0.0.21000401 (Command Line Tools) | host, già presente | +| CMake | 4.4.4 | venv della sandbox (pip) – **mancava sull'host** | +| Ninja | 1.13.2 | venv della sandbox (pip) – **mancava sull'host** | +| JUCE | 8.0.9 | scaricato da CMake `FetchContent` nella build dir della sandbox | +| clang-tidy | LLVM 23.1.4 | venv della sandbox (pip) | +| Python | 3.11 (uv venv) | per `tools/test_release_check.py` | + +Non disponibili e non installati: `pluginval` (richiede download di un binario esterno), `auval` su un +componente non installato in `~/Library/Audio/Plug-Ins/Components` (fuori dalla sandbox), toolchain Intel +(l'host è arm64; la CI costruisce l'universale). + +Modifiche sperimentali, **solo nella copia sandbox**: +1. `CMakeLists.txt`: aggiunto il target `host_probe` (vedi `review/experiments/CMakeLists-host_probe.txt`). +2. `tests/host_probe.cpp`: nuovo (copia in `review/experiments/`). +3. `third_party/DaisySP/Source/Noise/whitenoise.h`: patch per D-01 (`review/experiments/whitenoise-unsigned.patch`), applicata **dopo** la prima esecuzione dei sanitizer, per verificare il fix. +4. Riconfigurazione con `CMAKE_EXPORT_COMPILE_COMMANDS=ON` per clang-tidy. + +--- + +## 4. Configurazione e compilazione + +| Build | Esito | +|---|---| +| Release, Ninja, tutti i target (VST3, AU, Standalone, test) | OK, 199 passi, 53 warning | +| Debug + `-fsanitize=address,undefined` (render_test, voice_allocator_test, render_cli, ui_test) | OK | +| Firme ad hoc dei bundle (`codesign --verify --deep --strict`) | OK per VST3, AU, Standalone | +| AU `Info.plist` | `aumu` / `Dros` / `Cdsk`, versione 257 (0.1.1), bundle `com.codeskine.Drosera` | +| Binario | arm64, minOS 11.0; VST3 e AU ≈ 10 MB ciascuno (statico JUCE) | + +Dei 53 warning, 44 vengono da DaisySP (compilato con `-w`, ma gli header sono inclusi dal plugin con i +flag JUCE) e 9 dal codice del progetto: `ParameterRegistry.h` (shadowing dei campi nel costruttore, `;` +superflui), `DroseraCore.h:52` (conversione di segno), `PluginProcessor.cpp:243` (`!=` fra float, qui +intenzionale), `PluginProcessor.cpp:50` (`switch-enum`). Nessuno indica un bug. + +--- + +## 5. Risultati dei test + +### 5.1 Eseguiti con successo + +| Test | Risultato | +|---|---| +| `voice_allocator_test` | 14/14 OK | +| `render_test` (Release) | 471 verifiche OK, 0 FAIL; allocazioni durante il render (incluso recupero da NaN): 0 | +| `ui_test` (Release) | ALL OK; allocazioni nel percorso audio polifonico: 0 (hook `malloc_logger`) | +| `plugin_host_test` sul VST3 costruito, con fixture `state-baseline.bin` | ALL OK: identità dei 25 parametri e dei loro ID VST3, stato salvato dalla build baseline, round trip dello stato, livelli a 44.1/48/96 kHz × blocchi 64/512/1000, input audio, MIDI gate, latenza dichiarata = misurata (±3 campioni), nota MIDI 60 entro +0,2 cent a tutte le frequenze di campionamento, preset | +| `tools/hardware-comparison/check_reference.sh` | 10/10 WAV con SHA-256 identico ai riferimenti | +| `tools/test_release_check.py` | 8 test OK | +| `voice_allocator_test` sotto ASan+UBSan | OK, nessun report | +| `render_test` e `ui_test` sotto ASan+UBSan | Prima esecuzione: **interrotte al primo campione** dall'overflow in `whitenoise.h:34` (D-01). Con la patch di D-01 applicata alla copia sandbox: `ui_test` ALL OK e `render_test` 471/471 OK, **zero report** di ASan e UBSan in entrambi. | +| `host_probe` (esperimento, VST3 nell'host JUCE) | ALL OK: 22.05/44.1/88.2/176.4/192 kHz × blocchi 1/7/2048/8192 con input; latenza dichiarata = misurata a 22.05/88.2/192 kHz (±5); 60 s a 44.1 kHz con blocchi misti (4096, 64, 1000, 3, 2048, 512) senza buchi; blocco vuoto; bypass; stato corrotto e troncato; note-on con velocity 0 | +| Esperimenti DSP (`exp1_cpu`, `exp2_edges`, `exp3_order`, `exp4_size`) | Vedi §7, §8, §9 | + +### 5.2 Falliti + +Nessun test del progetto è fallito. L'unico esito negativo è il report UBSan di D-01, che impedisce di +usare i sanitizer così com'è. + +### 5.3 Non eseguibili per limiti ambientali + +- `pluginval` (la CI lo esegue a strictness 5 sul VST3): richiede il download di un eseguibile esterno. +- `auval` sull'AU: richiede l'installazione del componente in `~/Library`, fuori dalla sandbox. +- Build e test Intel x86_64: host arm64. I checksum di riferimento sono dichiarati validi solo su arm64. +- ThreadSanitizer: non eseguito (richiederebbe una terza build; il percorso audio↔UI è stato verificato a lettura, §8). + +### 5.4 Non previsti dal progetto + +- Validazione AU in CI (D-05). +- Test a sample rate ≠ 44.1/48/96 kHz e a blocchi > 1000 campioni (coperti qui da `host_probe`, tutti OK). +- Build con sanitizer in CI. + +--- + +## 6. Problemi individuati (per severità) + +Convenzione: file e righe si riferiscono al commit analizzato. + +### D-01 · Medium · Real-time / Bug +**File:** `third_party/DaisySP/Source/Noise/whitenoise.h:34` (`randseed_ *= 16807;`, `int32_t`) +**Descrizione:** moltiplicazione intera con segno che va in overflow a quasi ogni chiamata: comportamento +indefinito in C++. Viene eseguita per ogni campione da tre generatori (`Engine::Process`) e 60 020 volte in +`Engine::Init` (pre-roll dei semi). +**Impatto:** oggi i compilatori producono il wrap-around atteso, quindi il suono è corretto; ma il codice non +ha garanzie, un futuro compilatore o flag (`-ftrapv`, ottimizzazioni su UB) può cambiarne l'esito, e **oggi +rende inutilizzabili ASan/UBSan** sull'intera suite (si fermano al primo campione). +**Evidenza:** `runtime error: signed integer overflow: 282475249 * 16807 cannot be represented in type 'int'`, +stack `WhiteNoise::Process ← Engine::Init ← DroseraCore::Init ← prepareToPlay`. +**Soluzione:** `randseed_ = (int32_t) ((uint32_t) randseed_ * 16807u);` – stessi bit, stesso suono. +Verificato nella sandbox: con la patch, `check_reference.sh` dà 10/10 checksum identici e `render_test` passa +(471/471), quindi il suono non cambia di un bit. Registrare la modifica in `third_party/DaisySP/PATCHES.txt` (regola 4). +**Priorità:** 1 (banale, sblocca i sanitizer). + +### D-02 · Medium · DSP +**File:** `src/dsp/FeedbackSynthEngine.cpp:34-36` (`SetDelayTime(5.0f, true)`, `SetLagTime(0.5f)`), `src/dsp/EchoDelay.h:114-118` +**Descrizione:** `Engine::Init` imposta il tempo dell'echo a 5 s "immediatamente"; il valore reale arriva dal +registro al tick successivo ma come *target*, e `EchoDelay` lo insegue con un one-pole da 0,5 s. Il tempo +di ritardo quindi scivola da 5 s al valore della manopola nei primi secondi dopo **ogni** `prepareToPlay` +(caricamento, cambio di sample rate o buffer in molti host) e dopo ogni `Recover()`. +**Impatto:** le prime ripetizioni sono molto in ritardo e, poiché il ritardo varia mentre si legge, trasposte +verso il basso (effetto nastro). Dopo circa 3 s tutto è a posto. È comportamento del firmware, dove però +`Init` avviene solo all'accensione. +**Evidenza:** `exp2_edges`, sezione A, echo time 0,248 s: impulso a t=0 → prima ripetizione dopo 0,954 s; +a t=2 s → 0,296 s; a t=6 s → 0,248 s. +**Soluzione:** al primo push (`first_`) e dopo `Recover`, far saltare anche lo stato interno dell'echo: +`Engine::SetEchoDelayTime(float, bool immediate)` → `stereo_echo_->SetDelayTime(t, immediate)`, chiamato +da `DroseraCore::pushControls` con `imm`. Cambia i render di riferimento con echo: va dichiarato e +vanno rigenerati i checksum (regola 2). +**Nota emersa applicando il fix** (`review/experiments/exp5_stall.cpp`): il one-pole in `float` di `EchoDelay` non +raggiunge mai il suo target: si ferma quando il passo scende sotto mezzo ulp, cioè 8,6 campioni prima di 0,248 s, +68,7 prima di 1,29 s, 137 prima di 2,5 s (fino a 2,9 ms). Per questo i tre render con echo cambiano per tutta la +durata, non solo nei primi secondi. Lo stesso scarto resta, dopo il fix, a ogni movimento della manopola Echo Time: +inudibile (0,1 %), ma se si vuole esattezza basta agganciare il valore al target quando la distanza è sotto un +campione (opzionale, cambierebbe ancora i checksum). +**Priorità:** 3. + +### D-03 · Medium · Architecture (portabilità, determinismo) +**File:** `src/dsp/ParameterRegistry.h:67-71` e `:99`, `src/dsp/DroseraCore.h:141` (commento che ammette il problema), `:206-227` +**Descrizione:** gli handler vengono chiamati nell'ordine di iterazione di `std::unordered_map`, che +è un dettaglio della libreria standard. Con libc++ è l'inverso dell'ordine di registrazione (`exp3_order`: +`OutputVolume … FeedbackBody FeedbackGain Frequency`). L'handler di `Frequency` chiama `applyPitchAndBody(0)`, +che usa `body_s_`, `lpf_hz_`, `hpf_hz_` scritti dagli handler di Body/LPF/HPF: con l'ordine attuale vede i +valori del tick corrente, con un altro ordine quelli del tick precedente. Inoltre, ogni tick fa una ricerca +hash e una chiamata `std::function` per parametro. +**Impatto:** (a) i checksum di riferimento valgono solo per questa libreria standard (oltre che per arm64); +(b) un build con libstdc++ (Linux/Windows, citato in `BUILDING.md` come possibile ripresa) renderebbe +diverso senza che nulla lo segnali; (c) costo per tick evitabile. +**Evidenza:** `review/experiments/exp3_order.cpp`; `DroseraCore.h:141` ("more entries could reorder its handlers"). +**Soluzione:** sostituire la mappa con `std::array` indicizzato dall'enum e iterare in un +ordine scelto esplicitamente. Mantenendo l'ordine inverso attuale i render restano identici; in +alternativa dichiarare il cambio. Elimina `kParamSmoothingThresh` (inutilizzato) e il TODO. ~40 righe, +riduce codice. +**Priorità:** 2. + +### D-04 · Low · Compatibility +**File:** `src/plugin/PluginProcessor.cpp:168-170` (latenza dichiarata), nessun override di `processBlockBypassed` +**Descrizione:** con host ≠ 48 kHz e bus d'ingresso collegato il plugin dichiara una latenza (327 campioni a +44,1 kHz, 1264 a 192 kHz); il bypass di default di JUCE copia l'ingresso in uscita senza ritardo e, in +Debug, scatta `jassert(getLatencySamples() == 0)` (`juce_AudioProcessor.cpp:599`). +**Impatto:** durante il bypass, l'audio passante è spostato in avanti di quella latenza rispetto alla +compensazione dell'host; in Debug l'assert interrompe. Riguarda solo l'uso come effetto su un ingresso. +**Soluzione:** override di `processBlockBypassed` con una linea di ritardo di `getLatencySamples()` campioni +dimensionata in `prepareToPlay` (`juce::dsp::DelayLine` o un ring di `std::vector` già allocato). ~25 righe. +**Priorità:** 4. + +### D-05 · Low · Compatibility (AU) +**File:** `CMakeLists.txt:67` (`FORMATS VST3 AU Standalone`), `.github/workflows/ci.yml` (target `Drosera_VST3`, `pluginval` solo VST3) +**Descrizione:** l'AU viene costruito da ogni build locale, ma la CI non lo compila, non esegue `auval` né +`pluginval` su di esso, e la release pubblica solo il VST3. +**Impatto:** l'AU è un artefatto non verificato; chi lo installa da una build locale non ha garanzie +(Logic/GarageBand rifiutano i componenti che non passano `auval`). +**Soluzione:** o rimuovere `AU` dai formati finché non è supportato, oppure aggiungere alla CI: +build `Drosera_AU`, copia in `~/Library/Audio/Plug-Ins/Components`, `killall -9 AudioComponentRegistrar`, +`auval -v aumu Dros Cdsk`, `pluginval` sul `.component`. +**Priorità:** 5. + +### D-06 · Low · Bug (robustezza) +**File:** `src/plugin/PluginProcessor.cpp:142-150` +**Descrizione:** `ratio = 48000 / sampleRate`; con `sampleRate` 0 (alcuni host interrogano/preparano con +valori provvisori) `ratio` è infinito, `std::ceil(inf)` convertito a `size_t` è comportamento indefinito e +`outL.assign(...)` può chiedere una quantità enorme di memoria. +**Impatto:** crash o eccezione `bad_alloc` all'apertura in un host che passa 0. +**Soluzione:** all'inizio di `prepareToPlay`: `if (sampleRate <= 0.0) sampleRate = DroseraCore::kRate;`. 1 riga. +**Priorità:** 6. + +### D-07 · Low · Bug (UX) / Compatibility +**File:** `src/dsp/DroseraCore.h:192` (clamp a `kNoteMin..kNoteMax` tramite `Update`), `src/plugin/PluginProcessor.cpp:403-404` (`Interval` −24..+24) +**Descrizione:** la nota di una voce è limitata a 16..84; con la manopola Frequency in alto (nota 72) un +intervallo di +24 st suona la nota 84, come +12. Lo stesso in basso. +**Evidenza:** `exp2_edges`, sezione D: voce 1 (+24) e voce 2 (+12) pubblicano entrambe 84,0. +**Impatto:** la manopola mostra "+24 st" ma la voce non lo suona; nessun artefatto. +**Soluzione:** nel readout dell'intervallo mostrare l'intervallo effettivo (nota pubblicata − nota della +voce 0), oppure documentarlo nel manuale. Non cambiare il range del parametro (romperebbe le automazioni). +**Priorità:** 9. + +### D-08 · Low · Real-time +**File:** `src/dsp/DroseraCore.h:125-129` (`Recover`), `FeedbackSynthEngine.cpp:13-37` +**Descrizione:** il recupero da NaN re-inizializza tutto il motore sul thread audio: azzera 3 `ReverbSc` +(386 KB l'uno), le due linee dell'echo (940 KB l'una), corde e delay (≈ 160 KB per voce): ≈ 3,5 MB scritti +in un colpo, oltre a riportare l'echo a 5 s (D-02). +**Impatto:** un picco di carico di qualche centinaio di µs nel blocco in cui scatta; con buffer da 64 +campioni a 96 kHz (0,67 ms) può causare un dropout. È un percorso d'emergenza: in 3 minuti di render ai +valori massimi (`exp2_edges` B) non è mai scattato, e il test `recoversFromNan` ne verifica l'esito. +**Soluzione:** accettabile così; se si vuole, azzerare solo il necessario (delay line, filtri, riverberi) senza +ricalcolare i semi del rumore, e saltare subito l'echo al valore corrente. +**Priorità:** 10 (opzionale). + +### D-09 · Low · Real-time / Compatibility (thread) +**File:** `src/plugin/PluginProcessor.cpp:103-130` +**Descrizione:** `setCurrentProgram` scrive `apvts.state.setProperty(...)` (un `ValueTree`, non thread-safe) e +chiama `setValueNotifyingHost` con gesture. Dall'editor è sul thread messaggi; un host AU che ripristina un +preset da un altro thread farebbe una corsa con i listener dell'APVTS. +**Soluzione:** eseguire la parte `ValueTree` sul thread messaggi (`MessageManager::callAsync`) o almeno +`jassert(MessageManager::existsAndIsCurrentThread())`. ~5 righe. +**Priorità:** 11. + +### D-10 · Low · Security (supply chain) / Build +**File:** `CMakeLists.txt:48-51` +**Descrizione:** JUCE è scaricato con `GIT_TAG 8.0.9`. Un tag git è mutabile. +**Soluzione:** usare l'hash del commit del tag (con `GIT_SHALLOW` serve un server che supporti fetch per +SHA, GitHub lo fa) o verificare un hash dell'archivio. 1 riga. +**Priorità:** 7. + +### D-11 · Low · Bug (MIDI) +**File:** `src/plugin/PluginProcessor.cpp:158-159` +**Descrizione:** `prepareToPlay` azzera `heldCount`, `haveNote` e l'allocatore: una nota tenuta mentre l'host +cambia buffer o sample rate si perde (con MIDI Gate il suono si chiude finché non si suona un'altra nota). +Mancano anche il sustain pedal (CC 64) e la bend range configurabile (RPN), oggi fissa a ±2 st. +**Impatto:** raro nell'uso normale; i secondi due sono limiti di funzionalità, non bug. +**Soluzione:** conservare `held[]` e riapplicare `allocator.Assign(held, heldCount)` dopo `Init`; valutare +CC 64 e RPN 0 come funzionalità. +**Priorità:** 12. + +### D-12 · Low · Code quality +Warning nel codice del progetto (§4) e segnalazioni clang-tidy senza impatto: `performance-unnecessary-value-param` +per `Handler` in `ParameterRegistry.h:39,87` (copia di `std::function` alla registrazione, solo in `Init`), +`bugprone-implicit-widening-of-multiplication-result` in `PluginProcessor.cpp:147,150` e `FeedbackSynthEngine.h:77` +(valori piccoli, innocui), `cert-err33-c` su `snprintf` in `Readouts.h:14`. Correggere quando si tocca il file. +**Priorità:** 13. + +--- + +## 7. Analisi DSP e qualità audio + +**Motore a 48 kHz fissi.** Scelta corretta per preservare il comportamento del firmware (dimensioni delle +delay line, `ReverbSc` tarato su 48 kHz) e la riproducibilità dei checksum. Il costo è il resampler e la sua +latenza, dichiarata solo con l'ingresso collegato (coerente: senza ingresso non c'è nulla da allineare). + +**Resampler windowed-sinc.** Misure (`exp2_edges` C, 48 k → 44,1 k): guadagno 0,00 dB a 1, 10 e 18 kHz; +−16,5 dB a 21 kHz (banda di transizione, sotto la nuova Nyquist di 22,05 kHz); −94 dB a 23 kHz. Nessun +aliasing udibile, tabella normalizzata a guadagno unitario in DC. Il ponte input-driven/output-driven con +prime di 256 campioni ha retto 60 s di blocchi misti da 3 a 4096 campioni senza buchi; la latenza +misurata dopo quella prova è 329 campioni contro 327 dichiarati: nessuna deriva del FIFO. + +**Stabilità numerica.** L'anello è limitato da `fclamp(±20)` nella corda, `SoftClip` nell'overdrive e nel +echo, `Limiter` in uscita; `juce::ScopedNoDenormals` nel `processBlock` copre i denormali sul thread audio +(i test offline non lo attivano, ma non hanno mostrato rallentamenti). Ai valori massimi di tutto +(reverb decay 1,0, feedback +12 dB, echo feedback 150 %, PONG drive +12 dB, coupling 10 %) 3 minuti di +render restano finiti, picco ≤ 0,82, zero recuperi (`exp2_edges` B). Il controllo NaN/inf per blocco con +`Recover` è una rete di sicurezza ragionevole per un sistema caotico. + +**Headroom e clipping.** Il `Limiter` DaisySP usa `SoftLimit`, che non è limitato a ±1 sui transienti; il +`jlimit(−1, 1)` finale nel `processBlock` è quindi giustificato. Nei 3 minuti ai massimi non si è mai +superato 1,0 dopo il limiter. Il `0.5·(dry+wet)` dell'echo dimezza sempre il segnale diretto: è del +firmware, non un errore. + +**Filtri.** Biquad TDF-II a singola precisione con coefficienti bilineari standard (`BiquadFilters.cpp`), +Q 0,9 per LPF/HPF, cutoff limitato a `[1, fs/2]`. Il tracking band-pass (BPF24, Q 1,5) e la compensazione di +fase complessa in `applyPitchAndBody` sono corretti: la nota 60 misura +0,1/+0,2 cent attraverso il VST3 a +44,1/48/96 kHz. Lo smoothing della nota (t60 0,2 s) produce un portamento su ogni cambio di nota MIDI: è +coerente col carattere del firmware ma vale la pena sapere che non è configurabile. + +**Aliasing.** La saturazione del PONG non è sovracampionata; il manuale lo dichiara (−54 dB di potenza +inarmonica al massimo Drive). Per un echo "a nastro" è una scelta accettabile; un oversampling 2× sul solo +`SoftClip` di `ReadShaped` costerebbe poco (Medium complessità) se in futuro si volesse ripulirlo. + +**Continuità al cambio di parametri.** Tutto passa dal registro con smoothing a 12 kHz (nessun zipper); +le rampe di polifonia e PONG (20 ms) e il blend di tuning (50 ms) sono verificati click-free dai test. +Eccezione: D-02 all'avvio. + +**Sound design.** Le scelte creative (Body che "snappa" ai periodi della nota, Coupling con make-up, +WARP come doppler) sono deliberate e documentate nei design spec in `docs/superpowers/`. Non le classifico +come problemi. Il manuale elenca con onestà i limiti (note 16–18 e 80 mute, reverb che stona le note +MIDI, chord drift con Body alto). + +--- + +## 8. Problemi real-time + +Verifica del percorso `processBlock` → `renderChunk` → `DroseraCore::Render` → `Engine::Process`: + +| Aspetto | Esito | +|---|---| +| Allocazioni | Nessuna: vettori/tabelle allocati in `prepareToPlay`; `Engine::Init` alloca solo alla prima chiamata; `MidiMessage` costruito solo per messaggi ≤ 3 byte (i sysex allocherebbero, ed è evitato). Confermato da `render_test` (operator new contato: 0) e `ui_test` (`malloc_logger`: 0). | +| Lock | Nessuno. `SpectrumTap` è SPSC lock-free con acquire/release corretti; la UI legge solo atomici. | +| I/O, logging | Nessuno sul thread audio. | +| Determinismo | Nessuna chiamata a tempo variabile; `std::pow/tan/atan2/log2` sono costanti nel tempo. | +| Denormali | `ScopedNoDenormals`. | +| UB | D-01 (overflow nel rumore). | +| Costo in casi rari | `Recover()` (D-08). | +| Comunicazione UI ↔ audio | Parametri via atomici APVTS letti una volta per blocco; `published_note_`/`published_body_s_` atomici relaxed; `uiTab`, `currentProgram`, `hostRate` atomici; `setStateInformation` usa `replaceState` (thread-safe, non RT, mai dal thread audio). `setCurrentProgram` vedi D-09. | +| Race `prepared` | `bool` non atomico scritto in `prepareToPlay`/`releaseResources` e letto in `processBlock`: accettabile perché gli host serializzano queste chiamate, ma un `std::atomic` costa nulla. | + +--- + +## 9. Ottimizzazioni di performance + +Misure (`exp1_cpu`, 20 s di audio a 48 kHz, blocchi da 512, un core M-series, Release `-O3`): + +| Modalità | CPU | +|---|---| +| Default (mono, manopole) | 0,53 % | +| Keys (mono, tracking MIDI) | 0,91 % | +| Open Fifths (poly, LOCK) | 2,13 % | +| Sympathetic (poly, libero) | 1,35 % | +| Chord Keys (poly MIDI) | 2,19 % | +| Ember Pong (PONG) | 0,59 % | + +`render_test` riporta 9 ms per secondo di audio mono e 21 ms poly. A questo si aggiunge il resampler a +host ≠ 48 kHz (96 tap × 3 canali ≈ 300 MAC/campione, trascurabile). Memoria per istanza: `Engine` 475 KB +inline + 3 × `ReverbSc` 386 KB + echo 1,9 MB + tabelle del resampler 0,6 MB ≈ 3,5 MB. + +**Conclusione:** nessuna correzione necessaria. Ottimizzazioni *opzionali*, in ordine di resa: + +1. **Non ricalcolare i coefficienti a ogni tick** (`DroseraCore.h:212-215, 243-248`, `FeedbackVoice::SetFeedbackTracking`). + Oggi ogni 4 campioni ogni handler scrive nel motore anche se il valore è fermo: `tanf` per LPF/HPF ×3 + voci, 4 `tanf` per i BPF24 di tracking per voce, `expf` per il livello PONG, più `pow`, `polar`, `atan2`, + `log2` in `applyPitchAndBody`. Rimedio: in `SmoothedValue::get` restituire anche "cambiato" (confronto + bit a bit, dopo la convergenza il one-pole è esattamente stabile) e saltare l'handler. Beneficio stimato: + −30/50 % sulla parte di controllo (che è una frazione del 2 %). Rischio: l'ordine/dipendenza fra handler + (D-03) va risolto prima, perché `applyPitchAndBody` dipende da più parametri. Complessità media. +2. **`applyPitchAndBody(0)` chiamato due volte per tick** (handler di Frequency e di Body, `DroseraCore.h:207, 211`): + basta una chiamata dopo entrambi. Beneficio piccolo, rischio nullo se l'ordine è esplicito (D-03). +3. **Registro senza hash e `std::function`** (parte di D-03): array indicizzato dall'enum, handler come + puntatore a membro o `switch`. Beneficio piccolo; vale per il determinismo più che per la CPU. +4. **SIMD**: non consigliato. L'anello è ricorsivo campione per campione (feedback a ritardo frazionario), + non vettorizzabile; i tre voci sono indipendenti solo entro il campione. Il resampler è già un prodotto + scalare che il compilatore vettorizza da solo. + +--- + +## 10. Problemi architetturali e manutenibilità + +**Punti di forza.** Confine netto DSP/JUCE con test senza JUCE; `EditorBridge` tiene l'editor lontano dal +processor; `Presets.h`, `Readouts.h`, `SpectrumBands.h` testabili da soli; proprietà chiara (`unique_ptr` +per gli oggetti grandi, `Engine` non copiabile); nessun `new` nudo; ID parametri con `versionHint` per +l'ordinamento AU; regole di progetto scritte (`CLAUDE.md`, `CONTRIBUTING.md`) e design spec per ogni +funzionalità; CI con pluginval. + +**Da migliorare.** +- `ParameterRegistry` (D-03): la generalità del template e della mappa non è usata (tre registri con enum + fissi); un array la sostituisce con meno codice e più garanzie. +- `DroseraCore.h` è un header di 368 righe con matematica non banale (`applyPitchAndBody`, + `blendedStringNote`) e tre registri: resta leggibile grazie ai commenti, ma le funzioni di risposta in + frequenza (`stringLoopResponse`, `biquadResponse`) replicano a mano i coefficienti di `BiquadFilters.cpp` e + di `Tone`/`DcBlock`: se uno cambia, l'altro va aggiornato a mano. Un commento incrociato o una funzione + condivisa che restituisca i coefficienti eviterebbe la deriva. +- `PluginProcessor.cpp` tiene insieme layout dei parametri, stato MIDI (`held[]`, allocatore, bend), ponte + di sample rate e programmi: 475 righe ancora gestibili, ma lo stato MIDI (`handleMidi`, `applyMidiState`, + `removeNote`) è un candidato naturale a una piccola classe testabile senza JUCE, come `VoiceAllocator`. +- Cache di `std::atomic*` per 25 parametri: funziona, ma un array indicizzato da un enum dei + parametri plugin ridurrebbe le 15 righe di `getRawParameterValue` ripetute. +- `kSpecs` in `PluginProcessor.cpp` duplica range e curve di `DroseraCore::registerParams` ("Ranges/curves + mirror…"): una tabella condivisa nel DSP eviterebbe la duplicazione. +- Il commento in `FeedbackVoice.h:21-22` ("Init leaves fb_delay_samp_…") e quello in `Engine::Init` sono + essenziali perché `Init` serve anche da reset sul thread audio: convenzione delicata ma documentata. + +--- + +## 11. Compatibilità VST3 / AU + +| Aspetto | Stato | +|---|---| +| Parametri | 25 parametri 0..1 con `ParameterID` versionati (1 e 2), nomi in inglese, formattazione testo coerente con il motore; test d'identità ID/nome che blocca regressioni. Nel VST3 l'host vede 2107 parametri: i 25 + Bypass + 2080 di emulazione MIDI CC/aftertouch/pitch-bend (`JUCE_VST3_EMULATE_MIDI_CC_WITH_PARAMETERS`, default JUCE per i synth). Serve per ricevere il pitch bend in VST3; è normale per i synth JUCE, ma ingombra le liste di automazione degli host. Nessuna azione. | +| Automazione | Letta una volta per blocco, poi smoothing interno a 12 kHz: nessun zipper, niente automazione sample-accurate (standard JUCE). | +| Stato | XML in binario, tag verificato, parametri assenti → default (progetti vecchi caricano, test con fixture baseline), `uiTab` e `program` salvati. Stato corrotto o troncato ignorato senza crash (`host_probe`). | +| Programmi | 16 preset come programmi; `setCurrentProgram` imposta i parametri notificando l'host; indice persistito. Vedi D-09. | +| Bypass | Default JUCE; D-04 quando c'è latenza. | +| Bus | Uscita solo stereo; ingresso disabilitato/mono/stereo. Un host che chieda un'uscita mono non istanzia: accettabile per un synth. L'ingresso mono è letto dal solo canale 0 (corretto, il canale 1 del buffer può contenere spazzatura). | +| MIDI | Note on/off (velocity 0 = off), all notes/sound off, pitch wheel ±2 st, omni. Sysex ignorati senza allocare. Sample-accurate. D-11. | +| Sample rate e buffer | Verificato 22,05–192 kHz, blocchi 1–8192 e 0, con latenza dichiarata corretta. | +| Doppia precisione | Non supportata (dichiarato correttamente). | +| Tail | 10 s dichiarati; il drone è auto-oscillante, il valore è nominale. | +| AU | Bundle corretto (`aumu`/`Dros`/`Cdsk`, versione 257); mai validato (D-05). | +| Standalone | Costruito e firmato; non eseguito (richiede un dispositivo audio). | +| macOS | minOS 11.0; firma ad hoc rifatta dopo la build (corretto, perché JUCE aggiunge `moduleinfo.json` dopo la firma); la CI firma e notarizza se ci sono i segreti. | + +--- + +## 12. Piano di intervento prioritizzato + +| # | Intervento | ID | Tipo | Complessità | Effetto sui checksum | +|---|---|---|---|---|---| +| 1 | Moltiplicazione senza segno in `whitenoise.h` + nota in `PATCHES.txt`; aggiungere un job CI con ASan/UBSan su `render_test` e `ui_test` | D-01 | Correzione necessaria | Banale | Nessuno (verificato) | +| 2 | `ParameterRegistry` su array con ordine esplicito | D-03 | Correzione consigliata | Bassa (~40 righe, meno codice) | Nessuno se si conserva l'ordine attuale | +| 3 | Echo che salta subito al valore della manopola all'avvio e dopo `Recover` | D-02 | Correzione consigliata (udibile) | Bassa | Sì: rigenerare con motivazione | +| 4 | `processBlockBypassed` con ritardo pari alla latenza | D-04 | Correzione consigliata | Bassa | Nessuno | +| 5 | Validare l'AU in CI (`auval`, `pluginval`) o togliere `AU` dai formati | D-05 | Consigliata | Bassa (CI) | Nessuno | +| 6 | Clamp del sample rate in `prepareToPlay` | D-06 | Correzione (1 riga) | Banale | Nessuno | +| 7 | Pin di JUCE all'hash del commit | D-10 | Consigliata | Banale | Nessuno | +| 8 | `std::atomic prepared` | §8 | Opzionale | Banale | Nessuno | +| 9 | Readout dell'intervallo effettivo o nota nel manuale | D-07 | Opzionale | Banale | Nessuno | +| 10 | Recupero da NaN più leggero | D-08 | Opzionale | Media | Nessuno (solo dopo NaN) | +| 11 | `setCurrentProgram` sul thread messaggi | D-09 | Opzionale | Bassa | Nessuno | +| 12 | Note tenute attraverso `prepareToPlay`; sustain pedal; RPN bend range | D-11 | Funzionalità | Bassa/Media | Nessuno | +| 13 | Skip degli handler a valore fermo; una sola `applyPitchAndBody` per tick | §9 | Ottimizzazione opzionale | Media | Nessuno se fatto bit a bit | +| 14 | Pulizia warning e clang-tidy | D-12 | Opzionale | Banale | Nessuno | + +--- + +## 13. Interventi applicati (dopo la revisione) + +Su richiesta, gli interventi 1–7 del piano sono stati applicati nel worktree (non committati). Diff: 13 file, ++144/−56 righe. + +| # | ID | Cosa è cambiato | Verifica | +|---|---|---|---| +| 1 | D-01 | `whitenoise.h`: prodotto in `uint32_t` riconvertito a `int32_t`; nota in `PATCHES.txt` | 7 render senza echo identici al bit; `ui_test` e `voice_allocator_test` passano sotto ASan+UBSan senza report; `render_test` sotto ASan+UBSan: 472/472 OK, zero report | +| 2 | D-03 | `ParameterRegistry` su `std::array, N>`, `Process` scorre dall'id più alto al più basso (l'ordine che libc++ dava); gli enum hanno `NumVoiceParams`/`NumEchoParams`; −9 righe nette | I 7 render senza echo restano identici al bit; i 3 con echo cambiano solo per D-02 | +| 3 | D-02 | `Engine::SetEchoDelayTime(t, immediate)`; `DroseraCore` fa saltare l'echo al primo tick dopo `Init` e dopo `Recover` (`echo_jump_`); nuovo test `echoStartsAtKnobTime` | Prima ripetizione a 0,248 s con manopola a 0,248 s (prima: 0,954 s). `SHA256SUMS` rigenerati: p05, p06, p10 cambiano, gli altri 7 no. Motivo nel CHANGELOG | +| 4 | D-04 | `processBlockBypassed` con un ring per canale lungo `getLatencySamples()`, allocato in `prepareToPlay`; nuovo test `bypassKeepsLatency`/`bypassPassesThrough` | Bypass a 44,1 kHz: dichiarati 327, misurati 327; a 48 kHz passa inalterato | +| 5 | D-05 | CI: build di `Drosera_AU`, installazione nella cartella Components del runner, `auval -v aumu Dros Cdsk`, `pluginval` sull'AU | Non eseguibile qui (vedi §3); va osservato al primo run della CI | +| 6 | D-06 | `prepareToPlay`: sample rate ≤ 0 → 48 000 | Compila; comportamento a rate validi invariato (`plugin_host_test` ALL OK) | +| 7 | D-10 | `GIT_TAG` di JUCE = hash del commit di 8.0.9 | Il configure del worktree ha scaricato JUCE per hash con `GIT_SHALLOW ON` | + +Suite complete sul worktree dopo le modifiche: `voice_allocator_test` OK, `render_test` 472 verifiche OK, +`ui_test` ALL OK, `plugin_host_test` ALL OK (identità parametri, fixture baseline, latenza), `check_reference.sh` +10/10 con i nuovi checksum. `tests/fixtures/state-baseline.bin` non è stato toccato. + +Restano aperti gli opzionali 8–14 del piano e la nota sullo scarto residuo del one-pole dell'echo (in D-02). + +--- + +## 14. Misure per il sound design + +Le misure automatiche su livelli dei preset, tessitura MIDI, ripetibilità del caricamento, compatibilità mono, +durata, combinazioni estreme e sample rate sono in `review/2026-11-03-sound-design-measurements.md`. I due +punti da portare nel piano: lo stesso progetto può assestarsi su una parziale diversa a seconda di quando +l'host consegna lo stato (rimedio: salto immediato dei parametri al ripristino), e il riverbero nell'anello +stona le note MIDI sotto il 48 fino a ±46 cent al 35 % di Mix. + +--- + +## Valutazione complessiva e roadmap + +Il progetto è solido: il thread audio è pulito, il DSP è corretto e misurato (intonazione MIDI entro +0,2 cent, resampler a −94 dB in banda attenuata, stabilità per minuti ai valori estremi), la persistenza è +protetta da fixture, e le scelte sonore sono documentate con i loro limiti. I difetti trovati sono di +contorno: un UB ereditato da una libreria vendorizzata (che oggi nasconde la possibilità di usare i +sanitizer), un transitorio d'avvio ereditato dal firmware, un ordine di valutazione lasciato alla libreria +standard, e un AU costruito ma non verificato. + +Roadmap suggerita: +- **Subito (una sessione):** 1, 6, 7, 8 — correzioni banali senza effetto sui render — più il job CI con sanitizer. +- **Prossima release:** 2 e 3 (con rigenerazione dei checksum motivata nel changelog), 4, 5. +- **Quando si tocca l'area:** 9–14. + +Non controllato: pluginval e auval (vincoli della sandbox), build Intel, ThreadSanitizer, ascolto +soggettivo dei preset. diff --git a/review/2026-11-03-sound-design-measurements.md b/review/2026-11-03-sound-design-measurements.md new file mode 100644 index 0000000..92b3bd6 --- /dev/null +++ b/review/2026-11-03-sound-design-measurements.md @@ -0,0 +1,175 @@ +# Drosera – Misure per il sound design + +Data: 2026-11-03, sul commit `ec6e150` (fix della revisione inclusi). Motore a 48 kHz tramite `DroseraCore` +(`review/experiments/sd_measure.cpp`), VST3 caricato in un host JUCE per il confronto fra sample rate +(`review/experiments/sr_probe.cpp`). Tutto automatico: nessun ascolto. Le letture qui sotto dicono dove vale +la pena ascoltare. + +## Cosa dicono le misure + +1. **I preset non sono allineati di livello.** Da −9,2 dBFS (Cathedral Bloom) a −22,5 dBFS (Quiet Room): 13 dB di + escursione cambiando preset. Glass Wind e Cathedral Bloom sono i più caldi e hanno la cresta più bassa + (il limiter lavora). Glass Wind oscilla di 11,5 dB fra finestre di 2 s e ci mette 3,6 s a nascere: è il + preset più instabile. Razor Feedback e Chord Keys sono istantanei e fermi (spread 0,3–0,4 dB). + +2. **Tessitura sotto MIDI.** A secco tutte le note da 18 a 84 sono entro ±17 cent a ogni Body, con una sola + eccezione (nota 80, Body 0,7: −36 cent). La nota 16 suona sempre 11 semitoni sopra (+1104 cent): va + esclusa dal range MIDI o documentata come oggi. Nessuna nota muta in queste impostazioni (Lowpass 0,8, + Highpass 0,2). **Con Reverb Mix al 35 %** l'intonazione si rompe sotto la nota 48: +46 a 18, −25 a 24, + −27 a 36, −25 a 43, e un +105 a 52 con Body 0,9. Sopra 48 resta entro ±20. Il riverbero nell'anello + stona i bassi: è il limite più serio per un uso melodico, e suggerisce un avviso nel manuale ("sotto + il Do 3 tenere il riverbero basso") o la compensazione di fase del riverbero. + +3. **Lo stesso progetto non suona sempre uguale.** Un host applica lo stato *dopo* `prepareToPlay`, così i + parametri scivolano dai default al preset con lo smoothing (fino a 1 s per Body). Simulando l'arrivo + dello stato dopo 0, 1, 10 o 100 blocchi: 9 preset su 14 si assestano sulla stessa parziale, ma Doppler + Void (704 o 198 Hz), Dark Tide (195, 124 o 315 Hz, con 4 dB di differenza di livello), Echo Drift + (1142 o 832 Hz), Ember Pong (832 o 766 Hz) e Sub Cellar (668 o 721 Hz) cambiano "nota" a seconda del + momento. Per chi salva un brano è un problema reale. Rimedio piccolo: al ripristino dello stato far + saltare i parametri al valore (come fa `first_` dopo `Init`) invece di farli scivolare; i preset + caricati dalla finestra possono continuare a scivolare. + +4. **Mono.** Tutti i preset perdono fra 0,5 e 4,4 dB sommati a mono. I due canali sono quasi indipendenti + (correlazione ~0: Echo Drift, Razor, Sympathetic, Ember Pong, Keys), per via dello sfasamento di 4 + campioni fra i delay di sinistra e destra: −3 dB è la somma di due segnali scorrelati. Chord Keys ha + correlazione negativa (−0,27) e perde 4,4 dB: in mono si svuota. Glass Wind, Cathedral Bloom e Open + Fifths sono invece quasi mono (correlazione 0,6–0,8). Da decidere se è la larghezza voluta; un controllo + di Width sulle voci, come quello del PONG, lo renderebbe scelta e non caso. + +5. **Durata.** Nessuna deriva di livello, nessun DC, nessun recupero in 30 minuti complessivi ai valori + estremi. Un comportamento da conoscere: con Reverb Decay a 1,0 il centroide scende da 1130 a 160 Hz in + dieci minuti (già a 300 Hz dopo due): il drone affonda in un rombo grave e non torna. È il riverbero + infinito che accumula il basso che il DC blocker lascia passare. Udibile e forse voluto, ma vale un + ascolto lungo. + +6. **Combinazioni estreme.** Highpass sopra Lowpass dà silenzio assoluto (−171 dBFS): la finestra non lo + segnala. Le altre combinazioni restano limitate (picco ≤ 0,58) e suonano, compreso il runaway del PONG. + Il Resonator con una sinusoide in ingresso si aggancia all'ingresso (220 Hz), e con raffiche + intermittenti risuona sulla nota (110 Hz) fra una raffica e l'altra. + +7. **Sample rate.** Nel VST3 a 44,1, 48 e 96 kHz livelli identici al decimo di dB e stesse parziali in 7 preset + su 9; le due eccezioni (Echo Drift a 96 kHz, Chord Keys a 48 kHz) hanno RMS identico e cadono su parziali + vicine di livello (per Chord Keys sono il Mi e il Sol dell'accordo): la finestra d'analisi è a numero fisso + di campioni, a 96 kHz dura la metà. Letto come artefatto di misura, non verificato all'ascolto. + +## Cosa resta all'orecchio + +Ascolto dei 16 preset con queste tabelle davanti; fraseggio MIDI (glide fisso di 200 ms, velocity ignorata, +nessun sustain); movimento delle manopole; sorgenti reali nel Resonator; giudizio sull'aliasing del PONG a +Drive e Tone massimi. + +## Tabelle + +### A. Preset: livello, dinamica e tempo di nascita (20 s da Init) + +| Preset | RMS 5-20 s | Picco | Cresta | -3 dB dopo | Spread 2 s | Dominante | Centroide | +|---|---|---|---|---|---|---|---| +| Default | -18.3 dBFS | 0.33 | 8.8 dB | 0.95 s | 3.8 dB | 500 Hz | 3398 Hz | +| Sub Cellar | -17.5 dBFS | 0.47 | 11.1 dB | 0.85 s | 3.5 dB | 668 Hz | 1051 Hz | +| Glass Wind | -11.7 dBFS | 0.62 | 7.6 dB | 3.60 s | 11.5 dB | 294 Hz | 2066 Hz | +| Cathedral Bloom | -9.2 dBFS | 0.76 | 6.8 dB | 3.20 s | 4.3 dB | 175 Hz | 1154 Hz | +| Echo Drift | -19.8 dBFS | 0.26 | 8.1 dB | 0.45 s | 7.6 dB | 1142 Hz | 2433 Hz | +| Doppler Void | -19.3 dBFS | 0.36 | 10.4 dB | 0.60 s | 2.6 dB | 704 Hz | 1730 Hz | +| Dark Tide | -13.1 dBFS | 0.68 | 9.7 dB | 1.35 s | 5.1 dB | 195 Hz | 397 Hz | +| Razor Feedback | -16.8 dBFS | 0.26 | 5.1 dB | 0.05 s | 0.3 dB | 512 Hz | 7329 Hz | +| Quiet Room | -22.5 dBFS | 0.27 | 11.1 dB | 2.05 s | 3.8 dB | 543 Hz | 1117 Hz | +| Keys (play notes) | -18.3 dBFS | 0.33 | 8.8 dB | 0.40 s | 2.5 dB | 133 Hz | 765 Hz | +| Resonator (needs input) | (richiede ingresso: vedi F) | | | | | | | +| Init (silent) | (richiede ingresso: vedi F) | | | | | | | +| Open Fifths | -14.9 dBFS | 0.48 | 8.5 dB | 0.65 s | 2.9 dB | 151 Hz | 1075 Hz | +| Sympathetic | -17.5 dBFS | 0.52 | 11.8 dB | 0.75 s | 1.5 dB | 597 Hz | 1980 Hz | +| Chord Keys | -19.2 dBFS | 0.28 | 8.3 dB | 0.15 s | 0.4 dB | 165 Hz | 1253 Hz | +| Ember Pong | -21.3 dBFS | 0.23 | 8.4 dB | 0.35 s | 1.4 dB | 832 Hz | 3492 Hz | + +### B. Mappa della tessitura sotto MIDI Pitch (scarto in cent della parziale dominante; `mute` = sotto -40 dBFS; `*` = oltre 25 cent) + +#### Reverb Mix 0.00 + +| Body \ nota | 16 | 18 | 20 | 24 | 28 | 31 | 36 | 40 | 43 | 48 | 52 | 55 | 60 | 64 | 67 | 72 | 76 | 79 | 80 | 84 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 0.1 | +1104* | +1 | +1 | +1 | +0 | +1 | +0 | +1 | -3 | +2 | +2 | +2 | +5 | +7 | +0 | +0 | +0 | +16 | +17 | +0 | +| 0.3 | +1104* | +1 | +1 | +1 | +0 | +1 | +0 | +1 | -3 | +2 | +2 | +2 | -0 | +0 | +6 | +0 | +0 | +0 | +0 | +0 | +| 0.5 | +1104* | +1 | +1 | +1 | +0 | +1 | +0 | +1 | +0 | +2 | +2 | +2 | +3 | -0 | +4 | +0 | +4 | +0 | +0 | +0 | +| 0.7 | +1104* | +1 | +1 | +1 | +0 | +1 | +0 | +1 | +1 | +3 | +1 | +1 | +1 | -0 | +1 | +3 | +3 | +0 | -36* | +0 | +| 0.9 | +1104* | +1 | +1 | +1 | -0 | +1 | +0 | +1 | +1 | +1 | +2 | +2 | +1 | +1 | +1 | +1 | +1 | +2 | +0 | +0 | + +#### Reverb Mix 0.35 + +| Body \ nota | 16 | 18 | 20 | 24 | 28 | 31 | 36 | 40 | 43 | 48 | 52 | 55 | 60 | 64 | 67 | 72 | 76 | 79 | 80 | 84 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 0.1 | +1125* | +46* | -15 | -25* | +0 | +15 | -27* | -7 | -25* | +20 | +13 | +8 | -1 | -9 | +10 | +3 | +9 | -1 | -1 | +19 | +| 0.3 | +1125* | +46* | -15 | -25* | +0 | +15 | -27* | -7 | -25* | +20 | +13 | +8 | -8 | -15 | +10 | +4 | +7 | +2 | +11 | +10 | +| 0.5 | +1125* | +46* | -15 | -25* | +0 | +15 | -27* | -7 | -9 | +19 | +11 | +7 | +14 | +2 | +9 | -0 | +5 | +1 | +8 | -1 | +| 0.7 | +1125* | +46* | -15 | -25* | +0 | +15 | -24 | -5 | -8 | +10 | +9 | +7 | +6 | -1 | +7 | +3 | +4 | +3 | +0 | +32* | +| 0.9 | +1125* | +46* | -15 | -28* | -5 | +10 | +18 | -5 | -7 | +5 | +105* | +3 | -1 | -0 | +6 | -0 | +32* | +2 | -0 | +21 | + +### C. Ripetibilita: lo stesso preset raggiunto subito da Init, o dopo 1 / 10 / 100 blocchi ai valori di default (come un host che carica lo stato dopo prepareToPlay) + +| Preset | Dominante subito | dopo 1 blocco | dopo 10 | dopo 100 | RMS subito / 1 / 10 / 100 | +|---|---|---|---|---|---| +| Default | 500 Hz | 500 Hz | 500 Hz | 501 Hz | -19 / -19 / -19 / -19 | +| Sub Cellar | 668 Hz | 668 Hz | 668 Hz | 721 Hz | -17 / -17 / -17 / -17 | +| Glass Wind | 294 Hz | 294 Hz | 294 Hz | 296 Hz | -12 / -12 / -12 / -12 | +| Cathedral Bloom | 175 Hz | 175 Hz | 175 Hz | 176 Hz | -10 / -10 / -9 / -10 | +| Echo Drift | 1142 Hz | 832 Hz | 1142 Hz | 832 Hz | -20 / -21 / -21 / -18 | +| Doppler Void | 704 Hz | 198 Hz | 703 Hz | 198 Hz | -20 / -18 / -20 / -18 | +| Dark Tide | 195 Hz | 124 Hz | 124 Hz | 315 Hz | -14 / -16 / -17 / -18 | +| Razor Feedback | 512 Hz | 512 Hz | 512 Hz | 512 Hz | -17 / -17 / -17 / -17 | +| Quiet Room | 543 Hz | 543 Hz | 543 Hz | 544 Hz | -23 / -23 / -23 / -22 | +| Keys (play notes) | 133 Hz | 133 Hz | 133 Hz | 133 Hz | -18 / -18 / -18 / -18 | +| Open Fifths | 151 Hz | 151 Hz | 151 Hz | 151 Hz | -15 / -15 / -15 / -15 | +| Sympathetic | 597 Hz | 597 Hz | 597 Hz | 597 Hz | -18 / -17 / -17 / -18 | +| Chord Keys | 165 Hz | 165 Hz | 165 Hz | 165 Hz | -19 / -19 / -19 / -19 | +| Ember Pong | 832 Hz | 766 Hz | 766 Hz | 766 Hz | -21 / -20 / -20 / -20 | + +### D. Compatibilita mono (5-20 s): somma mono contro media dei canali, energia del lato, correlazione L/R + +| Preset | Mono - stereo | Side - Mid | Corr. L/R | +|---|---|---|---| +| Default | -1.9 dB | -2.6 dB | +0.29 | +| Sub Cellar | -2.7 dB | -0.6 dB | +0.07 | +| Glass Wind | -0.6 dB | -8.4 dB | +0.75 | +| Cathedral Bloom | -0.5 dB | -9.6 dB | +0.80 | +| Echo Drift | -3.2 dB | +0.4 dB | -0.04 | +| Doppler Void | -2.3 dB | -1.6 dB | +0.18 | +| Dark Tide | -2.0 dB | -2.3 dB | +0.26 | +| Razor Feedback | -3.2 dB | +0.4 dB | -0.04 | +| Quiet Room | -2.6 dB | -0.9 dB | +0.10 | +| Keys (play notes) | -3.5 dB | +0.9 dB | -0.11 | +| Open Fifths | -0.9 dB | -6.3 dB | +0.62 | +| Sympathetic | -3.1 dB | +0.1 dB | -0.01 | +| Chord Keys | -4.4 dB | +2.4 dB | -0.27 | +| Ember Pong | -3.1 dB | +0.2 dB | -0.03 | + +### F. Combinazioni estreme e ingresso (20 s) + +| Caso | RMS 5-20 s | Picco | Dominante | Centroide | Note | +|---|---|---|---|---|---| +| Highpass 4 kHz sopra Lowpass 100 Hz | -171.0 dBFS | 0.00 | 2319 Hz | 3467 Hz | i filtri si escludono | +| Sympathetic, LOCK off, Coupling 100 % | -16.2 dBFS | 0.58 | 597 Hz | 1888 Hz | voci libere accoppiate | +| Ember Pong, Echo Feedback 150 %, Drive max, Tone 3.2 kHz | -18.1 dBFS | 0.29 | 832 Hz | 6777 Hz | runaway saturato | +| Default, Feedback +12 dB, Body minimo | -20.3 dBFS | 0.27 | 1076 Hz | 6036 Hz | anello corto al massimo | +| Resonator, sinusoide 220 Hz in ingresso, nota MIDI 57 | -16.9 dBFS | 0.40 | 220 Hz | 642 Hz | ingresso continuo | +| Resonator, 110 Hz a raffiche di 0.5 s, nota 45 | -25.5 dBFS | 0.19 | 110 Hz | 254 Hz | ingresso intermittente | + +### E. Dieci minuti ai valori estremi: livello e centroide per minuto + +| Caso | RMS per minuto (dBFS) | Centroide per minuto (Hz) | Picco | DC | Recuperi | +|---|---|---|---|---|---| +| Reverb Decay 1.0, Feedback max | -11 -9 -9 -9 -10 -10 -11 -11 -11 -11 | 1130 300 226 199 177 167 165 159 157 159 | 0.74 | -0.00013 | 0 | +| Echo feedback 150 %, PONG drive max | -16 -16 -17 -16 -16 -17 -17 -16 -17 -17 | 2452 2721 2555 2526 2557 2563 2490 2578 2526 2775 | 0.45 | +0.00000 | 0 | +| Poly, Coupling max, Feedback max | -19 -19 -20 -19 -19 -19 -20 -19 -20 -19 | 4862 4999 4882 5237 5057 5259 5222 5005 4832 5114 | 0.41 | -0.00000 | 0 | + +### G. VST3 ai tre sample rate (15 s, RMS 5-15 s, spettro da 8 s) + +| Preset | 44.1 kHz: RMS / dominante / centroide | 48 kHz | 96 kHz | +|---|---|---|---| +| Default | -20.6 dBFS / 500 Hz / 3176 Hz | -20.6 dBFS / 501 Hz / 3156 Hz | -20.6 dBFS / 501 Hz / 2889 Hz | +| Sub Cellar | -19.4 dBFS / 721 Hz / 998 Hz | -19.4 dBFS / 721 Hz / 994 Hz | -19.4 dBFS / 721 Hz / 1011 Hz | +| Glass Wind | -12.4 dBFS / 294 Hz / 2081 Hz | -12.4 dBFS / 294 Hz / 1885 Hz | -12.4 dBFS / 292 Hz / 1521 Hz | +| Echo Drift | -23.2 dBFS / 832 Hz / 2354 Hz | -23.2 dBFS / 832 Hz / 2349 Hz | -23.2 dBFS / 1142 Hz / 2243 Hz | +| Razor Feedback | -20.0 dBFS / 511 Hz / 7083 Hz | -20.0 dBFS / 512 Hz / 7195 Hz | -20.0 dBFS / 511 Hz / 7061 Hz | +| Open Fifths | -15.4 dBFS / 151 Hz / 987 Hz | -15.4 dBFS / 151 Hz / 976 Hz | -15.4 dBFS / 151 Hz / 860 Hz | +| Sympathetic | -20.6 dBFS / 592 Hz / 2036 Hz | -20.6 dBFS / 592 Hz / 2035 Hz | -20.6 dBFS / 593 Hz / 2058 Hz | +| Ember Pong | -24.5 dBFS / 1981 Hz / 3575 Hz | -24.5 dBFS / 1981 Hz / 3566 Hz | -24.5 dBFS / 1981 Hz / 3452 Hz | +| Chord Keys (C E G) | -23.8 dBFS / 197 Hz / 1241 Hz | -23.5 dBFS / 165 Hz / 1235 Hz | -23.8 dBFS / 197 Hz / 1217 Hz | diff --git a/review/experiments/CMakeLists-host_probe.txt b/review/experiments/CMakeLists-host_probe.txt new file mode 100644 index 0000000..37734bd --- /dev/null +++ b/review/experiments/CMakeLists-host_probe.txt @@ -0,0 +1,8 @@ +# REVIEW EXPERIMENT (sandbox only): extra host-side probe, see tests/host_probe.cpp +if(DROSERA_BUILD_PLUGIN) + juce_add_console_app(host_probe PRODUCT_NAME "host_probe") + target_sources(host_probe PRIVATE tests/host_probe.cpp) + target_compile_definitions(host_probe PRIVATE JUCE_PLUGINHOST_VST3=1 JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0) + target_link_libraries(host_probe PRIVATE juce::juce_audio_processors juce::juce_recommended_config_flags) + add_dependencies(host_probe Drosera_VST3) +endif() diff --git a/review/experiments/CMakeLists-sr_probe.txt b/review/experiments/CMakeLists-sr_probe.txt new file mode 100644 index 0000000..8c5dc48 --- /dev/null +++ b/review/experiments/CMakeLists-sr_probe.txt @@ -0,0 +1,6 @@ + juce_add_console_app(sr_probe PRODUCT_NAME "sr_probe") + target_sources(sr_probe PRIVATE tests/sr_probe.cpp) + target_include_directories(sr_probe PRIVATE tests) + target_compile_definitions(sr_probe PRIVATE JUCE_PLUGINHOST_VST3=1 JUCE_WEB_BROWSER=0 JUCE_USE_CURL=0) + target_link_libraries(sr_probe PRIVATE juce::juce_audio_processors juce::juce_recommended_config_flags) +endif() diff --git a/review/experiments/analyze.py b/review/experiments/analyze.py new file mode 100644 index 0000000..1c92eb8 --- /dev/null +++ b/review/experiments/analyze.py @@ -0,0 +1,37 @@ +import numpy as np, struct, sys +sr = 48000 +def load_wav_f32(p): + d = open(p, "rb").read()[44:] + return np.frombuffer(d, dtype=" 5 for g, _ in out): out.append((f[i], db(X[i]))) + if len(out) == n: break + return sorted(out, key=lambda t: -t[1]) +def centroid(x): + m = x.mean(axis=1); X = np.abs(np.fft.rfft(m)); f = np.fft.rfftfreq(len(m), 1/sr) + return (f*X).sum()/X.sum() +def echo(x, lo=0.3, hi=3.0): + e = np.abs(x.mean(axis=1)); e = e - e.mean() + E = np.fft.rfft(e, 2*len(e)); ac = np.fft.irfft(E*np.conj(E))[:len(e)]; ac /= ac[0] + a, b = int(lo*sr), int(hi*sr); k = a + np.argmax(ac[a:b]); return k/sr, ac[k] +def report(name, x): + L, R = x[:,0], x[:,1] + print(f"--- {name}: {len(x)/sr:.1f} s") + print(f" rms L/R {db(np.sqrt((L**2).mean())):6.1f} / {db(np.sqrt((R**2).mean())):6.1f} dBFS peak {np.abs(x).max():.3f} DC {L.mean():+.4f}/{R.mean():+.4f}") + print(f" clipped samples (|x|>=0.999): {(np.abs(x)>=0.999).sum()} zero runs >= 5 ms: {int(np.sum(np.convolve((np.abs(L)<1e-6).astype(int), np.ones(240), 'valid')==240)>0)}") + print(f" L/R correlation {np.corrcoef(L, R)[0,1]:+.3f} spectral centroid {centroid(x):.0f} Hz") + t, c = echo(x); print(f" envelope autocorrelation peak at {t:.3f} s (strength {c:.2f}); knob Echo Time = 0.254 s (state), PONG on") + print(" strongest partials:", " ".join(f"{f:.1f} Hz ({d:.1f} dB)" for f, d in partials(x))) + rms_t = [db(np.sqrt((seg(x, s, s+1)**2).mean())) for s in range(0, int(len(x)/sr))] + print(" rms per second:", " ".join(f"{v:.0f}" for v in rms_t)) +report("STANDALONE (BlackHole)", rec) +report("OFFLINE render_cli, Default", off) diff --git a/review/experiments/exp1_cpu.cpp b/review/experiments/exp1_cpu.cpp new file mode 100644 index 0000000..bb9ac84 --- /dev/null +++ b/review/experiments/exp1_cpu.cpp @@ -0,0 +1,46 @@ +// CPU cost of DroseraCore per mode: renders N seconds at 48 kHz in 512-sample blocks and reports +// wall time as a percentage of real time (one core). Also reports the limiter's overshoot above 1.0. +#include +#include +#include +#include +#include "DroseraCore.h" +#include "Presets.h" + +using infrasonic::DroseraCore; + +static void apply(DroseraCore& c, const infrasonic::Preset& p) { + for (int i = 0; i < DroseraCore::NumParams; ++i) c.SetParam((DroseraCore::Param) i, p.knob[i]); + c.SetEchoWarp(p.echoHalve); c.SetPolyphony(p.poly); c.SetCoupling(p.coupling); + c.SetIntervals(p.interval2, p.interval3); c.SetStringLock(p.stringLock); + c.SetEchoPong(p.echoPong); c.SetEchoDrive(p.echoDrive); c.SetEchoTone(p.echoTone); + c.SetEchoWidth(p.echoWidth); c.SetEchoLevel(p.echoLevel); +} + +static void bench(const char* name, DroseraCore& core, double seconds) { + const size_t block = 512, total = (size_t) (seconds * 48000); + std::vector L(block), R(block); + size_t over = 0; double peak = 0; + auto t0 = std::chrono::steady_clock::now(); + for (size_t pos = 0; pos < total; pos += block) { + core.Render(nullptr, L.data(), R.data(), block); + for (size_t i = 0; i < block; ++i) { const double a = std::fabs(L[i]); if (a > 1.0) ++over; if (a > peak) peak = a; } + } + const double wall = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + std::printf("%-28s %6.2f%% of one core peak %.3f samples > 1.0: %zu recoveries %d\n", + name, 100.0 * wall / seconds, peak, over, core.RecoveryCount()); +} + +int main(int argc, char** argv) { + const double seconds = argc > 1 ? std::atof(argv[1]) : 20.0; + using infrasonic::kFactoryPresets; + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[0]); bench("Default (mono knob)", c, seconds); } + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[9]); c.SetMidiPitch(true, 48.0f); c.SetGate(true, true); bench("Keys (mono MIDI tracking)", c, seconds); } + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[12]); bench("Open Fifths (poly, lock)", c, seconds); } + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[13]); bench("Sympathetic (poly, free)", c, seconds); } + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[14]); c.SetMidiPitch(true, 48.0f); c.SetVoiceNote(1, 52.0f); c.SetVoiceNote(2, 55.0f); + for (size_t v = 0; v < 3; ++v) c.SetVoiceGate(v, true, true); bench("Chord Keys (poly MIDI)", c, seconds); } + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[15]); bench("Ember Pong (pong on)", c, seconds); } + { DroseraCore c; c.Init(); apply(c, kFactoryPresets[7]); bench("Razor Feedback (gain 0.95)", c, seconds); } + return 0; +} diff --git a/review/experiments/exp2_edges.cpp b/review/experiments/exp2_edges.cpp new file mode 100644 index 0000000..e8c283b --- /dev/null +++ b/review/experiments/exp2_edges.cpp @@ -0,0 +1,99 @@ +// DSP edge cases on the host-independent core. +// A) Echo start-up: the echo's delay line starts at 5 s and glides to the knob; measure the first repeat's delay +// of an impulse at t=0 against one at t=6 s. +// B) Extreme settings for minutes: reverb decay 1.0, feedback max, echo feedback 1.5, pong drive max. +// Count non-finite recoveries and samples above 1.0 after the limiter. +// C) Resampler: gain at 1 kHz and 18 kHz, and image/alias power, 48k -> 44.1k. +// D) Interval clamp: knob at the top with +24 st. +#include +#include +#include +#include +#include "DroseraCore.h" +#include "Presets.h" +#include "SincResampler.h" + +using infrasonic::DroseraCore; + +static void apply(DroseraCore& c, const infrasonic::Preset& p) { + for (int i = 0; i < DroseraCore::NumParams; ++i) c.SetParam((DroseraCore::Param) i, p.knob[i]); + c.SetEchoWarp(p.echoHalve); c.SetPolyphony(p.poly); c.SetCoupling(p.coupling); + c.SetIntervals(p.interval2, p.interval3); c.SetStringLock(p.stringLock); + c.SetEchoPong(p.echoPong); c.SetEchoDrive(p.echoDrive); c.SetEchoTone(p.echoTone); + c.SetEchoWidth(p.echoWidth); c.SetEchoLevel(p.echoLevel); +} + +static void render(DroseraCore& c, const float* in, std::vector& L, std::vector& R, size_t n) { + L.assign(n, 0.0f); R.assign(n, 0.0f); + for (size_t pos = 0; pos < n; pos += 256) c.Render(in ? in + pos : nullptr, &L[pos], &R[pos], std::min(256, n - pos)); +} + +static void echoStartup() { + // Feedback closed, no reverb, echo send 1, echo feedback 0, echo time norm 0.2 -> 0.05 + 0.04*4.95 = 0.248 s + const float p[] = {0.43f, 0.00f, 0.30f, 0.90f, 0.20f, 0.00f, 0.50f, 1.00f, 0.20f, 0.00f, 0.70f}; + const float expect = DroseraCore::EchoTimeSeconds(0.2f, false); + for (double at : {0.0, 2.0, 6.0}) { + DroseraCore c; c.Init(); + for (int i = 0; i < DroseraCore::NumParams; ++i) c.SetParam((DroseraCore::Param) i, p[i]); + const size_t n = 48000 * 12; + std::vector in(n, 0.0f), L, R; + in[(size_t) (at * 48000)] = 0.5f; + render(c, in.data(), L, R, n); + // the direct sound is the string's ring; find the echo as the loudest sample after 0.1 s past the impulse + size_t start = (size_t) (at * 48000) + 4800, best = start; double peak = 0; + for (size_t i = start; i < n; ++i) if (std::fabs(L[i]) > peak) { peak = std::fabs(L[i]); best = i; } + std::printf("A) impulse at %.0f s: first repeat after %.3f s (knob says %.3f s), level %.3f\n", at, (best - at * 48000) / 48000.0, expect, peak); + } +} + +static void extremes() { + struct Case { const char* name; float p[11]; bool pong; float drive; bool poly; float coupling; }; + const Case cases[] = { + {"everything max, reverb 1.0", {1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1}, false, 0, false, 0}, + {"max + pong drive max", {1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1}, true, 1, false, 0}, + {"max + poly + coupling max", {1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1}, true, 1, true, 1}, + {"low + poly + coupling max", {0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1}, false, 0, true, 1}, + }; + for (const auto& k : cases) { + DroseraCore c; c.Init(); + for (int i = 0; i < DroseraCore::NumParams; ++i) c.SetParam((DroseraCore::Param) i, k.p[i]); + c.SetEchoPong(k.pong); c.SetEchoDrive(k.drive); c.SetPolyphony(k.poly); c.SetCoupling(k.coupling); + const size_t n = 48000 * 180; // three minutes + std::vector L(256), R(256); + size_t over = 0; double peak = 0, sumSq = 0; + for (size_t pos = 0; pos < n; pos += 256) { + c.Render(nullptr, L.data(), R.data(), 256); + for (size_t i = 0; i < 256; ++i) { const double a = std::fabs(L[i]); if (a > 1.0) ++over; if (a > peak) peak = a; if (pos > n / 2) sumSq += a * a; } + } + std::printf("B) %-28s 3 min: peak %.3f, samples > 1.0: %zu, rms(2nd half) %.3f, recoveries %d\n", k.name, peak, over, std::sqrt(sumSq / (n / 2.0)), c.RecoveryCount()); + } +} + +static void resampler() { + infrasonic::SincResampler r; r.Init(48000.0 / 44100.0); + for (double hz : {1000.0, 10000.0, 18000.0, 21000.0, 23000.0}) { + r.Reset(); + const size_t nIn = 48000 * 2, nOut = 44100 * 2 - 200; + std::vector in(nIn), out(nOut); + for (size_t i = 0; i < nIn; ++i) in[i] = (float) std::sin(2.0 * M_PI * hz * i / 48000.0); + r.Process(in.data(), out.data(), (int) nOut); + // amplitude at hz (if below 22.05k) via Goertzel over the second half, and total power elsewhere + const size_t a = nOut / 2, len = nOut - a; + std::complex acc = 0; double total = 0; + for (size_t i = a; i < nOut; ++i) { acc += (double) out[i] * std::polar(1.0, -2.0 * M_PI * hz * (double) i / 44100.0); total += (double) out[i] * out[i]; } + const double amp = 2.0 * std::abs(acc) / (double) len; + const double rms = std::sqrt(total / (double) len); + std::printf("C) 48k->44.1k %6.0f Hz: amplitude %.4f (%.2f dB), output rms %.4f\n", hz, amp, 20 * std::log10(amp + 1e-12), rms); + } +} + +static void intervalClamp() { + DroseraCore c; c.Init(); + apply(c, infrasonic::kFactoryPresets[12]); + c.SetParam(DroseraCore::Frequency, 1.0f); // note 72 + c.SetIntervals(24, 12); + std::vector L, R; render(c, nullptr, L, R, 48000); + std::printf("D) knob note 72 with +24 st: voice 1 publishes note %.1f (voice 0 %.1f), voice 2 (+12) %.1f\n", c.PublishedNote(1), c.PublishedNote(0), c.PublishedNote(2)); +} + +int main() { echoStartup(); resampler(); intervalClamp(); extremes(); return 0; } diff --git a/review/experiments/exp3_order.cpp b/review/experiments/exp3_order.cpp new file mode 100644 index 0000000..ccc664e --- /dev/null +++ b/review/experiments/exp3_order.cpp @@ -0,0 +1,12 @@ +// Order in which ParameterRegistry::Process fires its handlers: it iterates a std::unordered_map, so the +// order is the standard library's, not the registration order. Mirrors DroseraCore's registration. +#include +#include "ParameterRegistry.h" +enum Param : int { Frequency = 0, FeedbackGain, FeedbackBody, FeedbackLPF, FeedbackHPF, ReverbMix, ReverbDecay, EchoSend, EchoTime, EchoFeedback, OutputVolume, NumParams }; +static const char* names[] = {"Frequency", "FeedbackGain", "FeedbackBody", "FeedbackLPF", "FeedbackHPF", "ReverbMix", "ReverbDecay", "EchoSend", "EchoTime", "EchoFeedback", "OutputVolume"}; +int main() { + infrasonic::ParameterRegistry r; r.Init(12000.0f); + for (int p = 0; p < NumParams; ++p) r.Register((Param) p, 0.5f, 0.0f, 1.0f, [p](float) { std::printf("%s ", names[p]); }); + std::printf("registration order: Frequency .. OutputVolume\nhandler order: "); r.Process(); std::printf("\n"); + return 0; +} diff --git a/review/experiments/exp4_size.cpp b/review/experiments/exp4_size.cpp new file mode 100644 index 0000000..18539d2 --- /dev/null +++ b/review/experiments/exp4_size.cpp @@ -0,0 +1,5 @@ +#include +#include "DroseraCore.h" +int main() { std::printf("sizeof Engine %zu KB, FeedbackVoice %zu KB, ReverbSc %zu KB, StereoEcho %zu KB, DroseraCore %zu KB\n", + sizeof(infrasonic::FeedbackSynth::Engine)/1024, sizeof(infrasonic::FeedbackSynth::FeedbackVoice)/1024, sizeof(daisysp::ReverbSc)/1024, + sizeof(infrasonic::StereoEcho<48000*5>)/1024, sizeof(infrasonic::DroseraCore)/1024); } diff --git a/review/experiments/exp5_stall.cpp b/review/experiments/exp5_stall.cpp new file mode 100644 index 0000000..24f08a5 --- /dev/null +++ b/review/experiments/exp5_stall.cpp @@ -0,0 +1,11 @@ +// Where does EchoDelay's float one-pole (lag 0.5 s at 48 kHz) stop when gliding from Init's 5 s to the knob? +#include +#include +int main() { + const float coef = 1.0f / (0.5f * 48000.0f); + for (float target : {0.248f, 1.2875f, 2.5f}) { + float cur = 5.0f; long n = 0; + for (; n < 48000L * 60; ++n) { const float next = cur + coef * (target - cur); if (next == cur) break; cur = next; } + std::printf("target %.4f s: stops at %.6f s after %.1f s, short by %.3f ms = %.1f samples\n", target, cur, n / 48000.0, (cur - target) * 1000.0, (cur - target) * 48000.0); + } +} diff --git a/review/experiments/host_probe.cpp b/review/experiments/host_probe.cpp new file mode 100644 index 0000000..33bd7f7 --- /dev/null +++ b/review/experiments/host_probe.cpp @@ -0,0 +1,141 @@ +// REVIEW EXPERIMENT (sandbox only, not part of the project): drives the built VST3 through cases the +// project's plugin_host_test does not cover: unusual sample rates, very small, very large and empty +// blocks, a long run with input at a non-48k rate, bypass, garbage state, and velocity-0 note-ons. +#include +#include + +static void setParam(juce::AudioProcessor& p, const char* name, float norm) { + for (auto* prm : p.getParameters()) + if (prm->getName(64) == name) { prm->setValue(norm); return; } + std::printf("missing parameter %s\n", name); std::exit(1); +} + +struct Stats { double peak = 0, sumSq = 0; long n = 0; bool finite = true; }; +static void accumulate(Stats& s, const juce::AudioBuffer& b) { + for (int c = 0; c < 2; ++c) + for (int i = 0; i < b.getNumSamples(); ++i) { + const float v = b.getSample(c, i); + if (!std::isfinite(v)) s.finite = false; + s.peak = std::max(s.peak, (double) std::abs(v)); s.sumSq += (double) v * v; ++s.n; + } +} + +int main(int argc, char** argv) { + if (argc < 2) { std::printf("usage: host_probe \n"); return 2; } + juce::ScopedJuceInitialiser_GUI init; + juce::VST3PluginFormat vst3; + juce::OwnedArray descs; + vst3.findAllTypesForFile(descs, argv[1]); + if (descs.isEmpty()) { std::printf("FAIL: no plugin found\n"); return 1; } + bool ok = true; + juce::String err; + + // 1) Unusual rates and block sizes, with input, feedback open: finite, bounded, audible, latency as declared. + for (double sr : {22050.0, 44100.0, 88200.0, 176400.0, 192000.0}) { + for (int block : {1, 7, 2048, 8192}) { + auto inst = vst3.createInstanceFromDescription(*descs[0], sr, block, err); + if (!inst) { std::printf("FAIL create %s\n", err.toRawUTF8()); return 1; } + inst->enableAllBuses(); + inst->prepareToPlay(sr, block); + juce::AudioBuffer buf(2, block); juce::MidiBuffer midi; + Stats s; double phase = 0; + const long total = (long) (sr * 3.0); + for (long pos = 0; pos < total; pos += block) { + for (int i = 0; i < block; ++i) { const float v = 0.2f * (float) std::sin(phase); phase += 2.0 * M_PI * 220.0 / sr; buf.setSample(0, i, v); buf.setSample(1, i, v); } + inst->processBlock(buf, midi); + if (pos > sr) accumulate(s, buf); + } + const double rms = std::sqrt(s.sumSq / (double) s.n); + const bool pass = s.finite && s.peak <= 1.0 && rms > 1e-3; + std::printf("sr=%-7.0f block=%-5d peak=%.3f rms=%.4f finite=%d latency=%d %s\n", sr, block, s.peak, rms, s.finite, inst->getLatencySamples(), pass ? "OK" : "FAIL"); + ok &= pass; + inst->releaseResources(); + } + } + + // 2) Latency at the extra rates: impulse with feedback closed. + for (double sr : {22050.0, 88200.0, 192000.0}) { + auto inst = vst3.createInstanceFromDescription(*descs[0], sr, 512, err); + inst->enableAllBuses(); + setParam(*inst, "Feedback", 0.0f); setParam(*inst, "Reverb Mix", 0.0f); setParam(*inst, "Echo Send", 0.0f); + inst->prepareToPlay(sr, 512); + juce::AudioBuffer buf(2, 512); juce::MidiBuffer m; + const long at = (long) sr; long peakAt = -1; double peak = 0; + for (long b = 0; b < (long) (sr * 2 / 512); ++b) { + buf.clear(); + if (at >= b * 512 && at < (b + 1) * 512) { buf.setSample(0, (int) (at - b * 512), 0.9f); buf.setSample(1, (int) (at - b * 512), 0.9f); } + inst->processBlock(buf, m); + for (int i = 0; i < 512; ++i) if (b * 512 + i >= at && std::abs(buf.getSample(0, i)) > peak) { peak = std::abs(buf.getSample(0, i)); peakAt = b * 512 + i; } + } + const long measured = peakAt - at; const int declared = inst->getLatencySamples(); + const bool pass = peak > 1e-3 && std::labs(measured - declared) <= 12; + std::printf("sr=%-7.0f latency: declared %d, measured %ld %s\n", sr, declared, measured, pass ? "OK" : "FAIL"); + ok &= pass; + } + + // 3) Long run at 44.1 kHz with input and a varying block size: output must stay continuous (no dropouts) for 60 s. + { + const double sr = 44100.0; + auto inst = vst3.createInstanceFromDescription(*descs[0], sr, 4096, err); + inst->enableAllBuses(); + setParam(*inst, "Feedback", 0.0f); setParam(*inst, "Reverb Mix", 0.0f); setParam(*inst, "Echo Send", 0.0f); // output is the resampled input + inst->prepareToPlay(sr, 4096); + juce::AudioBuffer buf(2, 4096); juce::MidiBuffer midi; + double phase = 0; long pos = 0; int quiet = 0, blocks = 0; bool finite = true; + const int sizes[] = {4096, 64, 1000, 3, 2048, 512}; + while (pos < (long) (sr * 60.0)) { + const int block = sizes[blocks % 6]; + juce::AudioBuffer view(buf.getArrayOfWritePointers(), 2, block); + for (int i = 0; i < block; ++i) { const float v = 0.2f * (float) std::sin(phase); phase += 2.0 * M_PI * 220.0 / sr; view.setSample(0, i, v); view.setSample(1, i, v); } + inst->processBlock(view, midi); + Stats s; accumulate(s, view); finite &= s.finite; + if (pos > sr && block >= 512 && std::sqrt(s.sumSq / (double) s.n) < 1e-4) ++quiet; + pos += block; ++blocks; + } + // After the long run: has the input-to-output delay drifted from what was declared? + juce::AudioBuffer b512(2, 512); long peakAt = -1, at = 0; double pk = 0; + for (long b = 0; b < 400; ++b) { b512.clear(); inst->processBlock(b512, midi); } // let the ring empty + for (long b = 0; b < 200; ++b) { + b512.clear(); + if (b == 10) { at = b * 512; b512.setSample(0, 0, 0.9f); b512.setSample(1, 0, 0.9f); } + inst->processBlock(b512, midi); + for (int i = 0; i < 512; ++i) if (b * 512 + i >= at && b >= 10 && std::abs(b512.getSample(0, i)) > pk) { pk = std::abs(b512.getSample(0, i)); peakAt = b * 512 + i; } + } + const long measured = peakAt - at; const int declared = inst->getLatencySamples(); + const bool pass = finite && quiet == 0 && std::labs(measured - declared) <= 12; + std::printf("60 s at 44.1k, mixed block sizes: finite=%d quiet blocks=%d, latency after: declared %d measured %ld %s\n", finite, quiet, declared, measured, pass ? "OK" : "FAIL"); + ok &= pass; + } + + // 4) Empty block, bypass, garbage state, note-on with velocity 0. + { + auto inst = vst3.createInstanceFromDescription(*descs[0], 48000.0, 512, err); + inst->prepareToPlay(48000.0, 512); + juce::AudioBuffer empty(2, 0); juce::MidiBuffer midi; + inst->processBlock(empty, midi); + juce::AudioBuffer buf(2, 512); + buf.clear(); inst->processBlockBypassed(buf, midi); + Stats s; accumulate(s, buf); + std::printf("empty block and bypass: finite=%d peak=%.3f %s\n", s.finite, s.peak, s.finite ? "OK" : "FAIL"); ok &= s.finite; + + const char garbage[] = "this is not a state"; + inst->setStateInformation(garbage, (int) sizeof(garbage)); + juce::MemoryBlock state; inst->getStateInformation(state); + inst->setStateInformation(state.getData(), (int) state.getSize() / 2); // truncated + buf.clear(); inst->processBlock(buf, midi); + Stats t; accumulate(t, buf); + std::printf("garbage and truncated state: still processing, finite=%d %s\n", t.finite, t.finite ? "OK" : "FAIL"); ok &= t.finite; + + setParam(*inst, "MIDI Gate", 1.0f); + midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8) 100), 0); + for (int b = 0; b < 100; ++b) { buf.clear(); inst->processBlock(buf, midi); midi.clear(); } + midi.addEvent(juce::MidiMessage::noteOn(1, 60, (juce::uint8) 0), 0); // running-status note-off + Stats held, after; + for (int b = 0; b < 400; ++b) { buf.clear(); inst->processBlock(buf, midi); midi.clear(); if (b > 300) accumulate(after, buf); } + const double rmsAfter = std::sqrt(after.sumSq / (double) after.n); + std::printf("note-on velocity 0 releases the gate: rms after %.5f %s\n", rmsAfter, rmsAfter < 1e-3 ? "OK" : "FAIL"); ok &= rmsAfter < 1e-3; + } + + std::printf(ok ? "HOST PROBE ALL OK\n" : "HOST PROBE: SOME FAILED\n"); + return ok ? 0 : 1; +} diff --git a/review/experiments/render_state.cpp b/review/experiments/render_state.cpp new file mode 100644 index 0000000..c367463 --- /dev/null +++ b/review/experiments/render_state.cpp @@ -0,0 +1,17 @@ +// Renders the state the Standalone restored (decoded from Drosera.settings) with the engine, 25 s at 48 kHz. +#include +#include +#include "DroseraCore.h" +using infrasonic::DroseraCore; +int main(int argc, char** argv) { + DroseraCore c; c.Init(); + const float knobs[11] = {0.765f, 0.929f, 0.745f, 0.799f, 0.458f, 0.300f, 0.500f, 0.882f, 0.203f, 0.818f, 0.700f}; + for (int i = 0; i < 11; ++i) c.SetParam((DroseraCore::Param) i, knobs[i]); + c.SetEchoWarp(false); c.SetPolyphony(true); c.SetCoupling(0.597f); c.SetIntervals(0, -7); c.SetStringLock(true); + c.SetEchoPong(true); c.SetEchoDrive(1.0f); c.SetEchoTone(0.640f); c.SetEchoWidth(0.865f); c.SetEchoLevel(0.833f); + const size_t n = 48000 * 25; std::vector L(n), R(n); + for (size_t p = 0; p < n; p += 512) c.Render(nullptr, &L[p], &R[p], 512); + FILE* f = fopen(argv[1], "wb"); char hdr[44] = {}; fwrite(hdr, 1, 44, f); + for (size_t i = 0; i < n; ++i) { fwrite(&L[i], 4, 1, f); fwrite(&R[i], 4, 1, f); } + fclose(f); std::printf("rendered %zu samples, recoveries %d\n", n, c.RecoveryCount()); +} diff --git a/review/experiments/sd_measure.cpp b/review/experiments/sd_measure.cpp new file mode 100644 index 0000000..13ec29a --- /dev/null +++ b/review/experiments/sd_measure.cpp @@ -0,0 +1,215 @@ +// Sound-design measurements on the engine (host independent, 48 kHz). Prints Markdown tables. +// A) every preset: RMS, peak, crest, time to reach -3 dB of its steady level, loudness spread over 2 s windows +// B) pitch map under MIDI: cents error of the dominant partial per note and Body, silent notes, and with reverb +// C) repeatability: the same preset reached from Init at once or after gliding from the defaults +// D) mono compatibility: mono sum against the stereo average, and L/R correlation +// E) long runs: 10 minutes at the extremes, level and centroid per minute +// F) extreme combinations +#include +#include +#include +#include +#include +#include +#include "DroseraCore.h" +#include "Presets.h" +#include "pitch_estimator.h" + +using infrasonic::DroseraCore; +using infrasonic::Preset; +static constexpr double kSr = 48000.0; + +struct Stereo { std::vector L, R; }; + +static void apply(DroseraCore& c, const Preset& p) { + for (int i = 0; i < DroseraCore::NumParams; ++i) c.SetParam((DroseraCore::Param) i, p.knob[i]); + c.SetEchoWarp(p.echoHalve); c.SetPolyphony(p.poly); c.SetCoupling(p.coupling); + c.SetIntervals(p.interval2, p.interval3); c.SetStringLock(p.stringLock); + c.SetEchoPong(p.echoPong); c.SetEchoDrive(p.echoDrive); c.SetEchoTone(p.echoTone); + c.SetEchoWidth(p.echoWidth); c.SetEchoLevel(p.echoLevel); +} +// MIDI presets need a note; the resonator needs an input. +static void play(DroseraCore& c, const Preset& p) { + if (!p.midiPitch && !p.midiGate) return; + c.SetMidiPitch(true, 48.0f); + if (p.poly) { c.SetVoiceNote(1, 52.0f); c.SetVoiceNote(2, 55.0f); for (size_t v = 0; v < 3; ++v) c.SetVoiceGate(v, true, true); } + else c.SetGate(p.midiGate, true); +} +static Stereo render(DroseraCore& c, double seconds, const float* in = nullptr) { + const size_t n = (size_t) (seconds * kSr); + Stereo s; s.L.assign(n, 0.0f); s.R.assign(n, 0.0f); + for (size_t p = 0; p < n; p += 256) c.Render(in ? in + p : nullptr, &s.L[p], &s.R[p], std::min(256, n - p)); + return s; +} +static double rmsDb(const std::vector& v, size_t a, size_t b) { + double s = 0; for (size_t i = a; i < b; ++i) s += (double) v[i] * v[i]; + return 20.0 * std::log10(std::sqrt(s / (double) (b - a)) + 1e-12); +} +static double rmsDb(const Stereo& s, double a, double b) { + return 10.0 * std::log10(0.5 * (std::pow(10.0, rmsDb(s.L, (size_t) (a * kSr), (size_t) (b * kSr)) / 10.0) + std::pow(10.0, rmsDb(s.R, (size_t) (a * kSr), (size_t) (b * kSr)) / 10.0))); +} +static double peakOf(const Stereo& s, double a, double b) { + double p = 0; for (size_t i = (size_t) (a * kSr); i < (size_t) (b * kSr); ++i) p = std::max({p, (double) std::fabs(s.L[i]), (double) std::fabs(s.R[i])}); + return p; +} +static std::vector mono(const Stereo& s) { std::vector m(s.L.size()); for (size_t i = 0; i < m.size(); ++i) m[i] = 0.5f * (s.L[i] + s.R[i]); return m; } +static std::vector side(const Stereo& s) { std::vector m(s.L.size()); for (size_t i = 0; i < m.size(); ++i) m[i] = 0.5f * (s.L[i] - s.R[i]); return m; } + +// Spectrum of 2^17 samples from `start`: dominant partial and centroid +struct Spec { double dominantHz, centroidHz; std::vector> top; }; +static Spec spectrum(const std::vector& x, size_t start) { + const size_t N = 1 << 17; + std::vector> a(N); + for (size_t i = 0; i < N; ++i) a[i] = (double) x[start + i] * (0.5 - 0.5 * std::cos(2.0 * M_PI * (double) i / (double) N)); + fft(a); + const double bin = kSr / (double) N; + double num = 0, den = 0; std::vector> peaks; + for (size_t k = 1; k < N / 2; ++k) { + const double m = std::abs(a[k]); num += m * k * bin; den += m; + if (k > 2 && m > std::abs(a[k - 1]) && m >= std::abs(a[k + 1]) && k * bin > 15.0) peaks.push_back({k * bin, m}); + } + std::sort(peaks.begin(), peaks.end(), [](auto& p, auto& q) { return p.second > q.second; }); + Spec s; s.centroidHz = den > 0 ? num / den : 0; s.dominantHz = peaks.empty() ? 0 : peaks[0].first; + for (size_t i = 0; i < std::min(3, peaks.size()); ++i) s.top.push_back({peaks[i].first, 20 * std::log10(peaks[i].second / (peaks[0].second + 1e-30))}); + return s; +} + +static void loudnessTable() { + std::printf("\n## A. Preset: livello, dinamica e tempo di nascita (20 s da Init)\n\n"); + std::printf("| Preset | RMS 5-20 s | Picco | Cresta | -3 dB dopo | Spread 2 s | Dominante | Centroide |\n|---|---|---|---|---|---|---|---|\n"); + for (const auto& p : infrasonic::kFactoryPresets) { + if (p.needsInput) { std::printf("| %s | (richiede ingresso: vedi F) | | | | | | |\n", p.name); continue; } + DroseraCore c; c.Init(); apply(c, p); play(c, p); + const Stereo s = render(c, 20.0); + const double steady = rmsDb(s, 5.0, 20.0), peak = peakOf(s, 5.0, 20.0); + double born = -1; for (double t = 0.0; t < 20.0; t += 0.05) if (rmsDb(s, t, t + 0.1) >= steady - 3.0) { born = t; break; } + double lo = 1e9, hi = -1e9; for (double t = 5.0; t + 2.0 <= 20.0; t += 1.0) { const double r = rmsDb(s, t, t + 2.0); lo = std::min(lo, r); hi = std::max(hi, r); } + const Spec sp = spectrum(mono(s), (size_t) (10.0 * kSr)); + std::printf("| %s | %.1f dBFS | %.2f | %.1f dB | %.2f s | %.1f dB | %.0f Hz | %.0f Hz |\n", p.name, steady, peak, 20 * std::log10(peak + 1e-12) - steady, born, hi - lo, sp.dominantHz, sp.centroidHz); + } +} + +static void pitchMap() { + std::printf("\n## B. Mappa della tessitura sotto MIDI Pitch (scarto in cent della parziale dominante; `mute` = sotto -40 dBFS; `*` = oltre 25 cent)\n"); + const float base[] = {0.43f, 0.75f, 0.30f, 0.80f, 0.20f, 0.00f, 0.50f, 0.00f, 0.50f, 0.00f, 0.70f}; // Keys preset, dry + const float bodies[] = {0.1f, 0.3f, 0.5f, 0.7f, 0.9f}; + const int notes[] = {16, 18, 20, 24, 28, 31, 36, 40, 43, 48, 52, 55, 60, 64, 67, 72, 76, 79, 80, 84}; + for (float revmix : {0.0f, 0.35f}) { + std::printf("\n### Reverb Mix %.2f\n\n| Body \\ nota |", revmix); + for (int n : notes) std::printf(" %d |", n); + std::printf("\n|---|"); for (size_t i = 0; i < sizeof(notes) / sizeof(notes[0]); ++i) std::printf("---|"); std::printf("\n"); + for (float body : bodies) { + std::printf("| %.1f |", body); + for (int n : notes) { + DroseraCore c; c.Init(); + for (int i = 0; i < DroseraCore::NumParams; ++i) c.SetParam((DroseraCore::Param) i, base[i]); + c.SetParam(DroseraCore::FeedbackBody, body); c.SetParam(DroseraCore::ReverbMix, revmix); + c.SetMidiPitch(true, (float) n); c.SetGate(true, true); + const Stereo s = render(c, 5.0); + const double level = rmsDb(s, 2.0, 5.0); + if (level < -40.0) { std::printf(" mute |"); continue; } + const double hz = dominantHz(mono(s), (size_t) (2.0 * kSr), kSr); + const double cents = 1200.0 * std::log2(hz / (440.0 * std::pow(2.0, (n - 69) / 12.0))); + std::printf(" %+.0f%s |", cents, std::fabs(cents) > 25 ? "*" : ""); + } + std::printf("\n"); + } + } +} + +static void repeatability() { + std::printf("\n## C. Ripetibilita: lo stesso preset raggiunto subito da Init, o dopo 1 / 10 / 100 blocchi ai valori di default (come un host che carica lo stato dopo prepareToPlay)\n\n"); + std::printf("| Preset | Dominante subito | dopo 1 blocco | dopo 10 | dopo 100 | RMS subito / 1 / 10 / 100 |\n|---|---|---|---|---|---|\n"); + for (const auto& p : infrasonic::kFactoryPresets) { + if (p.needsInput) continue; + std::printf("| %s |", p.name); + std::string rms; + for (int delayBlocks : {0, 1, 10, 100}) { + DroseraCore c; c.Init(); apply(c, infrasonic::kFactoryPresets[0]); // the defaults first + float l[256], r[256]; + for (int b = 0; b < delayBlocks; ++b) c.Render(nullptr, l, r, 256); + apply(c, p); play(c, p); + const Stereo s = render(c, 15.0); + const Spec sp = spectrum(mono(s), (size_t) (10.0 * kSr)); + std::printf(" %.0f Hz |", sp.dominantHz); + rms += (rms.empty() ? "" : " / ") + std::to_string((int) std::lround(rmsDb(s, 5.0, 15.0))); + } + std::printf(" %s |\n", rms.c_str()); + } +} + +static void monoCompat() { + std::printf("\n## D. Compatibilita mono (5-20 s): somma mono contro media dei canali, energia del lato, correlazione L/R\n\n"); + std::printf("| Preset | Mono - stereo | Side - Mid | Corr. L/R |\n|---|---|---|---|\n"); + for (const auto& p : infrasonic::kFactoryPresets) { + if (p.needsInput) continue; + DroseraCore c; c.Init(); apply(c, p); play(c, p); + const Stereo s = render(c, 20.0); + const auto m = mono(s), sd = side(s); + const size_t a = (size_t) (5.0 * kSr), b = s.L.size(); + const double mid = rmsDb(m, a, b), sideDb = rmsDb(sd, a, b), stereo = rmsDb(s, 5.0, 20.0); + double num = 0, dl = 0, dr = 0; for (size_t i = a; i < b; ++i) { num += (double) s.L[i] * s.R[i]; dl += (double) s.L[i] * s.L[i]; dr += (double) s.R[i] * s.R[i]; } + std::printf("| %s | %+.1f dB | %+.1f dB | %+.2f |\n", p.name, mid - stereo, sideDb - mid, num / std::sqrt(dl * dr + 1e-30)); + } +} + +static void longRuns() { + std::printf("\n## E. Dieci minuti ai valori estremi: livello e centroide per minuto\n\n"); + struct Case { const char* name; float knobs[11]; bool poly; float coupling; bool pong; float drive; }; + const Case cases[] = { + {"Reverb Decay 1.0, Feedback max", {0.43f, 1.0f, 0.3f, 0.9f, 0.2f, 0.5f, 1.0f, 0.2f, 0.5f, 0.4f, 0.7f}, false, 0, false, 0}, + {"Echo feedback 150 %, PONG drive max", {0.43f, 0.75f, 0.3f, 0.9f, 0.2f, 0.3f, 0.5f, 1.0f, 0.5f, 1.0f, 0.7f}, false, 0, true, 1}, + {"Poly, Coupling max, Feedback max", {0.43f, 1.0f, 0.3f, 0.9f, 0.2f, 0.3f, 0.5f, 0.2f, 0.5f, 0.4f, 0.7f}, true, 1, false, 0}, + }; + std::printf("| Caso | RMS per minuto (dBFS) | Centroide per minuto (Hz) | Picco | DC | Recuperi |\n|---|---|---|---|---|---|\n"); + for (const auto& k : cases) { + DroseraCore c; c.Init(); + for (int i = 0; i < 11; ++i) c.SetParam((DroseraCore::Param) i, k.knobs[i]); + c.SetPolyphony(k.poly); c.SetCoupling(k.coupling); c.SetEchoPong(k.pong); c.SetEchoDrive(k.drive); + std::string rms, cen; double peak = 0, dc = 0; size_t count = 0; + for (int minute = 0; minute < 10; ++minute) { + const Stereo s = render(c, 60.0); + rms += (minute ? " " : "") + std::to_string((int) std::lround(rmsDb(s, 0.0, 60.0))); + cen += (minute ? " " : "") + std::to_string((int) std::lround(spectrum(mono(s), (size_t) (30.0 * kSr)).centroidHz)); + peak = std::max(peak, peakOf(s, 0.0, 60.0)); + for (float v : s.L) { dc += v; ++count; } + } + std::printf("| %s | %s | %s | %.2f | %+.5f | %d |\n", k.name, rms.c_str(), cen.c_str(), peak, dc / (double) count, c.RecoveryCount()); + } +} + +static void extremes() { + std::printf("\n## F. Combinazioni estreme e ingresso (20 s)\n\n| Caso | RMS 5-20 s | Picco | Dominante | Centroide | Note |\n|---|---|---|---|---|---|\n"); + auto row = [](const char* name, DroseraCore& c, const float* in, const char* note) { + const Stereo s = render(c, 20.0, in); + const Spec sp = spectrum(mono(s), (size_t) (10.0 * kSr)); + std::printf("| %s | %.1f dBFS | %.2f | %.0f Hz | %.0f Hz | %s |\n", name, rmsDb(s, 5.0, 20.0), peakOf(s, 5.0, 20.0), sp.dominantHz, sp.centroidHz, note); + }; + { DroseraCore c; c.Init(); apply(c, infrasonic::kFactoryPresets[0]); c.SetParam(DroseraCore::FeedbackLPF, 0.0f); c.SetParam(DroseraCore::FeedbackHPF, 1.0f); + row("Highpass 4 kHz sopra Lowpass 100 Hz", c, nullptr, "i filtri si escludono"); } + { DroseraCore c; c.Init(); apply(c, infrasonic::kFactoryPresets[13]); c.SetStringLock(false); c.SetCoupling(1.0f); + row("Sympathetic, LOCK off, Coupling 100 %", c, nullptr, "voci libere accoppiate"); } + { DroseraCore c; c.Init(); apply(c, infrasonic::kFactoryPresets[15]); c.SetParam(DroseraCore::EchoFeedback, 1.0f); c.SetEchoDrive(1.0f); c.SetEchoTone(1.0f); + row("Ember Pong, Echo Feedback 150 %, Drive max, Tone 3.2 kHz", c, nullptr, "runaway saturato"); } + { DroseraCore c; c.Init(); apply(c, infrasonic::kFactoryPresets[0]); c.SetParam(DroseraCore::FeedbackGain, 1.0f); c.SetParam(DroseraCore::FeedbackBody, 0.0f); + row("Default, Feedback +12 dB, Body minimo", c, nullptr, "anello corto al massimo"); } + { // the resonator preset with a 220 Hz sine, then with pink-ish noise bursts + const size_t n = (size_t) (20.0 * kSr); std::vector sine(n), bursts(n); + for (size_t i = 0; i < n; ++i) { sine[i] = 0.3f * (float) std::sin(2.0 * M_PI * 220.0 * i / kSr); bursts[i] = ((i / 24000) % 2 == 0) ? 0.3f * (float) std::sin(2.0 * M_PI * 110.0 * i / kSr) : 0.0f; } + DroseraCore c; c.Init(); apply(c, infrasonic::kFactoryPresets[10]); c.SetMidiPitch(true, 57.0f); + row("Resonator, sinusoide 220 Hz in ingresso, nota MIDI 57", c, sine.data(), "ingresso continuo"); + DroseraCore d; d.Init(); apply(d, infrasonic::kFactoryPresets[10]); d.SetMidiPitch(true, 45.0f); + row("Resonator, 110 Hz a raffiche di 0.5 s, nota 45", d, bursts.data(), "ingresso intermittente"); + } +} + +int main(int argc, char** argv) { + const std::string which = argc > 1 ? argv[1] : "all"; + if (which == "all" || which == "A") loudnessTable(); + if (which == "all" || which == "B") pitchMap(); + if (which == "all" || which == "C") repeatability(); + if (which == "all" || which == "D") monoCompat(); + if (which == "all" || which == "F") extremes(); + if (which == "all" || which == "E") longRuns(); + return 0; +} diff --git a/review/experiments/sr_probe.cpp b/review/experiments/sr_probe.cpp new file mode 100644 index 0000000..943c8a4 --- /dev/null +++ b/review/experiments/sr_probe.cpp @@ -0,0 +1,44 @@ +// REVIEW EXPERIMENT: the same preset through the VST3 at 44.1, 48 and 96 kHz: level, dominant partial, centroid. +#include +#include +#include "pitch_estimator.h" + +static std::vector renderMono(juce::AudioPluginInstance& inst, double sr, int block, double seconds, juce::MidiBuffer* first) { + std::vector out; juce::AudioBuffer buf(2, block); juce::MidiBuffer none; + for (int b = 0; b < (int) (sr * seconds / block); ++b) { + buf.clear(); inst.processBlock(buf, first != nullptr && b == 0 ? *first : none); + for (int i = 0; i < block; ++i) out.push_back(0.5f * (buf.getSample(0, i) + buf.getSample(1, i))); + } + return out; +} +int main(int argc, char** argv) { + if (argc < 2) return 2; + juce::ScopedJuceInitialiser_GUI init; juce::VST3PluginFormat vst3; juce::OwnedArray descs; + vst3.findAllTypesForFile(descs, argv[1]); if (descs.isEmpty()) { std::printf("no plugin\n"); return 1; } + const struct { int program; const char* name; bool chord; } cases[] = { + {0, "Default", false}, {1, "Sub Cellar", false}, {2, "Glass Wind", false}, {4, "Echo Drift", false}, {7, "Razor Feedback", false}, + {12, "Open Fifths", false}, {13, "Sympathetic", false}, {15, "Ember Pong", false}, {14, "Chord Keys (C E G)", true}}; + std::printf("| Preset | 44.1 kHz: RMS / dominante / centroide | 48 kHz | 96 kHz |\n|---|---|---|---|\n"); + for (const auto& k : cases) { + std::printf("| %s |", k.name); + for (double sr : {44100.0, 48000.0, 96000.0}) { + juce::String err; auto inst = vst3.createInstanceFromDescription(*descs[0], sr, 512, err); + inst->setCurrentProgram(k.program); + inst->prepareToPlay(sr, 512); + juce::MidiBuffer chord; + if (k.chord) { chord.addEvent(juce::MidiMessage::noteOn(1, 48, (juce::uint8) 100), 0); chord.addEvent(juce::MidiMessage::noteOn(1, 52, (juce::uint8) 100), 0); chord.addEvent(juce::MidiMessage::noteOn(1, 55, (juce::uint8) 100), 0); } + auto x = renderMono(*inst, sr, 512, 15.0, k.chord ? &chord : nullptr); + const size_t a = (size_t) (5.0 * sr); double s = 0; for (size_t i = a; i < x.size(); ++i) s += (double) x[i] * x[i]; + const double rms = 20 * std::log10(std::sqrt(s / (double) (x.size() - a)) + 1e-12); + // centroid and dominant over 2^17 samples from 8 s + const size_t N = 1 << 17, start = (size_t) (8.0 * sr); std::vector> f(N); + for (size_t i = 0; i < N; ++i) f[i] = (double) x[start + i] * (0.5 - 0.5 * std::cos(2.0 * M_PI * (double) i / (double) N)); + fft(f); const double bin = sr / (double) N; double num = 0, den = 0, mx = 0, dom = 0; + for (size_t q = 1; q < N / 2; ++q) { const double m = std::abs(f[q]); num += m * q * bin; den += m; if (q * bin > 15 && q * bin < 20000 && m > mx) { mx = m; dom = q * bin; } } + std::printf(" %.1f dBFS / %.0f Hz / %.0f Hz |", rms, dom, den > 0 ? num / den : 0); + inst->releaseResources(); + } + std::printf("\n"); + } + return 0; +} diff --git a/review/experiments/whitenoise-unsigned.patch b/review/experiments/whitenoise-unsigned.patch new file mode 100644 index 0000000..a050fb0 --- /dev/null +++ b/review/experiments/whitenoise-unsigned.patch @@ -0,0 +1,11 @@ +--- /private/tmp/claude-501/-Users-sveloccia-Workspace-codeskine-Drosera--claude-worktrees-audio-plugin-vst3-au-review-9b137d/cf63893b-a926-47b7-aaf4-baf9028bbf08/scratchpad/sandbox/experiments/whitenoise.h.orig 2026-11-03 13:45:07 ++++ /private/tmp/claude-501/-Users-sveloccia-Workspace-codeskine-Drosera--claude-worktrees-audio-plugin-vst3-au-review-9b137d/cf63893b-a926-47b7-aaf4-baf9028bbf08/scratchpad/sandbox/src/third_party/DaisySP/Source/Noise/whitenoise.h 2026-11-03 13:45:07 +@@ -31,7 +31,7 @@ + */ + inline float Process() + { +- randseed_ *= 16807; ++ randseed_ = (int32_t) ((uint32_t) randseed_ * 16807u); // REVIEW EXPERIMENT: same bits, no signed overflow + return (randseed_ * coeff_) * amp_; + } + diff --git a/src/dsp/DroseraCore.h b/src/dsp/DroseraCore.h index a565de3..8278fa2 100644 --- a/src/dsp/DroseraCore.h +++ b/src/dsp/DroseraCore.h @@ -44,6 +44,7 @@ class DroseraCore { for (auto& l : limiter_) l.Init(); phase_ = 0; first_ = true; + echo_jump_ = true; voices_running_ = false; tracking_.fill(false); } @@ -121,9 +122,14 @@ class DroseraCore { } } + /// The next Render takes the parameters as they are, with no glide, as the first one after Init does. For a + /// restored project: gliding from the defaults can land the drone on another resonance each time it loads. + void JumpToTargets() { first_ = true; echo_jump_ = true; } + /// Clears the engine's state and keeps the current parameters. Safe on the audio thread. void Recover() { engine_->Init(kRate); + echo_jump_ = true; tracking_.fill(false); for (auto& l : limiter_) l.Init(); } @@ -136,12 +142,12 @@ class DroseraCore { int RecoveryCount() const { return recovery_count_; } private: - using Registry = ParameterRegistry; + using Registry = ParameterRegistry; - // Their own registries: registry_ iterates an unordered_map, so more entries could reorder its handlers. - enum VoiceParam : int { Voice1Pitch, Voice2Pitch, Voice1Gain, Voice2Gain, Coupling, Voice0Receive, Voice1Receive, Voice2Receive }; + // Their own registries, so registry_ keeps the handler order the reference renders were made with. + enum VoiceParam : int { Voice1Pitch, Voice2Pitch, Voice1Gain, Voice2Gain, Coupling, Voice0Receive, Voice1Receive, Voice2Receive, NumVoiceParams }; - enum EchoParam : int { Drive, Tone, Width, Level }; + enum EchoParam : int { Drive, Tone, Width, Level, NumEchoParams }; void pushControls() { const bool imm = first_; // jump to initial values instead of gliding from defaults @@ -219,8 +225,9 @@ class DroseraCore { std::bind(&Engine::SetReverbFeedback, &e, _1)); registry_.Register(EchoSend, 0.0f, 0.0f, 1.0f, std::bind(&Engine::SetEchoDelaySendAmount, &e, _1), 0.05f, daisysp::Mapping::EXP); + // Engine::Init leaves the echo at 5 s: jump there on the first tick, or the first repeats come late and warped. registry_.Register(EchoTime, 0.5f, kEchoMin, kEchoMax, - std::bind(&Engine::SetEchoDelayTime, &e, _1), 0.1f, daisysp::Mapping::EXP); + [this, &e](float s) { e.SetEchoDelayTime(s, echo_jump_); echo_jump_ = false; }, 0.1f, daisysp::Mapping::EXP); registry_.Register(EchoFeedback, 0.0f, 0.0f, 1.5f, std::bind(&Engine::SetEchoDelayFeedback, &e, _1)); registry_.Register(OutputVolume, 0.5f, 0.0f, 1.0f, @@ -343,8 +350,8 @@ class DroseraCore { float body_s_ = 0.001f, lpf_hz_ = 18000.0f, hpf_hz_ = 250.0f; std::array tracking_ {}; Registry registry_; - ParameterRegistry voice_registry_; - ParameterRegistry echo_registry_; + ParameterRegistry voice_registry_; + ParameterRegistry echo_registry_; daisysp::Limiter limiter_[2]; std::array norm_ {}; bool echo_warp_ = false; @@ -359,7 +366,7 @@ class DroseraCore { float coupling_norm_ = 0.0f; std::array interval_ { 0, 7, 12 }; int recovery_count_ = 0; - bool first_ = true; + bool first_ = true, echo_jump_ = true; size_t phase_ = 0; std::atomic published_note_[kNumVoices] { { 40.0f }, { 40.0f }, { 40.0f } }; std::atomic published_body_s_[kNumVoices] { { 0.001f }, { 0.001f }, { 0.001f } }; diff --git a/src/dsp/FeedbackSynthEngine.cpp b/src/dsp/FeedbackSynthEngine.cpp index c5b0a7c..af01e02 100644 --- a/src/dsp/FeedbackSynthEngine.cpp +++ b/src/dsp/FeedbackSynthEngine.cpp @@ -64,8 +64,8 @@ void Engine::SetFeedbackHPFCutoff(const float cutoff_hz) { for (auto &voice : voices_) voice.SetFeedbackHPFCutoff(cutoff_hz); } -void Engine::SetEchoDelayTime(const float echo_time) { - stereo_echo_->SetDelayTime(echo_time); +void Engine::SetEchoDelayTime(const float echo_time, const bool immediately) { + stereo_echo_->SetDelayTime(echo_time, immediately); } void Engine::SetEchoDelayFeedback(const float echo_fb) { diff --git a/src/dsp/FeedbackSynthEngine.h b/src/dsp/FeedbackSynthEngine.h index 06ef6fb..2f2dbac 100644 --- a/src/dsp/FeedbackSynthEngine.h +++ b/src/dsp/FeedbackSynthEngine.h @@ -31,7 +31,8 @@ class Engine { void SetFeedbackLPFCutoff(const float cutoff_hz); void SetFeedbackHPFCutoff(const float cutoff_hz); - void SetEchoDelayTime(const float echo_time); + // Immediate skips the echo's own 0.5 s lag, which otherwise glides from Init's 5 s + void SetEchoDelayTime(const float echo_time, const bool immediately = false); void SetEchoDelayFeedback(const float echo_fb); void SetEchoDelaySendAmount(const float echo_send); diff --git a/src/dsp/ParameterRegistry.h b/src/dsp/ParameterRegistry.h index 120200f..ad3b7b8 100644 --- a/src/dsp/ParameterRegistry.h +++ b/src/dsp/ParameterRegistry.h @@ -2,8 +2,9 @@ #ifndef INFS_CONTROLS_H #define INFS_CONTROLS_H +#include #include -#include +#include #include #include "SmoothedValue.h" #include "DSPUtils.h" @@ -12,68 +13,61 @@ namespace infrasonic { /** * @brief - * Generic control parameter registry with built in update smoothing + * Control parameter registry with built in update smoothing. Fixed storage: no allocation after Register. * - * @tparam ParamId Template parameter specifying the parameter ID type + * Process applies the handlers from the highest id down to the lowest. That is the order the firmware's + * unordered_map happened to give, and the reference renders depend on it: a handler may read what another + * wrote in the same tick (see DroseraCore::applyPitchAndBody). + * + * @tparam ParamId Enum whose values are 0 .. NumParams - 1 */ -template +template class ParameterRegistry { public: using Handler = std::function; - ParameterRegistry() {}; - ~ParameterRegistry() {}; - void Init(const float control_rate) { control_rate_ = control_rate; } + // Registering an id again leaves the first registration, smoothing state included. void Register(const ParamId id, const float initial_value, const float min, const float max, Handler handler, const float smooth_time = 0.05f, const daisysp::Mapping mapping = daisysp::Mapping::LINEAR) { - const float coef = onepole_coef_t60(smooth_time, control_rate_); - ParamState state(initial_value, min, max, coef, mapping, handler); - param_states_.insert({id, state}); + auto &state = param_states_[(size_t) id]; + if (state) return; + state.emplace(initial_value, min, max, onepole_coef_t60(smooth_time, control_rate_), mapping, std::move(handler)); } // Called in response to new control input. Updates target, does not apply value. void Update(const ParamId id, const float value, const bool immediate = false) { - auto it = param_states_.find(id); - if (it != param_states_.end()) { - auto &state = it->second; - auto clampedValue = daisysp::fclamp(value, state.min, state.max); - state.value.set(clampedValue, immediate); + if (auto &state = param_states_[(size_t) id]) { + state->value.set(daisysp::fclamp(value, state->min, state->max), immediate); } } void UpdateNormalized(const ParamId id, const float normValue, const bool immediate = false) { - auto it = param_states_.find(id); - if (it != param_states_.end()) { - auto &state = it->second; - auto mappedValue = daisysp::fmap(daisysp::fclamp(normValue, 0.0f, 1.0f), state.min, state.max, state.mapping); - state.value.set(mappedValue, immediate); + if (auto &state = param_states_[(size_t) id]) { + state->value.set(daisysp::fmap(daisysp::fclamp(normValue, 0.0f, 1.0f), state->min, state->max, state->mapping), immediate); } } // Updates param values with smoothing and applies using handler. void Process() { - for (auto ¶m : param_states_) { - auto &state = param.second; - state.handler(state.value.get()); + for (size_t i = NumParams; i-- > 0;) { + if (auto &state = param_states_[i]) state->handler(state->value.get()); } } private: - static constexpr float kParamSmoothingThresh = 0.001f; - struct ParamState { SmoothedValue value; @@ -82,24 +76,19 @@ class ParameterRegistry { const daisysp::Mapping mapping; const Handler handler; - ParamState() = delete; - ParamState(const float initial, const float min, const float max, - const float coef, const daisysp::Mapping mapping, const Handler handler) + ParamState(const float initial, const float min_, const float max_, + const float coef, const daisysp::Mapping mapping_, Handler handler_) : value(initial, coef) - , min(min) - , max(max) - , mapping(mapping) - , handler(handler) + , min(min_) + , max(max_) + , mapping(mapping_) + , handler(std::move(handler_)) { } }; - // TODO: redo this to use libDaisy Parameter objects and - // avoid using STL container - using ParamStates = std::unordered_map; - - float control_rate_; - ParamStates param_states_; + float control_rate_ = 0.0f; + std::array, NumParams> param_states_; }; } diff --git a/src/plugin/PluginProcessor.cpp b/src/plugin/PluginProcessor.cpp index f8a92a7..d46ec9a 100644 --- a/src/plugin/PluginProcessor.cpp +++ b/src/plugin/PluginProcessor.cpp @@ -138,6 +138,7 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge } void prepareToPlay(double sampleRate, int) override { + if (sampleRate <= 0.0) sampleRate = DroseraCore::kRate; // some hosts prepare with a placeholder rate core->Init(); // allocates; only ever called off the audio thread ratio = (double) DroseraCore::kRate / sampleRate; // engine samples consumed per host output sample inRatio = sampleRate / (double) DroseraCore::kRate; // host samples consumed per engine input sample @@ -168,11 +169,33 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge const auto latency = (double) SincResampler::kLatencyInputSamples + ((double) kPrime + SincResampler::kLatencyInputSamples) / ratio; setLatencySamples(useResampler && getTotalNumInputChannels() > 0 ? juce::roundToInt(latency) : 0); + for (auto& ring : bypassRing) ring.assign((size_t) std::max(1, getLatencySamples()), 0.0f); + bypassPos = 0; prepared = true; } void releaseResources() override { prepared = false; } + // Bypass must delay the input by the declared latency, or the host's compensation shifts the bypassed audio. + void processBlockBypassed(juce::AudioBuffer& buffer, juce::MidiBuffer&) override { + const int total = buffer.getNumSamples(), latency = prepared ? getLatencySamples() : 0; + const int numIn = prepared ? getTotalNumInputChannels() : 0; + for (int c = 0; c < buffer.getNumChannels(); ++c) { + float* d = buffer.getWritePointer(c); + if (c >= numIn) { juce::FloatVectorOperations::clear(d, total); continue; } + if (latency == 0 || c >= 2) continue; + float* ring = bypassRing[(size_t) c].data(); + int p = bypassPos; + for (int i = 0; i < total; ++i) { + const float in = d[i]; + d[i] = ring[p]; + ring[p] = in; + if (++p == latency) p = 0; + } + } + if (latency > 0) bypassPos = (bypassPos + total) % latency; + } + void processBlock(juce::AudioBuffer& buffer, juce::MidiBuffer& midi) override { juce::ScopedNoDenormals noDenormals; const int total = buffer.getNumSamples(); @@ -215,6 +238,7 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge uiTab.store(xml->getIntAttribute(kTabProperty, 0)); // an atomic: hosts may call this off the message thread apvts.replaceState(juce::ValueTree::fromXml(*xml)); currentProgram = juce::jlimit(0, getNumPrograms() - 1, (int) apvts.state.getProperty("program", 0)); + stateJump.store(true); // a project must load onto the same drone every time: no glide from the defaults } } @@ -250,6 +274,7 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge allocator.Assign(held, heldCount); if (heldCount == 0 && haveNote) { allocator.NoteOn(lastNote); allocator.NoteOff(lastNote); } // a latched drone keeps sounding } + if (stateJump.exchange(false)) core->JumpToTargets(); for (int i = 0; i < DroseraCore::NumParams; ++i) core->SetParam((DroseraCore::Param) i, raw[i]->load()); core->SetEchoWarp(halveRaw->load() > 0.5f); @@ -456,6 +481,7 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge std::atomic midiPitchActive_ { false }; // read by the editor std::atomic polyMidiActive_ { false }; std::atomic polyLocked_ { false }; + std::atomic stateJump { false }; // set by setStateInformation, taken by the next block std::atomic voiceNotes_[VoiceAllocator::kVoices] { -1.0f, -1.0f, -1.0f }; drosera::SpectrumTap tap; @@ -468,6 +494,8 @@ class DroseraProcessor final : public juce::AudioProcessor, public EditorBridge SincResampler resampleL, resampleR, resampleIn; std::vector outL, outR, inFifo, in48, inMono; size_t outCount = 0, inCount = 0; + std::vector bypassRing[2]; // the input, delayed by the declared latency while bypassed + int bypassPos = 0; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(DroseraProcessor) }; diff --git a/tests/render_test.cpp b/tests/render_test.cpp index 4c9ba1d..cdfaf3b 100644 --- a/tests/render_test.cpp +++ b/tests/render_test.cpp @@ -313,6 +313,24 @@ static bool recoversFromNan() { return pass; } +// The echo's own delay line starts at 5 s (Engine::Init). The first repeat must still come at the knob's time. +static bool echoStartsAtKnobTime() { + // freq gain body lpf hpf rvMix rvDec eSend eTime eFb vol + const float p[] = {0.43f, 0.00f, 0.30f, 0.90f, 0.20f, 0.00f, 0.50f, 1.00f, 0.20f, 0.00f, 0.70f}; + const float expect = DroseraCore::EchoTimeSeconds(p[DroseraCore::EchoTime], false); + const size_t N = 48000 * 2; + std::vector in(N, 0.0f), L(N), R(N); + in[0] = 0.5f; + DroseraCore core; makeCore(core, p); + for (size_t pos = 0; pos < N; pos += 64) core.Render(&in[pos], &L[pos], &R[pos], 64); + size_t at = 4800; // past the string's own ring + for (size_t i = 4800; i < N; ++i) if (std::fabs(L[i]) > std::fabs(L[at])) at = i; + const double seconds = (double) at / 48000.0; + const bool pass = std::fabs(seconds - expect) < 0.01; + printf("echo first repeat at %.3f s, knob %.3f s: %s\n", seconds, expect, pass ? "OK" : "FAIL"); + return pass; +} + static bool filtersClearOnInit() { infrasonic::LPF12 lpf; lpf.Init(48000.0f); @@ -1919,6 +1937,7 @@ int main(int argc, char** argv) { ok &= presetTests(outDir); ok &= noAllocationOnRender(); ok &= recoversFromNan(); + ok &= echoStartsAtKnobTime(); ok &= filtersClearOnInit(); ok &= publishedValues(); ok &= engineVoiceTests(); diff --git a/tests/ui_test.cpp b/tests/ui_test.cpp index aa9f2a4..470c3a2 100644 --- a/tests/ui_test.cpp +++ b/tests/ui_test.cpp @@ -847,6 +847,48 @@ static void stateTests() { check(std::abs(param("lpf")->getValue() - 0.37f) < 0.001f, "old state: Lowpass loads"); } +// Bypassed, the input must come out delayed by the declared latency, and untouched when there is none. +static void bypassTests() { + for (double sr : {44100.0, 48000.0}) { + std::unique_ptr p(createPluginFilter()); + p->enableAllBuses(); + p->prepareToPlay(sr, 512); + const int declared = p->getLatencySamples(); + juce::AudioBuffer buffer(2, 512); + juce::MidiBuffer none; + long peakAt = -1; float peak = 0.0f; + for (int b = 0; b < 4; ++b) { + buffer.clear(); + if (b == 1) buffer.setSample(0, 100, 0.9f); + p->processBlockBypassed(buffer, none); + for (int i = 0; i < 512; ++i) + if (std::abs(buffer.getSample(0, i)) > peak) { peak = std::abs(buffer.getSample(0, i)); peakAt = b * 512 + i; } + } + const long measured = peakAt - (512 + 100); + std::printf(" bypass at %.0f Hz: declared %d, measured %ld, peak %.2f\n", sr, declared, measured, peak); + check(peak > 0.89f && measured == declared && (sr != 48000.0 || declared == 0), sr == 44100.0 ? "bypassKeepsLatency" : "bypassPassesThrough"); + } +} + +// A restored state must not glide from the defaults: the drone would settle differently on every load. +static void stateJumpTests() { + std::unique_ptr source(createPluginFilter()); + setParameter(*source, "freq", 0.9f); + juce::MemoryBlock state; source->getStateInformation(state); + const float target = 16.0f + 0.9f * 56.0f; // the knob's note + + std::unique_ptr glided(createPluginFilter()), jumped(createPluginFilter()); + for (auto* p : {glided.get(), jumped.get()}) { p->prepareToPlay(48000.0, 512); render(*p, 0.1); } + setParameter(*glided, "freq", 0.9f); // a knob move glides (0.2 s) + jumped->setStateInformation(state.getData(), (int) state.getSize()); // a loaded project jumps + render(*glided, 512.0 / 48000.0); render(*jumped, 512.0 / 48000.0); + const float noteGlided = dynamic_cast(glided.get())->soundingNote(); + const float noteJumped = dynamic_cast(jumped.get())->soundingNote(); + std::printf(" after one block: knob move %.1f, state restore %.1f, target %.1f\n", noteGlided, noteJumped, target); + check(std::abs(noteJumped - target) < 0.01f, "restoredStateJumps"); + check(noteGlided < target - 1.0f, "knobMoveStillGlides"); +} + int main() { juce::ScopedJuceInitialiser_GUI init; tapTests(); @@ -855,6 +897,8 @@ int main() { plantTests(); editorTests(); stateTests(); + bypassTests(); + stateJumpTests(); std::printf(failures == 0 ? "ALL OK\n" : "SOME FAILED\n"); return failures == 0 ? 0 : 1; } diff --git a/third_party/DaisySP/PATCHES.txt b/third_party/DaisySP/PATCHES.txt index 23f327a..109f109 100644 --- a/third_party/DaisySP/PATCHES.txt +++ b/third_party/DaisySP/PATCHES.txt @@ -1 +1,3 @@ -Vendored DaisySP commit 7f67768 (MIT). Only change: headers got #include /. +Vendored DaisySP commit 7f67768 (MIT). Changes: +- Headers got #include /. +- Noise/whitenoise.h: the LCG multiplies as uint32_t, so it no longer overflows a signed int (undefined behaviour). Same output bits. diff --git a/third_party/DaisySP/Source/Noise/whitenoise.h b/third_party/DaisySP/Source/Noise/whitenoise.h index 1cfcdb7..f172008 100644 --- a/third_party/DaisySP/Source/Noise/whitenoise.h +++ b/third_party/DaisySP/Source/Noise/whitenoise.h @@ -31,7 +31,8 @@ class WhiteNoise */ inline float Process() { - randseed_ *= 16807; + // Unsigned: the signed product overflows (undefined behaviour), the unsigned one wraps to the same bits. + randseed_ = (int32_t) ((uint32_t) randseed_ * 16807u); return (randseed_ * coeff_) * amp_; } diff --git a/tools/hardware-comparison/reference/SHA256SUMS b/tools/hardware-comparison/reference/SHA256SUMS index 5de47af..a49fadd 100644 --- a/tools/hardware-comparison/reference/SHA256SUMS +++ b/tools/hardware-comparison/reference/SHA256SUMS @@ -2,9 +2,9 @@ 15889e0cc73498d1dfd160c47a0a60957cf6c60f778bc3ab4ee14a3825e49717 p02_low_dark.wav db4c6212396e32d22fb3db066e7e50d095be258cf92e6c8dcc695621c555313f p03_high_bright.wav 23f4eb9396fdf50dc3b7ec7934172f5bf7ae26f0745f1849db5f5440180c962c p04_reverb_wash.wav -eec58b696c53776d2ae85fae5ad4f440d6b6fc3116a0dcf57bdd40fd394c672f p05_echo_swell.wav -cdbb872cd47a77e2193c89363af9132b8ee81c348314d279ea94c891a82f0202 p06_echo_halved.wav +a4c2a63a2976e54d986194af93f8fbc81b3c265cf651eac3af6827cbec75e966 p05_echo_swell.wav +83df7687a6581d726fbb3c9acff51a059d9f255381ec2a64af1a21128d22822c p06_echo_halved.wav 142c5207b3f664ccb5cf5fd83faa6072d9fa3c07f1f4f3c4a6136bab77dd84a4 p07_long_body.wav 890175fe2850f783d2a40a4d581df12e7fdf13235e77f93633f882699d630f30 p08_max_feedback.wav d905d42d25c665e9cd57565bbb48f047c65a6dca8b34b86bc6d68dcd0356af1e p09_dark_filtered.wav -66bb1b52526a3e264d4c546f5d92be88b811509dbb0a36dc226d484776da7154 p10_everything_mid.wav +7fb72bf06b3015dabdb914ccce42114d146a68c8e790a4a9d63f94b7ae94cbe8 p10_everything_mid.wav