chromaflock/AGENTS.md
2026-07-13 22:00:04 +02:00

263 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```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 <juce_core/...>` 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<float, 1024> scopeBuffer` — ring buffer for waveform data
- `std::atomic<int> 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 <juce_dsp/juce_dsp.h>`.
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)