diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-06 11:12:34 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-06 11:12:34 +0100 |
| commit | 5a1adde097e489c259bd052971546e95683c3596 (patch) | |
| tree | bf03cf8b803604638ad84ddd9cc26de64baea64f | |
| parent | 83f34fb955524c09b7f3e124b97c3d4feef02a0c (diff) | |
feat(audio): Add Spectral Brush runtime (Phase 1 of Task #5)
Implement C++ runtime foundation for procedural audio tracing tool.
Changes:
- Created spectral_brush.h/cc with core API
- Linear Bezier interpolation
- Vertical profile evaluation (Gaussian, Decaying Sinusoid, Noise)
- draw_bezier_curve() for spectrogram rendering
- Home-brew deterministic RNG for noise profile
- Added comprehensive unit tests (test_spectral_brush.cc)
- Tests Bezier interpolation, profiles, edge cases
- Tests full spectrogram rendering pipeline
- All 9 tests pass
- Integrated into CMake build system
- Fixed test_assets.cc include (asset_manager_utils.h)
Design:
- Spectral Brush = Central Curve (Bezier) + Vertical Profile
- Enables 50-100x compression (5KB .spec to 100 bytes C++ code)
- Future: Cubic Bezier, composite profiles, multi-dimensional curves
Documentation:
- Added doc/SPECTRAL_BRUSH_EDITOR.md (complete architecture)
- Updated TODO.md with Phase 1-4 implementation plan
- Updated PROJECT_CONTEXT.md to mark Task #5 in progress
Test results: 21/21 tests pass (100%)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
| -rw-r--r-- | CMakeLists.txt | 6 | ||||
| -rw-r--r-- | PROJECT_CONTEXT.md | 12 | ||||
| -rw-r--r-- | TODO.md | 70 | ||||
| -rw-r--r-- | doc/SPECTRAL_BRUSH_EDITOR.md | 497 | ||||
| -rw-r--r-- | src/audio/spectral_brush.cc | 171 | ||||
| -rw-r--r-- | src/audio/spectral_brush.h | 80 | ||||
| -rw-r--r-- | src/generated/assets_data.cc | 6 | ||||
| -rw-r--r-- | src/tests/test_assets.cc | 2 | ||||
| -rw-r--r-- | src/tests/test_spectral_brush.cc | 236 |
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. @@ -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; +} |
