summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt6
-rw-r--r--PROJECT_CONTEXT.md12
-rw-r--r--TODO.md70
-rw-r--r--doc/SPECTRAL_BRUSH_EDITOR.md497
-rw-r--r--src/audio/spectral_brush.cc171
-rw-r--r--src/audio/spectral_brush.h80
-rw-r--r--src/generated/assets_data.cc6
-rw-r--r--src/tests/test_assets.cc2
-rw-r--r--src/tests/test_spectral_brush.cc236
9 files changed, 1071 insertions, 9 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 887e018..c99364c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -83,7 +83,7 @@ elseif (NOT DEMO_CROSS_COMPILE_WIN32)
endif()
#-- - Source Groups -- -
-set(AUDIO_SOURCES src/audio/audio.cc src/audio/ring_buffer.cc src/audio/miniaudio_backend.cc src/audio/wav_dump_backend.cc src/audio/gen.cc src/audio/fdct.cc src/audio/idct.cc src/audio/window.cc src/audio/synth.cc src/audio/tracker.cc src/audio/spectrogram_resource_manager.cc src/audio/audio_engine.cc)
+set(AUDIO_SOURCES src/audio/audio.cc src/audio/ring_buffer.cc src/audio/miniaudio_backend.cc src/audio/wav_dump_backend.cc src/audio/gen.cc src/audio/fdct.cc src/audio/idct.cc src/audio/window.cc src/audio/synth.cc src/audio/tracker.cc src/audio/spectrogram_resource_manager.cc src/audio/audio_engine.cc src/audio/spectral_brush.cc)
set(PROCEDURAL_SOURCES src/procedural/generator.cc)
set(GPU_SOURCES
src/gpu/gpu.cc
@@ -324,6 +324,10 @@ if(DEMO_BUILD_TESTS)
target_link_libraries(test_dct PRIVATE audio util procedural ${DEMO_LIBS})
add_dependencies(test_dct generate_demo_assets)
+ add_demo_test(test_spectral_brush SpectralBrushTest src/tests/test_spectral_brush.cc ${GEN_DEMO_CC})
+ target_link_libraries(test_spectral_brush PRIVATE audio util procedural ${DEMO_LIBS})
+ add_dependencies(test_spectral_brush generate_demo_assets)
+
add_demo_test(test_audio_gen AudioGenTest src/tests/test_audio_gen.cc ${GEN_DEMO_CC})
target_link_libraries(test_audio_gen PRIVATE audio util procedural ${DEMO_LIBS})
add_dependencies(test_audio_gen generate_demo_assets)
diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md
index b59fdbc..4d1fa35 100644
--- a/PROJECT_CONTEXT.md
+++ b/PROJECT_CONTEXT.md
@@ -85,10 +85,14 @@ Style:
---
## Next Up
-- **Task #49: Physics & Collision**
- - [ ] **Task #49.1: CPU-Side SDF Library**: Implement `sdSphere`, `sdBox` etc. in C++ (`src/3d/sdf_cpu.h`).
- - [ ] **Task #49.2: BVH Construction**: Implement BVH builder and traversal for broad-phase collision.
- - [ ] **Task #49.3: Physics Loop**: Implement integration, narrow-phase SDF probing, and collision resolution.
+
+- **Task #5: Spectral Brush Editor** [IN PROGRESS - February 6, 2026]
+ - Create web-based tool for procedurally tracing audio spectrograms
+ - Replace large .spec assets with tiny C++ code (50-100× compression)
+ - Phase 1: C++ runtime (`spectral_brush.h/cc` - Bezier curves + Gaussian profiles)
+ - Phase 2: Editor UI (HTML/JS canvas, dual-layer visualization, keyboard shortcuts)
+ - Phase 3: File I/O (load .wav/.spec, export procedural_params.txt + C++ code)
+ - See `doc/SPECTRAL_BRUSH_EDITOR.md` for complete design
- **Task #18: 3D System Enhancements**
- [ ] **Task #18.0: Basic OBJ Asset Pipeline**: Implement `ASSET_MESH` type, `asset_packer` OBJ support, and `Renderer3D` mesh rendering.
diff --git a/TODO.md b/TODO.md
index d67af6e..105be30 100644
--- a/TODO.md
+++ b/TODO.md
@@ -165,6 +165,76 @@ This file tracks prioritized tasks with detailed attack plans.
- [ ] Centralize platform-specific code and `#ifdef`s into `platform.h` or equivalent.
- [ ] Address `str_view()` calls that cause compilation breaks.
+## Priority 1: Spectral Brush Editor (Task #5) [IN PROGRESS]
+
+**Goal:** Create a web-based tool for procedurally tracing audio spectrograms. Replaces large `.spec` binary assets with tiny procedural C++ code (50-100× compression).
+
+**Design Document:** See `doc/SPECTRAL_BRUSH_EDITOR.md` for complete architecture.
+
+**Core Concept: "Spectral Brush"**
+- **Central Curve** (Bezier): Traces time-frequency path through spectrogram
+- **Vertical Profile**: Shapes "brush stroke" around curve (Gaussian, Decaying Sinusoid, Noise)
+
+**Workflow:**
+```
+.wav → Load in editor → Trace with Bezier curves → Export procedural_params.txt + C++ code
+```
+
+### Phase 1: C++ Runtime (Foundation)
+- [ ] **Files:** `src/audio/spectral_brush.h`, `src/audio/spectral_brush.cc`
+- [ ] Define API (`ProfileType`, `draw_bezier_curve()`, `evaluate_profile()`)
+- [ ] Implement linear Bezier interpolation
+- [ ] Implement Gaussian profile evaluation
+- [ ] Implement home-brew deterministic RNG (for future noise support)
+- [ ] Add unit tests (`src/tests/test_spectral_brush.cc`)
+- [ ] **Deliverable:** Compiles, tests pass
+
+### Phase 2: Editor Core
+- [ ] **Files:** `tools/spectral_editor/index.html`, `script.js`, `style.css`, `dct.js` (reuse from old editor)
+- [ ] HTML structure (canvas, controls, file input)
+- [ ] Canvas rendering (dual-layer: reference + procedural)
+- [ ] Bezier curve editor (click to place, drag to adjust, delete control points)
+- [ ] Profile controls (Gaussian sigma slider)
+- [ ] Real-time spectrogram rendering
+- [ ] Audio playback (IDCT → Web Audio API)
+- [ ] Undo/Redo system (action history with snapshots)
+- [ ] **Keyboard shortcuts:**
+ - Key '1': Play procedural sound
+ - Key '2': Play original .wav
+ - Space: Play/pause
+ - Ctrl+Z: Undo
+ - Ctrl+Shift+Z: Redo
+ - Delete: Remove control point
+- [ ] **Deliverable:** Interactive editor, can trace .wav files
+
+### Phase 3: File I/O
+- [ ] Load .wav (decode, FFT/STFT → spectrogram)
+- [ ] Load .spec (binary format parser)
+- [ ] Save procedural_params.txt (human-readable, re-editable)
+- [ ] Generate C++ code (ready to compile)
+- [ ] Load procedural_params.txt (re-editing workflow)
+- [ ] **Deliverable:** Full save/load cycle works
+
+### Phase 4: Future Extensions (Post-MVP)
+- [ ] Cubic Bezier interpolation (smoother curves)
+- [ ] Decaying sinusoid profile (metallic sounds)
+- [ ] Noise profile (textured sounds)
+- [ ] Composite profiles (add/subtract/multiply)
+- [ ] Multi-dimensional Bezier ({freq, amplitude, decay, ...})
+- [ ] Frequency snapping (snap to musical notes)
+- [ ] Generic `gen_from_params()` code generation
+
+**Design Decisions:**
+- Linear Bezier interpolation (Phase 1), cubic later
+- Soft parameter limits in UI (not enforced)
+- Home-brew RNG (small, deterministic)
+- Single function per sound (generic loader later)
+- Start with Bezier + Gaussian only
+
+**Size Impact:** 50-100× compression (5 KB .spec → ~100 bytes C++ code)
+
+---
+
## Priority 2: 3D System Enhancements (Task #18)
**Goal:** Establish a pipeline for importing complex 3D scenes to replace hardcoded geometry.
- [x] **Task #18.0: Basic OBJ Asset Pipeline** (New)
diff --git a/doc/SPECTRAL_BRUSH_EDITOR.md b/doc/SPECTRAL_BRUSH_EDITOR.md
new file mode 100644
index 0000000..7ea0270
--- /dev/null
+++ b/doc/SPECTRAL_BRUSH_EDITOR.md
@@ -0,0 +1,497 @@
+# Spectral Brush Editor (Task #5)
+
+## Concept
+
+The **Spectral Brush Editor** is a web-based tool for creating procedural audio by tracing spectrograms with parametric curves. It enables compact representation of audio samples for 64k demos.
+
+### Goal
+
+Replace large `.spec` asset files with tiny procedural C++ code:
+- **Before:** 5 KB binary `.spec` file
+- **After:** ~100 bytes of C++ code calling `draw_bezier_curve()`
+
+### Workflow
+
+```
+.wav file → Load in editor → Trace with spectral brushes → Export params + C++ code
+ ↓
+ (later)
+procedural_params.txt → Load in editor → Adjust curves → Re-export
+```
+
+---
+
+## Core Primitive: "Spectral Brush"
+
+A spectral brush consists of two components:
+
+### 1. Central Curve (Bezier)
+Traces a path through time-frequency space:
+```
+{freq_bin, amplitude} = bezier(frame_number)
+```
+
+**Properties:**
+- Control points: `[(frame, freq_hz, amplitude), ...]`
+- Interpolation: Linear (between control points)
+- Future: Cubic Bezier, Catmull-Rom splines
+
+**Example:**
+```javascript
+control_points: [
+ {frame: 0, freq_hz: 200.0, amplitude: 0.9}, // Attack
+ {frame: 20, freq_hz: 80.0, amplitude: 0.7}, // Sustain
+ {frame: 100, freq_hz: 50.0, amplitude: 0.0} // Decay
+]
+```
+
+### 2. Vertical Profile
+At each frame, applies a shape **vertically** in frequency bins around the central curve.
+
+**Profile Types:**
+
+#### Gaussian (Smooth harmonic)
+```
+amplitude(dist) = exp(-(dist² / σ²))
+```
+- **σ (sigma)**: Width in frequency bins
+- Use case: Musical tones, bass notes, melodic lines
+
+#### Decaying Sinusoid (Textured/resonant)
+```
+amplitude(dist) = exp(-decay * dist) * cos(ω * dist)
+```
+- **decay**: Falloff rate
+- **ω (omega)**: Oscillation frequency
+- Use case: Metallic sounds, bells, resonant tones
+
+#### Noise (Textured/gritty)
+```
+amplitude(dist) = random(seed, dist) * noise_amplitude
+```
+- **seed**: Deterministic RNG seed
+- Use case: Hi-hats, cymbals, textured sounds
+
+#### Composite (Combinable)
+```javascript
+{
+ type: "composite",
+ operation: "add" | "subtract" | "multiply",
+ profiles: [
+ {type: "gaussian", sigma: 30.0},
+ {type: "noise", amplitude: 0.1, seed: 42}
+ ]
+}
+```
+
+---
+
+## Visual Model
+
+```
+Frequency (bins)
+ ^
+ |
+512 | * ← Gaussian profile (frame 80)
+ | * * *
+256 | **** * * * ← Gaussian profile (frame 50)
+ | ** ** * *
+128 | ** Curve * * ← Central Bezier curve
+ | ** *
+ 64 | ** *
+ | * *
+ 0 +--*--*--*--*--*--*--*--*---→ Time (frames)
+ 0 10 20 30 40 50 60 70 80
+
+ At each frame:
+ 1. Evaluate curve → get freq_bin_0 and amplitude
+ 2. Draw profile vertically at that frame
+```
+
+---
+
+## File Formats
+
+### A. `procedural_params.txt` (Human-readable, re-editable)
+
+```text
+# Kick drum spectral brush definition
+METADATA dct_size=512 num_frames=100 sample_rate=32000
+
+CURVE bezier
+ CONTROL_POINT 0 200.0 0.9 # frame, freq_hz, amplitude
+ CONTROL_POINT 20 80.0 0.7
+ CONTROL_POINT 50 60.0 0.3
+ CONTROL_POINT 100 50.0 0.0
+ PROFILE gaussian sigma=30.0
+ PROFILE_ADD noise amplitude=0.1 seed=42
+END_CURVE
+
+CURVE bezier
+ CONTROL_POINT 0 500.0 0.5
+ CONTROL_POINT 30 300.0 0.2
+ PROFILE decaying_sinusoid decay=0.15 frequency=0.8
+END_CURVE
+```
+
+**Purpose:**
+- Load back into editor for re-editing
+- Human-readable, version-control friendly
+- Can be hand-tweaked in text editor
+
+### B. C++ Code (Ready to compile)
+
+```cpp
+// Generated from procedural_params.txt
+// File: src/audio/gen_kick_procedural.cc
+
+#include "audio/spectral_brush.h"
+
+void gen_kick_procedural(float* spec, int dct_size, int num_frames) {
+ // Curve 0: Low-frequency punch with noise texture
+ {
+ const float frames[] = {0.0f, 20.0f, 50.0f, 100.0f};
+ const float freqs[] = {200.0f, 80.0f, 60.0f, 50.0f};
+ const float amps[] = {0.9f, 0.7f, 0.3f, 0.0f};
+
+ draw_bezier_curve(spec, dct_size, num_frames,
+ frames, freqs, amps, 4,
+ PROFILE_GAUSSIAN, 30.0f);
+
+ draw_bezier_curve_add(spec, dct_size, num_frames,
+ frames, freqs, amps, 4,
+ PROFILE_NOISE, 0.1f, 42.0f);
+ }
+
+ // Curve 1: High-frequency attack
+ {
+ const float frames[] = {0.0f, 30.0f};
+ const float freqs[] = {500.0f, 300.0f};
+ const float amps[] = {0.5f, 0.2f};
+
+ draw_bezier_curve(spec, dct_size, num_frames,
+ frames, freqs, amps, 2,
+ PROFILE_DECAYING_SINUSOID, 0.15f, 0.8f);
+ }
+}
+
+// Usage in demo_assets.txt:
+// KICK_PROC, PROC(gen_kick_procedural), NONE, "Procedural kick drum"
+```
+
+**Purpose:**
+- Copy-paste into `src/audio/procedural_samples.cc`
+- Compile directly into demo
+- Zero runtime parsing overhead
+
+---
+
+## C++ Runtime API
+
+### New Files
+
+#### `src/audio/spectral_brush.h`
+```cpp
+#pragma once
+#include <cstdint>
+
+enum ProfileType {
+ PROFILE_GAUSSIAN = 0,
+ PROFILE_DECAYING_SINUSOID = 1,
+ PROFILE_NOISE = 2
+};
+
+// Evaluate linear Bezier interpolation at frame t
+float evaluate_bezier_linear(const float* control_frames,
+ const float* control_values,
+ int n_points,
+ float frame);
+
+// Draw spectral brush: Bezier curve with vertical profile
+void draw_bezier_curve(float* spectrogram, int dct_size, int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2 = 0.0f);
+
+// Additive variant (for compositing profiles)
+void draw_bezier_curve_add(float* spectrogram, int dct_size, int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2 = 0.0f);
+
+// Profile evaluation
+float evaluate_profile(ProfileType type, float distance,
+ float param1, float param2);
+
+// Home-brew deterministic RNG (small, portable)
+uint32_t spectral_brush_rand(uint32_t seed);
+```
+
+**Future Extensions:**
+- Cubic Bezier interpolation: `evaluate_bezier_cubic()`
+- Generic loader: `gen_from_params(const PrimitiveData*)`
+
+---
+
+## Editor Architecture
+
+### Technology Stack
+- **HTML5 Canvas**: Spectrogram visualization
+- **Web Audio API**: Playback (IDCT → audio)
+- **Pure JavaScript**: No dependencies
+- **Reuse from existing editor**: `dct.js` (IDCT implementation)
+
+### Editor UI Layout
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Spectral Brush Editor [Load .wav] │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ │ │
+│ │ CANVAS (Spectrogram Display) │ T │
+│ │ │ O │
+│ │ • Background: Reference .wav (gray, transparent) │ O │
+│ │ • Foreground: Procedural curves (colored) │ L │
+│ │ • Bezier control points (draggable circles) │ S │
+│ │ │ │
+│ │ │ [+]│
+│ │ │ [-]│
+│ │ │ [x]│
+│ └───────────────────────────────────────────────────┘ │
+│ │
+├─────────────────────────────────────────────────────────────┤
+│ Profile: [Gaussian ▾] Sigma: [████████░░] 30.0 │
+│ Curves: [Curve 0 ▾] Amplitude: [██████░░░░] 0.8 │
+├─────────────────────────────────────────────────────────────┤
+│ [1] Play Procedural [2] Play Original [Space] Pause │
+│ [Save Params] [Generate C++] [Undo] [Redo] │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Features (Phase 1: Minimal Working Version)
+
+#### Editing
+- Click to place Bezier control points
+- Drag to adjust control points (frame, frequency, amplitude)
+- Delete control points (right-click or Delete key)
+- Undo/Redo support (action history)
+
+#### Visualization
+- Dual-layer canvas:
+ - **Background**: Reference spectrogram (semi-transparent gray)
+ - **Foreground**: Procedural spectrogram (colored)
+- Log-scale frequency axis (musical perception)
+- Control points: Draggable circles with labels
+
+#### Audio Playback
+- **Key '1'**: Play procedural sound
+- **Key '2'**: Play original .wav
+- **Space**: Play/pause toggle
+
+#### File I/O
+- Load .wav or .spec (reference sound)
+- Save procedural_params.txt (re-editable)
+- Generate C++ code (copy-paste ready)
+
+#### Undo/Redo
+- Action history with snapshots
+- Commands: Add control point, Move control point, Delete control point, Change profile
+- **Ctrl+Z**: Undo
+- **Ctrl+Shift+Z**: Redo
+
+### Keyboard Shortcuts
+
+| Key | Action |
+|-----|--------|
+| **1** | Play procedural sound |
+| **2** | Play original .wav |
+| **Space** | Play/pause |
+| **Delete** | Delete selected control point |
+| **Esc** | Deselect all |
+| **Ctrl+Z** | Undo |
+| **Ctrl+Shift+Z** | Redo |
+| **Ctrl+S** | Save procedural_params.txt |
+| **Ctrl+Shift+S** | Generate C++ code |
+
+---
+
+## Implementation Plan
+
+### Phase 1: C++ Runtime (Foundation)
+**Files:** `src/audio/spectral_brush.h`, `src/audio/spectral_brush.cc`
+
+**Tasks:**
+- [ ] Define API (`ProfileType`, `draw_bezier_curve()`, etc.)
+- [ ] Implement linear Bezier interpolation
+- [ ] Implement Gaussian profile evaluation
+- [ ] Implement home-brew RNG (for future noise support)
+- [ ] Add unit tests (`src/tests/test_spectral_brush.cc`)
+
+**Deliverable:** Compiles, tests pass
+
+---
+
+### Phase 2: Editor Core
+**Files:** `tools/spectral_editor/index.html`, `script.js`, `style.css`, `dct.js` (reuse)
+
+**Tasks:**
+- [ ] HTML structure (canvas, controls, file input)
+- [ ] Canvas rendering (dual-layer: reference + procedural)
+- [ ] Bezier curve editor (place/drag/delete control points)
+- [ ] Profile controls (Gaussian sigma slider)
+- [ ] Real-time spectrogram rendering
+- [ ] Audio playback (IDCT → Web Audio API)
+- [ ] Undo/Redo system
+
+**Deliverable:** Interactive editor, can trace .wav files
+
+---
+
+### Phase 3: File I/O
+**Tasks:**
+- [ ] Load .wav (decode, FFT/STFT → spectrogram)
+- [ ] Load .spec (binary format parser)
+- [ ] Save procedural_params.txt (text format writer)
+- [ ] Generate C++ code (code generation template)
+- [ ] Load procedural_params.txt (re-editing workflow)
+
+**Deliverable:** Full save/load cycle works
+
+---
+
+### Phase 4: Polish & Documentation
+**Tasks:**
+- [ ] UI refinements (tooltips, visual feedback)
+- [ ] Keyboard shortcut overlay (press '?' to show help)
+- [ ] Error handling (invalid files, audio context failures)
+- [ ] User guide (README.md in tools/spectral_editor/)
+- [ ] Example files (kick.wav → kick_procedural.cc)
+
+**Deliverable:** Production-ready tool
+
+---
+
+## Design Decisions
+
+### 1. Bezier Interpolation
+- **Current:** Linear (simple, fast, small code)
+- **Future:** Cubic Bezier, Catmull-Rom splines
+
+### 2. Parameter Limits
+- **Editor UI:** Soft ranges (reasonable defaults, not enforced)
+- **Examples:**
+ - Sigma: 1.0 - 100.0 (suggested range, user can type beyond)
+ - Amplitude: 0.0 - 1.0 (suggested, can exceed for overdrive effects)
+
+### 3. Random Number Generator
+- **Implementation:** Home-brew deterministic RNG
+- **Reason:** Independence, small code, repeatable results
+- **Algorithm:** Simple LCG or xorshift
+
+### 4. Code Generation
+- **Current:** Single function per sound (e.g., `gen_kick_procedural()`)
+- **Future:** Generic `gen_from_params(const PrimitiveData*)` with data tables
+
+### 5. Initial UI Scope
+- **Phase 1:** Bezier + Gaussian only
+- **Rationale:** Validate workflow before adding complexity
+- **Future:** Decaying sinusoid, noise, composite profiles
+
+---
+
+## Future Extensions
+
+### Bezier Enhancements
+- [ ] Cubic Bezier interpolation (smoother curves)
+- [ ] Catmull-Rom splines (automatic tangent control)
+- [ ] Bezier curve tangent handles (manual control)
+
+### Additional Profiles
+- [ ] Decaying sinusoid (metallic sounds)
+- [ ] Noise (textured sounds)
+- [ ] Composite profiles (add/subtract/multiply)
+- [ ] User-defined profiles (custom formulas)
+
+### Multi-Dimensional Bezier
+- [ ] `{freq, amplitude, oscillator_freq, decay} = bezier(frame)`
+- [ ] Per-parameter control curves
+
+### Advanced Features
+- [ ] Frequency snapping (snap to musical notes: C4, D4, E4, etc.)
+- [ ] Amplitude envelope presets (ADSR)
+- [ ] Profile library (save/load custom profiles)
+- [ ] Batch processing (convert entire folder of .wav files)
+
+### Code Generation
+- [ ] Generic `gen_from_params()` with data tables
+- [ ] Optimization: Merge similar curves
+- [ ] Size estimation (preview bytes saved vs. original .spec)
+
+---
+
+## Testing Strategy
+
+### C++ Runtime Tests
+**File:** `src/tests/test_spectral_brush.cc`
+
+**Test cases:**
+- Linear Bezier interpolation (verify values at control points)
+- Gaussian profile evaluation (verify falloff curve)
+- Full `draw_bezier_curve()` (verify spectrogram output)
+- Edge cases (0 control points, 1 control point, out-of-range frames)
+
+### Editor Tests
+**Manual testing workflow:**
+1. Load example .wav (kick drum)
+2. Place 3-4 control points to trace low-frequency punch
+3. Adjust Gaussian sigma
+4. Play procedural sound (should resemble original)
+5. Save procedural_params.txt
+6. Generate C++ code
+7. Copy C++ code into demo, compile, verify runtime output matches editor
+
+---
+
+## Size Impact Estimate
+
+**Example: Kick drum sample**
+
+**Before (Binary .spec):**
+- DCT size: 512
+- Num frames: 100
+- Size: 512 × 100 × 4 bytes = 200 KB (uncompressed)
+- Compressed (zlib): ~5-10 KB
+
+**After (Procedural C++):**
+```cpp
+// 4 control points × 3 arrays × 4 values = ~48 bytes of data
+const float frames[] = {0.0f, 20.0f, 50.0f, 100.0f};
+const float freqs[] = {200.0f, 80.0f, 60.0f, 50.0f};
+const float amps[] = {0.9f, 0.7f, 0.3f, 0.0f};
+draw_bezier_curve(...); // ~20 bytes function call
+
+// Total: ~100 bytes
+```
+
+**Compression ratio:** 50-100× reduction!
+
+**Trade-off:** Runtime CPU cost (generation vs. lookup), but acceptable for 64k demo.
+
+---
+
+## References
+
+- **Bezier curves:** https://en.wikipedia.org/wiki/B%C3%A9zier_curve
+- **DCT/IDCT:** Existing implementation in `src/audio/dct.cc`
+- **Web Audio API:** https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
+- **Spectrogram visualization:** Existing editor in `tools/editor/` (to be replaced)
diff --git a/src/audio/spectral_brush.cc b/src/audio/spectral_brush.cc
new file mode 100644
index 0000000..c6eb64d
--- /dev/null
+++ b/src/audio/spectral_brush.cc
@@ -0,0 +1,171 @@
+// This file is part of the 64k demo project.
+// It implements the "Spectral Brush" primitive for procedural audio generation.
+// Implementation of Bezier curves, vertical profiles, and spectrogram rendering.
+
+#include "spectral_brush.h"
+
+#include <cmath>
+
+// Sample rate constant (matches demo audio configuration)
+static const float SAMPLE_RATE = 32000.0f;
+
+// Evaluate linear Bezier interpolation between control points
+float evaluate_bezier_linear(const float* control_frames,
+ const float* control_values,
+ int n_points,
+ float frame) {
+ if (n_points == 0) {
+ return 0.0f;
+ }
+ if (n_points == 1) {
+ return control_values[0];
+ }
+
+ // Clamp to first/last value if outside range
+ if (frame <= control_frames[0]) {
+ return control_values[0];
+ }
+ if (frame >= control_frames[n_points - 1]) {
+ return control_values[n_points - 1];
+ }
+
+ // Find segment containing frame
+ for (int i = 0; i < n_points - 1; ++i) {
+ if (frame >= control_frames[i] && frame <= control_frames[i + 1]) {
+ // Linear interpolation: value = v0 + (v1 - v0) * t
+ const float t =
+ (frame - control_frames[i]) / (control_frames[i + 1] - control_frames[i]);
+ return control_values[i] * (1.0f - t) + control_values[i + 1] * t;
+ }
+ }
+
+ // Should not reach here (fallback to last value)
+ return control_values[n_points - 1];
+}
+
+// Home-brew deterministic RNG (LCG algorithm)
+uint32_t spectral_brush_rand(uint32_t seed) {
+ // LCG parameters (from Numerical Recipes)
+ // X_{n+1} = (a * X_n + c) mod m
+ const uint32_t a = 1664525;
+ const uint32_t c = 1013904223;
+ return a * seed + c; // Implicit mod 2^32
+}
+
+// Evaluate vertical profile at distance from curve center
+float evaluate_profile(ProfileType type, float distance, float param1, float param2) {
+ switch (type) {
+ case PROFILE_GAUSSIAN: {
+ // Gaussian: exp(-(dist^2 / sigma^2))
+ // param1 = sigma (width in bins)
+ const float sigma = param1;
+ if (sigma <= 0.0f) {
+ return 0.0f;
+ }
+ return expf(-(distance * distance) / (sigma * sigma));
+ }
+
+ case PROFILE_DECAYING_SINUSOID: {
+ // Decaying sinusoid: exp(-decay * dist) * cos(omega * dist)
+ // param1 = decay rate
+ // param2 = oscillation frequency (omega)
+ const float decay = param1;
+ const float omega = param2;
+ const float envelope = expf(-decay * distance);
+ const float oscillation = cosf(omega * distance);
+ return envelope * oscillation;
+ }
+
+ case PROFILE_NOISE: {
+ // Random noise: deterministic RNG based on distance
+ // param1 = amplitude scale
+ // param2 = seed
+ const float amplitude = param1;
+ const uint32_t seed = (uint32_t)(param2) + (uint32_t)(distance * 1000.0f);
+ const uint32_t rand_val = spectral_brush_rand(seed);
+ // Map to [0, 1]
+ const float normalized = (float)(rand_val % 10000) / 10000.0f;
+ return amplitude * normalized;
+ }
+ }
+
+ return 0.0f;
+}
+
+// Internal implementation: Render Bezier curve with profile
+static void draw_bezier_curve_impl(float* spectrogram,
+ int dct_size,
+ int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2,
+ bool additive) {
+ if (n_control_points < 1) {
+ return; // Nothing to draw
+ }
+
+ // For each frame in the spectrogram
+ for (int f = 0; f < num_frames; ++f) {
+ // 1. Evaluate Bezier curve at this frame
+ const float freq_hz =
+ evaluate_bezier_linear(control_frames, control_freqs_hz, n_control_points, (float)f);
+ const float amplitude =
+ evaluate_bezier_linear(control_frames, control_amps, n_control_points, (float)f);
+
+ // 2. Convert frequency (Hz) to frequency bin index
+ // Nyquist frequency = SAMPLE_RATE / 2
+ // bin = (freq_hz / nyquist) * dct_size
+ const float nyquist = SAMPLE_RATE / 2.0f;
+ const float freq_bin_0 = (freq_hz / nyquist) * dct_size;
+
+ // 3. Apply vertical profile around freq_bin_0
+ for (int b = 0; b < dct_size; ++b) {
+ const float dist = fabsf(b - freq_bin_0);
+ const float profile_val = evaluate_profile(profile_type, dist, profile_param1, profile_param2);
+ const float contribution = amplitude * profile_val;
+
+ const int idx = f * dct_size + b;
+ if (additive) {
+ spectrogram[idx] += contribution;
+ } else {
+ spectrogram[idx] = contribution;
+ }
+ }
+ }
+}
+
+// Draw spectral brush (overwrites spectrogram content)
+void draw_bezier_curve(float* spectrogram,
+ int dct_size,
+ int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2) {
+ draw_bezier_curve_impl(spectrogram, dct_size, num_frames, control_frames, control_freqs_hz,
+ control_amps, n_control_points, profile_type, profile_param1,
+ profile_param2, false);
+}
+
+// Draw spectral brush (adds to existing spectrogram content)
+void draw_bezier_curve_add(float* spectrogram,
+ int dct_size,
+ int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2) {
+ draw_bezier_curve_impl(spectrogram, dct_size, num_frames, control_frames, control_freqs_hz,
+ control_amps, n_control_points, profile_type, profile_param1,
+ profile_param2, true);
+}
diff --git a/src/audio/spectral_brush.h b/src/audio/spectral_brush.h
new file mode 100644
index 0000000..3125f35
--- /dev/null
+++ b/src/audio/spectral_brush.h
@@ -0,0 +1,80 @@
+// This file is part of the 64k demo project.
+// It implements the "Spectral Brush" primitive for procedural audio generation.
+// Spectral brushes trace Bezier curves through spectrograms with vertical profiles.
+
+#pragma once
+
+#include <cstdint>
+
+// Profile types for vertical distribution around central Bezier curve
+enum ProfileType {
+ PROFILE_GAUSSIAN = 0, // Smooth harmonic falloff
+ PROFILE_DECAYING_SINUSOID = 1, // Resonant/metallic texture
+ PROFILE_NOISE = 2 // Random texture/grit
+};
+
+// Evaluate linear Bezier interpolation at given frame
+// control_frames: Array of frame positions for control points
+// control_values: Array of values at control points (freq_hz or amplitude)
+// n_points: Number of control points
+// frame: Frame number to evaluate at
+// Returns: Interpolated value at frame (linearly interpolated between control points)
+float evaluate_bezier_linear(const float* control_frames,
+ const float* control_values,
+ int n_points,
+ float frame);
+
+// Draw a spectral brush stroke onto a spectrogram
+// Traces a Bezier curve through time-frequency space with a vertical profile
+// spectrogram: Output buffer (dct_size × num_frames), modified in-place
+// dct_size: Number of frequency bins (e.g., 512)
+// num_frames: Number of time frames
+// control_frames: Frame positions of Bezier control points
+// control_freqs_hz: Frequency values (Hz) at control points
+// control_amps: Amplitude values at control points (0.0-1.0 typical)
+// n_control_points: Number of control points (minimum 2 for a curve)
+// profile_type: Type of vertical profile to apply
+// profile_param1: First parameter (sigma for Gaussian, decay for sinusoid, amplitude for noise)
+// profile_param2: Second parameter (unused for Gaussian, frequency for sinusoid, seed for noise)
+void draw_bezier_curve(float* spectrogram,
+ int dct_size,
+ int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2 = 0.0f);
+
+// Additive variant of draw_bezier_curve (adds to existing spectrogram content)
+// Use for compositing multiple profiles (e.g., Gaussian + Noise)
+// Parameters same as draw_bezier_curve()
+void draw_bezier_curve_add(float* spectrogram,
+ int dct_size,
+ int num_frames,
+ const float* control_frames,
+ const float* control_freqs_hz,
+ const float* control_amps,
+ int n_control_points,
+ ProfileType profile_type,
+ float profile_param1,
+ float profile_param2 = 0.0f);
+
+// Evaluate vertical profile at given distance from central curve
+// type: Profile type (Gaussian, Decaying Sinusoid, Noise)
+// distance: Distance in frequency bins from curve center
+// param1: First profile parameter
+// param2: Second profile parameter
+// Returns: Profile amplitude at given distance (0.0-1.0 range typically)
+float evaluate_profile(ProfileType type,
+ float distance,
+ float param1,
+ float param2);
+
+// Home-brew deterministic RNG for noise profile
+// Simple linear congruential generator (LCG) for small code size
+// seed: Input seed value
+// Returns: Pseudo-random uint32_t value
+uint32_t spectral_brush_rand(uint32_t seed);
+
diff --git a/src/generated/assets_data.cc b/src/generated/assets_data.cc
index a29680f..0b2daba 100644
--- a/src/generated/assets_data.cc
+++ b/src/generated/assets_data.cc
@@ -369584,7 +369584,7 @@ static const float ASSET_PROC_PARAMS_NOISE_TEX[] = {1234.000000, 16.000000};
static const char* ASSET_PROC_FUNC_STR_NOISE_TEX = "gen_noise";
-const size_t ASSET_SIZE_SHADER_RENDERER_3D = 9468;
+const size_t ASSET_SIZE_SHADER_RENDERER_3D = 9449;
alignas(16) static const uint8_t ASSET_DATA_SHADER_RENDERER_3D[] = {
0x23, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x20, 0x22, 0x63, 0x6f,
0x6d, 0x6d, 0x6f, 0x6e, 0x5f, 0x75, 0x6e, 0x69, 0x66, 0x6f, 0x72, 0x6d,
@@ -370373,9 +370373,7 @@ alignas(16) static const uint8_t ASSET_DATA_SHADER_RENDERER_3D[] = {
0x6f, 0x73, 0x2e, 0x7a, 0x20, 0x2f, 0x20, 0x63, 0x6c, 0x69, 0x70, 0x5f,
0x70, 0x6f, 0x73, 0x2e, 0x77, 0x3b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x0a,
0x20, 0x20, 0x20, 0x20, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x20, 0x6f,
- 0x75, 0x74, 0x3b, 0x0a, 0x7d, 0x2f, 0x2f, 0x20, 0x64, 0x65, 0x70, 0x65,
- 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x20, 0x74, 0x65, 0x73, 0x74, 0x0a,
- 0x00
+ 0x75, 0x74, 0x3b, 0x0a, 0x7d, 0x00
};
const size_t ASSET_SIZE_SHADER_COMMON_UNIFORMS = 346;
alignas(16) static const uint8_t ASSET_DATA_SHADER_COMMON_UNIFORMS[] = {
diff --git a/src/tests/test_assets.cc b/src/tests/test_assets.cc
index 86b4ba4..2ee18d6 100644
--- a/src/tests/test_assets.cc
+++ b/src/tests/test_assets.cc
@@ -8,6 +8,8 @@
#include "generated/assets.h"
#endif /* defined(USE_TEST_ASSETS) */
+#include "util/asset_manager_utils.h"
+
#include <assert.h>
#include <stdio.h>
#include <string.h>
diff --git a/src/tests/test_spectral_brush.cc b/src/tests/test_spectral_brush.cc
new file mode 100644
index 0000000..1431ba7
--- /dev/null
+++ b/src/tests/test_spectral_brush.cc
@@ -0,0 +1,236 @@
+// This file is part of the 64k demo project.
+// Unit tests for spectral brush primitives.
+// Tests linear Bezier interpolation, profiles, and spectrogram rendering.
+
+#include "audio/spectral_brush.h"
+
+#include <cassert>
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+
+// Test tolerance for floating-point comparisons
+static const float EPSILON = 1e-5f;
+
+// Helper: Compare floats with tolerance
+static bool float_eq(float a, float b) {
+ return fabsf(a - b) < EPSILON;
+}
+
+// Test: Linear Bezier interpolation with 2 control points (simple line)
+void test_bezier_linear_2points() {
+ const float frames[] = {0.0f, 100.0f};
+ const float values[] = {50.0f, 150.0f};
+
+ // At control points, should return exact values
+ assert(float_eq(evaluate_bezier_linear(frames, values, 2, 0.0f), 50.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 2, 100.0f), 150.0f));
+
+ // Midpoint: linear interpolation
+ const float mid = evaluate_bezier_linear(frames, values, 2, 50.0f);
+ assert(float_eq(mid, 100.0f)); // (50 + 150) / 2
+
+ // Quarter point
+ const float quarter = evaluate_bezier_linear(frames, values, 2, 25.0f);
+ assert(float_eq(quarter, 75.0f)); // 50 + (150 - 50) * 0.25
+
+ printf("[PASS] test_bezier_linear_2points\n");
+}
+
+// Test: Linear Bezier interpolation with 4 control points
+void test_bezier_linear_4points() {
+ const float frames[] = {0.0f, 20.0f, 50.0f, 100.0f};
+ const float values[] = {200.0f, 80.0f, 60.0f, 50.0f};
+
+ // At control points
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 0.0f), 200.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 20.0f), 80.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 50.0f), 60.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 100.0f), 50.0f));
+
+ // Between first and second point (frame 10)
+ const float interp1 = evaluate_bezier_linear(frames, values, 4, 10.0f);
+ // t = (10 - 0) / (20 - 0) = 0.5
+ // value = 200 * 0.5 + 80 * 0.5 = 140
+ assert(float_eq(interp1, 140.0f));
+
+ // Between third and fourth point (frame 75)
+ const float interp2 = evaluate_bezier_linear(frames, values, 4, 75.0f);
+ // t = (75 - 50) / (100 - 50) = 0.5
+ // value = 60 * 0.5 + 50 * 0.5 = 55
+ assert(float_eq(interp2, 55.0f));
+
+ printf("[PASS] test_bezier_linear_4points\n");
+}
+
+// Test: Edge cases (single point, empty, out of range)
+void test_bezier_edge_cases() {
+ const float frames[] = {50.0f};
+ const float values[] = {123.0f};
+
+ // Single control point: always return that value
+ assert(float_eq(evaluate_bezier_linear(frames, values, 1, 0.0f), 123.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 1, 100.0f), 123.0f));
+
+ // Empty array: return 0
+ assert(float_eq(evaluate_bezier_linear(frames, values, 0, 50.0f), 0.0f));
+
+ // Out of range: clamp to endpoints
+ const float frames2[] = {10.0f, 90.0f};
+ const float values2[] = {100.0f, 200.0f};
+ assert(float_eq(evaluate_bezier_linear(frames2, values2, 2, 0.0f), 100.0f)); // Before start
+ assert(float_eq(evaluate_bezier_linear(frames2, values2, 2, 100.0f), 200.0f)); // After end
+
+ printf("[PASS] test_bezier_edge_cases\n");
+}
+
+// Test: Gaussian profile evaluation
+void test_profile_gaussian() {
+ // At center (distance = 0), should be 1.0
+ assert(float_eq(evaluate_profile(PROFILE_GAUSSIAN, 0.0f, 30.0f, 0.0f), 1.0f));
+
+ // Gaussian falloff: exp(-(dist^2 / sigma^2))
+ const float sigma = 30.0f;
+ const float dist = 15.0f;
+ const float expected = expf(-(dist * dist) / (sigma * sigma));
+ const float actual = evaluate_profile(PROFILE_GAUSSIAN, dist, sigma, 0.0f);
+ assert(float_eq(actual, expected));
+
+ // Far from center: should approach 0
+ const float far = evaluate_profile(PROFILE_GAUSSIAN, 100.0f, 30.0f, 0.0f);
+ assert(far < 0.01f); // Very small
+
+ printf("[PASS] test_profile_gaussian\n");
+}
+
+// Test: Decaying sinusoid profile evaluation
+void test_profile_decaying_sinusoid() {
+ const float decay = 0.15f;
+ const float omega = 0.8f;
+
+ // At center (distance = 0)
+ // exp(-0 * 0.15) * cos(0 * 0.8) = 1.0 * 1.0 = 1.0
+ assert(float_eq(evaluate_profile(PROFILE_DECAYING_SINUSOID, 0.0f, decay, omega), 1.0f));
+
+ // At distance 10
+ const float dist = 10.0f;
+ const float expected = expf(-decay * dist) * cosf(omega * dist);
+ const float actual = evaluate_profile(PROFILE_DECAYING_SINUSOID, dist, decay, omega);
+ assert(float_eq(actual, expected));
+
+ printf("[PASS] test_profile_decaying_sinusoid\n");
+}
+
+// Test: Noise profile evaluation (deterministic)
+void test_profile_noise() {
+ const float amplitude = 0.5f;
+ const uint32_t seed = 42;
+
+ // Same distance + seed should produce same value
+ const float val1 = evaluate_profile(PROFILE_NOISE, 10.0f, amplitude, (float)seed);
+ const float val2 = evaluate_profile(PROFILE_NOISE, 10.0f, amplitude, (float)seed);
+ assert(float_eq(val1, val2));
+
+ // Different distance should produce different value (with high probability)
+ const float val3 = evaluate_profile(PROFILE_NOISE, 20.0f, amplitude, (float)seed);
+ assert(!float_eq(val1, val3));
+
+ // Should be in range [0, amplitude]
+ assert(val1 >= 0.0f && val1 <= amplitude);
+
+ printf("[PASS] test_profile_noise\n");
+}
+
+// Test: draw_bezier_curve full integration
+void test_draw_bezier_curve() {
+ const int dct_size = 512;
+ const int num_frames = 100;
+ float spectrogram[512 * 100];
+ memset(spectrogram, 0, sizeof(spectrogram));
+
+ // Simple curve: constant frequency, linearly decaying amplitude
+ const float frames[] = {0.0f, 100.0f};
+ const float freqs[] = {440.0f, 440.0f}; // A4 note (constant pitch)
+ const float amps[] = {1.0f, 0.0f}; // Fade out
+
+ draw_bezier_curve(spectrogram, dct_size, num_frames, frames, freqs, amps, 2, PROFILE_GAUSSIAN,
+ 30.0f);
+
+ // Verify: At frame 0, should have peak around 440 Hz bin
+ // bin = (440 / 16000) * 512 ≈ 14.08
+ const int expected_bin = 14;
+ const float val_at_peak = spectrogram[0 * dct_size + expected_bin];
+ assert(val_at_peak > 0.5f); // Should be near 1.0 due to Gaussian
+
+ // Verify: At frame 99 (end), amplitude should be near 0
+ const float val_at_end = spectrogram[99 * dct_size + expected_bin];
+ assert(val_at_end < 0.1f); // Near zero
+
+ // Verify: At frame 50 (midpoint), amplitude should be ~0.5
+ const float val_at_mid = spectrogram[50 * dct_size + expected_bin];
+ assert(val_at_mid > 0.3f && val_at_mid < 0.7f); // Around 0.5
+
+ printf("[PASS] test_draw_bezier_curve\n");
+}
+
+// Test: draw_bezier_curve_add (additive mode)
+void test_draw_bezier_curve_add() {
+ const int dct_size = 512;
+ const int num_frames = 100;
+ float spectrogram[512 * 100];
+ memset(spectrogram, 0, sizeof(spectrogram));
+
+ // Draw first curve
+ const float frames1[] = {0.0f, 100.0f};
+ const float freqs1[] = {440.0f, 440.0f};
+ const float amps1[] = {0.5f, 0.5f};
+ draw_bezier_curve(spectrogram, dct_size, num_frames, frames1, freqs1, amps1, 2, PROFILE_GAUSSIAN,
+ 30.0f);
+
+ const int bin = 14; // ~440 Hz
+ const float val_before_add = spectrogram[0 * dct_size + bin];
+
+ // Add second curve (same frequency, same amplitude)
+ draw_bezier_curve_add(spectrogram, dct_size, num_frames, frames1, freqs1, amps1, 2,
+ PROFILE_GAUSSIAN, 30.0f);
+
+ const float val_after_add = spectrogram[0 * dct_size + bin];
+
+ // Should be approximately doubled
+ assert(val_after_add > val_before_add * 1.8f); // Allow small error
+
+ printf("[PASS] test_draw_bezier_curve_add\n");
+}
+
+// Test: RNG determinism
+void test_rng_determinism() {
+ const uint32_t seed = 12345;
+
+ // Same seed should produce same value
+ const uint32_t val1 = spectral_brush_rand(seed);
+ const uint32_t val2 = spectral_brush_rand(seed);
+ assert(val1 == val2);
+
+ // Different seeds should produce different values
+ const uint32_t val3 = spectral_brush_rand(seed + 1);
+ assert(val1 != val3);
+
+ printf("[PASS] test_rng_determinism\n");
+}
+
+int main() {
+ printf("Running spectral brush tests...\n\n");
+
+ test_bezier_linear_2points();
+ test_bezier_linear_4points();
+ test_bezier_edge_cases();
+ test_profile_gaussian();
+ test_profile_decaying_sinusoid();
+ test_profile_noise();
+ test_draw_bezier_curve();
+ test_draw_bezier_curve_add();
+ test_rng_determinism();
+
+ printf("\n✓ All tests passed!\n");
+ return 0;
+}