This guide is for human listening validation of the maintained vivid-wavetable package surface.
The production model is now:
note stream -> synth
When a patch needs shared per-note control state, add:
note stream -> NoteBreakout
Use NoteBreakout for voice_gates, voice_ids, voice_freqs, and other per-voice control lanes. The legacy VoiceAllocator graph operator was removed in Phase 3 of the midi-native-protocol migration.
Validate these as the current maintained path:
WavetableLayer(built-in per-voice ADSR in the Envelope group:attack,decay,sustain,release; expression in the Expression group:pressure_to_amp,timbre_to_position)NoteBreakoutFilter/DualFilterkeytracking fromNoteBreakout/voice_freqs- External
Envelopefor filter modulation and for auxiliary per-voice layers — no longer for layer voice gain (PR3 retiredvoice_gain_audio; the layer's internal ADSR is the sole per-voice gain envelope) - Layer-based modules such as
LayerPad,DualWavetablePad,HybridKeys, andSubAirPad
Validate these as advanced legacy or auxiliary surfaces:
WavetableOscVoiceMixerwhen reducing auxiliary per-voice layers- interaction-heavy modules that specifically require
WavetableOsc
- note sources emit
notes_out - synths consume
notes_in WavetableLayerallocates and sums voices internally, and runs its own per-voice ADSRNoteBreakoutexists when multiple downstream operators need the same per-voice control view (filter envelopes, aux layer gain, etc.)
Think of it like this:
- before the synth, the graph carries a native note stream
NoteBreakoutturns that stream into shared control lanes for envelopes, filters, and velocity-aware reducers- after
WavetableLayer, the signal is already stereo audio with amplitude articulation already applied
Build:
WavetableLayernamedwtaudio_outnamedout
Connect:
wt/output -> out/input
What you should hear:
- silence
Why this is correct:
WavetableLayershould not self-start without a note stream
Add:
ClocknamedclockChordProgressionnamedchords
Connect:
clock/beat_phase -> chords/beat_phase
chords/notes_out -> wt/notes_in
Recommended params:
clock/bpm = 96wt/amplitude = 0.05(per-voice; see Tone & headroom checks for why this is much lower than a mono lead would use)chords/gate_length = 0.97(sustain across chord boundaries; see Tone & headroom checks)
What you should hear:
- a playable stereo wavetable voice
- note changes that follow the chord source
If it sounds wrong:
- silence usually means
notes_out -> notes_inis missing - stuck notes usually mean the note source is not emitting the stream you expect
WavetableLayer runs its own per-voice ADSR — there is no voice_gain_audio port to wire an external envelope into. Articulation is set directly on the layer.
Set on wt:
wt/attack = 0.01(seconds)wt/decay = 0.20wt/sustain = 0.70wt/release = 0.40
What you should hear:
- notes articulate cleanly instead of staying constantly open
- release tails ride out per-note (the layer tracks voice identity internally)
If it sounds wrong:
- flat sustain usually means
sustainis at 1.0 — drop it - chopped attacks usually mean
attackis too short for the patch (try 0.01–0.05 s) - no release tail usually means
releaseis below the gate gap
Optional MPE expression (Stage 1.5):
wt/pressure_to_amp = 0.5scales voice gain by1 + depth × slot.pressure(default 0.5; 0 disables, seesrc/wavetable_layer.cpp:532–536)wt/timbre_to_positionoffsets wavetable position from MPE Y/timbre (src/wavetable_layer.cpp:506)
These have no effect for non-MPE note sources but cost nothing to leave at defaults.
This is where NoteBreakout enters — the filter envelope and the filter's pitch tracking both need per-voice lanes.
Add:
NoteBreakoutnamedvoice_breakoutFilterorDualFilterEnvelopenamedfilt_env
Connect:
chords/notes_out -> voice_breakout/notes_in
wt/output -> filter/input
voice_breakout/voice_freqs -> filter/frequencies
voice_breakout/voice_gates -> filt_env/gate
voice_breakout/voice_ids -> filt_env/lane_ids
filt_env/value -> filter/cutoff_mod
filter/output -> out/input
What you should hear:
- filter motion that follows each note rather than acting like one global sweep
- keytracking that keeps higher notes brighter when enabled
If it sounds wrong:
- static tone usually means
filt_env/value -> filter/cutoff_modis missing - incorrect keyboard tracking usually means
voice_freqs -> filter/frequenciesis missing
For layered instruments, keep one shared note stream and one shared NoteBreakout. Each layer's amp envelope is its own (the layer's internal ADSR for WavetableLayer; explicit Envelope + VoiceMixer/amp_env_audio for auxiliary layers like AnalogOsc / SubOsc reduced through VoiceMixer).
Typical pattern:
chords/notes_out -> wt/notes_in
chords/notes_out -> analog/notes_in
chords/notes_out -> sub/notes_in
chords/notes_out -> voice_breakout/notes_in
analog/voices_out -> mix_analog/input
sub/voices_out -> mix_sub/input
amp_env/value -> mix_analog/amp_env_audio
amp_env/value -> mix_sub/amp_env_audio
voice_breakout/voice_velocities -> mix_analog/velocities
voice_breakout/voice_velocities -> mix_sub/velocities
What you should hear:
- the same note stream driving all layers coherently
VoiceMixeronly reducing auxiliary per-voice layers, not replacingWavetableLayer's stereo output path
If it sounds wrong:
- layers drifting apart usually means the synths are not sharing the same note stream family
- dead per-note modulation on auxiliary layers usually means their
amp_env_audioorvelocitieshookups are missing
If multiple presets sound wrong in similar ways (dull, harsh, thin, distorted, clicking, lopsided), the cause is almost always below the preset level — wavetable lookup, internal ADSR, voice summing, expression routing, or the filter — not a parameter you can fix per-preset. Tuning a preset on top of a broken DSP path just hides where the rot is. This section is a stepwise reduction: build the simplest possible signal and add one layer of complexity at a time until the symptom appears. The rung where it appears is the rung that owns the bug.
Run each step in a fresh, hand-built graph (don't open a preset). At each step, compare against the "good" description before proceeding — moving on past a wrong-sounding step folds noise into the next.
Build: Oscillator (the audio operator, not WavetableLayer) → audio_out. Set frequency = 440, waveform = sine, amplitude = 0.1.
- Good: clean 440 Hz sine, no clicks, no aliasing, no DC.
- Bad: aliased / crackly / silent / DC-offset.
- Implicates: not a wavetable problem. Runtime, sample rate, output path, or master gain. Stop here and check
mcp__vivid__runtime_statusand the audio device config.
Build: Clock → ChordProgression (single degree, voicing=1) → WavetableLayer/notes_in → audio_out.
WavetableLayer params:
-
amplitude = 0.1,unison_voices = 1 -
wavetable_family = 0,wavetable_member = 0(basic sine),position = 0 -
attack = 0.01,decay = 0.1,sustain = 1.0,release = 0.1 -
pressure_to_amp = 0,timbre_to_position = 0(force expression off) -
phase_random = 0,drift_amount = 0 -
Good: clean fundamental on each note, no harmonics beyond what the wavetable contains, no clicks at the 256-sample declick boundary.
-
Bad symptoms and what they implicate:
- Aliased / fizzy harmonic content → wavetable lookup or interpolation (
src/wavetable_layer_renderer*.cpp) - Sounds detuned or warbling at
unison_voices=1→ unison spread or detune leaking when it shouldn't - Click on each note-on past 5 ms → declick ramp not being applied (
kDeClickSamplesatsrc/wavetable_layer_renderer.h:85) - Silent →
notes_out → notes_innot propagating, or per-voice gain stuck at 0
- Aliased / fizzy harmonic content → wavetable lookup or interpolation (
Same graph as Step 2, but set sustain = 0.5, release = 0.5, gate_length on chords = 0.5 (short gate, audible release tail).
- Good: each note attacks, decays to half, sustains, releases over ~0.5 s with no clicks at any envelope-stage boundary.
- Bad: stuck-on (no envelope shape) → ADSR not running. Click at note-off → release stage not blending with declick.
Same as Step 3 but use a 4-voice chord (degree_0..3, all voicing_N=1).
- Good: 4 distinct partials sum cleanly. Loudness ≈ 4× single-voice (no per-poly normalization — the guide's headroom section explains why).
- Bad symptoms:
- Some voices missing or stuck → voice allocator (
VoiceTable<N>persrc/wavetable_layer.cpp). - Loudness scales weirdly (e.g. quieter than 1 voice) → summing or normalization regression. Compare against
4 × amplitude × (1/√unison)fromsrc/wavetable_layer.cpp:247. - Voices "fight" (intermittent dropouts) → voice stealing.
- Some voices missing or stuck → voice allocator (
Critical step: the Phase 4/5 expression params are recent (pressure_to_amp, timbre_to_position). Their defaults must be inert for non-MPE note sources.
Take the Step 4 graph and toggle pressure_to_amp between 0 and the default (0.5) without changing anything else. ChordProgression emits no MPE pressure, so slot.pressure = 0 and the math gain *= (1 + 0.5 × 0) = 1.0 should produce bit-identical output.
- Good: switching
pressure_to_ampbetween 0 and 0.5 produces no audible change. - Bad: any audible change → either
slot.pressureis not initializing to 0 for non-MPE notes (initialization bug in the note-stream → slot path) or the math atsrc/wavetable_layer.cpp:532–536is off. This is a prime suspect if every preset sounds louder/quieter than it should — the default boost would apply to every voice silently.
Repeat with timbre_to_position: should also be inert at default for non-MPE input. Reference: src/wavetable_layer.cpp:506.
Take the Step 4 graph (no expression). Add Filter between wt/output and out/input. Set cutoff = 20000, resonance = 0, mode = 0 (LP).
- Good: a 20 kHz LP with Q=0 should sound identical to no filter. Bypass the filter via
mcp__vivid__set_node_bypassedand A/B — they should match. - Bad: filter darkens the signal at 20 kHz / Q=0 → filter coefficients are wrong, or the filter is processing in a state that doesn't account for the requested cutoff.
Add an Lfo at audio rate (rate ~0.5 Hz) and connect its output to wt/pitch_mod_audio. Depth should produce ~5 cents of pitch wobble.
- Good: pitch wobble at 0.5 Hz audible.
- Bad: silent / no wobble → audio-rate mod port regression. There is a known prior incident around audio-rate mod ports (port indexing,
_mod_audiovs_modconfusion, VoiceMixeramp_env_audiozero-filled buffers). If this step fails, the symptom is consistent with a regression of one of those fixes; inspectgraph_compiler.cppport indexing andWavetableLayer::collect_portsfor the audio mod port declarations.
Pick a preset that's known to have sounded right historically (the prior audio-rate mod debug from late March 2026 cited dream_keys.json at RMS 0.061, peak 0.288). Load it via mcp__vivid__load_graph, wait_for_audio_settle, sample_node_outputs on audio_out.
- If the historical reference also sounds wrong now: regression at HEAD.
git bisectbetween the last known-good commit and HEAD using "preset loads and produces clean audio at expected RMS" as the test. - If the historical reference sounds right: issue is in newer presets, or in the recent harshness/headroom tuning pass — start from the offending preset and reduce by toggling its newest-changed nodes off one at a time.
When a step fails, capture:
- The step number and exact graph (
mcp__vivid__inspect_graphwithdetail=full). mcp__vivid__sample_node_outputson the relevant node (raw audio float values are more diagnostic than RMS).- Current
git rev-parse HEADand any uncommitted state (git status,git diff). - The "good" expectation from this guide alongside the observed behavior.
This packet is enough to either bisect or to point a fix at a specific operator without re-running the whole isolation chain.
These are the listening targets the recent preset pass converged on. A patch that lands outside these ranges is usually too bright, too loud, or clicking on chord changes.
Voice summing has no auto-normalization across polyphony. A per-voice amplitude tuned at 1 voice clips at 8. Targets:
- pad / keys patches at 6–8-voice polyphony:
amplitude≈ 0.02–0.05 per voice - drive / sub layers feeding
VoiceMixer: ≈ 0.005–0.05 - monophonic leads: 0.10–0.20 is fine
Filter cutoff is the harshness knob. Targets after the recent tuning pass:
- pads / keys: cutoff 1.4–2.4 kHz
- leads / plucks: cutoff 3–5 kHz
- resonance below ≈ 0.25 unless the patch is explicitly resonant — Q above that brings the high-mid sparkle back
Retrigger clicks live at chord boundaries. Two settings keep them away:
- on the note source,
gate_length ≈ 0.95–0.98so voices sustain across the chord boundary instead of releasing+re-attacking - the layer applies a 256-sample declick ramp on note-on (
src/wavetable_layer_renderer.h:85); this masks the transient but does not remove the cause — long gates do
If a chord-driven preset clicks: lengthen the gate first, then the layer's attack, before reaching for declick or filter changes.
Listen against the tuned module presets — they reflect both the Phase 3–5 wiring and the harshness/headroom pass:
graphs/presets/dual_wavetable_pad_module_demo.jsonagainstmodules/dual_wavetable_pad.vivid-module.jsongraphs/presets/hybrid_keys_module_demo.jsonagainstmodules/hybrid_keys.vivid-module.jsongraphs/presets/sub_air_pad_module_demo.jsonagainstmodules/sub_air_pad.vivid-module.jsongraphs/presets/glass_interaction_keys_module_demo.jsonagainstmodules/glass_interaction_keys.vivid-module.json
For raw WavetableLayer validation (no module shell):
graphs/core/wavetable_layer_filter_integration.json— single layer + external monoFilterpost stereo reductiongraphs/core/wavetable_modular_demo.json— two layers with sharedfilt_envdriving per-filter cutoffgraphs/core/wavetable_layer_stress.json— four parallel layers, 4-voice unison each (Phase 5 stress)
These fixtures use raw layer wiring at pre-tuning amplitudes — they exercise the layer's voicing and routing surface, not the harshness/headroom targets that apply to presets.