12 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 (27 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 |
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: 480 (needs to increase to ~620 for visualizers)
- Section Y origin: 56px (below header line at y=48)
- Knob size: 112×112
- Knob spacing: 10px
- Label height: 18px
- Combo box height: 48px
Section Layout (from paint/drawSection calls)
| Section | X | Y | W | H |
|---|---|---|---|---|
| OSC 1 | 10 | 52 | 498 | 200 |
| OSC 2 | 520 | 52 | 620 | 200 |
| FILTER | 1152 | 52 | 498 | 200 |
| AMP ENV | 10 | 262 | 498 | 150 |
| FILTER ENV | 520 | 262 | 498 | 150 |
| MASTER | 1030 | 262 | 376 | 150 |
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 (not yet implemented — currently uses default V4 look)
- Needs custom 3D LAF similar to knobs (gradient, shadow, highlight)
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
Planned / In-Progress Work
Filter Crash Fix (HIGH PRIORITY)
Problem: Single Filter filter in SubtractiveVoice is called for both L and R in sequence (lines 87-88 of Voice.h). The filter's internal state (stage[4]) is shared/corrupted between channels, causing instability when filter env amount > 0.
Fix: Add Filter filterR member. In renderNextBlock():
- Process left channel through
filter - Process right channel through
filterR - In
startNote(), callfilter.prepare(sr)ANDfilterR.prepare(sr)
Scope Buffer for Visualizers (HIGH PRIORITY)
Need to add to ChromaFlockProcessor:
std::array<float, 1024> scopeBuffer— ring buffer for waveform datastd::atomic<int> scopeWritePos— write position index- Write to buffer in
processBlock()aftersynth.renderNextBlock(), interleaving L+R samples - Add getter method:
const float* getScopeData() const - Add
juce::juce_dspto CMakeLists.txt for FFT
Visualizer Components (HIGH PRIORITY)
Two new classes to add to PluginEditor.h:
WaveformDisplay (juce::Component)
- Reads from processor scope buffer
- Draws waveform as line graph on dark background
- Timer callback at 30fps
- Positioned at bottom-left of UI (~190px tall, ~830px wide)
SpectrumDisplay (juce::Component)
- Uses
juce::dsp::FFTto compute spectrum from scope buffer - Draws vertical bars with HSV color gradient (cool→warm based on frequency)
- Timer callback at 30fps
- Positioned at bottom-right of UI (~190px tall, ~830px wide)
Height Expansion
baseHeightmust increase from 480 to ~620- Bottom sections (AMP ENV, FILTER ENV, MASTER) at y=262 currently end at y=412
- Visualizers fill the remaining space (y=412 to y=612)
- Editor window resized accordingly
ComboBox 3D LAF (MEDIUM PRIORITY)
Custom look-and-feel for combo boxes matching the 3D knob aesthetic.
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)