From 79f590ff6da25e430cfbdc4efa90b6c7b3aaa713 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 14:28:33 +0100 Subject: [PATCH 01/15] docs: add demo song & per-pattern BPM design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-12-demo-song-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-12-demo-song-design.md diff --git a/docs/superpowers/specs/2026-03-12-demo-song-design.md b/docs/superpowers/specs/2026-03-12-demo-song-design.md new file mode 100644 index 0000000..8cd8622 --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-demo-song-design.md @@ -0,0 +1,188 @@ +# Demo Song & Per-Pattern BPM + +**Date:** 2026-03-12 +**Status:** Approved + +## Overview + +Add per-pattern BPM to drum patterns and ship a 10-scene "demo song" as the factory default project. Each scene is a cohesive musical idea with matching drum pattern, drum kit sounds, Synth A (bass), and Synth B (lead/pad) at a genre-appropriate tempo. + +## Feature 1: Per-Pattern BPM + +### Data Change + +Add `bpm: Option` to `PatternData` in `project.rs`: + +```rust +#[derive(Serialize, Deserialize, Clone)] +pub struct PatternData { + pub name: String, + pub steps: Vec, // hex-encoded + #[serde(default)] + pub bpm: Option, +} +``` + +`#[serde(default)]` ensures backward compatibility — old saves load as `None`. + +### Behavior + +- When the active drum pattern changes and the new pattern has `Some(bpm)`, update `app.transport.bpm` and send `UiToAudio::SetBpm(bpm)` to the audio thread. +- If `None`, keep current BPM unchanged. +- For **queued** pattern switches (QWERTYUIOP, `[`/`]`): BPM changes when the pattern actually activates at loop boundary, not when queued. +- For **immediate** switches (Shift+QWERTYUIOP, `{`/`}`): BPM changes immediately. + +### Where to Wire + +BPM application happens entirely on the **UI thread** — no audio-thread changes needed. + +1. **`app.rs` — `switch_pattern()`** — This method is called for both immediate switches and queued activations. Add BPM application here: if the new pattern has `Some(bpm)`, set `self.transport.bpm` and send `UiToAudio::SetBpm(bpm)`. This single point covers all cases: + - Immediate switches (Shift+QWERTYUIOP, `{`/`}`) call `switch_pattern()` directly from `keys.rs` + - Queued switches activate at loop boundary in `app.rs:745` which also calls `switch_pattern()` +2. **`mouse.rs`** — If pattern switching is clickable, it should also call `switch_pattern()`, so BPM is handled automatically. + +### UI + +No new UI needed. The transport bar already displays `app.transport.bpm`. BPM updates are visible immediately. + +### No per-pattern BPM editing UI yet + +Users set BPM globally with `-`/`=`. A pattern's stored BPM is set only by the demo song (or by editing the .tsp file). A "save current BPM to pattern" feature can be added later. + +## Feature 2: Demo Song + +### Where It Lives + +Enhance the existing `ProjectFile::demo_project()` in `project.rs`. This is the factory default when no save file exists. + +### Scene Lineup + +| Slot | Key | Genre | BPM | Drum Pattern Preset | Drum Kit Style | Synth A (bass) | Synth B (lead/pad) | +|------|-----|-------|-----|---------------------|----------------|----------------|---------------------| +| 1 | Q | Acid Techno | 138 | Acid House | 808 | Wobble Bass | Screamer | +| 2 | W | House | 122 | Classic House | 909 | Acid Bass | Electric Piano | +| 3 | E | Deep House | 120 | Deep House | 909 | Reese Bass | Shimmer Pad | +| 4 | R | Techno | 130 | Driving Techno | 909 | Pulse Bass | Saw Lead | +| 5 | T | Downtempo / Lo-Fi | 85 | Lo-Fi Hip Hop | Lo-Fi | Sub Bass | Warm Pad | +| 6 | Y | Trance | 140 | Classic Trance | 909 | FM Bass | Trance Lead | +| 7 | U | Drum & Bass | 174 | Amen Break | Acoustic | Growl Bass | Basic Pluck | +| 8 | I | Electro | 128 | Electro Funk | 808 | Rubber Bass | Square Lead | +| 9 | O | Dub Techno | 118 | Basic Chain | 808 | Sub Bass | Ethereal | +| 10 | P | Ambient | 90 | Sparse Pulse | Minimal | Dark Pad | Bell Pluck | + +### Preset Lookup + +All presets are looked up by **exact name**, not by genre filtering. The implementer calls a lookup function (e.g., `find_preset_by_name()`) that searches the full preset array. + +### Per Scene, We Set + +- **Drum pattern**: Load steps from matching `pattern_presets.rs` preset (by name) +- **Drum kit**: Apply per-track drum sound presets from `drum_presets.rs` matching the genre (kick, snare, closed hat, open hat, clap, ride, perc, tom) +- **Synth A pattern**: Load from `synth_pattern_presets.rs` using the **"[Genre] Bass"** genre variant (e.g., "Acid Techno Bass 1") +- **Synth A kit**: Apply synth preset params from `synth_presets.rs` by name (e.g., "Wobble Bass") +- **Synth B pattern**: Load from `synth_pattern_presets.rs` using the **melodic** genre variant (e.g., "Acid Techno 1") +- **Synth B kit**: Apply synth preset params from `synth_presets.rs` by name (e.g., "Screamer") +- **BPM**: Set `Some(bpm)` on the drum pattern + +### Available Synth Pattern Genres + +Melodic (for Synth B): Techno, Acid Techno, Trance, Dub Techno, IDM, EDM, Drum & Bass, House, Breakbeat, Jungle, Garage, Ambient, Glitch, Electro, Downtempo + +Bass (for Synth A): Techno Bass, Acid Techno Bass, Trance Bass, Dub Techno Bass, IDM Bass, EDM Bass, Drum & Bass Bass, House Bass, Breakbeat Bass, Jungle Bass, Garage Bass, Ambient Bass, Glitch Bass, Electro Bass, Downtempo Bass + +### Genre Mapping for Scenes Without Exact Match + +| Scene | Synth A Pattern Genre | Synth B Pattern Genre | +|-------|-----------------------|-----------------------| +| Acid Techno | Acid Techno Bass | Acid Techno | +| House | House Bass | House | +| Deep House | House Bass | House | +| Techno | Techno Bass | Techno | +| Downtempo | Downtempo Bass | Downtempo | +| Trance | Trance Bass | Trance | +| Drum & Bass | Drum & Bass Bass | Drum & Bass | +| Electro | Electro Bass | Electro | +| Dub Techno | Dub Techno Bass | Dub Techno | +| Ambient | Ambient Bass | Ambient | + +Note: "Deep House" has no dedicated synth pattern genre — use "House" / "House Bass" with different preset numbers (e.g., pick preset 3-4 instead of 1 for variety). + +### Pattern Naming + +| Slot | Pattern Name | +|------|-------------| +| 1 | Acid Techno 138 | +| 2 | House 122 | +| 3 | Deep House 120 | +| 4 | Techno 130 | +| 5 | Downtempo 85 | +| 6 | Trance 140 | +| 7 | Drum & Bass 174 | +| 8 | Electro 128 | +| 9 | Dub Techno 118 | +| 10 | Ambient 90 | + +### Kit Sharing + +8 kits available, 10 patterns. Group by genre family: +- Kit 0: 808 sounds (Acid Techno, Electro, Dub Techno) +- Kit 1: 909 sounds (House, Deep House, Techno, Trance) +- Kit 2: Lo-Fi sounds (Downtempo) +- Kit 3: Acoustic sounds (DnB) +- Kit 4: Minimal sounds (Ambient) +- Kits 5-7: Duplicates or variations for future use + +Same approach for synth kits — 8 slots, shared across 10 patterns by timbre family. + +### Synth Kit Mapping + +**Synth A kits (bass-focused):** +- Kit 0: Wobble Bass (Acid Techno) +- Kit 1: Acid Bass (House) +- Kit 2: Reese Bass (Deep House) +- Kit 3: Pulse Bass (Techno) +- Kit 4: Sub Bass (Downtempo, Dub Techno) +- Kit 5: FM Bass (Trance) +- Kit 6: Growl Bass (DnB) +- Kit 7: Rubber Bass (Electro) + +**Synth B kits (lead/pad-focused):** +- Kit 0: Screamer (Acid Techno) +- Kit 1: Electric Piano (House) +- Kit 2: Shimmer Pad (Deep House) +- Kit 3: Saw Lead (Techno) +- Kit 4: Warm Pad (Downtempo) +- Kit 5: Trance Lead (Trance) +- Kit 6: Basic Pluck (DnB) +- Kit 7: Square Lead (Electro) + +**Kit sharing for patterns 9-10:** Patterns 1-8 map 1:1 to kits 0-7. Patterns 9 and 10 reuse existing kits: +- Pattern 9 (Dub Techno): Synth A reuses Kit 4 (Sub Bass — same as Downtempo), Synth B reuses Kit 2 (Shimmer Pad is close to Ethereal) +- Pattern 10 (Ambient): Synth A reuses Kit 5 (FM Bass — works as a dark texture), Synth B reuses Kit 6 (Basic Pluck — Bell Pluck is similar) + +The `active_synth_kit` / `active_synth_b_kit` index is set per-pattern in `demo_project()` so each scene points to the right kit. Since users don't switch patterns simultaneously, kit sharing has no audible impact. + +## Edge Cases + +- **Old saves**: `bpm: None` on all patterns — no behavior change, fully backward compatible. +- **User edits BPM manually**: Works as before. The transport BPM changes, but the pattern's stored `bpm` is not updated (read-only for now). +- **Queued switch timing**: BPM must change at loop boundary, not at queue time. The queued pattern index is already tracked — when it activates, apply BPM. +- **Demo project override**: If the user has a saved project, demo_project() is never called. No data loss risk. + +## Files to Modify + +1. **`src/sequencer/project.rs`** — Add `bpm: Option` to `PatternData`, update `demo_project()` with full scene data +2. **`src/app.rs`** — In `switch_pattern()`, apply BPM from pattern if `Some`. This covers both immediate and queued switches. +3. **`src/sequencer/transport.rs`** — No change needed, transport already has `bpm: f64` + +## Testing + +1. **Serialization roundtrip**: Extend `project_serialize_roundtrip` test to verify `bpm: Some(130.0)` survives save/load. +2. **Backward compat**: Add test that JSON without `bpm` field deserializes to `bpm: None`. +3. **Demo project validity**: Add test that `demo_project()` produces 10 patterns each with `Some(bpm)`, 8 kits, valid synth kit indices, and non-empty step data. + +## Future Work (Not In Scope) + +- **Scene abstraction (Approach B)**: Bundle drum + synth A + synth B + BPM into a `Scene` struct for linked switching +- **Save BPM to pattern**: UI action to store current transport BPM into the active pattern's `bpm` field +- **Per-pattern swing**: Similar `Option` for swing per pattern From 7e8b260fabcacaed22e356108b9541d6afcbab4d Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 14:41:55 +0100 Subject: [PATCH 02/15] docs: add demo song implementation plan Co-Authored-By: Claude Opus 4.6 --- .../superpowers/plans/2026-03-12-demo-song.md | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-12-demo-song.md diff --git a/docs/superpowers/plans/2026-03-12-demo-song.md b/docs/superpowers/plans/2026-03-12-demo-song.md new file mode 100644 index 0000000..0cb0674 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-demo-song.md @@ -0,0 +1,577 @@ +# Demo Song Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the default demo project with 10 cohesive musical scenes, each with matching drum pattern, drum kit, Synth A (bass), and Synth B (lead/pad) presets at genre-appropriate BPMs. + +**Architecture:** Rewrite `demo_project()` in `project.rs` to load presets by name from the existing preset arrays (`PATTERN_PRESETS`, `SYNTH_PRESETS`, `SYNTH_PATTERN_PRESETS`). Add helper lookup functions to each preset module. Per-pattern BPM is already implemented (`bpm: f64` on `PatternData`, applied in `switch_pattern()` when > 0.0). + +**Tech Stack:** Rust, serde, existing preset infrastructure + +**Spec:** `docs/superpowers/specs/2026-03-12-demo-song-design.md` + +--- + +## File Structure + +- **Modify:** `src/presets/pattern_presets.rs` — add `preset_by_name()` lookup +- **Modify:** `src/presets/synth_presets.rs` — add `preset_by_name()` lookup +- **Modify:** `src/presets/synth_pattern_presets.rs` — add `preset_by_name()` lookup +- **Modify:** `src/presets/drum_presets.rs` — add `preset_by_category_for_voice()` lookup +- **Modify:** `src/sequencer/project.rs` — rewrite `demo_project()`, update tests + +--- + +## Chunk 1: Preset Lookup Helpers + +### Task 1: Add drum pattern preset lookup by name + +**Files:** +- Modify: `src/presets/pattern_presets.rs` + +- [ ] **Step 1: Add `preset_by_name()` function** + +Add after the existing `presets_for_genre()` function (line ~267): + +```rust +pub fn preset_by_name(name: &str) -> Option<&'static PatternPreset> { + PATTERN_PRESETS.iter().find(|p| p.name == name) +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` +Expected: compiles successfully + +- [ ] **Step 3: Commit** + +```bash +git add src/presets/pattern_presets.rs +git commit -m "feat(presets): add drum pattern lookup by name" +``` + +### Task 2: Add synth sound preset lookup by name + +**Files:** +- Modify: `src/presets/synth_presets.rs` + +- [ ] **Step 1: Add `preset_by_name()` function** + +Add after the existing `categories()` function (line ~658): + +```rust +pub fn preset_by_name(name: &str) -> Option<&'static SynthSoundPreset> { + SYNTH_PRESETS.iter().find(|p| p.name == name) +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` +Expected: compiles successfully + +- [ ] **Step 3: Commit** + +```bash +git add src/presets/synth_presets.rs +git commit -m "feat(presets): add synth sound lookup by name" +``` + +### Task 3: Add synth pattern preset lookup by name + +**Files:** +- Modify: `src/presets/synth_pattern_presets.rs` + +- [ ] **Step 1: Add `preset_by_name()` function** + +Add after the existing `presets_for_genre()` function (line ~677): + +```rust +pub fn preset_by_name(name: &str) -> Option<&'static SynthPatternPreset> { + SYNTH_PATTERN_PRESETS.iter().find(|p| p.name == name) +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` +Expected: compiles successfully + +- [ ] **Step 3: Commit** + +```bash +git add src/presets/synth_pattern_presets.rs +git commit -m "feat(presets): add synth pattern lookup by name" +``` + +### Task 4: Add drum sound preset lookup by category per voice + +**Files:** +- Modify: `src/presets/drum_presets.rs` + +- [ ] **Step 1: Add `preset_by_name_for_voice()` function** + +Add after the existing `presets_for_voice()` function (line ~146): + +```rust +pub fn preset_by_name_for_voice(voice: DrumTrackId, name: &str) -> Option<&'static DrumSoundPreset> { + presets_for_voice(voice).iter().find(|p| p.name == name) +} + +pub fn first_preset_for_category(voice: DrumTrackId, category: &str) -> Option<&'static DrumSoundPreset> { + presets_for_voice(voice).iter().find(|p| p.category == category) +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` +Expected: compiles successfully + +- [ ] **Step 3: Commit** + +```bash +git add src/presets/drum_presets.rs +git commit -m "feat(presets): add drum sound lookup by name and category" +``` + +--- + +## Chunk 2: Rewrite demo_project() — Drum Patterns + +### Task 5: Replace drum patterns with 10-scene lineup + +**Files:** +- Modify: `src/sequencer/project.rs:505-668` — replace the `patterns` vec in `demo_project()` + +The 10 scenes use drum pattern presets loaded by name from `PATTERN_PRESETS`. For each scene, load steps from the preset. If a preset name doesn't exist, fall back to handcrafted hex patterns. + +- [ ] **Step 1: Add preset imports** + +At the top of `project.rs`, add (if not already present): + +```rust +use crate::presets::pattern_presets; +use crate::presets::synth_presets; +use crate::presets::synth_pattern_presets; +``` + +- [ ] **Step 2: Create a helper to build PatternData from a drum pattern preset** + +Add above `demo_project()`: + +```rust +fn pattern_from_preset(name: &str, display_name: &str, bpm: f64) -> PatternData { + if let Some(preset) = pattern_presets::preset_by_name(name) { + PatternData { + name: display_name.to_string(), + bpm, + steps: preset.steps.iter().map(|s| s.to_string()).collect(), + } + } else { + PatternData { + name: display_name.to_string(), + bpm, + steps: vec!["00000000".into(); NUM_DRUM_TRACKS], + } + } +} +``` + +- [ ] **Step 3: Replace the `patterns` vec in `demo_project()`** + +Replace the entire `let patterns = vec![...]` block (lines 506-668) with: + +```rust +let patterns = vec![ + pattern_from_preset("Acid House", "Acid Techno 138", 138.0), + pattern_from_preset("Classic House", "House 122", 122.0), + pattern_from_preset("Deep House", "Deep House 120", 120.0), + pattern_from_preset("Driving Techno", "Techno 130", 130.0), + pattern_from_preset("Lo-Fi Hip Hop", "Downtempo 85", 85.0), + pattern_from_preset("Classic Trance", "Trance 140", 140.0), + pattern_from_preset("Amen Break", "Drum & Bass 174", 174.0), + pattern_from_preset("Electro Funk", "Electro 128", 128.0), + pattern_from_preset("Basic Chain", "Dub Techno 118", 118.0), + pattern_from_preset("Sparse Pulse", "Ambient 90", 90.0), +]; +``` + +Note: "Basic Chain" is a Dub Techno preset — verify it exists in `PATTERN_PRESETS`. If not, use "Hazy" or another Dub Techno preset. Check by searching for `genre: "Dub Techno"` in `pattern_presets.rs`. + +- [ ] **Step 4: Update project metadata** + +Change project name from "Demo Beats" to "Demo Song" and default bpm to 138.0 (first scene): + +```rust +metadata: ProjectMetadata { + name: "Demo Song".to_string(), + ..Default::default() +}, +// ... +bpm: 138.0, +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` +Expected: compiles (tests may fail — we'll fix them in Chunk 4) + +- [ ] **Step 6: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat(demo): replace drum patterns with 10-scene lineup" +``` + +--- + +## Chunk 3: Wire Synth Presets and Patterns + +### Task 6: Create synth A kits (bass presets) + +**Files:** +- Modify: `src/sequencer/project.rs` — synth_kits section of `demo_project()` + +- [ ] **Step 1: Create helper to build SynthKitData from a synth preset name** + +Add above `demo_project()`: + +```rust +fn synth_kit_from_preset(name: &str, display_name: &str) -> SynthKitData { + if let Some(preset) = synth_presets::preset_by_name(name) { + SynthKitData { + name: display_name.to_string(), + params: preset.params, + } + } else { + SynthKitData { + name: display_name.to_string(), + ..Default::default() + } + } +} +``` + +- [ ] **Step 2: Replace synth_kits initialization** + +Replace the synth_kits loop (lines ~679-685) with: + +```rust +let synth_kits = vec![ + synth_kit_from_preset("Wobble Bass", "Wobble Bass"), // Kit 0: Acid Techno + synth_kit_from_preset("Acid Bass", "Acid Bass"), // Kit 1: House + synth_kit_from_preset("Reese Bass", "Reese Bass"), // Kit 2: Deep House + synth_kit_from_preset("Pulse Bass", "Pulse Bass"), // Kit 3: Techno + synth_kit_from_preset("Sub Bass", "Sub Bass"), // Kit 4: Downtempo, Dub Techno + synth_kit_from_preset("FM Bass", "FM Bass"), // Kit 5: Trance, Ambient (reuse) + synth_kit_from_preset("Growl Bass", "Growl Bass"), // Kit 6: DnB + synth_kit_from_preset("Rubber Bass", "Rubber Bass"), // Kit 7: Electro +]; +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` + +- [ ] **Step 4: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat(demo): wire synth A kits with bass presets" +``` + +### Task 7: Create synth B kits (lead/pad presets) + +**Files:** +- Modify: `src/sequencer/project.rs` — synth_b_kits section of `demo_project()` + +- [ ] **Step 1: Replace synth_b_kits initialization** + +Replace the synth_b_kits loop (lines ~693-699) with: + +```rust +let synth_b_kits = vec![ + synth_kit_from_preset("Screamer", "Screamer"), // Kit 0: Acid Techno + synth_kit_from_preset("Electric Piano", "Electric Piano"), // Kit 1: House + synth_kit_from_preset("Shimmer Pad", "Shimmer Pad"), // Kit 2: Deep House, Dub Techno (reuse) + synth_kit_from_preset("Saw Lead", "Saw Lead"), // Kit 3: Techno + synth_kit_from_preset("Warm Pad", "Warm Pad"), // Kit 4: Downtempo + synth_kit_from_preset("Trance Lead", "Trance Lead"), // Kit 5: Trance + synth_kit_from_preset("Basic Pluck", "Basic Pluck"), // Kit 6: DnB, Ambient (reuse) + synth_kit_from_preset("Square Lead", "Square Lead"), // Kit 7: Electro +]; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` + +- [ ] **Step 3: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat(demo): wire synth B kits with lead/pad presets" +``` + +### Task 8: Create synth A patterns (bass patterns) + +**Files:** +- Modify: `src/sequencer/project.rs` — synth_patterns section of `demo_project()` + +- [ ] **Step 1: Create helper to build SynthPatternData from a synth pattern preset** + +Add above `demo_project()`: + +```rust +fn synth_pattern_from_preset(preset_name: &str, display_name: &str) -> SynthPatternData { + if let Some(preset) = synth_pattern_presets::preset_by_name(preset_name) { + SynthPatternData { + name: display_name.to_string(), + steps: preset.steps.iter().map(|&(note, vel, len)| { + SynthStepData { + active: vel > 0, + note, + velocity: vel as f32 / 127.0, + gate: 1.0, + length: len, + } + }).collect(), + } + } else { + SynthPatternData { + name: display_name.to_string(), + ..Default::default() + } + } +} +``` + +Note: `SynthStepData` has fields: `active: bool`, `note: u8`, `velocity: f32` (0.0-1.0), `gate: f32`, `length: u8`. The preset tuple `(u8, u8, u8)` is `(note, velocity_0_127, length)` — velocity must be converted with `vel as f32 / 127.0` and `active` is `vel > 0`. This matches the existing `from_synth_pattern()` conversion pattern. + +- [ ] **Step 2: Replace synth_patterns initialization** + +Replace the synth_patterns loop (lines ~672-678) with: + +```rust +let synth_patterns = vec![ + synth_pattern_from_preset("Acid Techno Bass 1", "Acid Techno Bass"), + synth_pattern_from_preset("House Bass 1", "House Bass"), + synth_pattern_from_preset("House Bass 3", "Deep House Bass"), // Use different House Bass for variety + synth_pattern_from_preset("Techno Bass 1", "Techno Bass"), + synth_pattern_from_preset("Downtempo Bass 1", "Downtempo Bass"), + synth_pattern_from_preset("Trance Bass 1", "Trance Bass"), + synth_pattern_from_preset("Drum & Bass Bass 1", "DnB Bass"), + synth_pattern_from_preset("Electro Bass 1", "Electro Bass"), + synth_pattern_from_preset("Dub Techno Bass 1", "Dub Techno Bass"), + synth_pattern_from_preset("Ambient Bass 1", "Ambient Bass"), +]; +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` + +- [ ] **Step 4: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat(demo): wire synth A patterns with bass presets" +``` + +### Task 9: Create synth B patterns (lead/pad patterns) + +**Files:** +- Modify: `src/sequencer/project.rs` — synth_b_patterns section of `demo_project()` + +- [ ] **Step 1: Replace synth_b_patterns initialization** + +Replace the synth_b_patterns loop (lines ~686-692) with: + +```rust +let synth_b_patterns = vec![ + synth_pattern_from_preset("Acid Techno 1", "Acid Techno Lead"), + synth_pattern_from_preset("House 1", "House Keys"), + synth_pattern_from_preset("House 3", "Deep House Pad"), // Use different House for variety + synth_pattern_from_preset("Techno 1", "Techno Lead"), + synth_pattern_from_preset("Downtempo 1", "Downtempo Pad"), + synth_pattern_from_preset("Trance 1", "Trance Lead"), + synth_pattern_from_preset("Drum & Bass 1", "DnB Pluck"), + synth_pattern_from_preset("Electro 1", "Electro Lead"), + synth_pattern_from_preset("Dub Techno 1", "Dub Techno Pad"), + synth_pattern_from_preset("Ambient 1", "Ambient Bells"), +]; +``` + +- [ ] **Step 2: Set active kit indices for patterns 9-10 (kit sharing)** + +After building the `ProjectFile`, set the kit indices so patterns 9 and 10 point to reused kits. In the `ProjectFile` struct literal, update: + +```rust +active_synth_kit: 0, // Scene 1 (Acid Techno) starts with kit 0 +active_synth_b_kit: 0, // Scene 1 starts with kit 0 +``` + +Note: Kit switching per-pattern isn't automatic yet — that's the future Scene abstraction (Approach B). For now, each pattern stores steps but the active kit is global. The demo just sets the initial kit to match scene 1. Users switch kits manually. Same applies to drum kits — `genre_kits()` provides 8 kits (808, 909, Techno, House, Minimal, Lo-Fi, Electro, Ambient) that roughly match our scenes. Per-scene drum kit auto-switching will come with Approach B. + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build 2>&1 | tail -5` + +- [ ] **Step 4: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat(demo): wire synth B patterns with lead/pad presets" +``` + +--- + +## Chunk 4: Update Tests + +### Task 10: Fix existing tests and add new ones + +**Files:** +- Modify: `src/sequencer/project.rs:1219+` — `demo_tests` module + +- [ ] **Step 1: Update existing tests** + +The existing tests reference old pattern names/BPMs. Update them: + +```rust +#[cfg(test)] +mod demo_tests { + use super::*; + + #[test] + fn demo_project_has_10_patterns() { + let proj = demo_project(); + assert_eq!(proj.patterns.len(), 10); + } + + #[test] + fn demo_patterns_have_bpm() { + let proj = demo_project(); + assert!((proj.patterns[0].bpm - 138.0).abs() < 0.01); // Acid Techno + assert!((proj.patterns[6].bpm - 174.0).abs() < 0.01); // D&B + assert!((proj.patterns[9].bpm - 90.0).abs() < 0.01); // Ambient + } + + #[test] + fn demo_patterns_have_nonempty_steps() { + let proj = demo_project(); + for (i, pat) in proj.patterns.iter().enumerate() { + let has_notes = pat.steps.iter().any(|s| s != "00000000"); + assert!(has_notes, "Pattern {} ({}) has no steps", i, pat.name); + } + } + + #[test] + fn demo_synth_kits_have_presets() { + let proj = demo_project(); + assert_eq!(proj.synth_kits.len(), NUM_KITS); + assert_eq!(proj.synth_b_kits.len(), NUM_KITS); + // First kit should be Wobble Bass — check volume differs from default + let default_vol = SynthParams::default().volume; + // Verify kits have named presets (not default "Synth Kit N" names) + assert_eq!(proj.synth_kits[0].name, "Wobble Bass"); + assert_eq!(proj.synth_b_kits[0].name, "Screamer"); + } + + #[test] + fn demo_synth_patterns_have_notes() { + let proj = demo_project(); + assert_eq!(proj.synth_patterns.len(), NUM_PATTERNS); + assert_eq!(proj.synth_b_patterns.len(), NUM_PATTERNS); + for (i, pat) in proj.synth_patterns.iter().enumerate() { + let has_notes = pat.steps.iter().any(|s| s.active); + assert!(has_notes, "Synth A pattern {} ({}) has no notes", i, pat.name); + } + for (i, pat) in proj.synth_b_patterns.iter().enumerate() { + let has_notes = pat.steps.iter().any(|s| s.active); + assert!(has_notes, "Synth B pattern {} ({}) has no notes", i, pat.name); + } + } + + #[test] + fn demo_serialize_roundtrip() { + let proj = demo_project(); + let json = serde_json::to_string(&proj).unwrap(); + let loaded: ProjectFile = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.patterns.len(), 10); + assert_eq!(loaded.patterns[0].name, "Acid Techno 138"); + assert!((loaded.patterns[0].bpm - 138.0).abs() < 0.01); + assert_eq!(loaded.synth_kits[0].name, "Wobble Bass"); + } +} +``` + +Note: Check whether `SynthParams` implements `PartialEq` — if not, compare a specific field like `volume` instead of using `!=`. + +- [ ] **Step 2: Remove the old `demo_house_kick_is_four_on_the_floor` test** + +This test was specific to the old House pattern at index 0. Remove it since pattern 0 is now Acid Techno. + +- [ ] **Step 3: Run all tests** + +Run: `cargo test 2>&1 | tail -20` +Expected: all tests pass + +- [ ] **Step 4: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "test(demo): update demo project tests for new scene lineup" +``` + +--- + +## Chunk 5: Verification & Cleanup + +### Task 11: Full build and manual verification + +- [ ] **Step 1: Run full test suite** + +Run: `cargo test` +Expected: all 23+ tests pass + +- [ ] **Step 2: Run release build** + +Run: `cargo build --release 2>&1 | tail -5` +Expected: compiles without warnings + +- [ ] **Step 3: Quick manual check (if possible)** + +Run: `cargo run` +- Press Space to start playback — should hear Acid Techno at 138 BPM +- Press W — should switch to House at 122 BPM (BPM changes in transport bar) +- Press P — should switch to Ambient at 90 BPM +- Press Q — back to Acid Techno at 138 BPM + +- [ ] **Step 4: Update spec status** + +In `docs/superpowers/specs/2026-03-12-demo-song-design.md`, change: +``` +**Status:** Approved +``` +to: +``` +**Status:** Implemented +``` + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "feat(demo): complete 10-scene demo song with synth presets + +Each pattern slot (Q-P) is a cohesive musical scene: +1. Acid Techno (138) 2. House (122) 3. Deep House (120) +4. Techno (130) 5. Downtempo (85) 6. Trance (140) +7. D&B (174) 8. Electro (128) 9. Dub Techno (118) +10. Ambient (90) + +Synth A = bass presets, Synth B = lead/pad presets. +Per-pattern BPM switches automatically on pattern change." +``` From 0a16c87572aabf96550973c563a9ce63e55cd676 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 14:46:03 +0100 Subject: [PATCH 03/15] feat(presets): add by-name lookup functions for all preset types Add lookup helpers to all 4 preset modules: - pattern_presets: preset_by_name() - synth_presets: preset_by_name() - synth_pattern_presets: preset_by_name() - drum_presets: preset_by_name_for_voice() + first_preset_for_category() These functions enable direct preset lookup by name for loading presets from saved projects and applying them programmatically. Co-Authored-By: Claude Opus 4.6 --- src/presets/drum_presets.rs | 8 ++++++++ src/presets/pattern_presets.rs | 4 ++++ src/presets/synth_pattern_presets.rs | 4 ++++ src/presets/synth_presets.rs | 4 ++++ 4 files changed, 20 insertions(+) diff --git a/src/presets/drum_presets.rs b/src/presets/drum_presets.rs index bb3b0aa..ede983d 100644 --- a/src/presets/drum_presets.rs +++ b/src/presets/drum_presets.rs @@ -156,3 +156,11 @@ pub fn categories_for_voice(voice: DrumTrackId) -> Vec<&'static str> { } cats } + +pub fn preset_by_name_for_voice(voice: DrumTrackId, name: &str) -> Option<&'static DrumSoundPreset> { + presets_for_voice(voice).iter().find(|p| p.name == name) +} + +pub fn first_preset_for_category(voice: DrumTrackId, category: &str) -> Option<&'static DrumSoundPreset> { + presets_for_voice(voice).iter().find(|p| p.category == category) +} diff --git a/src/presets/pattern_presets.rs b/src/presets/pattern_presets.rs index 0e59b3a..d5ed073 100644 --- a/src/presets/pattern_presets.rs +++ b/src/presets/pattern_presets.rs @@ -266,3 +266,7 @@ pub fn genres() -> Vec<&'static str> { pub fn presets_for_genre(genre: &str) -> Vec<&'static PatternPreset> { PATTERN_PRESETS.iter().filter(|p| p.genre == genre).collect() } + +pub fn preset_by_name(name: &str) -> Option<&'static PatternPreset> { + PATTERN_PRESETS.iter().find(|p| p.name == name) +} diff --git a/src/presets/synth_pattern_presets.rs b/src/presets/synth_pattern_presets.rs index 188c68a..dc174fd 100644 --- a/src/presets/synth_pattern_presets.rs +++ b/src/presets/synth_pattern_presets.rs @@ -675,3 +675,7 @@ pub fn genres() -> Vec<&'static str> { pub fn presets_for_genre(genre: &str) -> Vec<&'static SynthPatternPreset> { SYNTH_PATTERN_PRESETS.iter().filter(|p| p.genre == genre).collect() } + +pub fn preset_by_name(name: &str) -> Option<&'static SynthPatternPreset> { + SYNTH_PATTERN_PRESETS.iter().find(|p| p.name == name) +} diff --git a/src/presets/synth_presets.rs b/src/presets/synth_presets.rs index 3ff0231..8431269 100644 --- a/src/presets/synth_presets.rs +++ b/src/presets/synth_presets.rs @@ -662,3 +662,7 @@ pub fn categories() -> Vec<&'static str> { } cats } + +pub fn preset_by_name(name: &str) -> Option<&'static SynthSoundPreset> { + SYNTH_PRESETS.iter().find(|p| p.name == name) +} From e1a72bef0b7e5f0b8c95220c7c96e6c5934dc854 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 14:53:29 +0100 Subject: [PATCH 04/15] feat(demo): replace drum patterns with 10-scene lineup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded PatternData structs with pattern_from_preset() helper that loads from pattern_presets module. Update demo project metadata: - Name: "Demo Beats" → "Demo Song" - BPM: 125 → 138 (matches first scene) New 10-scene lineup: 1. Acid Techno 138 (Acid House preset) 2. House 122 (Classic House preset) 3. Deep House 120 4. Techno 130 (Driving Techno preset) 5. Downtempo 85 (Lo-Fi Hip Hop preset) 6. Trance 140 (Classic Trance preset) 7. Drum & Bass 174 (Amen Break preset) 8. Electro 128 (Electro Funk preset) 9. Dub Techno 118 (Basic Chain preset) 10. Ambient 90 (Sparse Pulse preset) Note: 2 demo tests now fail (expected, will be fixed in Task 10). Co-Authored-By: Claude Opus 4.6 --- src/sequencer/project.rs | 194 +++++++-------------------------------- 1 file changed, 31 insertions(+), 163 deletions(-) diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 001ee1b..401f726 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -5,6 +5,9 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use crate::params::EffectParams; +use crate::presets::pattern_presets; +use crate::presets::synth_presets; +use crate::presets::synth_pattern_presets; use crate::sequencer::drum_pattern::{ DrumTrackParams, DrumTrackId, MAX_STEPS, NUM_DRUM_TRACKS, TRACK_IDS, }; @@ -501,170 +504,35 @@ impl Default for ProjectFile { } } +fn pattern_from_preset(name: &str, display_name: &str, bpm: f64) -> PatternData { + if let Some(preset) = pattern_presets::preset_by_name(name) { + PatternData { + name: display_name.to_string(), + bpm, + steps: preset.steps.iter().map(|s| s.to_string()).collect(), + } + } else { + PatternData { + name: display_name.to_string(), + bpm, + steps: vec!["00000000".into(); NUM_DRUM_TRACKS], + } + } +} + /// Create a demo project with 10 pre-filled genre patterns from classic drum programming. pub fn demo_project() -> ProjectFile { let patterns = vec![ - // 1. House (125 BPM) - // Kick: four-on-the-floor | Snare: beat 3 | CHH: 16ths except offbeats - // OHH: offbeat 16ths + running 16ths on beat 4 | Tom: beat 2 accent - PatternData { - name: "House".into(), - bpm: 125.0, - steps: vec![ - "88880000".into(), // Kick: 0,4,8,12 - "00800000".into(), // Snare: 8 - "eee00000".into(), // CHH: 0,1,2,4,5,6,8,9,10 - "111f0000".into(), // OHH: 3,7,11,12,13,14,15 - "00000000".into(), // Ride - "00000000".into(), // Clap - "00000000".into(), // Cowbell - "08000000".into(), // Tom: 4 - ], - }, - // 2. Chicago House (122 BPM) - // Kick with upbeat anticipation | Snare on 2&4 | Paired CHH/OHH | Clap on 2&4 - PatternData { - name: "Chicago House".into(), - bpm: 122.0, - steps: vec![ - "88820000".into(), // Kick: 0,4,8,14 - "08080000".into(), // Snare: 4,12 - "cccc0000".into(), // CHH: 0,1,4,5,8,9,12,13 - "33100000".into(), // OHH: 2,3,6,7,11 - "00000000".into(), // Ride - "08080000".into(), // Clap: 4,12 - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, - // 3. Brit House (122 BPM) - // Syncopated kick | CHH/OHH like House | Clap on beat 3 | Ride accents - PatternData { - name: "Brit House".into(), - bpm: 122.0, - steps: vec![ - "89800000".into(), // Kick: 0,4,7,8 - "00000000".into(), // Snare - "eee00000".into(), // CHH: 0,1,2,4,5,6,8,9,10 - "111f0000".into(), // OHH: 3,7,11,12,13,14,15 - "08090000".into(), // Ride: 4,12,15 - "00800000".into(), // Clap: 8 - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, - // 4. French House (124 BPM) - // Funky syncopated kick | Shaker (cowbell) on 8ths | Clap on beat 3 - PatternData { - name: "French House".into(), - bpm: 124.0, - steps: vec![ - "98980000".into(), // Kick: 0,3,4,8,11,12 - "00000000".into(), // Snare - "cce00000".into(), // CHH: 0,1,4,5,8,9,10 - "331f0000".into(), // OHH: 2,3,6,7,11,12,13,14,15 - "00000000".into(), // Ride - "00800000".into(), // Clap: 8 - "aaa00000".into(), // Cowbell: 0,2,4,6,8,10 (shakers) - "00000000".into(), // Tom - ], - }, - // 5. Dirty House (126 BPM) - // Syncopated kick | Sparse hats | Offbeat claps - PatternData { - name: "Dirty House".into(), - bpm: 126.0, - steps: vec![ - "98880000".into(), // Kick: 0,3,4,8,12 - "00800000".into(), // Snare: 8 - "08080000".into(), // CHH: 4,12 - "02020000".into(), // OHH: 6,14 - "00000000".into(), // Ride - "22200000".into(), // Clap: 2,6,10 - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, - // 6. Trance (138 BPM) - // Four-on-the-floor | Dense CHH/OHH pattern | Crash on beat 1 - PatternData { - name: "Trance".into(), - bpm: 138.0, - steps: vec![ - "88880000".into(), // Kick: 0,4,8,12 - "00000000".into(), // Snare - "eee00000".into(), // CHH: 0,1,2,4,5,6,8,9,10 - "111f0000".into(), // OHH: 3,7,11,12,13,14,15 - "80000000".into(), // Ride: 0 (crash) - "00000000".into(), // Clap - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, - // 7. Techno (130 BPM) - // Four-on-the-floor | 8th-note CHH | Offbeat ride | Clap on 2&4 - PatternData { - name: "Techno".into(), - bpm: 130.0, - steps: vec![ - "88880000".into(), // Kick: 0,4,8,12 - "00000000".into(), // Snare - "aaaa0000".into(), // CHH: 0,2,4,6,8,10,12,14 (8th notes) - "00000000".into(), // OHH - "22220000".into(), // Ride: 2,6,10,14 (offbeat 8ths) - "08080000".into(), // Clap: 4,12 - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, - // 8. Drum & Bass (170 BPM) - // Breakbeat kick pattern | Snare on beat 3 | Dense hats - PatternData { - name: "Drum & Bass".into(), - bpm: 170.0, - steps: vec![ - "80200000".into(), // Kick: 0,10 - "00800000".into(), // Snare: 8 - "eee00000".into(), // CHH: 0,1,2,4,5,6,8,9,10 - "111f0000".into(), // OHH: 3,7,11,12,13,14,15 - "00000000".into(), // Ride - "00000000".into(), // Clap - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, - // 9. Trap (140 BPM) - // Sparse kick with anticipation | Hi-hat rolls | Tom fills - PatternData { - name: "Trap".into(), - bpm: 140.0, - steps: vec![ - "88020000".into(), // Kick: 0,4,14 - "00800000".into(), // Snare: 8 - "ffaa0000".into(), // CHH: 0,1,2,3,4,5,6,7,8,10,12,14 (rolls) - "00000000".into(), // OHH - "00000000".into(), // Ride - "00000000".into(), // Clap - "00000000".into(), // Cowbell - "00210000".into(), // Tom: 10,15 - ], - }, - // 10. Moombahton (100 BPM) - // Dembow-influenced | Snare on 2,3,4 | OHH fills gaps - PatternData { - name: "Moombahton".into(), - bpm: 100.0, - steps: vec![ - "88800000".into(), // Kick: 0,4,8 - "08880000".into(), // Snare: 4,8,12 - "eccc0000".into(), // CHH: 0,1,2,4,5,8,9,12,13 - "13330000".into(), // OHH: 3,6,7,10,11,14,15 - "80000000".into(), // Ride: 0 - "00000000".into(), // Clap - "00000000".into(), // Cowbell - "00000000".into(), // Tom - ], - }, + pattern_from_preset("Acid House", "Acid Techno 138", 138.0), + pattern_from_preset("Classic House", "House 122", 122.0), + pattern_from_preset("Deep House", "Deep House 120", 120.0), + pattern_from_preset("Driving Techno", "Techno 130", 130.0), + pattern_from_preset("Lo-Fi Hip Hop", "Downtempo 85", 85.0), + pattern_from_preset("Classic Trance", "Trance 140", 140.0), + pattern_from_preset("Amen Break", "Drum & Bass 174", 174.0), + pattern_from_preset("Electro Funk", "Electro 128", 128.0), + pattern_from_preset("Basic Chain", "Dub Techno 118", 118.0), + pattern_from_preset("Sparse Pulse", "Ambient 90", 90.0), ]; let kits = genre_kits(); @@ -701,7 +569,7 @@ pub fn demo_project() -> ProjectFile { ProjectFile { textstep: FileHeader::default(), metadata: ProjectMetadata { - name: "Demo Beats".to_string(), + name: "Demo Song".to_string(), ..Default::default() }, kit: DrumKit::default(), @@ -709,7 +577,7 @@ pub fn demo_project() -> ProjectFile { active_kit: 0, patterns, active_pattern: 0, - bpm: 125.0, + bpm: 138.0, loop_length: 16, swing: 0.50, effects: EffectParams::default(), From df14f85696d3f197743c75774a4cf00cb66539ff Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 14:58:46 +0100 Subject: [PATCH 05/15] feat(demo): wire synth A/B kits and patterns with presets Add helper functions synth_kit_from_preset() and synth_pattern_from_preset() to load synth sounds and patterns from the preset system. Wire all 8 synth kits and 10 patterns for both Synth A (bass) and Synth B (lead/pad) channels. Synth A uses bass presets (Wobble, Acid, Reese, etc.) paired with bass patterns. Synth B uses melodic presets (Screamer, Electric Piano, pads, leads) with corresponding melodic patterns. Each pattern slot matches its drum pattern genre. Note: Velocity conversion from u8 (0-127) to f32 (0.0-1.0) in pattern loader. Co-Authored-By: Claude Opus 4.6 --- src/sequencer/project.rs | 108 +++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 28 deletions(-) diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 401f726..7066cb9 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -520,6 +520,42 @@ fn pattern_from_preset(name: &str, display_name: &str, bpm: f64) -> PatternData } } +fn synth_kit_from_preset(name: &str, display_name: &str) -> SynthKitData { + if let Some(preset) = synth_presets::preset_by_name(name) { + SynthKitData { + name: display_name.to_string(), + params: preset.params, + } + } else { + SynthKitData { + name: display_name.to_string(), + ..Default::default() + } + } +} + +fn synth_pattern_from_preset(preset_name: &str, display_name: &str) -> SynthPatternData { + if let Some(preset) = synth_pattern_presets::preset_by_name(preset_name) { + SynthPatternData { + name: display_name.to_string(), + steps: preset.steps.iter().map(|&(note, vel, len)| { + SynthStepData { + active: vel > 0, + note, + velocity: vel as f32 / 127.0, + gate: 1.0, + length: len, + } + }).collect(), + } + } else { + SynthPatternData { + name: display_name.to_string(), + ..Default::default() + } + } +} + /// Create a demo project with 10 pre-filled genre patterns from classic drum programming. pub fn demo_project() -> ProjectFile { let patterns = vec![ @@ -537,34 +573,50 @@ pub fn demo_project() -> ProjectFile { let kits = genre_kits(); - let mut synth_patterns = Vec::with_capacity(NUM_PATTERNS); - for i in 0..NUM_PATTERNS { - synth_patterns.push(SynthPatternData { - name: format!("Synth {}", i + 1), - ..Default::default() - }); - } - let mut synth_kits = Vec::with_capacity(NUM_KITS); - for i in 0..NUM_KITS { - synth_kits.push(SynthKitData { - name: format!("Synth Kit {}", i + 1), - ..Default::default() - }); - } - let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS); - for i in 0..NUM_PATTERNS { - synth_b_patterns.push(SynthPatternData { - name: format!("Synth B {}", i + 1), - ..Default::default() - }); - } - let mut synth_b_kits = Vec::with_capacity(NUM_KITS); - for i in 0..NUM_KITS { - synth_b_kits.push(SynthKitData { - name: format!("Synth B Kit {}", i + 1), - ..Default::default() - }); - } + let synth_patterns = vec![ + synth_pattern_from_preset("Acid Techno Bass 1", "Acid Techno Bass"), + synth_pattern_from_preset("House Bass 1", "House Bass"), + synth_pattern_from_preset("House Bass 3", "Deep House Bass"), + synth_pattern_from_preset("Techno Bass 1", "Techno Bass"), + synth_pattern_from_preset("Downtempo Bass 1", "Downtempo Bass"), + synth_pattern_from_preset("Trance Bass 1", "Trance Bass"), + synth_pattern_from_preset("Drum & Bass Bass 1", "DnB Bass"), + synth_pattern_from_preset("Electro Bass 1", "Electro Bass"), + synth_pattern_from_preset("Dub Techno Bass 1", "Dub Techno Bass"), + synth_pattern_from_preset("Ambient Bass 1", "Ambient Bass"), + ]; + let synth_kits = vec![ + synth_kit_from_preset("Wobble Bass", "Wobble Bass"), // Kit 0: Acid Techno + synth_kit_from_preset("Acid Bass", "Acid Bass"), // Kit 1: House + synth_kit_from_preset("Reese Bass", "Reese Bass"), // Kit 2: Deep House + synth_kit_from_preset("Pulse Bass", "Pulse Bass"), // Kit 3: Techno + synth_kit_from_preset("Sub Bass", "Sub Bass"), // Kit 4: Downtempo, Dub Techno + synth_kit_from_preset("FM Bass", "FM Bass"), // Kit 5: Trance, Ambient (reuse) + synth_kit_from_preset("Growl Bass", "Growl Bass"), // Kit 6: DnB + synth_kit_from_preset("Rubber Bass", "Rubber Bass"), // Kit 7: Electro + ]; + let synth_b_patterns = vec![ + synth_pattern_from_preset("Acid Techno 1", "Acid Techno Lead"), + synth_pattern_from_preset("House 1", "House Keys"), + synth_pattern_from_preset("House 3", "Deep House Pad"), + synth_pattern_from_preset("Techno 1", "Techno Lead"), + synth_pattern_from_preset("Downtempo 1", "Downtempo Pad"), + synth_pattern_from_preset("Trance 1", "Trance Lead"), + synth_pattern_from_preset("Drum & Bass 1", "DnB Pluck"), + synth_pattern_from_preset("Electro 1", "Electro Lead"), + synth_pattern_from_preset("Dub Techno 1", "Dub Techno Pad"), + synth_pattern_from_preset("Ambient 1", "Ambient Bells"), + ]; + let synth_b_kits = vec![ + synth_kit_from_preset("Screamer", "Screamer"), // Kit 0: Acid Techno + synth_kit_from_preset("Electric Piano", "Electric Piano"), // Kit 1: House + synth_kit_from_preset("Shimmer Pad", "Shimmer Pad"), // Kit 2: Deep House, Dub Techno + synth_kit_from_preset("Saw Lead", "Saw Lead"), // Kit 3: Techno + synth_kit_from_preset("Warm Pad", "Warm Pad"), // Kit 4: Downtempo + synth_kit_from_preset("Trance Lead", "Trance Lead"), // Kit 5: Trance + synth_kit_from_preset("Basic Pluck", "Basic Pluck"), // Kit 6: DnB, Ambient + synth_kit_from_preset("Square Lead", "Square Lead"), // Kit 7: Electro + ]; ProjectFile { textstep: FileHeader::default(), From eb0de1515f9a7e359d790d590426089d00323420 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 15:02:59 +0100 Subject: [PATCH 06/15] test(demo): update demo project tests for new scene lineup - Replace old demo_house_kick_is_four_on_the_floor test - Update BPM assertions for new patterns (Acid Techno 138, D&B 174, Ambient 90) - Add tests for nonempty drum steps - Add tests for synth kits (Wobble Bass, Screamer) - Add tests for synth patterns having notes (synth A & B) - Update serialize roundtrip to check new pattern names Co-Authored-By: Claude Opus 4.6 --- src/sequencer/project.rs | 52 ++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 7066cb9..3045441 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -1141,26 +1141,51 @@ mod demo_tests { use super::*; #[test] - fn demo_house_kick_is_four_on_the_floor() { + fn demo_project_has_10_patterns() { let proj = demo_project(); - let steps = hex_to_steps(&proj.patterns[0].steps[0]); // Kick - assert!(steps[0]); // beat 1 - assert!(steps[4]); // beat 2 - assert!(steps[8]); // beat 3 - assert!(steps[12]); // beat 4 + assert_eq!(proj.patterns.len(), 10); } #[test] - fn demo_project_has_10_patterns() { + fn demo_patterns_have_bpm() { let proj = demo_project(); - assert_eq!(proj.patterns.len(), 10); + assert!((proj.patterns[0].bpm - 138.0).abs() < 0.01); // Acid Techno + assert!((proj.patterns[6].bpm - 174.0).abs() < 0.01); // D&B + assert!((proj.patterns[9].bpm - 90.0).abs() < 0.01); // Ambient } #[test] - fn demo_patterns_have_bpm() { + fn demo_patterns_have_nonempty_steps() { let proj = demo_project(); - assert!((proj.patterns[0].bpm - 125.0).abs() < 0.01); // House - assert!((proj.patterns[7].bpm - 170.0).abs() < 0.01); // D&B + for (i, pat) in proj.patterns.iter().enumerate() { + let has_notes = pat.steps.iter().any(|s| s != "00000000"); + assert!(has_notes, "Pattern {} ({}) has no steps", i, pat.name); + } + } + + #[test] + fn demo_synth_kits_have_presets() { + let proj = demo_project(); + assert_eq!(proj.synth_kits.len(), NUM_KITS); + assert_eq!(proj.synth_b_kits.len(), NUM_KITS); + // Verify kits have named presets (not default names) + assert_eq!(proj.synth_kits[0].name, "Wobble Bass"); + assert_eq!(proj.synth_b_kits[0].name, "Screamer"); + } + + #[test] + fn demo_synth_patterns_have_notes() { + let proj = demo_project(); + assert_eq!(proj.synth_patterns.len(), NUM_PATTERNS); + assert_eq!(proj.synth_b_patterns.len(), NUM_PATTERNS); + for (i, pat) in proj.synth_patterns.iter().enumerate() { + let has_notes = pat.steps.iter().any(|s| s.active); + assert!(has_notes, "Synth A pattern {} ({}) has no notes", i, pat.name); + } + for (i, pat) in proj.synth_b_patterns.iter().enumerate() { + let has_notes = pat.steps.iter().any(|s| s.active); + assert!(has_notes, "Synth B pattern {} ({}) has no notes", i, pat.name); + } } #[test] @@ -1169,8 +1194,9 @@ mod demo_tests { let json = serde_json::to_string(&proj).unwrap(); let loaded: ProjectFile = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.patterns.len(), 10); - assert_eq!(loaded.patterns[0].name, "House"); - assert!((loaded.patterns[0].bpm - 125.0).abs() < 0.01); + assert_eq!(loaded.patterns[0].name, "Acid Techno 138"); + assert!((loaded.patterns[0].bpm - 138.0).abs() < 0.01); + assert_eq!(loaded.synth_kits[0].name, "Wobble Bass"); } /// Render all genre kit voices to WAV files for auditioning. From a692f870c18b027526b8e2cd76fd8fc9e4b65c28 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 15:04:46 +0100 Subject: [PATCH 07/15] docs: mark demo song spec as implemented Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/specs/2026-03-12-demo-song-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-03-12-demo-song-design.md b/docs/superpowers/specs/2026-03-12-demo-song-design.md index 8cd8622..473325d 100644 --- a/docs/superpowers/specs/2026-03-12-demo-song-design.md +++ b/docs/superpowers/specs/2026-03-12-demo-song-design.md @@ -1,7 +1,7 @@ # Demo Song & Per-Pattern BPM **Date:** 2026-03-12 -**Status:** Approved +**Status:** Implemented ## Overview From 4c49d04c06fcd076620486f5dbf8b1f08c8e4e58 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 15:09:47 +0100 Subject: [PATCH 08/15] fix: load synth patterns and kits from project at startup Previously App::new() ignored the project's synth patterns and loaded hardcoded "Techno 2" / "Four on the Floor" presets. Now it loads patterns and kits from the demo project for both Synth A and B, and sends Synth B state to the audio thread on startup. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/app.rs b/src/app.rs index 85fae55..7c2345f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -635,48 +635,27 @@ impl App { } } + // Load synth patterns and kits from project let mut synth_a_pattern = SynthPattern::default(); + project.load_synth_pattern(0, &mut synth_a_pattern); + project.load_synth_kit(0, &mut synth_a_pattern); - // Load startup presets: "Four on the Floor" drum + "Techno 2" synth - { - use crate::presets::pattern_presets::PATTERN_PRESETS; - use crate::presets::synth_pattern_presets::SYNTH_PATTERN_PRESETS; - use crate::sequencer::project::hex_to_steps; - - if let Some(preset) = PATTERN_PRESETS.iter().find(|p| p.name == "Four on the Floor") { - for (track, hex) in preset.steps.iter().enumerate() { - let src = hex_to_steps(hex); - // Find effective source length, rounded to 8-step boundary - let src_len = src.iter().rposition(|&s| s).map_or(16, |i| { - ((i / 8) + 1) * 8 - }).min(crate::sequencer::drum_pattern::MAX_STEPS); - // Tile to fill all 32 steps - for s in 0..crate::sequencer::drum_pattern::MAX_STEPS { - drum_pattern.steps[track][s] = src[s % src_len]; - } - } - } - - if let Some(preset) = SYNTH_PATTERN_PRESETS.iter().find(|p| p.name == "Techno 2") { - for (i, &(note, vel, len)) in preset.steps.iter().enumerate() { - synth_a_pattern.steps[i].note = note; - synth_a_pattern.steps[i].velocity = vel; - synth_a_pattern.steps[i].length = len; - } - } - } + let mut synth_b_pattern = SynthPattern::default(); + project.load_synth_b_pattern(0, &mut synth_b_pattern); + project.load_synth_b_kit(0, &mut synth_b_pattern); // Send initial state to audio thread so it has the pattern from the start let _ = tx.send(UiToAudio::SetTransport(transport)); let _ = tx.send(UiToAudio::SetDrumPattern(drum_pattern.clone())); let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::A, synth_a_pattern.clone())); + let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::B, synth_b_pattern.clone())); Self { ui: UiState::default(), transport, drum_pattern, synth_a_pattern, - synth_b_pattern: SynthPattern::default(), + synth_b_pattern, effect_params: EffectParams::default(), project, project_path: None, From 9c3e346c6a327b0a219b93814bd59553a06bb65d Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 12 Mar 2026 15:20:04 +0100 Subject: [PATCH 09/15] fix: synth B pattern/kit switching + 32-step default + no loop - Fix switch_synth_pattern_for(B): load pattern from project instead of resetting to empty default - Fix switch_synth_kit_for(B): load kit from project - Add queued synth B pattern activation at loop boundary - Set demo project loop_length to 32 (was 16) - Set synth_b_length default to 32 (was 16) Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 15 +++++++++++++-- src/sequencer/project.rs | 2 +- src/sequencer/transport.rs | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7c2345f..2f0369f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -733,6 +733,13 @@ impl App { } } + // Check for queued synth B pattern switch at loop wrap + if synth_b_step == 0 && global_step > 0 { + if let Some(next) = self.ui.synth_b.queued_pattern.take() { + self.switch_synth_pattern_for(SynthId::B, next); + } + } + // Flash triggered tracks for track in 0..NUM_DRUM_TRACKS { if triggered & (1 << track) != 0 { @@ -970,9 +977,11 @@ impl App { self.send_synth_pattern(SynthId::A); } SynthId::B => { - // For synth B, use synth_b_pattern (project B storage is a future task) + self.project.save_synth_b_pattern(self.ui.synth_b.active_pattern, &self.synth_b_pattern); self.ui.synth_b.active_pattern = index; + self.project.active_synth_b_pattern = index; self.synth_b_pattern = SynthPattern::default(); + self.project.load_synth_b_pattern(index, &mut self.synth_b_pattern); self.send_synth_pattern(SynthId::B); } } @@ -1004,8 +1013,10 @@ impl App { self.send_synth_pattern(SynthId::A); } SynthId::B => { - // For synth B, just update UI state (project B storage is a future task) + self.project.save_synth_b_kit(self.ui.synth_b.active_kit, &self.synth_b_pattern.params); self.ui.synth_b.active_kit = index; + self.project.active_synth_b_kit = index; + self.project.load_synth_b_kit(index, &mut self.synth_b_pattern); self.send_synth_pattern(SynthId::B); } } diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 3045441..510f683 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -630,7 +630,7 @@ pub fn demo_project() -> ProjectFile { patterns, active_pattern: 0, bpm: 138.0, - loop_length: 16, + loop_length: 32, swing: 0.50, effects: EffectParams::default(), synth_kits, diff --git a/src/sequencer/transport.rs b/src/sequencer/transport.rs index 1210148..0739126 100644 --- a/src/sequencer/transport.rs +++ b/src/sequencer/transport.rs @@ -27,7 +27,7 @@ pub struct LoopConfig { pub synth_b_length: u8, // 8, 16, 24, or 32 } -fn default_synth_b_length() -> u8 { 16 } +fn default_synth_b_length() -> u8 { 32 } impl Default for LoopConfig { fn default() -> Self { @@ -35,7 +35,7 @@ impl Default for LoopConfig { enabled: false, drum_length: 32, synth_a_length: 32, - synth_b_length: 16, + synth_b_length: 32, } } } From 0c0074c3a27139597d87c2b5bed2064961de5ffd Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 16 Mar 2026 11:04:05 +0100 Subject: [PATCH 10/15] feat: add Scene struct, NUM_SCENES, and serialization --- src/sequencer/project.rs | 104 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 510f683..906c9c8 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -18,6 +18,7 @@ use super::synth_pattern::{ pub const NUM_PATTERNS: usize = 10; pub const NUM_KITS: usize = 8; +pub const NUM_SCENES: usize = 16; // ── Serializable sound params (no mute/solo) ─────────────────────────────── @@ -369,6 +370,27 @@ impl SynthPatternData { // ── Project ───────────────────────────────────────────────────────────────── +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Scene { + pub name: String, + #[serde(default)] + pub drum_pattern: usize, + #[serde(default)] + pub drum_kit: usize, + #[serde(default)] + pub synth_a_pattern: usize, + #[serde(default)] + pub synth_a_kit: usize, + #[serde(default)] + pub synth_b_pattern: usize, + #[serde(default)] + pub synth_b_kit: usize, + #[serde(default = "default_bpm")] + pub bpm: f64, + #[serde(default = "default_swing")] + pub swing: f32, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProjectFile { pub textstep: FileHeader, @@ -409,6 +431,8 @@ pub struct ProjectFile { pub synth_b_patterns: Vec, #[serde(default)] pub active_synth_b_pattern: usize, + #[serde(default)] + pub scenes: Vec>, } fn default_bpm() -> f64 { 120.0 } @@ -500,6 +524,7 @@ impl Default for ProjectFile { active_synth_b_kit: 0, synth_b_patterns, active_synth_b_pattern: 0, + scenes: Vec::new(), } } } @@ -641,6 +666,7 @@ pub fn demo_project() -> ProjectFile { active_synth_b_kit: 0, synth_b_patterns, active_synth_b_pattern: 0, + scenes: Vec::new(), } } @@ -855,6 +881,18 @@ impl ProjectFile { if self.active_synth_b_pattern >= self.synth_b_patterns.len() { self.active_synth_b_pattern = 0; } + + // Normalize scenes + for scene in &mut self.scenes { + if let Some(s) = scene { + if s.drum_pattern >= NUM_PATTERNS { s.drum_pattern = 0; } + if s.drum_kit >= NUM_KITS { s.drum_kit = 0; } + if s.synth_a_pattern >= NUM_PATTERNS { s.synth_a_pattern = 0; } + if s.synth_a_kit >= NUM_KITS { s.synth_a_kit = 0; } + if s.synth_b_pattern >= NUM_PATTERNS { s.synth_b_pattern = 0; } + if s.synth_b_kit >= NUM_KITS { s.synth_b_kit = 0; } + } + } } } @@ -1281,4 +1319,70 @@ mod demo_tests { } println!("Done! Files in: {}/", output_dir.display()); } + + #[test] + fn scene_serializes_roundtrip() { + let scene = Scene { + name: "Test Scene".to_string(), + drum_pattern: 2, + drum_kit: 3, + synth_a_pattern: 4, + synth_a_kit: 1, + synth_b_pattern: 5, + synth_b_kit: 6, + bpm: 130.0, + swing: 0.55, + }; + let json = serde_json::to_string(&scene).unwrap(); + let loaded: Scene = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.name, "Test Scene"); + assert_eq!(loaded.drum_pattern, 2); + assert_eq!(loaded.drum_kit, 3); + assert_eq!(loaded.synth_a_pattern, 4); + assert_eq!(loaded.synth_a_kit, 1); + assert_eq!(loaded.synth_b_pattern, 5); + assert_eq!(loaded.synth_b_kit, 6); + assert!((loaded.bpm - 130.0).abs() < 0.01); + assert!((loaded.swing - 0.55).abs() < 0.01); + } + + #[test] + fn old_project_without_scenes_loads() { + let json = r#"{ + "textstep": {"format_version": 1}, + "kit": {"tracks": []}, + "kits": [], + "active_kit": 0, + "patterns": [], + "active_pattern": 0, + "active_synth_kit": 0, + "active_synth_pattern": 0 + }"#; + let project: ProjectFile = serde_json::from_str(json).unwrap(); + assert!(project.scenes.is_empty()); + } + + #[test] + fn normalize_clamps_scene_indices() { + let mut project = ProjectFile::default(); + project.scenes = vec![Some(Scene { + name: "Bad".to_string(), + drum_pattern: 99, + drum_kit: 99, + synth_a_pattern: 99, + synth_a_kit: 99, + synth_b_pattern: 99, + synth_b_kit: 99, + bpm: 130.0, + swing: 0.5, + })]; + project.normalize(); + let s = project.scenes[0].as_ref().unwrap(); + assert!(s.drum_pattern < NUM_PATTERNS); + assert!(s.drum_kit < NUM_KITS); + assert!(s.synth_a_pattern < NUM_PATTERNS); + assert!(s.synth_a_kit < NUM_KITS); + assert!(s.synth_b_pattern < NUM_PATTERNS); + assert!(s.synth_b_kit < NUM_KITS); + } } From 55078b39535d59ec93a64018acf6100b1728ef6b Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 16 Mar 2026 11:17:19 +0100 Subject: [PATCH 11/15] feat: add SceneBrowserState, queued_scene, and scene methods - Add SceneBrowserState struct and SceneBrowser modal variant - Add RenameScene(usize) to ModalAction enum - Add queued_scene field to UiState with None default - Implement scene methods: open_scene_browser, save_scene, apply_scene_immediate, queue_scene, delete_scene, rename_scene - Wire queued_scene into StepAdvance handler (applies before individual pattern switches) - Add stub handlers in keys.rs and ui/mod.rs for compilation (proper impl in Tasks 4-5) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/keys.rs | 8 +++++ src/ui/mod.rs | 4 +++ 3 files changed, 100 insertions(+) diff --git a/src/app.rs b/src/app.rs index 2f0369f..c1acea6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -331,6 +331,12 @@ impl SplashState { } } +/// Scene browser state. +#[derive(Clone, Debug, PartialEq)] +pub struct SceneBrowserState { + pub selected: usize, +} + /// Modal overlay state for save/load dialogs. #[derive(Clone, Debug, PartialEq)] pub enum ModalState { @@ -352,6 +358,8 @@ pub enum ModalState { PresetBrowser(crate::presets::PresetBrowserState), /// Pattern preset browser PatternBrowser(crate::presets::PatternBrowserState), + /// Scene browser + SceneBrowser(SceneBrowserState), } #[derive(Clone, Debug, PartialEq)] @@ -361,6 +369,7 @@ pub enum ModalAction { SaveKit, LoadProject, LoadKit, + RenameScene(usize), } // ── Mouse State ────────────────────────────────────────────────────────────── @@ -535,6 +544,8 @@ pub struct UiState { pub active_pattern: usize, /// Queued pattern to switch to at end of loop (None = no change pending) pub queued_pattern: Option, + /// Queued scene to apply at drum loop wrap (None = no scene pending) + pub queued_scene: Option, /// Current active kit index (0-7) pub active_kit: usize, /// Modal overlay @@ -587,6 +598,7 @@ impl Default for UiState { trigger_flash: [0; NUM_DRUM_TRACKS], active_pattern: 0, queued_pattern: None, + queued_scene: None, active_kit: 0, modal: ModalState::None, status_msg: None, @@ -721,6 +733,9 @@ impl App { // Check for queued drum pattern switch at loop wrap (step 0) if drum_step == 0 && global_step > 0 { + if let Some(scene_idx) = self.ui.queued_scene.take() { + self.apply_scene_immediate(scene_idx); + } if let Some(next) = self.ui.queued_pattern.take() { self.switch_pattern(next); } @@ -892,6 +907,79 @@ impl App { self.project.active_synth_kit = self.ui.synth_a.active_kit; } + // ── Scene management ──────────────────────────────────────────────── + + /// Open the scene browser modal. + pub fn open_scene_browser(&mut self) { + self.ui.modal = ModalState::SceneBrowser(SceneBrowserState { selected: 0 }); + } + + /// Save the current state as a scene in the given slot. + pub fn save_scene(&mut self, slot: usize) { + if slot >= project::NUM_SCENES { return; } + let scene = project::Scene { + name: format!("Scene {}", slot + 1), + drum_pattern: self.ui.active_pattern, + drum_kit: self.ui.active_kit, + synth_a_pattern: self.ui.synth_a.active_pattern, + synth_a_kit: self.ui.synth_a.active_kit, + synth_b_pattern: self.ui.synth_b.active_pattern, + synth_b_kit: self.ui.synth_b.active_kit, + bpm: self.transport.bpm, + swing: self.transport.swing, + }; + // Extend scenes vec if needed + while self.project.scenes.len() <= slot { + self.project.scenes.push(None); + } + self.project.scenes[slot] = Some(scene); + } + + /// Apply a scene immediately (switch all patterns/kits/bpm/swing). + pub fn apply_scene_immediate(&mut self, slot: usize) { + let scene = match self.project.scenes.get(slot) { + Some(Some(s)) => s.clone(), + _ => return, + }; + self.store_current_to_project(); + self.switch_pattern(scene.drum_pattern); + self.switch_kit(scene.drum_kit); + self.switch_synth_pattern(scene.synth_a_pattern); + self.switch_synth_kit(scene.synth_a_kit); + self.switch_synth_pattern_for(SynthId::B, scene.synth_b_pattern); + self.switch_synth_kit_for(SynthId::B, scene.synth_b_kit); + self.transport.bpm = scene.bpm; + self.transport.swing = scene.swing; + self.send_transport(); + } + + /// Queue a scene to apply at drum loop wrap. Clears individual queued patterns/kits. + pub fn queue_scene(&mut self, slot: usize) { + if matches!(self.project.scenes.get(slot), Some(Some(_))) { + self.ui.queued_scene = Some(slot); + // Clear individual queued changes + self.ui.queued_pattern = None; + self.ui.synth_a.queued_pattern = None; + self.ui.synth_b.queued_pattern = None; + } + } + + /// Delete a scene from the given slot. + pub fn delete_scene(&mut self, slot: usize) { + if let Some(entry) = self.project.scenes.get_mut(slot) { + *entry = None; + } + } + + /// Rename a scene in the given slot. + pub fn rename_scene(&mut self, slot: usize, name: &str) { + if let Some(Some(scene)) = self.project.scenes.get_mut(slot) { + scene.name = name.to_string(); + } + } + + // ── Pattern management (continued) ────────────────────────────────── + /// Switch to a different pattern immediately. pub fn switch_pattern(&mut self, index: usize) { if index >= NUM_PATTERNS { return; } diff --git a/src/keys.rs b/src/keys.rs index e8c171d..203e9a5 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -104,6 +104,14 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { handle_pattern_browser(app, key); return; } + ModalState::SceneBrowser(_) => { + // TODO: Implement in Task 5 + // For now, just allow Esc to close + if key.code == KeyCode::Esc { + app.ui.modal = ModalState::None; + } + return; + } ModalState::None => {} } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 38a4eae..5529236 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -161,6 +161,10 @@ pub fn render(f: &mut Frame, app: &App) { ModalState::PatternBrowser(pb) => { render_pattern_browser(f, size, pb); } + ModalState::SceneBrowser(_browser) => { + // TODO: Implement in Task 4 + // Placeholder stub - proper rendering will be added in Task 4 + } ModalState::None => {} } } From 4b9158a52254e7193f09596f4612952bb9a72197 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 16 Mar 2026 11:19:58 +0100 Subject: [PATCH 12/15] feat: render scene browser modal with theme symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add render_scene_browser() function displaying 14 scenes with pattern/kit indicators - Show scene name (truncated), drum pattern/kit, synth A/B patterns/kits using ♫/◈ symbols - Cyan theme for browser borders, amber for actions, pink for commands - Footer shows nav keys, queue/now actions, save/rename/delete commands - Add scene_truncate_name() helper for name overflow handling - Replace SceneBrowser stub with actual render call to app.project.scenes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/mod.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++++-- src/ui/theme.rs | 4 ++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5529236..57b1618 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -161,9 +161,8 @@ pub fn render(f: &mut Frame, app: &App) { ModalState::PatternBrowser(pb) => { render_pattern_browser(f, size, pb); } - ModalState::SceneBrowser(_browser) => { - // TODO: Implement in Task 4 - // Placeholder stub - proper rendering will be added in Task 4 + ModalState::SceneBrowser(sb) => { + render_scene_browser(f, size, sb, &app.project.scenes); } ModalState::None => {} } @@ -605,3 +604,104 @@ fn render_pattern_browser( let paragraph = Paragraph::new(lines).block(block); f.render_widget(paragraph, popup); } + +/// Render a centered scene browser modal. +fn render_scene_browser( + f: &mut Frame, + area: Rect, + sb: &crate::app::SceneBrowserState, + scenes: &[Option], +) { + use crate::sequencer::project::NUM_SCENES; + + let title = " Scenes "; + let max_items = NUM_SCENES.min(14); + let w = 64u16.min(area.width.saturating_sub(4)); + let h = (max_items as u16 + 5).min(area.height.saturating_sub(2)); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let popup = Rect::new(x, y, w, h); + + f.render_widget(Clear, popup); + + let block = Block::default() + .title(title) + .title_style(Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD)) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::CYAN)); + + let inner_w = (w as usize).saturating_sub(2); + let mut lines: Vec = Vec::new(); + + for i in 0..max_items { + let is_sel = i == sb.selected; + let prefix = if is_sel { "\u{25B6} " } else { " " }; + + let line_text = if let Some(Some(scene)) = scenes.get(i) { + format!( + "{}{:2}. {:<14} DR:{}-{} {}-{} SA:{}-{} {}-{} SB:{}-{} {}-{}", + prefix, + i + 1, + scene_truncate_name(&scene.name, 14), + theme::SCENE_PAT_SYMBOL, + scene.drum_pattern + 1, + theme::SCENE_KIT_SYMBOL, + scene.drum_kit + 1, + theme::SCENE_PAT_SYMBOL, + scene.synth_a_pattern + 1, + theme::SCENE_KIT_SYMBOL, + scene.synth_a_kit + 1, + theme::SCENE_PAT_SYMBOL, + scene.synth_b_pattern + 1, + theme::SCENE_KIT_SYMBOL, + scene.synth_b_kit + 1, + ) + } else { + format!("{}{:2}. (empty)", prefix, i + 1) + }; + + let style = if is_sel { + Style::default().fg(Color::Black).bg(theme::CYAN).add_modifier(Modifier::BOLD) + } else if scenes.get(i).and_then(|s| s.as_ref()).is_some() { + Style::default().fg(theme::TEXT) + } else { + Style::default().fg(theme::DIM_TEXT) + }; + + lines.push(Line::from(Span::styled(line_text, style))); + } + + // Separator + lines.push(Line::from(Span::styled( + "\u{2500}".repeat(inner_w), + Style::default().fg(Color::Rgb(50, 50, 50)), + ))); + + // Footer + lines.push(Line::from(vec![ + Span::styled("\u{2191}\u{2193}", Style::default().fg(theme::CYAN)), + Span::styled(" nav ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("Enter", Style::default().fg(theme::AMBER)), + Span::styled(" queue ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("S+Ret", Style::default().fg(theme::AMBER)), + Span::styled(" now ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("S", Style::default().fg(theme::PINK)), + Span::styled(" save ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("R", Style::default().fg(theme::PINK)), + Span::styled(" ren ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("D", Style::default().fg(theme::PINK)), + Span::styled(" del", Style::default().fg(theme::DIM_TEXT)), + ])); + + let paragraph = Paragraph::new(lines).block(block); + f.render_widget(paragraph, popup); +} + +/// Truncate a name to max_len characters, adding ".." if truncated. +fn scene_truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}..", &name[..max_len - 2]) + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 3db09c8..e48a391 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -50,6 +50,10 @@ pub const GAUGE_EMPTY: &str = "\u{2591}"; // ░ pub const STEP_ACTIVE: &str = "\u{25A0}"; // ■ pub const STEP_INACTIVE: &str = "\u{25A1}"; // □ +// ── Scene symbols ──────────────────────────────────────────── +pub const SCENE_PAT_SYMBOL: &str = "\u{266B}"; // ♫ +pub const SCENE_KIT_SYMBOL: &str = "\u{25C8}"; // ◈ + /// Style for a focused section border. pub fn focus_border_style(focused: bool) -> Style { if focused { From 1feb9226bfea6dc114e0b873c46a2a94c449bb10 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 16 Mar 2026 11:29:00 +0100 Subject: [PATCH 13/15] feat: keyboard handling for scene browser (Ctrl+E) - Add Ctrl+E global keybinding to open scene browser - Implement full scene browser keyboard navigation: - Up/Down: navigate scenes - Enter: queue scene (Shift+Enter: immediate switch) - S: save current state to selected scene - D: delete selected scene - R: rename selected scene (opens text input modal) - Esc: close browser - Add ModalAction::RenameScene handler in text input modal - Follow existing modal pattern with handle_scene_browser() helper Co-Authored-By: Claude Opus 4.6 (1M context) --- src/keys.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/src/keys.rs b/src/keys.rs index 203e9a5..3e9dba4 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -105,11 +105,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } ModalState::SceneBrowser(_) => { - // TODO: Implement in Task 5 - // For now, just allow Esc to close - if key.code == KeyCode::Esc { - app.ui.modal = ModalState::None; - } + handle_scene_browser(app, key); return; } ModalState::None => {} @@ -169,6 +165,12 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } + // Scene browser + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.open_scene_browser(); + return; + } + // Sound preset browser KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.open_preset_browser(); @@ -664,6 +666,12 @@ fn handle_text_input(app: &mut App, key: KeyEvent) { app.show_status(format!("Pattern renamed: {}", name)); } } + ModalAction::RenameScene(slot) => { + if !name.is_empty() { + app.rename_scene(slot, &name); + app.show_status(format!("Scene renamed: {}", name)); + } + } ModalAction::SaveKit => { if !name.is_empty() { app.save_kit_with_name(&name); @@ -1262,6 +1270,67 @@ fn handle_pattern_browser(app: &mut App, key: KeyEvent) { } } +fn handle_scene_browser(app: &mut App, key: KeyEvent) { + let browser = if let ModalState::SceneBrowser(ref mut b) = app.ui.modal { + b + } else { + return; + }; + + match key.code { + KeyCode::Esc => { + app.ui.modal = ModalState::None; + } + KeyCode::Up => { + if browser.selected > 0 { + browser.selected -= 1; + } + } + KeyCode::Down => { + let max = crate::sequencer::project::NUM_SCENES.min(14) - 1; + if browser.selected < max { + browser.selected += 1; + } + } + KeyCode::Enter => { + let idx = browser.selected; + if key.modifiers.contains(KeyModifiers::SHIFT) { + // Immediate switch + app.apply_scene_immediate(idx); + app.show_status(format!("Scene {} applied", idx + 1)); + } else { + // Queue switch + app.queue_scene(idx); + app.show_status(format!("Scene {} queued", idx + 1)); + } + app.ui.modal = ModalState::None; + } + KeyCode::Char('s') | KeyCode::Char('S') => { + let idx = browser.selected; + app.save_scene(idx); + app.show_status(format!("Scene {} saved", idx + 1)); + } + KeyCode::Char('d') | KeyCode::Char('D') => { + let idx = browser.selected; + app.delete_scene(idx); + app.show_status(format!("Scene {} deleted", idx + 1)); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + let idx = browser.selected; + let current_name = app.project.scenes.get(idx) + .and_then(|s| s.as_ref()) + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Scene {}", idx + 1)); + app.ui.modal = ModalState::TextInput { + prompt: format!("Rename scene {}:", idx + 1), + buffer: current_name, + on_confirm: ModalAction::RenameScene(idx), + }; + } + _ => {} + } +} + fn preview_preset(app: &mut App) { use crate::sequencer::transport::PlayState; From 42765106f571c211795eb795bac575d46f7d0320 Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 16 Mar 2026 11:31:22 +0100 Subject: [PATCH 14/15] feat: populate demo project with 10 genre scenes Add 10 diverse genre scenes to demo_project(): Acid Techno, Classic House, Deep House, Driving Techno, Lo-Fi Hip Hop, Trance, Drum & Bass, Electro Funk, Dub Techno, and Ambient. Each scene references appropriate pattern/kit indices and BPM settings. Added test to verify demo project contains at least 5 populated scenes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/sequencer/project.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 906c9c8..00fd30a 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -666,7 +666,18 @@ pub fn demo_project() -> ProjectFile { active_synth_b_kit: 0, synth_b_patterns, active_synth_b_pattern: 0, - scenes: Vec::new(), + scenes: vec![ + Some(Scene { name: "Acid Techno".into(), drum_pattern: 0, drum_kit: 0, synth_a_pattern: 0, synth_a_kit: 0, synth_b_pattern: 0, synth_b_kit: 0, bpm: 138.0, swing: 0.50 }), + Some(Scene { name: "Classic House".into(), drum_pattern: 1, drum_kit: 3, synth_a_pattern: 1, synth_a_kit: 1, synth_b_pattern: 1, synth_b_kit: 1, bpm: 122.0, swing: 0.50 }), + Some(Scene { name: "Deep House".into(), drum_pattern: 2, drum_kit: 3, synth_a_pattern: 2, synth_a_kit: 2, synth_b_pattern: 2, synth_b_kit: 2, bpm: 120.0, swing: 0.50 }), + Some(Scene { name: "Driving Techno".into(),drum_pattern: 3, drum_kit: 2, synth_a_pattern: 3, synth_a_kit: 3, synth_b_pattern: 3, synth_b_kit: 3, bpm: 130.0, swing: 0.50 }), + Some(Scene { name: "Lo-Fi Hip Hop".into(), drum_pattern: 4, drum_kit: 5, synth_a_pattern: 4, synth_a_kit: 4, synth_b_pattern: 4, synth_b_kit: 4, bpm: 85.0, swing: 0.50 }), + Some(Scene { name: "Trance".into(), drum_pattern: 5, drum_kit: 1, synth_a_pattern: 5, synth_a_kit: 5, synth_b_pattern: 5, synth_b_kit: 5, bpm: 140.0, swing: 0.50 }), + Some(Scene { name: "Drum & Bass".into(), drum_pattern: 6, drum_kit: 6, synth_a_pattern: 6, synth_a_kit: 6, synth_b_pattern: 6, synth_b_kit: 6, bpm: 174.0, swing: 0.50 }), + Some(Scene { name: "Electro Funk".into(), drum_pattern: 7, drum_kit: 6, synth_a_pattern: 7, synth_a_kit: 7, synth_b_pattern: 7, synth_b_kit: 7, bpm: 128.0, swing: 0.50 }), + Some(Scene { name: "Dub Techno".into(), drum_pattern: 8, drum_kit: 2, synth_a_pattern: 8, synth_a_kit: 4, synth_b_pattern: 8, synth_b_kit: 2, bpm: 118.0, swing: 0.50 }), + Some(Scene { name: "Ambient".into(), drum_pattern: 9, drum_kit: 7, synth_a_pattern: 9, synth_a_kit: 5, synth_b_pattern: 9, synth_b_kit: 6, bpm: 90.0, swing: 0.50 }), + ], } } @@ -1172,6 +1183,14 @@ mod tests { assert_eq!(project.metadata.name, "Old Project"); assert_eq!(project.synth_patterns[0].name, "Synth 1"); } + + #[test] + fn demo_project_has_scenes() { + let proj = demo_project(); + assert!(!proj.scenes.is_empty()); + let populated = proj.scenes.iter().filter(|s| s.is_some()).count(); + assert!(populated >= 5, "Expected at least 5 demo scenes, got {}", populated); + } } #[cfg(test)] From 2c80b455828ffef4f0e64493d57859d8aecf95ef Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 16 Mar 2026 12:15:35 +0100 Subject: [PATCH 15/15] fix: scene browser bugs, 32-step patterns, panel defaults, version bump - Scene modal stays open on Enter/! (no auto-close) - Use ! for immediate scene switch (Shift+Enter unreliable in terminals) - Footer labels: "Return" queue, "!" now - Mirror 16-step drum presets to fill all 32 steps - Default panel visibility: both synth knobs collapsed, all grids visible - Load first scene at startup (bonza as scene 1) - Bump version to 1.3.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 2 +- docs/superpowers/plans/2026-03-16-scenes.md | 745 ++++++++++++++++++ .../specs/2026-03-16-scenes-design.md | 110 +++ src/app.rs | 54 +- src/keys.rs | 17 +- src/sequencer/project.rs | 13 +- src/ui/layout.rs | 8 +- src/ui/mod.rs | 4 +- 8 files changed, 918 insertions(+), 35 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-16-scenes.md create mode 100644 docs/superpowers/specs/2026-03-16-scenes-design.md diff --git a/Cargo.toml b/Cargo.toml index ede3c4b..4c93f19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textstep" -version = "1.2.5" +version = "1.3.0" edition = "2024" [dependencies] diff --git a/docs/superpowers/plans/2026-03-16-scenes.md b/docs/superpowers/plans/2026-03-16-scenes.md new file mode 100644 index 0000000..fb1e56d --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-scenes.md @@ -0,0 +1,745 @@ +# Scenes Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add scene snapshots (pattern/kit index combos) with a modal browser for save/recall/rename/delete. + +**Architecture:** Scene is a lightweight struct of 6 indices + BPM + swing, stored in ProjectFile. A new ModalState::SceneBrowser variant drives a keyboard-navigable popup. Queued scene switching triggers on drum pattern loop wrap; immediate switching reuses existing switch_* methods. + +**Tech Stack:** Rust, ratatui, serde + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/sequencer/project.rs` | Modify | `Scene` struct, `NUM_SCENES`, `scenes` field on `ProjectFile`, `normalize()`, demo scenes | +| `src/app.rs` | Modify | `SceneBrowserState`, `ModalState::SceneBrowser`, `queued_scene`, scene methods on `App` | +| `src/keys.rs` | Modify | Ctrl+E opens modal, keyboard handling inside SceneBrowser modal | +| `src/ui/mod.rs` | Modify | `render_scene_browser()` function | +| `src/ui/theme.rs` | Modify | `SCENE_PAT_SYMBOL` and `SCENE_KIT_SYMBOL` constants | + +--- + +### Task 1: Scene Data Model + Serialization + +**Files:** +- Modify: `src/sequencer/project.rs` + +- [ ] **Step 1: Write failing test for Scene serialization round-trip** + +In the `#[cfg(test)]` module at the bottom of `project.rs`, add: + +```rust +#[test] +fn scene_serializes_roundtrip() { + let scene = Scene { + name: "Test Scene".to_string(), + drum_pattern: 2, + drum_kit: 3, + synth_a_pattern: 4, + synth_a_kit: 1, + synth_b_pattern: 5, + synth_b_kit: 6, + bpm: 130.0, + swing: 0.55, + }; + let json = serde_json::to_string(&scene).unwrap(); + let loaded: Scene = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.name, "Test Scene"); + assert_eq!(loaded.drum_pattern, 2); + assert_eq!(loaded.drum_kit, 3); + assert_eq!(loaded.synth_a_pattern, 4); + assert_eq!(loaded.synth_a_kit, 1); + assert_eq!(loaded.synth_b_pattern, 5); + assert_eq!(loaded.synth_b_kit, 6); + assert!((loaded.bpm - 130.0).abs() < 0.01); + assert!((loaded.swing - 0.55).abs() < 0.01); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test scene_serializes_roundtrip` +Expected: FAIL — `Scene` type does not exist yet. + +- [ ] **Step 3: Add Scene struct, NUM_SCENES, and scenes field** + +At the top of `project.rs`, after the existing constants (`NUM_PATTERNS`, `NUM_KITS`): + +```rust +pub const NUM_SCENES: usize = 16; +``` + +After the `SynthKitData` struct block (before the `ProjectFile` struct), add: + +```rust +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Scene { + pub name: String, + #[serde(default)] + pub drum_pattern: usize, + #[serde(default)] + pub drum_kit: usize, + #[serde(default)] + pub synth_a_pattern: usize, + #[serde(default)] + pub synth_a_kit: usize, + #[serde(default)] + pub synth_b_pattern: usize, + #[serde(default)] + pub synth_b_kit: usize, + #[serde(default = "default_bpm")] + pub bpm: f64, + #[serde(default = "default_swing")] + pub swing: f32, +} +``` + +Add `scenes` field to `ProjectFile`: + +```rust + #[serde(default)] + pub scenes: Vec>, +``` + +Add the field to `Default for ProjectFile` (after `active_synth_b_pattern: 0`): + +```rust + scenes: Vec::new(), +``` + +Add the field to `demo_project()` return — will be populated in Task 5. + +```rust + scenes: Vec::new(), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test scene_serializes_roundtrip` +Expected: PASS + +- [ ] **Step 5: Write failing test for backward compat (old files without scenes)** + +```rust +#[test] +fn old_project_without_scenes_loads() { + let json = r#"{ + "textstep": {"format_version": 1}, + "kit": {"tracks": []}, + "kits": [], + "active_kit": 0, + "patterns": [], + "active_pattern": 0, + "active_synth_kit": 0, + "active_synth_pattern": 0 + }"#; + let project: ProjectFile = serde_json::from_str(json).unwrap(); + assert!(project.scenes.is_empty()); +} +``` + +- [ ] **Step 6: Run test — should pass already due to #[serde(default)]** + +Run: `cargo test old_project_without_scenes_loads` +Expected: PASS (serde default gives empty vec) + +- [ ] **Step 7: Write failing test for normalize() clamping scene indices** + +```rust +#[test] +fn normalize_clamps_scene_indices() { + let mut project = ProjectFile::default(); + project.scenes = vec![Some(Scene { + name: "Bad".to_string(), + drum_pattern: 99, + drum_kit: 99, + synth_a_pattern: 99, + synth_a_kit: 99, + synth_b_pattern: 99, + synth_b_kit: 99, + bpm: 130.0, + swing: 0.5, + })]; + project.normalize(); + let s = project.scenes[0].as_ref().unwrap(); + assert!(s.drum_pattern < NUM_PATTERNS); + assert!(s.drum_kit < NUM_KITS); + assert!(s.synth_a_pattern < NUM_PATTERNS); + assert!(s.synth_a_kit < NUM_KITS); + assert!(s.synth_b_pattern < NUM_PATTERNS); + assert!(s.synth_b_kit < NUM_KITS); +} +``` + +- [ ] **Step 8: Run test to verify it fails** + +Run: `cargo test normalize_clamps_scene_indices` +Expected: FAIL — normalize() does not handle scenes yet. + +- [ ] **Step 9: Add scene normalization to normalize()** + +At the end of the `normalize()` method (after the synth_b blocks), add: + +```rust + // Normalize scenes + for scene in &mut self.scenes { + if let Some(s) = scene { + if s.drum_pattern >= NUM_PATTERNS { s.drum_pattern = 0; } + if s.drum_kit >= NUM_KITS { s.drum_kit = 0; } + if s.synth_a_pattern >= NUM_PATTERNS { s.synth_a_pattern = 0; } + if s.synth_a_kit >= NUM_KITS { s.synth_a_kit = 0; } + if s.synth_b_pattern >= NUM_PATTERNS { s.synth_b_pattern = 0; } + if s.synth_b_kit >= NUM_KITS { s.synth_b_kit = 0; } + } + } +``` + +- [ ] **Step 10: Run test to verify it passes** + +Run: `cargo test normalize_clamps_scene_indices` +Expected: PASS + +- [ ] **Step 11: Run full test suite** + +Run: `cargo test` +Expected: All tests pass. + +- [ ] **Step 12: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat: add Scene struct, NUM_SCENES, and serialization" +``` + +--- + +### Task 2: Scene Browser State + Modal Variant + +**Files:** +- Modify: `src/app.rs` + +- [ ] **Step 1: Add SceneBrowserState struct and ModalState variant** + +In `app.rs`, before the `ModalState` enum, add: + +```rust +#[derive(Clone, Debug, PartialEq)] +pub struct SceneBrowserState { + pub selected: usize, +} +``` + +Add a new variant to `ModalState`: + +```rust + /// Scene browser + SceneBrowser(SceneBrowserState), +``` + +- [ ] **Step 2: Add queued_scene field to UiState** + +In the `UiState` struct, after `queued_pattern`: + +```rust + /// Queued scene to apply at drum loop wrap (None = no scene pending) + pub queued_scene: Option, +``` + +In `Default for UiState`, after `queued_pattern: None`: + +```rust + queued_scene: None, +``` + +- [ ] **Step 3: Add scene methods on App** + +After the existing `store_current_to_project()` method, add these methods: + +```rust + /// Open the scene browser modal. + pub fn open_scene_browser(&mut self) { + self.ui.modal = ModalState::SceneBrowser(SceneBrowserState { selected: 0 }); + } + + /// Save the current state as a scene in the given slot. + pub fn save_scene(&mut self, slot: usize) { + if slot >= project::NUM_SCENES { return; } + let scene = project::Scene { + name: format!("Scene {}", slot + 1), + drum_pattern: self.ui.active_pattern, + drum_kit: self.ui.active_kit, + synth_a_pattern: self.ui.synth_a.active_pattern, + synth_a_kit: self.ui.synth_a.active_kit, + synth_b_pattern: self.ui.synth_b.active_pattern, + synth_b_kit: self.ui.synth_b.active_kit, + bpm: self.transport.bpm, + swing: self.transport.swing, + }; + // Extend scenes vec if needed + while self.project.scenes.len() <= slot { + self.project.scenes.push(None); + } + self.project.scenes[slot] = Some(scene); + } + + /// Apply a scene immediately (switch all patterns/kits/bpm/swing). + pub fn apply_scene_immediate(&mut self, slot: usize) { + let scene = match self.project.scenes.get(slot) { + Some(Some(s)) => s.clone(), + _ => return, + }; + self.store_current_to_project(); + self.switch_pattern(scene.drum_pattern); + self.switch_kit(scene.drum_kit); + self.switch_synth_pattern(scene.synth_a_pattern); + self.switch_synth_kit(scene.synth_a_kit); + self.switch_synth_pattern_for(SynthId::B, scene.synth_b_pattern); + self.switch_synth_kit_for(SynthId::B, scene.synth_b_kit); + self.transport.bpm = scene.bpm; + self.transport.swing = scene.swing; + self.send_transport(); + } + + /// Queue a scene to apply at drum loop wrap. Clears individual queued patterns/kits. + pub fn queue_scene(&mut self, slot: usize) { + if matches!(self.project.scenes.get(slot), Some(Some(_))) { + self.ui.queued_scene = Some(slot); + // Clear individual queued changes + self.ui.queued_pattern = None; + self.ui.synth_a.queued_pattern = None; + self.ui.synth_b.queued_pattern = None; + } + } + + /// Delete a scene from the given slot. + pub fn delete_scene(&mut self, slot: usize) { + if let Some(entry) = self.project.scenes.get_mut(slot) { + *entry = None; + } + } + + /// Rename a scene in the given slot. + pub fn rename_scene(&mut self, slot: usize, name: &str) { + if let Some(Some(scene)) = self.project.scenes.get_mut(slot) { + scene.name = name.to_string(); + } + } +``` + +- [ ] **Step 4: Wire queued scene into the step-advance handler** + +In the `AudioToUi::StepAdvance` handler (around line 722), where `queued_pattern` is checked at drum step 0, add **before** the existing `queued_pattern` check: + +```rust + // Check for queued scene switch at drum loop wrap + if drum_step == 0 && global_step > 0 { + if let Some(scene_idx) = self.ui.queued_scene.take() { + self.apply_scene_immediate(scene_idx); + } + } +``` + +Note: The existing individual `queued_pattern` checks remain — if a scene was queued, `apply_scene_immediate` already ran, and the individual queued fields were cleared by `queue_scene()`, so they will be `None`. + +- [ ] **Step 5: Verify compilation** + +Run: `cargo build` +Expected: Compiles with no errors. + +- [ ] **Step 6: Run full test suite** + +Run: `cargo test` +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/app.rs +git commit -m "feat: add SceneBrowserState, queued_scene, and scene methods" +``` + +--- + +### Task 3: Theme Constants + +**Files:** +- Modify: `src/ui/theme.rs` + +- [ ] **Step 1: Add scene symbols to theme.rs** + +After the step character constants (around line 50), add: + +```rust +// ── Scene symbols ──────────────────────────────────────────── +pub const SCENE_PAT_SYMBOL: &str = "\u{266B}"; // ♫ +pub const SCENE_KIT_SYMBOL: &str = "\u{25C8}"; // ◈ +``` + +- [ ] **Step 2: Verify compilation** + +Run: `cargo build` +Expected: Compiles (dead_code is already allowed in theme.rs). + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/theme.rs +git commit -m "feat: add scene pattern/kit unicode symbols to theme" +``` + +--- + +### Task 4: Scene Browser Modal Rendering + +**Files:** +- Modify: `src/ui/mod.rs` + +- [ ] **Step 1: Add render_scene_browser function** + +After the existing `render_pattern_browser` function, add: + +```rust +/// Render a centered scene browser modal. +fn render_scene_browser( + f: &mut Frame, + area: Rect, + sb: &crate::app::SceneBrowserState, + scenes: &[Option], +) { + use crate::sequencer::project::NUM_SCENES; + + let title = " Scenes "; + let max_items = NUM_SCENES.min(14); + let w = 64u16.min(area.width.saturating_sub(4)); + let h = (max_items as u16 + 5).min(area.height.saturating_sub(2)); + let x = area.x + (area.width.saturating_sub(w)) / 2; + let y = area.y + (area.height.saturating_sub(h)) / 2; + let popup = Rect::new(x, y, w, h); + + f.render_widget(Clear, popup); + + let block = Block::default() + .title(title) + .title_style(Style::default().fg(theme::CYAN).add_modifier(Modifier::BOLD)) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::CYAN)); + + let inner_w = (w as usize).saturating_sub(2); + let mut lines: Vec = Vec::new(); + + for i in 0..max_items { + let is_sel = i == sb.selected; + let prefix = if is_sel { "\u{25B6} " } else { " " }; + + let line_text = if let Some(Some(scene)) = scenes.get(i) { + format!( + "{}{:2}. {:<14} DR:{}-{} {}-{} SA:{}-{} {}-{} SB:{}-{} {}-{}", + prefix, + i + 1, + truncate_name(&scene.name, 14), + theme::SCENE_PAT_SYMBOL, + scene.drum_pattern + 1, + theme::SCENE_KIT_SYMBOL, + scene.drum_kit + 1, + theme::SCENE_PAT_SYMBOL, + scene.synth_a_pattern + 1, + theme::SCENE_KIT_SYMBOL, + scene.synth_a_kit + 1, + theme::SCENE_PAT_SYMBOL, + scene.synth_b_pattern + 1, + theme::SCENE_KIT_SYMBOL, + scene.synth_b_kit + 1, + ) + } else { + format!("{}{:2}. (empty)", prefix, i + 1) + }; + + let style = if is_sel { + Style::default().fg(Color::Black).bg(theme::CYAN).add_modifier(Modifier::BOLD) + } else if scenes.get(i).and_then(|s| s.as_ref()).is_some() { + Style::default().fg(theme::TEXT) + } else { + Style::default().fg(theme::DIM_TEXT) + }; + + lines.push(Line::from(Span::styled(line_text, style))); + } + + // Separator + lines.push(Line::from(Span::styled( + "\u{2500}".repeat(inner_w), + Style::default().fg(Color::Rgb(50, 50, 50)), + ))); + + // Footer + lines.push(Line::from(vec![ + Span::styled("\u{2191}\u{2193}", Style::default().fg(theme::CYAN)), + Span::styled(" nav ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("Enter", Style::default().fg(theme::AMBER)), + Span::styled(" queue ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("Shift+Enter", Style::default().fg(theme::AMBER)), + Span::styled(" now ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("S", Style::default().fg(theme::PINK)), + Span::styled(" save ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("R", Style::default().fg(theme::PINK)), + Span::styled(" rename ", Style::default().fg(theme::DIM_TEXT)), + Span::styled("D", Style::default().fg(theme::PINK)), + Span::styled(" del", Style::default().fg(theme::DIM_TEXT)), + ])); + + let paragraph = Paragraph::new(lines).block(block); + f.render_widget(paragraph, popup); +} + +/// Truncate a name to max_len characters, adding ".." if truncated. +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() <= max_len { + name.to_string() + } else { + format!("{}..", &name[..max_len - 2]) + } +} +``` + +- [ ] **Step 2: Wire render_scene_browser into the modal dispatch** + +In the `render()` function, find where the other modals are matched (the `match &app.ui.modal` block). Add the `SceneBrowser` arm: + +```rust + ModalState::SceneBrowser(ref sb) => { + render_scene_browser(f, area, sb, &app.project.scenes); + } +``` + +- [ ] **Step 3: Verify compilation** + +Run: `cargo build` +Expected: Compiles. If `truncate_name` has a name conflict, rename to `scene_truncate_name`. + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/mod.rs +git commit -m "feat: render scene browser modal" +``` + +--- + +### Task 5: Keyboard Handling + +**Files:** +- Modify: `src/keys.rs` + +- [ ] **Step 1: Add Ctrl+E to open scene browser** + +In the global key handler (the early section that handles Ctrl+S, Ctrl+K, Ctrl+J, Ctrl+L), add: + +```rust + // Scene browser + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.open_scene_browser(); + } +``` + +- [ ] **Step 2: Add SceneBrowser modal key handling** + +In the modal key handling section (where `ModalState::TextInput`, `ModalState::FilePicker`, etc. are matched), add before the closing fallthrough: + +```rust + ModalState::SceneBrowser(ref mut sb) => { + match key.code { + KeyCode::Esc => { + app.ui.modal = ModalState::None; + } + KeyCode::Up => { + if sb.selected > 0 { + sb.selected -= 1; + } + } + KeyCode::Down => { + let max = crate::sequencer::project::NUM_SCENES.min(14) - 1; + if sb.selected < max { + sb.selected += 1; + } + } + KeyCode::Enter => { + let idx = sb.selected; + if key.modifiers.contains(KeyModifiers::SHIFT) { + // Immediate switch + app.apply_scene_immediate(idx); + app.show_status(format!("Scene {} applied", idx + 1)); + } else { + // Queue switch + app.queue_scene(idx); + app.show_status(format!("Scene {} queued", idx + 1)); + } + app.ui.modal = ModalState::None; + } + KeyCode::Char('s') | KeyCode::Char('S') => { + let idx = sb.selected; + app.save_scene(idx); + app.show_status(format!("Scene {} saved", idx + 1)); + } + KeyCode::Char('d') | KeyCode::Char('D') => { + let idx = sb.selected; + app.delete_scene(idx); + app.show_status(format!("Scene {} deleted", idx + 1)); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + let idx = sb.selected; + let current_name = app.project.scenes.get(idx) + .and_then(|s| s.as_ref()) + .map(|s| s.name.clone()) + .unwrap_or_else(|| format!("Scene {}", idx + 1)); + app.ui.modal = ModalState::TextInput { + prompt: format!("Rename scene {}:", idx + 1), + buffer: current_name, + on_confirm: ModalAction::RenameScene(idx), + }; + } + _ => {} + } + return; + } +``` + +- [ ] **Step 3: Add RenameScene variant to ModalAction** + +In `app.rs`, add to the `ModalAction` enum: + +```rust + RenameScene(usize), +``` + +- [ ] **Step 4: Handle RenameScene confirmation** + +In `keys.rs`, in the `ModalAction` match (where `RenamePattern`, `SaveProject`, etc. are handled), add: + +```rust + ModalAction::RenameScene(slot) => { + if !name.is_empty() { + app.rename_scene(slot, &name); + app.show_status(format!("Scene renamed: {}", name)); + } + } +``` + +- [ ] **Step 5: Verify compilation** + +Run: `cargo build` +Expected: Compiles. + +- [ ] **Step 6: Run full test suite** + +Run: `cargo test` +Expected: All tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/keys.rs src/app.rs +git commit -m "feat: keyboard handling for scene browser (Ctrl+E)" +``` + +--- + +### Task 6: Demo Project Scenes + +**Files:** +- Modify: `src/sequencer/project.rs` + +- [ ] **Step 1: Write failing test for demo project scenes** + +```rust +#[test] +fn demo_project_has_scenes() { + let proj = demo_project(); + assert!(!proj.scenes.is_empty()); + // At least one scene should be populated + let populated = proj.scenes.iter().filter(|s| s.is_some()).count(); + assert!(populated >= 5, "Expected at least 5 demo scenes, got {}", populated); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test demo_project_has_scenes` +Expected: FAIL — demo_project() has empty scenes vec. + +- [ ] **Step 3: Populate demo scenes in demo_project()** + +Replace `scenes: Vec::new()` in `demo_project()` with: + +```rust + scenes: vec![ + Some(Scene { name: "Acid Techno".into(), drum_pattern: 0, drum_kit: 0, synth_a_pattern: 0, synth_a_kit: 0, synth_b_pattern: 0, synth_b_kit: 0, bpm: 138.0, swing: 0.50 }), + Some(Scene { name: "Classic House".into(), drum_pattern: 1, drum_kit: 3, synth_a_pattern: 1, synth_a_kit: 1, synth_b_pattern: 1, synth_b_kit: 1, bpm: 122.0, swing: 0.50 }), + Some(Scene { name: "Deep House".into(), drum_pattern: 2, drum_kit: 3, synth_a_pattern: 2, synth_a_kit: 2, synth_b_pattern: 2, synth_b_kit: 2, bpm: 120.0, swing: 0.50 }), + Some(Scene { name: "Driving Techno".into(),drum_pattern: 3, drum_kit: 2, synth_a_pattern: 3, synth_a_kit: 3, synth_b_pattern: 3, synth_b_kit: 3, bpm: 130.0, swing: 0.50 }), + Some(Scene { name: "Lo-Fi Hip Hop".into(), drum_pattern: 4, drum_kit: 5, synth_a_pattern: 4, synth_a_kit: 4, synth_b_pattern: 4, synth_b_kit: 4, bpm: 85.0, swing: 0.50 }), + Some(Scene { name: "Trance".into(), drum_pattern: 5, drum_kit: 1, synth_a_pattern: 5, synth_a_kit: 5, synth_b_pattern: 5, synth_b_kit: 5, bpm: 140.0, swing: 0.50 }), + Some(Scene { name: "Drum & Bass".into(), drum_pattern: 6, drum_kit: 6, synth_a_pattern: 6, synth_a_kit: 6, synth_b_pattern: 6, synth_b_kit: 6, bpm: 174.0, swing: 0.50 }), + Some(Scene { name: "Electro Funk".into(), drum_pattern: 7, drum_kit: 6, synth_a_pattern: 7, synth_a_kit: 7, synth_b_pattern: 7, synth_b_kit: 7, bpm: 128.0, swing: 0.50 }), + Some(Scene { name: "Dub Techno".into(), drum_pattern: 8, drum_kit: 2, synth_a_pattern: 8, synth_a_kit: 4, synth_b_pattern: 8, synth_b_kit: 2, bpm: 118.0, swing: 0.50 }), + Some(Scene { name: "Ambient".into(), drum_pattern: 9, drum_kit: 7, synth_a_pattern: 9, synth_a_kit: 5, synth_b_pattern: 9, synth_b_kit: 6, bpm: 90.0, swing: 0.50 }), + ], +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test demo_project_has_scenes` +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `cargo test` +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/sequencer/project.rs +git commit -m "feat: populate demo project with 10 genre scenes" +``` + +--- + +### Task 7: Visual Verification + +- [ ] **Step 1: Run the app and open the scene browser** + +Run: `cargo run` +Press Ctrl+E to open the scene browser modal. + +- [ ] **Step 2: Verify the modal renders as a proper closed box** + +Check that: +- The border is a complete rectangle (no gaps or misaligned edges) +- Unicode symbols (♫ and ◈) display correctly with dash separators +- Scene names are left-aligned and not overflowing +- Footer help text fits within the box +- Selected row highlight (cyan background) spans correctly + +- [ ] **Step 3: Test all keybindings** + +- Up/Down navigates the list +- S on an empty slot saves a scene, S on a populated slot overwrites +- R opens rename dialog, typing a name and pressing Enter renames +- D on a populated slot clears it +- Enter queues (status shows "queued") +- Shift+Enter switches immediately +- Esc closes the modal + +- [ ] **Step 4: Show the user the result for visual approval** + +Take a screenshot or show the running TUI to the user before committing any visual fixes. + +- [ ] **Step 5: Fix any rendering issues found and commit** + +```bash +git add -u +git commit -m "fix: scene browser rendering adjustments" +``` diff --git a/docs/superpowers/specs/2026-03-16-scenes-design.md b/docs/superpowers/specs/2026-03-16-scenes-design.md new file mode 100644 index 0000000..0a03a1d --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-scenes-design.md @@ -0,0 +1,110 @@ +# Scenes Feature Design + +## Overview + +A scene is a named snapshot of the current instrument state: which pattern and kit is active for drums, synth A, and synth B, plus BPM. Scenes allow quick recall of full arrangements from a single modal dialog. + +## Data Model + +```rust +pub const NUM_SCENES: usize = 16; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Scene { + pub name: String, + pub drum_pattern: usize, + pub drum_kit: usize, + pub synth_a_pattern: usize, + pub synth_a_kit: usize, + pub synth_b_pattern: usize, + pub synth_b_kit: usize, + pub bpm: f64, + pub swing: f32, +} +``` + +- `NUM_SCENES = 16` constant, consistent with `NUM_PATTERNS` / `NUM_KITS` style. +- Stored as `scenes: Vec>` in `ProjectFile`. +- `#[serde(default)]` for backward compatibility with existing .tsp files. +- `ProjectFile::normalize()` must clamp `scenes` vec length to `NUM_SCENES` and validate all indices within each scene (clamp `drum_pattern` to `NUM_PATTERNS`, `drum_kit` to `NUM_KITS`, etc.). +- Demo project pre-populates scenes matching its genre patterns. + +## Modal UI + +Opened with **Ctrl+E**. Rendered as a new `ModalState::SceneBrowser` variant. + +``` ++-- Scenes -------------------------------------------+ +| | +| 1. Acid Intro DR: music-1 diamond-1 SA: music-1 diamond-1 SB: music-1 diamond-1 | +| 2. House Build DR: music-2 diamond-4 SA: music-2 diamond-2 SB: music-2 diamond-2 | +| >3. Techno Drop DR: music-4 diamond-3 SA: music-4 diamond-4 SB: music-4 diamond-3 | +| 4. (empty) | +| ... | +| | +| Enter: queue Shift+Enter: now | +| S: save here R: rename D: delete Esc: close | ++-----------------------------------------------------+ +``` + +(In actual rendering: music = unicode char, diamond = unicode char, with dash separators for spacing.) + +Display format per scene line: +`DR: - - SA: - - SB: - -` + +Where pat_symbol and kit_symbol are unicode characters defined in `theme.rs`. + +## Keybindings (within modal) + +| Key | Action | +|---------------|-------------------------------------------------| +| Up / Down | Navigate scene list | +| Enter | Queue scene switch (applies at pattern end) | +| Shift+Enter | Immediate scene switch | +| S | Save current state into selected slot | +| R | Open text input to rename selected scene | +| D | Delete (clear) selected scene | +| Esc | Close modal | + +## Scene Switching Behavior + +### Queued (Enter) +- Stores target scene index in a `queued_scene: Option` field on `UiState`. +- The scene switch triggers when the **drum pattern** loops (step wraps to 0). The drum pattern is the rhythmic backbone and the most natural loop boundary. Synth patterns reset to step 0 at the same moment. +- Queuing a scene **clears any individually queued pattern/kit changes** (and vice versa: queuing an individual pattern/kit clears any queued scene). Only one type of queued change is active at a time. +- The queued scene indicator can be shown in the transport bar. + +### Immediate (Shift+Enter) +- Switches all 6 indices + BPM + swing immediately. +- Reuses existing `switch_drum_pattern`, `switch_synth_kit_for`, etc. methods on `App`. + +### Saving (S key) +- Snapshots the current 6 active indices + BPM + swing from `UiState` / `App` state. Scenes reference indices only, not pattern/kit data, so no `store_current_to_project()` call is needed. + +## Implementation Scope + +### Files to modify +- `src/sequencer/project.rs` — Add `Scene` struct, `scenes` field to `ProjectFile`, populate in `demo_project()` +- `src/app.rs` — Add `ModalState::SceneBrowser`, `SceneBrowserState` struct, `queued_scene` field, scene recall/save methods +- `src/keys.rs` — Handle Ctrl+E to open modal, handle keys within `SceneBrowser` modal +- `src/ui/mod.rs` — Render `SceneBrowser` modal (closed box, proper alignment) +- `src/ui/theme.rs` — Add `SCENE_PAT_SYMBOL` and `SCENE_KIT_SYMBOL` unicode constants +- `src/ui/transport_bar.rs` — Optional: show queued scene indicator + +### Files NOT modified +- Audio engine, DSP — scenes only change indices, no new audio messages needed. +- `src/mouse.rs` — mouse interaction for the scene modal (click-to-select rows) is a future enhancement. For now, keyboard-only within the modal. + +## Persistence + +- Scenes serialize/deserialize as part of the .tsp project file. +- Empty slots stored as `null` in JSON array. +- Old project files without `scenes` field default to an empty vec (no scenes). + +## Testing + +- Unit test: scene save/recall round-trip (save current indices, recall, verify indices match) +- Unit test: serialization round-trip (scene data survives JSON encode/decode) +- Unit test: backward compat — old .tsp without `scenes` field loads with empty scenes vec +- Unit test: `normalize()` clamps out-of-bounds scene indices +- Unit test: demo project has expected pre-populated scenes diff --git a/src/app.rs b/src/app.rs index c1acea6..0ac1e2b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -486,10 +486,10 @@ pub struct PanelVisibility { impl Default for PanelVisibility { fn default() -> Self { Self { - synth_a_knobs: true, + synth_a_knobs: false, // Knobs collapsed by default synth_a_grid: true, - synth_b_knobs: false, // Synth B collapsed by default - synth_b_grid: false, + synth_b_knobs: false, // Knobs collapsed by default + synth_b_grid: true, drum_grid: true, drum_knobs: true, waveform: true, @@ -632,29 +632,43 @@ pub struct App { impl App { pub fn new(tx: Sender, rx: Receiver, display_buf: Arc) -> Self { let project = project::demo_project(); + + // Use first scene if available, otherwise default to index 0 + let (dp, dk, sap, sak, sbp, sbk, scene_bpm, scene_swing) = + if let Some(Some(scene)) = project.scenes.first() { + (scene.drum_pattern, scene.drum_kit, + scene.synth_a_pattern, scene.synth_a_kit, + scene.synth_b_pattern, scene.synth_b_kit, + Some(scene.bpm), Some(scene.swing)) + } else { + (0, 0, 0, 0, 0, 0, None, None) + }; + let mut drum_pattern = DrumPattern::default(); - project.apply_kit_to_pattern(0, &mut drum_pattern); - project.load_pattern_steps(0, &mut drum_pattern); + project.apply_kit_to_pattern(dk, &mut drum_pattern); + project.load_pattern_steps(dp, &mut drum_pattern); let mut transport = Transport::default(); - transport.bpm = project.bpm; + transport.bpm = scene_bpm.unwrap_or(project.bpm); transport.loop_config.drum_length = project.loop_length; - transport.swing = project.swing; - // Apply per-pattern BPM if set - if let Some(pat) = project.patterns.first() { - if pat.bpm > 0.0 { - transport.bpm = pat.bpm; + transport.swing = scene_swing.unwrap_or(project.swing); + // Apply per-pattern BPM if no scene BPM + if scene_bpm.is_none() { + if let Some(pat) = project.patterns.get(dp) { + if pat.bpm > 0.0 { + transport.bpm = pat.bpm; + } } } // Load synth patterns and kits from project let mut synth_a_pattern = SynthPattern::default(); - project.load_synth_pattern(0, &mut synth_a_pattern); - project.load_synth_kit(0, &mut synth_a_pattern); + project.load_synth_pattern(sap, &mut synth_a_pattern); + project.load_synth_kit(sak, &mut synth_a_pattern); let mut synth_b_pattern = SynthPattern::default(); - project.load_synth_b_pattern(0, &mut synth_b_pattern); - project.load_synth_b_kit(0, &mut synth_b_pattern); + project.load_synth_b_pattern(sbp, &mut synth_b_pattern); + project.load_synth_b_kit(sbk, &mut synth_b_pattern); // Send initial state to audio thread so it has the pattern from the start let _ = tx.send(UiToAudio::SetTransport(transport)); @@ -662,8 +676,16 @@ impl App { let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::A, synth_a_pattern.clone())); let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::B, synth_b_pattern.clone())); + let mut ui = UiState::default(); + ui.active_pattern = dp; + ui.active_kit = dk; + ui.synth_a.active_pattern = sap; + ui.synth_a.active_kit = sak; + ui.synth_b.active_pattern = sbp; + ui.synth_b.active_kit = sbk; + Self { - ui: UiState::default(), + ui, transport, drum_pattern, synth_a_pattern, diff --git a/src/keys.rs b/src/keys.rs index 3e9dba4..98178ed 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1294,16 +1294,13 @@ fn handle_scene_browser(app: &mut App, key: KeyEvent) { } KeyCode::Enter => { let idx = browser.selected; - if key.modifiers.contains(KeyModifiers::SHIFT) { - // Immediate switch - app.apply_scene_immediate(idx); - app.show_status(format!("Scene {} applied", idx + 1)); - } else { - // Queue switch - app.queue_scene(idx); - app.show_status(format!("Scene {} queued", idx + 1)); - } - app.ui.modal = ModalState::None; + app.queue_scene(idx); + app.show_status(format!("Scene {} queued", idx + 1)); + } + KeyCode::Char('!') => { + let idx = browser.selected; + app.apply_scene_immediate(idx); + app.show_status(format!("Scene {} applied", idx + 1)); } KeyCode::Char('s') | KeyCode::Char('S') => { let idx = browser.selected; diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 00fd30a..7929bdc 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -531,10 +531,19 @@ impl Default for ProjectFile { fn pattern_from_preset(name: &str, display_name: &str, bpm: f64) -> PatternData { if let Some(preset) = pattern_presets::preset_by_name(name) { + // Mirror 16-step patterns to fill 32 steps: repeat first 4 hex chars into last 4 + let steps: Vec = preset.steps.iter().map(|s| { + if s.len() == 8 && &s[4..] == "0000" { + // First half has data, second half empty — repeat first half + format!("{}{}", &s[..4], &s[..4]) + } else { + s.to_string() + } + }).collect(); PatternData { name: display_name.to_string(), bpm, - steps: preset.steps.iter().map(|s| s.to_string()).collect(), + steps, } } else { PatternData { @@ -667,7 +676,7 @@ pub fn demo_project() -> ProjectFile { synth_b_patterns, active_synth_b_pattern: 0, scenes: vec![ - Some(Scene { name: "Acid Techno".into(), drum_pattern: 0, drum_kit: 0, synth_a_pattern: 0, synth_a_kit: 0, synth_b_pattern: 0, synth_b_kit: 0, bpm: 138.0, swing: 0.50 }), + Some(Scene { name: "bonza".into(), drum_pattern: 5, drum_kit: 7, synth_a_pattern: 5, synth_a_kit: 5, synth_b_pattern: 4, synth_b_kit: 5, bpm: 140.0, swing: 0.50 }), Some(Scene { name: "Classic House".into(), drum_pattern: 1, drum_kit: 3, synth_a_pattern: 1, synth_a_kit: 1, synth_b_pattern: 1, synth_b_kit: 1, bpm: 122.0, swing: 0.50 }), Some(Scene { name: "Deep House".into(), drum_pattern: 2, drum_kit: 3, synth_a_pattern: 2, synth_a_kit: 2, synth_b_pattern: 2, synth_b_kit: 2, bpm: 120.0, swing: 0.50 }), Some(Scene { name: "Driving Techno".into(),drum_pattern: 3, drum_kit: 2, synth_a_pattern: 3, synth_a_kit: 3, synth_b_pattern: 3, synth_b_kit: 3, bpm: 130.0, swing: 0.50 }), diff --git a/src/ui/layout.rs b/src/ui/layout.rs index c601651..2342757 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -370,16 +370,16 @@ mod tests { #[test] fn default_visibility_layout() { - // Default: synth B collapsed, everything else expanded + // Default: both synth knobs collapsed, both grids + drums + waveform visible let vis = PanelVisibility::default(); let ly = compute_dual_layout(term(100), &vis); - assert!(ly.synth_a_knobs.height > 0); + assert_eq!(ly.synth_a_knobs, Rect::default()); + assert!(ly.synth_a_knobs_collapsed.height > 0); assert!(ly.synth_a_grid.height > 0); assert_eq!(ly.synth_b_knobs, Rect::default()); - assert_eq!(ly.synth_b_grid, Rect::default()); assert!(ly.synth_b_knobs_collapsed.height > 0); - assert!(ly.synth_b_grid_collapsed.height > 0); + assert!(ly.synth_b_grid.height > 0); assert!(ly.drum_grid.height > 0); assert!(ly.drum_knobs.height > 0); assert!(ly.waveform.height > 0); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 57b1618..726bd18 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -681,9 +681,9 @@ fn render_scene_browser( lines.push(Line::from(vec![ Span::styled("\u{2191}\u{2193}", Style::default().fg(theme::CYAN)), Span::styled(" nav ", Style::default().fg(theme::DIM_TEXT)), - Span::styled("Enter", Style::default().fg(theme::AMBER)), + Span::styled("Return", Style::default().fg(theme::AMBER)), Span::styled(" queue ", Style::default().fg(theme::DIM_TEXT)), - Span::styled("S+Ret", Style::default().fg(theme::AMBER)), + Span::styled("!", Style::default().fg(theme::AMBER)), Span::styled(" now ", Style::default().fg(theme::DIM_TEXT)), Span::styled("S", Style::default().fg(theme::PINK)), Span::styled(" save ", Style::default().fg(theme::DIM_TEXT)),