# AGENTS.md — ChromaFlock Development Guide ## Project Overview ChromaFlock is a JUCE-based subtractive synthesizer plugin (VST3/AU/Standalone). Built with CMake, targets macOS. 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 ```bash # 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_dsp` to `target_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 `Waveform` enum - Phase accumulator, setFrequency/setWaveform/setLevel/setPan/setPhase - Output is stereo (pan-law: `sqrt((1±pan)/2)`) - NOTE: `juce::Random random` member — needs `#include ` but currently compiles without explicit include **Filter** (`DSP/Filter.h`) - TPT (Topology-Preserving Transform) SVF, 2 cascaded biquad stages - `FilterType` enum: 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 - `ADSRStage` enum: Idle, Attack, Decay, Sustain, Release - `prepare(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 `filter` instance processes both L and R sequentially — state gets contaminated between channels, causing crashes when filter env amount > 0. FIX: add `Filter filterR` member, process left through `filter`, right through `filterR`. ## Plugin Processor **ChromaFlockProcessor** (`PluginProcessor.h/.cpp`) - `juce::AudioProcessorValueTreeState apvts` — all parameters - `juce::Synthesiser synth` — 16 voices (`maxVoices = 16`) - `prepareToPlay()`: sets sample rate, calls `updateVoiceParameters()` - `processBlock()`: clears buffer, calls `updateVoiceParameters()`, then `synth.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 - `MainContentComponent` is painted/laid out at `baseWidth × baseHeight` - `ChromaFlockEditor::resized()` applies `AffineTransform::scale(currentScale)` to content - `setUIScale()` resizes the editor window to `baseWidth*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 = 12px` currently) - Colors: - Knob body: `0xff2a2a2a` fill, `0xff555555` outline - Thumb/pointer: `0xffccaa44` / `0xffeebb55` - Section labels: `0xffccaa44` (gold) - Knob labels: `0xff888888`, 10pt font - Text boxes: `0xffcccccc` text on `0xff1a1a1a` background ### 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()`, call `filter.prepare(sr)` AND `filterR.prepare(sr)` ### Scope Buffer for Visualizers (HIGH PRIORITY) **Need to add to `ChromaFlockProcessor`**: - `std::array scopeBuffer` — ring buffer for waveform data - `std::atomic scopeWritePos` — write position index - Write to buffer in `processBlock()` after `synth.renderNextBlock()`, interleaving L+R samples - Add getter method: `const float* getScopeData() const` - Add `juce::juce_dsp` to 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::FFT` to 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 - `baseHeight` must 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 1. **ComboBox items**: Must manually call `addItemList()` BEFORE creating `ComboBoxAttachment` — the attachment alone does NOT populate items. 2. **AffineTransform::scale**: Use `AffineTransform::scale(float, float)`, NOT `scaled()` — the method name in JUCE is `AffineTransform::scale`. 3. **Filter state sharing**: Never share a single `Filter` instance between L/R or between voices — each needs its own filter state. 4. **JUCE modules**: If you use `juce::dsp::FFT` or any `juce_dsp` functionality, you must link `juce::juce_dsp` in CMakeLists.txt AND add `#include `. 5. **Denormals**: `processBlock()` wraps with `juce::ScopedNoDenormals` — always keep this. 6. **Voice parameter update**: `updateVoiceParameters()` is called every `processBlock()` — this reads all 27 params and pushes to all 16 voices. This is intentional for simplicity but is CPU-expensive. 7. **Oscillator pan**: The oscillator's `process()` adds directly to output (`*leftOut += ...`), so it's additive between the two oscs in each voice. 8. **Drive math**: `std::tanh(left * drive) / std::tanh(drive)` — when `drive = 1.0`, division by `tanh(1.0) ≈ 0.762` normalizes. `drive` is clamped to min 1.001 in `setParameters()`. ## 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)