chromaflock/AGENTS.md

259 lines
15 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 # 17 waveforms (Sine, Saw, Square, Triangle, Noise, Pulse25, Pulse12, ReverseSaw, FullRectSine, HalfRectSine, Stair4, Stair8, SoftSine, Sinc, ExpPulse, DoubleSine, SuperSaw)
│ ├── 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`)
- 17 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 (32 total)
| ID | Name | Range | Default |
|---------------------|------------------|--------------------|---------|
| osc1Wave | OSC1 Wave | Choice(17) | 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(17) | 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(20) | 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(7) 1/32..2 | 1 (1/16)|
## 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**: 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 = 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 (implemented — `ComboBoxLookAndFeel`)
- Custom 3D LAF (gradient spotlight, drop shadow, gold arrow, highlighted popup items)
- `drawPopupMenuItem` highlights 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 (13, clamped to MIDI range), and sequences it per pattern.
- Patterns: Up, Down, UpDown, DownUp, Random, As Played, Chord, Up&Down X, Down&Up X, Random Once, Octave Up, Octave Down, Pinky Up, Pinky Down. X variants are endpoint-exclusive bounces; Random Once shuffles per cycle (no repeat until full pass); Octave Up/Down play each root followed by its ±12 copies spanning the selected OCTAVES count; Pinky Up/Down alternate extremes low/high. Up arpeggiate a minor third + fifth (root, +3, +7) and the Up major variant a major third + fifth (root, +4, +7); UpDown/DownUp arpeggiate major (root, +4, +7) plus an UpDown minor variant (root, +3, +7), each as a separate step. Dropdown labels: "Up / Minor", "Down / Minor", "Up & Down / Major", "Down & Up / Major", "Up / Major", "Down / Major", "Up & Down / Minor". "Down" patterns always descend from each held note through the chord tones below it (root, fifth below, third below, next-octave root — e.g. C3, G2, E2, C2 for major), never playing above the held note; any harmony pattern does the same when DIR is set to Down. C3/C4 1/2/3 anchor to the most recently held key and play fixed 8-step loops alternating it and its octave above (3-3-4-3-4-3-3-4, 4-3-3-3-3-3-3-3, 3-3-3-4-3-3-3-3).
- `arpDirection` (Up/Down) controls whether octave copies extend above or below the root AND sorts sequential patterns descending so Direction Down actually steps downward; Random and As Played ignore the inversion. Harmony patterns with Down direction descend through chord tones below each root (see above).
- `arpRate` step divisions (beats): 1/32, 1/16, 1/8, 1/4, 1/2, 1, 2 — tick period = `rateBeats * 60 / bpm`.
- Routing (`PluginProcessor.cpp::processBlock`): when `arpEnabled`, MIDI note events feed the arp instead of the synth; the arp drives `synth.noteOn/noteOff` (with transpose) on each tick via `arpSampleCount` accumulation. Piano-roll `noteOn/noteOff` route 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-transparent `juce::dsp::FFT` spectrum overlay behind it (30 fps). Requires `juce::juce_dsp` linked and `#include <juce_dsp/juce_dsp.h>`.
- Standalone `SpectrumAnalyzer` component 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 `masterLevel` for 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 --apply` to rewrite the `masterLevel` entries in `Source/PresetManager.h` (reports a count mismatch and aborts if preset order/count ever drifts).
- Applied 2026: all 136 presets re-balanced; `masterLevel` now 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::Random` reseeds per process), so re-running can shift borderline values by a few percent.
## 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)