14 KiB
AGENTS.md — ChromaFlock Development Guide
Project Overview
ChromaFlock is a JUCE-based subtractive synthesizer plugin (VST3/AU/Standalone on macOS, VST3/Standalone on Linux). Built with CMake, targets macOS and Linux. All DSP is custom (no heavy third-party libs).
Directory Structure
chromaflock/
├── CMakeLists.txt # Build config, link libraries
├── JUCE/ # JUCE framework (gitignored, not in repo)
├── Source/
│ ├── PluginProcessor.h/.cpp # Audio processor, APVTS, synth management
│ ├── PluginEditor.h/.cpp # UI: LAF, MainContentComponent, ChromaFlockEditor
│ └── DSP/
│ ├── Oscillator.h # 5 waveforms (Sine, Saw, Square, Triangle, Noise)
│ ├── Filter.h # TPT SVF (LP12, LP24, BP, HP, Notch)
│ ├── Envelope.h # ADSR envelope (linear ramps)
│ └── Voice.h # SubtractiveVoice, SubtractiveSound
└── AGENTS.md # This file
Build & Run
# Configure (first time or after CMakeLists change)
cmake -B build -DCMAKE_BUILD_TYPE=Release
# Build
cmake --build build --config Release -j8
# Output locations
# build/ChromaFlock_artefacts/Release/VST3/ChromaFlock.vst3
# build/ChromaFlock_artefacts/Release/au/ChromaFlock.component
# build/ChromaFlock_artefacts/Release/Standalone/ChromaFlock.app
Plugins auto-copy to system folders via COPY_PLUGIN_AFTER_BUILD TRUE.
CMake Configuration
- C++ standard: C++17
- juce_dsp: NOT currently linked (needed for visualizer FFT — add
juce::juce_dsptotarget_link_libraries) - Linked libraries: juce_audio_basics, juce_audio_devices, juce_audio_formats, juce_audio_plugin_client, juce_audio_processors, juce_audio_utils, juce_core, juce_data_structures, juce_events, juce_graphics, juce_gui_basics, juce_gui_extra
DSP Architecture
Signal Flow (per voice)
Osc1 + Osc2 → Amplitude Envelope × Velocity → Filter (cutoff modulated by Filter Env + Key Tracking) → Saturation (tanh) → Output
Key DSP Classes
Oscillator (DSP/Oscillator.h)
- 5 waveforms via
Waveformenum - Phase accumulator, setFrequency/setWaveform/setLevel/setPan/setPhase
- Output is stereo (pan-law:
sqrt((1±pan)/2)) - NOTE:
juce::Random randommember — needs#include <juce_core/...>but currently compiles without explicit include
Filter (DSP/Filter.h)
- TPT (Topology-Preserving Transform) SVF, 2 cascaded biquad stages
FilterTypeenum: LowPass12, LowPass24, BandPass, HighPass, Notch- Coefficients computed from tan-based transform
prepare(sampleRate)resets internal state
ADSREnvelope (DSP/Envelope.h)
- Linear ramp attack/decay/release
ADSRStageenum: Idle, Attack, Decay, Sustain, Releaseprepare(sampleRate)resets state to Idle
SubtractiveVoice (DSP/Voice.h)
- Inherits
juce::SynthesiserVoice - Contains: osc1, osc2, filter, env (amp), filterEnv
renderNextBlock()processes sample-by-sample: osc → envelope → filter → drive → output- KNOWN BUG: Single
filterinstance processes both L and R sequentially — state gets contaminated between channels, causing crashes when filter env amount > 0. FIX: addFilter filterRmember, process left throughfilter, right throughfilterR.
Plugin Processor
ChromaFlockProcessor (PluginProcessor.h/.cpp)
juce::AudioProcessorValueTreeState apvts— all parametersjuce::Synthesiser synth— 16 voices (maxVoices = 16)prepareToPlay(): sets sample rate, callsupdateVoiceParameters()processBlock(): clears buffer, callsupdateVoiceParameters(), thensynth.renderNextBlock()- State save/restore via XML binary copy
Parameters (32 total)
| ID | Name | Range | Default |
|---|---|---|---|
| osc1Wave | OSC1 Wave | Choice(5) | 1 (Saw) |
| osc1Oct | OSC1 Octave | -3..3 (int) | 0 |
| osc1Semi | OSC1 Semi | -12..12 (int) | 0 |
| osc1Fine | OSC1 Fine | -100..100 | 0 |
| osc1Level | OSC1 Level | 0..1 | 0.7 |
| osc2Wave | OSC2 Wave | Choice(5) | 2 (Square) |
| osc2Oct | OSC2 Octave | -3..3 (int) | -1 |
| osc2Semi | OSC2 Semi | -12..12 (int) | 0 |
| osc2Fine | OSC2 Fine | -100..100 | 7 |
| osc2Level | OSC2 Level | 0..1 | 0.7 |
| phaseOffset | Phase Offset | 0..1 | 0.5 |
| filterType | Filter Type | Choice(5) | 0 (LP12) |
| filterCutoff | Filter Cutoff | 20..20000 (log) | 8000 |
| filterRes | Filter Res | 0..1 | 0.3 |
| filterEnvAmt | Filter Env Amt | 0..1 | 0 |
| keyTrack | Key Tracking | 0..1 | 0.5 |
| fEnvAttack | Filter Attack | 0.001..5 (log) | 0.01 |
| fEnvDecay | Filter Decay | 0.001..5 (log) | 0.3 |
| fEnvSustain | Filter Sustain | 0..1 | 0.5 |
| fEnvRelease | Filter Release | 0.001..10 (log) | 0.5 |
| envAttack | Attack | 0.001..5 (log) | 0.01 |
| envDecay | Decay | 0.001..5 (log) | 0.3 |
| envSustain | Sustain | 0..1 | 0.7 |
| envRelease | Release | 0.001..10 (log) | 0.5 |
| pan | Pan | -1..1 | 0 |
| drive | Drive | 1..5 (log) | 1.5 |
| masterLevel | Master Level | 0..1 | 0.8 |
| arpEnabled | Arp On | Choice(2) Off/On | 0 (Off) |
| arpPattern | Arp Pattern | Choice(7) | 0 (Up) |
| arpOctaves | Arp Octaves | Choice(3) 1/2/3 | 1 (2) |
| arpDirection | Arp Direction | Choice(2) Up/Down | 0 (Up) |
| arpRate | Arp Rate | Choice(6) 1/16..2 | 1 (1/8) |
UI Architecture
Class Hierarchy
ChromaFlockEditor (juce::AudioProcessorEditor)
└── MainContentComponent (juce::Component) — scaled via AffineTransform::scale()
├── Section labels (gold, 13pt bold)
├── Combo boxes (osc1/osc2 wave, filter type, UI scale)
└── Knobs (112×112, RotaryVerticalDrag, custom LAF)
Scaling System
MainContentComponentis painted/laid out atbaseWidth × baseHeightChromaFlockEditor::resized()appliesAffineTransform::scale(currentScale)to contentsetUIScale()resizes the editor window tobaseWidth*scale × baseHeight*scale- Scale options: 100%, 125%, 150%, 200%
Layout Dimensions (base coordinates, 1× scale)
- baseWidth: 1660
- baseHeight: 1028
- Header: y 0..80 (logo + preset buttons + transpose + scale)
- Knob size: 112×112 (main rows), 80×80 (FX/LFO rows)
- Visualizer row: y=746, h=128 (VU meter, waveform w/ spectrum overlay, arpeggiator)
- Piano roll: y=884, h=136
Section Layout (from paint/drawSection calls)
| Section | X | Y | W | H |
|---|---|---|---|---|
| OSC 1 | 10 | 80 | 498 | 200 |
| OSC 2 | 518 | 80 | 624 | 200 |
| FILTER | 1152 | 80 | 498 | 200 |
| AMP ENV | 10 | 290 | 563 | 160 |
| FILTER ENV | 583 | 290 | 502 | 160 |
| MASTER | 1095 | 290 | 555 | 160 |
| DISTORTION | 10 | 460 | 510 | 115 |
| COMPRESSION | 528 | 460 | 468 | 115 |
| PANNING | 1004 | 460 | 268 | 115 |
| LIMITER | 1280 | 460 | 370 | 115 |
| LFO 1 | 10 | 586 | 540 | 150 |
| LFO 2 | 560 | 586 | 530 | 150 |
| DELAY | 1015 | 586 | 375 | 150 |
| REVERB | 1395 | 586 | 255 | 150 |
| ARPEGGIATOR | 860 | 746 | 790 | 128 |
| PIANO ROLL | 10 | 884 | 1640 | 136 |
Look-and-Feel (KnobLookAndFeel)
- Inherits
juce::LookAndFeel_V4 drawRotarySlider()renders:- 3D-ish appearance: dark circle fill, colored outline, gold thumb dot, pointer line
- Label drawn at top of bounds (
labelH = 12pxcurrently)
- Colors:
- Knob body:
0xff2a2a2afill,0xff555555outline - Thumb/pointer:
0xffccaa44/0xffeebb55 - Section labels:
0xffccaa44(gold) - Knob labels:
0xff888888, 10pt font - Text boxes:
0xffcccccctext on0xff1a1a1abackground
- Knob body:
ComboBox LAF (implemented — ComboBoxLookAndFeel)
- Custom 3D LAF (gradient spotlight, drop shadow, gold arrow, highlighted popup items)
drawPopupMenuItemhighlights the selected entry persistently and inverts text color on hover
Header
- Title: "CHROMAFLOCK" at (20, 8), 22pt bold gold
- Subtitle: "SUBTRACTIVE SYNTHESIZER" at (20, 30), 10pt gray
- Divider line at y=48, full width, gold
UI Scale Controls
- Scale label at (1520, 10), 42×20
- Scale combo at (1566, 8), 80×24
Implemented Features
Filter Crash Fix
SubtractiveVoice now has separate filter (L) and filterR (R) instances; startNote() prepares both. Resolves the state-sharing crash when filter env amount > 0.
Arpeggiator
- New class
DSP/Arpeggiator.h— tempo-synced, pure note-logic (no audio). Tracks held notes (press order for the As Played pattern), builds a pitch pool from held notes × octave range (1–3, clamped to MIDI range), and sequences it per pattern. - Patterns: Up, Down, UpDown, DownUp, Random, As Played, Chord.
arpDirection(Up/Down) controls whether octave copies extend above or below the root; sequential patterns always order by ascending pitch.arpRatestep divisions (beats): 1/16, 1/8, 1/4, 1/2, 1, 2 — tick period =rateBeats * 60 / bpm.- Routing (
PluginProcessor.cpp::processBlock): whenarpEnabled, MIDI note events feed the arp instead of the synth; the arp drivessynth.noteOn/noteOff(with transpose) on each tick viaarpSampleCountaccumulation. Piano-rollnoteOn/noteOffroute through the same logic. When disabled,arp.reset()stops any sounding arp note and notes play normally. - UI: bottom-right section at (860, 746) titled ARPEGGIATOR with 5 combos (ON, PATTERN, OCTAVES, DIR, RATE).
Visualizers
WaveformDisplay(bottom-left, 60×746, 790×128) draws the scope ring buffer waveform AND a semi-transparentjuce::dsp::FFTspectrum overlay behind it (30 fps). Requiresjuce::juce_dsplinked and#include <juce_dsp/juce_dsp.h>.- Standalone
SpectrumAnalyzercomponent was removed.
Preset Level Normalization (Tools/LevelNormalizer.cpp)
- Standalone JUCE console tool that renders every preset offline (C4, 8s, 44.1 kHz) and derives a new
masterLevelfor consistent loudness: peak normalized to 0.80, RMS-normalized to 0.15, final value =min(gainPeak, gainRms) * current, clamped to 0..1. - Build target:
cmake --build build --target level_normalizer; run./build/level_normalizer --applyto rewrite themasterLevelentries inSource/PresetManager.h(reports a count mismatch and aborts if preset order/count ever drifts). - Applied 2026: all 136 presets re-balanced;
masterLevelnow ranges 0.325..1.0. Sustained/washy presets (heavy reverb/delay) get the largest cuts; quiet transient presets clamp at 1.0. - Caveat: noise-based presets render with slight run-to-run variance (
juce::Randomreseeds per process), so re-running can shift borderline values by a few percent.
Common Pitfalls & Gotchas
-
ComboBox items: Must manually call
addItemList()BEFORE creatingComboBoxAttachment— the attachment alone does NOT populate items. -
AffineTransform::scale: Use
AffineTransform::scale(float, float), NOTscaled()— the method name in JUCE isAffineTransform::scale. -
Filter state sharing: Never share a single
Filterinstance between L/R or between voices — each needs its own filter state. -
JUCE modules: If you use
juce::dsp::FFTor anyjuce_dspfunctionality, you must linkjuce::juce_dspin CMakeLists.txt AND add#include <juce_dsp/juce_dsp.h>. -
Denormals:
processBlock()wraps withjuce::ScopedNoDenormals— always keep this. -
Voice parameter update:
updateVoiceParameters()is called everyprocessBlock()— this reads all 27 params and pushes to all 16 voices. This is intentional for simplicity but is CPU-expensive. -
Oscillator pan: The oscillator's
process()adds directly to output (*leftOut += ...), so it's additive between the two oscs in each voice. -
Drive math:
std::tanh(left * drive) / std::tanh(drive)— whendrive = 1.0, division bytanh(1.0) ≈ 0.762normalizes.driveis clamped to min 1.001 insetParameters().
Theme Colors Reference
| Element | Color | Usage |
|---|---|---|
| Background | 0xff1a1a1a |
Main background |
| Knob body | 0xff2a2a2a |
Circle fill |
| Knob outline | 0xff555555 |
Circle stroke |
| Gold accent | 0xffccaa44 |
Title, section labels, thumb |
| Pointer gold | 0xffeebb55 |
Knob pointer line |
| Text light | 0xffcccccc |
Parameter values, knob labels |
| Text dim | 0xff888888 |
Subtitle, knob label text |
| Section border | 0xff333333 |
Section box outlines |
| Text box bg | 0xff1a1a1a |
Slider text boxes |
| Popup highlight | 0xffccaa44 |
ComboBox dropdown highlight |
| Popup text | 0xff1a1a1a |
ComboBox dropdown text on highlight |
Testing / Verification
- Build with
cmake --build build --config Release -j8 - Load VST3 in any DAW (Ableton, Logic, Reaper, etc.)
- Test: play notes, sweep filter cutoff, turn up ENV AMT knob (crash test for filter fix)
- Test: UI scale switching (100% → 200% → back)
- Test: state save/load (preset recall)