mirror of
https://codeberg.org/armin/chromaflock.git
synced 2026-09-01 04:10:47 +02:00
267 lines
16 KiB
Markdown
267 lines
16 KiB
Markdown
# 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(9) | 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 |
|
||
| velToCut | Vel → Cutoff | 0..1 | 0 |
|
||
| velToRes | Vel → Resonance | 0..1 | 0 |
|
||
| 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.
|
||
|
||
### Per-Note Filter Velocity Routing
|
||
- `VEL CUT` (`velToCut`) and `VEL RES` (`velToRes`) knobs map each note's velocity to filter cutoff / resonance. Velocity is the only per-note MIDI data a DAW sequencer sends, so cutoff & resonance become drawable per note in the DAW's piano-roll velocity lane — no built-in step sequencer added.
|
||
- Cutoff path is envelope-independent: `modCutoff *= 2^(velToCut * noteVelocity * 4)` (up to +4 octaves at full depth/velocity). Resonance: `res * (1 + velToRes * noteVelocity)`, clamped to 0..1.
|
||
- Works through the arpeggiator (held-note velocities are forwarded) and the plugin's own piano roll (sends 0.8).
|
||
- FILTER section layout: type dropdown moved into the header row, knobs now a 3x2 grid (CUTOFF/RES/ENV AMT over KEY TRK/VEL CUT/VEL RES).
|
||
|
||
### 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, 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)
|