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-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." +``` 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-12-demo-song-design.md b/docs/superpowers/specs/2026-03-12-demo-song-design.md new file mode 100644 index 0000000..473325d --- /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:** Implemented + +## 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 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 85fae55..0ac1e2b 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 ────────────────────────────────────────────────────────────── @@ -477,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, @@ -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, @@ -620,63 +632,64 @@ 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(sap, &mut synth_a_pattern); + project.load_synth_kit(sak, &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(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)); 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())); + + 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, - synth_b_pattern: SynthPattern::default(), + synth_b_pattern, effect_params: EffectParams::default(), project, project_path: None, @@ -742,6 +755,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); } @@ -754,6 +770,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 { @@ -906,6 +929,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; } @@ -991,9 +1087,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); } } @@ -1025,8 +1123,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/keys.rs b/src/keys.rs index e8c171d..98178ed 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -104,6 +104,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { handle_pattern_browser(app, key); return; } + ModalState::SceneBrowser(_) => { + handle_scene_browser(app, key); + return; + } ModalState::None => {} } @@ -161,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(); @@ -656,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); @@ -1254,6 +1270,64 @@ 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; + 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; + 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; 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) +} diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index 001ee1b..7929bdc 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, }; @@ -15,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) ─────────────────────────────── @@ -366,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, @@ -406,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 } @@ -497,211 +524,138 @@ impl Default for ProjectFile { active_synth_b_kit: 0, synth_b_patterns, active_synth_b_pattern: 0, + scenes: Vec::new(), } } } -/// 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 +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: "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 + name: display_name.to_string(), + bpm, + steps, + } + } else { 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 - ], - }, - ]; - - 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() - }); + name: display_name.to_string(), + bpm, + steps: vec!["00000000".into(); NUM_DRUM_TRACKS], + } } - 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), +} + +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() - }); + } } - 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), +} + +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![ + 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(); + + 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(), metadata: ProjectMetadata { - name: "Demo Beats".to_string(), + name: "Demo Song".to_string(), ..Default::default() }, kit: DrumKit::default(), @@ -709,8 +663,8 @@ pub fn demo_project() -> ProjectFile { active_kit: 0, patterns, active_pattern: 0, - bpm: 125.0, - loop_length: 16, + bpm: 138.0, + loop_length: 32, swing: 0.50, effects: EffectParams::default(), synth_kits, @@ -721,6 +675,18 @@ pub fn demo_project() -> ProjectFile { active_synth_b_kit: 0, synth_b_patterns, active_synth_b_pattern: 0, + scenes: vec![ + 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 }), + 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 }), + ], } } @@ -935,6 +901,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; } + } + } } } @@ -1214,6 +1192,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)] @@ -1221,26 +1207,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(); + 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!((proj.patterns[0].bpm - 125.0).abs() < 0.01); // House - assert!((proj.patterns[7].bpm - 170.0).abs() < 0.01); // D&B + 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] @@ -1249,8 +1260,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. @@ -1335,4 +1347,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); + } } 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, } } } 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 38a4eae..726bd18 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -161,6 +161,9 @@ pub fn render(f: &mut Frame, app: &App) { ModalState::PatternBrowser(pb) => { render_pattern_browser(f, size, pb); } + ModalState::SceneBrowser(sb) => { + render_scene_browser(f, size, sb, &app.project.scenes); + } ModalState::None => {} } } @@ -601,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("Return", Style::default().fg(theme::AMBER)), + Span::styled(" queue ", Style::default().fg(theme::DIM_TEXT)), + 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)), + 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 {