diff --git a/Cargo.lock b/Cargo.lock index b9cdfd6..6fb4757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,7 +455,7 @@ dependencies = [ "mlua", "pest", "pest_derive", - "rand", + "rand 0.9.2", "rhai", "rmp-serde", "rosc", @@ -1259,7 +1259,7 @@ source = "git+https://github.com/sourcebox/mi-plaits-dsp-rs?rev=dc55bd55e73bd6f8 dependencies = [ "dyn-clone", "num-traits", - "spin", + "spin 0.10.0", ] [[package]] @@ -1279,6 +1279,15 @@ dependencies = [ "windows 0.56.0", ] +[[package]] +name = "minimad" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c5d708226d186590a7b6d4a9780e2bdda5f689e0d58cd17012a298efd745d2" +dependencies = [ + "once_cell", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1401,6 +1410,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin 0.5.2", +] + [[package]] name = "nom" version = "7.1.3" @@ -1788,14 +1806,35 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1805,7 +1844,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1895,6 +1943,7 @@ checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1" dependencies = [ "ahash", "bitflags 2.10.0", + "no-std-compat", "num-traits", "once_cell", "rhai_codegen", @@ -2043,6 +2092,8 @@ dependencies = [ "cpal", "crossterm", "doux", + "minimad", + "rand 0.8.5", "ratatui", "rhai", "rusty_link", @@ -2206,6 +2257,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.10.0" diff --git a/seq/Cargo.toml b/seq/Cargo.toml index e18afeb..755c499 100644 --- a/seq/Cargo.toml +++ b/seq/Cargo.toml @@ -13,8 +13,10 @@ rusty_link = "0.4" ratatui = "0.29" crossterm = "0.28" cpal = "0.15" -rhai = "1.24" +rhai = { version = "1.24", features = ["sync"] } +rand = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" tui-textarea = "0.7" arboard = "3" +minimad = "0.13" diff --git a/seq/docs/keybindings.md b/seq/docs/keybindings.md new file mode 100644 index 0000000..24995e0 --- /dev/null +++ b/seq/docs/keybindings.md @@ -0,0 +1,58 @@ +# Keybindings + +## Navigation + +- **Ctrl+Left/Right**: Switch between pages (Main, Audio, Doc) +- **q**: Quit (with confirmation) + +## Main Page - Sequencer Focus + +- **Arrow keys**: Navigate steps in pattern +- **Enter**: Toggle step active/inactive +- **Tab**: Switch focus to editor +- **Space**: Play/pause + +### Pattern Controls + +- **< / >**: Decrease/increase pattern length +- **[ / ]**: Decrease/increase pattern speed +- **p**: Open pattern picker +- **b**: Open bank picker + +### Slots + +- **1-8**: Toggle slot on/off +- **g**: Queue current pattern to first free slot +- **G**: Queue removal of current pattern from its slot + +### Files + +- **s**: Save project +- **l**: Load project +- **Ctrl+C**: Copy step script +- **Ctrl+V**: Paste step script + +### Tempo + +- **+ / =**: Increase tempo +- **-**: Decrease tempo + +## Main Page - Editor Focus + +- **Tab / Esc**: Return to sequencer focus +- **Ctrl+E**: Compile current step script + +## Audio Page + +- **h**: Hush (stop all sounds gracefully) +- **p**: Panic (kill all sounds immediately) +- **r**: Reset peak voice counter +- **t**: Test sound (plays 440Hz sine) +- **Space**: Play/pause + +## Doc Page + +- **j / Down**: Next topic +- **k / Up**: Previous topic +- **PgDn**: Scroll content down +- **PgUp**: Scroll content up diff --git a/seq/docs/scripting.md b/seq/docs/scripting.md new file mode 100644 index 0000000..6d96c94 --- /dev/null +++ b/seq/docs/scripting.md @@ -0,0 +1,115 @@ +# Scripting + +Steps are programmed using Rhai, a simple scripting language. + +## Basic Syntax + +Create sounds using `sound()` and chain parameters: + +``` +sound("kick").gain(0.8) +``` + +``` +sound("hat").freq(8000).decay(0.1) +``` + +## Context Variables + +These are available in every step script: + +- `step`: Current step index (0-based) +- `beat`: Current beat position +- `bank`: Current bank index +- `pattern`: Current pattern index +- `tempo`: Current BPM +- `phase`: Phase within the bar (0.0 to 1.0) +- `slot`: Slot number playing this pattern + +## Randomness + +- `rand(min, max)`: Random float in range +- `rrand(min, max)`: Random integer in range (inclusive) +- `seed(n)`: Set random seed for reproducibility + +## Variables + +Store and retrieve values across steps: + +- `set("name", value)`: Store a value +- `get("name")`: Retrieve a value + +## Sound Parameters + +### Core + +- `sound(name)`: Create sound command +- `freq(hz)`: Frequency +- `note(midi)`: MIDI note number +- `gain(amp)`: Volume (0.0-1.0) +- `pan(pos)`: Stereo position (-1.0 to 1.0) +- `dur(secs)`: Duration +- `gate(secs)`: Gate time + +### Envelope + +- `attack(secs)`: Attack time +- `decay(secs)`: Decay time +- `sustain(level)`: Sustain level +- `release(secs)`: Release time + +### Filter + +- `lpf(hz)`: Lowpass frequency +- `lpq(q)`: Lowpass resonance +- `hpf(hz)`: Highpass frequency +- `bpf(hz)`: Bandpass frequency + +### Effects + +- `delay(mix)`: Delay amount +- `delaytime(secs)`: Delay time +- `delayfeedback(amt)`: Delay feedback +- `verb(mix)`: Reverb amount +- `verbdecay(secs)`: Reverb decay + +### Modulation + +- `vib(hz)`: Vibrato rate +- `vibmod(amt)`: Vibrato depth +- `fm(hz)`: FM modulator frequency +- `fmh(ratio)`: FM harmonic ratio + +### Sample Playback + +- `speed(ratio)`: Playback speed +- `begin(pos)`: Start position (0.0-1.0) +- `end(pos)`: End position (0.0-1.0) + +## Examples + +Conditional based on step: + +``` +if step % 4 == 0 { + sound("kick").gain(1.0) +} else { + sound("hat").gain(0.5) +} +``` + +Random variation: + +``` +sound("synth") + .freq(rand(200.0, 800.0)) + .gain(rand(0.3, 0.7)) +``` + +Using variables: + +``` +let n = get("counter"); +set("counter", n + 1); +sound("beep").note(60 + (n % 12)) +``` diff --git a/seq/docs/sequencer.md b/seq/docs/sequencer.md new file mode 100644 index 0000000..130481d --- /dev/null +++ b/seq/docs/sequencer.md @@ -0,0 +1,72 @@ +# Sequencer + +## Structure + +The sequencer is organized into: + +- **Banks**: 16 banks (B01-B16) +- **Patterns**: 16 patterns per bank (P01-P16) +- **Steps**: Up to 32 steps per pattern +- **Slots**: 8 concurrent playback slots + +## Patterns + +Each pattern has: + +- **Length**: Number of steps (1-32) +- **Speed**: Playback rate relative to tempo +- **Steps**: Each step can have a script + +### Speed Settings + +- 1/4: Quarter speed +- 1/2: Half speed +- 1x: Normal speed +- 2x: Double speed +- 4x: Quadruple speed + +## Slots + +Slots allow multiple patterns to play simultaneously. + +- Press **1-8** to toggle a slot +- Slot changes are quantized to the next bar +- A "?" indicates a slot queued to start +- A "x" indicates a slot queued to stop + +### Workflow + +1. Edit a pattern in the main view +2. Press **g** to queue it to the first free slot +3. It starts playing at the next bar boundary +4. Press **G** to queue its removal + +## Steps + +Steps are the basic unit of the sequencer: + +- Navigate with arrow keys +- Toggle active with Enter +- Each step can contain a Rhai script + +### Active vs Inactive + +- Active steps (highlighted) execute their script +- Inactive steps are skipped during playback +- Toggle with Enter key + +## Playback + +The sequencer uses Ableton Link for timing: + +- Syncs with other Link-enabled apps +- Bar boundaries are used for slot changes +- Phase shows position within the current bar + +## Files + +Projects are saved as JSON files: + +- **s**: Save with dialog +- **l**: Load with dialog +- File extension: `.buboseq` diff --git a/seq/src/app.rs b/seq/src/app.rs index 81ad3ac..aa3aa64 100644 --- a/seq/src/app.rs +++ b/seq/src/app.rs @@ -1,13 +1,18 @@ +use rand::rngs::StdRng; +use rand::SeedableRng; +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use std::time::Instant; use tui_textarea::TextArea; +use crate::audio::{SlotChange, MAX_SLOTS}; use crate::file; use crate::link::LinkState; use crate::model::{Pattern, Project}; use crate::page::Page; -use crate::script::{ScriptEngine, StepContext}; +use crate::script::{Rng, ScriptEngine, StepContext, Variables}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum Focus { @@ -18,11 +23,18 @@ pub enum Focus { #[derive(Clone, PartialEq, Eq)] pub enum Modal { None, - ConfirmQuit, + ConfirmQuit { selected: bool }, SaveAs(String), LoadFrom(String), - PatternPicker { cursor: usize }, - BankPicker { cursor: usize }, + RenameBank { bank: usize, name: String }, + RenamePattern { bank: usize, pattern: usize, name: String }, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PatternsViewLevel { + #[default] + Banks, + Patterns { bank: usize }, } pub struct App { @@ -38,14 +50,18 @@ pub struct App { pub focus: Focus, pub page: Page, pub current_step: usize, - pub playback_step: usize, pub edit_bank: usize, pub edit_pattern: usize, - pub playback_bank: usize, - pub playback_pattern: usize, - pub queued_bank: Option, - pub queued_pattern: Option, + + pub patterns_view_level: PatternsViewLevel, + pub patterns_cursor: usize, + + // Slot playback state (synced from audio thread) + pub slot_data: [(bool, usize, usize); MAX_SLOTS], // (active, bank, pattern) + pub slot_steps: [usize; MAX_SLOTS], + pub queued_changes: Vec, + pub event_count: usize, pub active_voices: usize, pub peak_voices: usize, @@ -54,12 +70,16 @@ pub struct App { pub sample_pool_mb: f32, pub scope: [f32; 64], pub script_engine: ScriptEngine, + pub variables: Variables, + pub rng: Rng, pub file_path: Option, pub status_message: Option, pub editor: TextArea<'static>, pub flash_until: Option, pub modal: Modal, pub clipboard: Option, + pub doc_topic: usize, + pub doc_scroll: usize, } impl App { @@ -76,14 +96,17 @@ impl App { focus: Focus::Sequencer, page: Page::default(), current_step: 0, - playback_step: 0, edit_bank: 0, edit_pattern: 0, - playback_bank: 0, - playback_pattern: 0, - queued_bank: None, - queued_pattern: None, + + patterns_view_level: PatternsViewLevel::default(), + patterns_cursor: 0, + + slot_data: [(false, 0, 0); MAX_SLOTS], + slot_steps: [0; MAX_SLOTS], + queued_changes: Vec::new(), + event_count: 0, active_voices: 0, peak_voices: 0, @@ -92,12 +115,16 @@ impl App { sample_pool_mb: 0.0, scope: [0.0; 64], script_engine: ScriptEngine::new(), + variables: Arc::new(Mutex::new(HashMap::new())), + rng: Arc::new(Mutex::new(StdRng::seed_from_u64(0))), file_path: None, status_message: None, editor: TextArea::default(), flash_until: None, modal: Modal::None, clipboard: arboard::Clipboard::new().ok(), + doc_topic: 0, + doc_scroll: 0, } } @@ -155,17 +182,33 @@ impl App { pub fn step_up(&mut self) { let len = self.current_edit_pattern().length; - if self.current_step >= 8 { - self.current_step -= 8; + let num_rows = match len { + 0..=8 => 1, + 9..=16 => 2, + 17..=24 => 3, + _ => 4, + }; + let steps_per_row = len.div_ceil(num_rows); + + if self.current_step >= steps_per_row { + self.current_step -= steps_per_row; } else { - self.current_step = (self.current_step + len - 8) % len; + self.current_step = (self.current_step + len - steps_per_row) % len; } self.load_step_to_editor(); } pub fn step_down(&mut self) { let len = self.current_edit_pattern().length; - self.current_step = (self.current_step + 8) % len; + let num_rows = match len { + 0..=8 => 1, + 9..=16 => 2, + 17..=24 => 3, + _ => 4, + }; + let steps_per_row = len.div_ceil(num_rows); + + self.current_step = (self.current_step + steps_per_row) % len; self.load_step_to_editor(); } @@ -177,6 +220,39 @@ impl App { } } + pub fn length_increase(&mut self) { + let (bank, pattern) = (self.edit_bank, self.edit_pattern); + let current_len = self.project.pattern_at(bank, pattern).length; + self.project + .pattern_at_mut(bank, pattern) + .set_length(current_len + 1); + } + + pub fn length_decrease(&mut self) { + let (bank, pattern) = (self.edit_bank, self.edit_pattern); + let current_len = self.project.pattern_at(bank, pattern).length; + self.project + .pattern_at_mut(bank, pattern) + .set_length(current_len.saturating_sub(1)); + let new_len = self.project.pattern_at(bank, pattern).length; + if self.current_step >= new_len { + self.current_step = new_len - 1; + self.load_step_to_editor(); + } + } + + pub fn speed_increase(&mut self) { + let (bank, pattern) = (self.edit_bank, self.edit_pattern); + let pat = self.project.pattern_at_mut(bank, pattern); + pat.speed = pat.speed.next(); + } + + pub fn speed_decrease(&mut self) { + let (bank, pattern) = (self.edit_bank, self.edit_pattern); + let pat = self.project.pattern_at_mut(bank, pattern); + pat.speed = pat.speed.prev(); + } + fn load_step_to_editor(&mut self) { let step_idx = self.current_step; if let Some(step) = self.current_edit_pattern().step(step_idx) { @@ -223,9 +299,10 @@ impl App { pattern, tempo: self.tempo, phase: self.phase, + slot: 0, }; - match self.script_engine.evaluate(&script, &ctx) { + match self.script_engine.evaluate(&script, &ctx, &self.variables, &self.rng) { Ok(cmd) => { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { step.command = Some(cmd); @@ -268,9 +345,10 @@ impl App { pattern, tempo: self.tempo, phase: 0.0, + slot: 0, }; - if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) { + if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx, &self.variables, &self.rng) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { step.command = Some(cmd); } @@ -278,14 +356,55 @@ impl App { } } - pub fn queue_current_for_playback(&mut self) { - self.queued_bank = Some(self.edit_bank); - self.queued_pattern = Some(self.edit_pattern); - self.status_message = Some(format!( - "Queued B{:02} P{:02} (next loop)", - self.edit_bank + 1, - self.edit_pattern + 1 - )); + pub fn is_pattern_queued(&self, bank: usize, pattern: usize) -> Option { + self.queued_changes.iter().find_map(|c| match c { + SlotChange::Add { slot: _, bank: b, pattern: p } if *b == bank && *p == pattern => { + Some(true) + } + SlotChange::Remove { slot } => { + let (active, b, p) = self.slot_data[*slot]; + if active && b == bank && p == pattern { + Some(false) + } else { + None + } + } + _ => None, + }) + } + + pub fn toggle_pattern_playback(&mut self, bank: usize, pattern: usize) { + let playing_slot = self.slot_data.iter().enumerate().find_map(|(i, (active, b, p))| { + if *active && *b == bank && *p == pattern { + Some(i) + } else { + None + } + }); + + let pending = self.queued_changes.iter().position(|c| match c { + SlotChange::Add { bank: b, pattern: p, .. } => *b == bank && *p == pattern, + SlotChange::Remove { slot } => { + let (_, b, p) = self.slot_data[*slot]; + b == bank && p == pattern + } + }); + + if let Some(idx) = pending { + self.queued_changes.remove(idx); + self.status_message = Some(format!("B{:02}:P{:02} change cancelled", bank + 1, pattern + 1)); + } else if let Some(slot_idx) = playing_slot { + self.queued_changes.push(SlotChange::Remove { slot: slot_idx }); + self.status_message = Some(format!("B{:02}:P{:02} queued to stop", bank + 1, pattern + 1)); + } else { + let free_slot = (0..MAX_SLOTS).find(|&i| !self.slot_data[i].0); + if let Some(slot_idx) = free_slot { + self.queued_changes.push(SlotChange::Add { slot: slot_idx, bank, pattern }); + self.status_message = Some(format!("B{:02}:P{:02} queued to play", bank + 1, pattern + 1)); + } else { + self.status_message = Some("All slots occupied".to_string()); + } + } } pub fn select_edit_pattern(&mut self, pattern: usize) { diff --git a/seq/src/audio.rs b/seq/src/audio.rs index 179e685..bec3830 100644 --- a/seq/src/audio.rs +++ b/seq/src/audio.rs @@ -6,21 +6,34 @@ use std::sync::{Arc, Mutex}; use crate::link::LinkState; use crate::model::Project; +use crate::script::{Rng, ScriptEngine, StepContext, Variables}; + +pub const MAX_SLOTS: usize = 8; + +#[derive(Clone, Copy, Default)] +pub struct PatternSlot { + pub bank: usize, + pub pattern: usize, + pub step_index: usize, + pub active: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SlotChange { + Add { slot: usize, bank: usize, pattern: usize }, + Remove { slot: usize }, +} pub struct AudioState { prev_beat: f64, - step_index: usize, - bank: usize, - pattern: usize, + pub slots: [PatternSlot; MAX_SLOTS], } impl AudioState { fn new() -> Self { Self { prev_beat: -1.0, - step_index: 0, - bank: 0, - pattern: 0, + slots: [PatternSlot::default(); MAX_SLOTS], } } } @@ -30,12 +43,12 @@ pub fn build_stream( link: Arc, playing: Arc, project: Arc>, - playback_step: Arc, + slot_steps: [Arc; MAX_SLOTS], event_count: Arc, - playback_bank: Arc, - playback_pattern: Arc, - queued_bank: Arc, - queued_pattern: Arc, + slot_data: Arc>, + slot_changes: Arc>>, + variables: Variables, + rng: Rng, ) -> (Stream, f32) { let host = cpal::default_host(); let device = host.default_output_device().expect("no output device"); @@ -50,6 +63,7 @@ pub fn build_stream( let quantum = 4.0; let audio_state = Arc::new(Mutex::new(AudioState::new())); + let script_engine = ScriptEngine::new(); let sr = sample_rate; let stream = device @@ -65,44 +79,85 @@ pub fn build_stream( let state = link.capture_audio_state(); let time = link.clock_micros(); let beat = state.beat_at_time(time, quantum); + let tempo = state.tempo(); let mut audio = audio_state.lock().unwrap(); - let beat_int = (beat * 4.0).floor() as i64; - let prev_beat_int = (audio.prev_beat * 4.0).floor() as i64; + let proj = project.lock().unwrap(); - if beat_int != prev_beat_int && audio.prev_beat >= 0.0 { - let proj = project.lock().unwrap(); - let pattern = proj.pattern_at(audio.bank, audio.pattern); - let step_idx = audio.step_index % pattern.length; - - playback_step.store(step_idx, Ordering::Relaxed); - playback_bank.store(audio.bank, Ordering::Relaxed); - playback_pattern.store(audio.pattern, Ordering::Relaxed); - - if let Some(step) = pattern.step(step_idx) { - if step.active { - if let Some(ref cmd) = step.command { - engine.lock().unwrap().evaluate(cmd); - event_count.fetch_add(1, Ordering::Relaxed); + // Apply queued slot changes at bar boundaries (every 4 beats) + let bar = (beat / quantum).floor() as i64; + let prev_bar = (audio.prev_beat / quantum).floor() as i64; + if bar != prev_bar && audio.prev_beat >= 0.0 { + let mut changes = slot_changes.lock().unwrap(); + for change in changes.drain(..) { + match change { + SlotChange::Add { slot, bank, pattern } => { + audio.slots[slot] = PatternSlot { + bank, + pattern, + step_index: 0, + active: true, + }; + } + SlotChange::Remove { slot } => { + audio.slots[slot].active = false; } } } + } - let next_step = (audio.step_index + 1) % pattern.length; - audio.step_index = next_step; + // Read prev_beat before the mutable borrow of slots + let prev_beat = audio.prev_beat; - if next_step == 0 { - let qb = queued_bank.load(Ordering::Relaxed); - let qp = queued_pattern.load(Ordering::Relaxed); - if qb != usize::MAX && qp != usize::MAX { - audio.bank = qb; - audio.pattern = qp; - audio.step_index = 0; - queued_bank.store(usize::MAX, Ordering::Relaxed); - queued_pattern.store(usize::MAX, Ordering::Relaxed); + // Iterate all active slots + for (slot_idx, slot) in audio.slots.iter_mut().enumerate() { + if !slot.active { + continue; + } + + let pattern = proj.pattern_at(slot.bank, slot.pattern); + let speed_mult = pattern.speed.multiplier(); + + let beat_int = (beat * 4.0 * speed_mult).floor() as i64; + let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64; + + if beat_int != prev_beat_int && prev_beat >= 0.0 { + let step_idx = slot.step_index % pattern.length; + + slot_steps[slot_idx].store(step_idx, Ordering::Relaxed); + + if let Some(step) = pattern.step(step_idx) { + if step.active && !step.script.trim().is_empty() { + let ctx = StepContext { + step: step_idx, + beat, + bank: slot.bank, + pattern: slot.pattern, + tempo, + phase: beat % quantum, + slot: slot_idx, + }; + if let Ok(cmd) = + script_engine.evaluate(&step.script, &ctx, &variables, &rng) + { + engine.lock().unwrap().evaluate(&cmd); + event_count.fetch_add(1, Ordering::Relaxed); + } + } } + + slot.step_index = (slot.step_index + 1) % pattern.length; } } + + // Update shared slot data for UI + { + let mut sd = slot_data.lock().unwrap(); + for (i, slot) in audio.slots.iter().enumerate() { + sd[i] = (slot.active, slot.bank, slot.pattern); + } + } + audio.prev_beat = beat; } diff --git a/seq/src/main.rs b/seq/src/main.rs index d43f745..dc2ec8f 100644 --- a/seq/src/main.rs +++ b/seq/src/main.rs @@ -7,6 +7,7 @@ mod page; mod script; mod ui; mod views; +mod widgets; use std::io; use std::path::PathBuf; @@ -24,6 +25,7 @@ use ratatui::prelude::CrosstermBackend; use ratatui::Terminal; use app::{App, Focus, Modal}; +use audio::{SlotChange, MAX_SLOTS}; use link::LinkState; use model::Project; use page::Page; @@ -36,12 +38,14 @@ fn main() -> io::Result<()> { link.enable(); let playing = Arc::new(AtomicBool::new(true)); - let playback_step = Arc::new(AtomicUsize::new(0)); let event_count = Arc::new(AtomicUsize::new(0)); - let playback_bank = Arc::new(AtomicUsize::new(0)); - let playback_pattern = Arc::new(AtomicUsize::new(0)); - let queued_bank = Arc::new(AtomicUsize::new(usize::MAX)); - let queued_pattern = Arc::new(AtomicUsize::new(usize::MAX)); + + // Slot state shared between audio thread and UI + let slot_steps: [Arc; MAX_SLOTS] = std::array::from_fn(|_| Arc::new(AtomicUsize::new(0))); + let slot_data: Arc> = + Arc::new(Mutex::new([(false, 0, 0); MAX_SLOTS])); + let slot_changes: Arc>> = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(TEMPO, QUANTUM); let engine = Arc::new(Mutex::new(Engine::new(44100.0))); @@ -52,12 +56,12 @@ fn main() -> io::Result<()> { Arc::clone(&link), Arc::clone(&playing), Arc::clone(&project), - Arc::clone(&playback_step), + slot_steps.clone(), Arc::clone(&event_count), - Arc::clone(&playback_bank), - Arc::clone(&playback_pattern), - Arc::clone(&queued_bank), - Arc::clone(&queued_pattern), + Arc::clone(&slot_data), + Arc::clone(&slot_changes), + Arc::clone(&app.variables), + Arc::clone(&app.rng), ); { @@ -75,7 +79,6 @@ fn main() -> io::Result<()> { loop { app.update_from_link(&link); app.playing = playing.load(Ordering::Relaxed); - app.playback_step = playback_step.load(Ordering::Relaxed); app.event_count = event_count.load(Ordering::Relaxed); { @@ -89,14 +92,19 @@ fn main() -> io::Result<()> { } } - app.playback_bank = playback_bank.load(Ordering::Relaxed); - app.playback_pattern = playback_pattern.load(Ordering::Relaxed); + // Sync slot state from audio thread + { + let sd = slot_data.lock().unwrap(); + app.slot_data = *sd; + } + for (i, step_atomic) in slot_steps.iter().enumerate() { + app.slot_steps[i] = step_atomic.load(Ordering::Relaxed); + } - if app.queued_bank.is_some() { - queued_bank.store(app.queued_bank.unwrap(), Ordering::Relaxed); - queued_pattern.store(app.queued_pattern.unwrap(), Ordering::Relaxed); - app.queued_bank = None; - app.queued_pattern = None; + // Push queued changes to audio thread + if !app.queued_changes.is_empty() { + let mut changes = slot_changes.lock().unwrap(); + changes.extend(app.queued_changes.drain(..)); } { @@ -111,11 +119,21 @@ fn main() -> io::Result<()> { app.clear_status(); match &mut app.modal { - Modal::ConfirmQuit => match key.code { + Modal::ConfirmQuit { ref mut selected } => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => break, KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { app.modal = Modal::None; } + KeyCode::Left | KeyCode::Right => { + *selected = !*selected; + } + KeyCode::Enter => { + if *selected { + break; + } else { + app.modal = Modal::None; + } + } _ => {} }, Modal::SaveAs(path) => match key.code { @@ -152,56 +170,42 @@ fn main() -> io::Result<()> { } _ => {} }, - Modal::PatternPicker { ref mut cursor } => { - match key.code { - KeyCode::Enter => { - let selected = *cursor; - app.modal = Modal::None; - app.select_edit_pattern(selected); - } - KeyCode::Esc => { - app.modal = Modal::None; - } - KeyCode::Left => { - *cursor = (*cursor + 15) % 16; - } - KeyCode::Right => { - *cursor = (*cursor + 1) % 16; - } - KeyCode::Up => { - *cursor = (*cursor + 12) % 16; - } - KeyCode::Down => { - *cursor = (*cursor + 4) % 16; - } - _ => {} + Modal::RenameBank { bank, name } => match key.code { + KeyCode::Enter => { + let bank_idx = *bank; + let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; + app.project.banks[bank_idx].name = new_name; + app.modal = Modal::None; } - } - Modal::BankPicker { ref mut cursor } => { - match key.code { - KeyCode::Enter => { - let selected = *cursor; - app.modal = Modal::None; - app.select_edit_bank(selected); - } - KeyCode::Esc => { - app.modal = Modal::None; - } - KeyCode::Left => { - *cursor = (*cursor + 15) % 16; - } - KeyCode::Right => { - *cursor = (*cursor + 1) % 16; - } - KeyCode::Up => { - *cursor = (*cursor + 12) % 16; - } - KeyCode::Down => { - *cursor = (*cursor + 4) % 16; - } - _ => {} + KeyCode::Esc => { + app.modal = Modal::None; } - } + KeyCode::Backspace => { + name.pop(); + } + KeyCode::Char(c) => { + name.push(c); + } + _ => {} + }, + Modal::RenamePattern { bank, pattern, name } => match key.code { + KeyCode::Enter => { + let (bank_idx, pattern_idx) = (*bank, *pattern); + let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; + app.project.banks[bank_idx].patterns[pattern_idx].name = new_name; + app.modal = Modal::None; + } + KeyCode::Esc => { + app.modal = Modal::None; + } + KeyCode::Backspace => { + name.pop(); + } + KeyCode::Char(c) => { + name.push(c); + } + _ => {} + }, Modal::None => { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); @@ -213,12 +217,20 @@ fn main() -> io::Result<()> { app.page.right(); continue; } + if ctrl && key.code == KeyCode::Up { + app.page.up(); + continue; + } + if ctrl && key.code == KeyCode::Down { + app.page.down(); + continue; + } match app.page { Page::Main => match app.focus { Focus::Sequencer => match key.code { KeyCode::Char('q') => { - app.modal = Modal::ConfirmQuit; + app.modal = Modal::ConfirmQuit { selected: false }; } KeyCode::Char(' ') => { app.toggle_playing(); @@ -230,16 +242,6 @@ fn main() -> io::Result<()> { KeyCode::Up => app.step_up(), KeyCode::Down => app.step_down(), KeyCode::Enter => app.toggle_step(), - KeyCode::Char('p') => { - app.modal = - Modal::PatternPicker { cursor: app.edit_pattern }; - } - KeyCode::Char('b') => { - app.modal = Modal::BankPicker { cursor: app.edit_bank }; - } - KeyCode::Char('g') => { - app.queue_current_for_playback(); - } KeyCode::Char('s') => { let default = app .file_path @@ -253,6 +255,14 @@ fn main() -> io::Result<()> { } KeyCode::Char('+') | KeyCode::Char('=') => app.tempo_up(&link), KeyCode::Char('-') => app.tempo_down(&link), + KeyCode::Char('<') | KeyCode::Char(',') => { + app.length_decrease() + } + KeyCode::Char('>') | KeyCode::Char('.') => { + app.length_increase() + } + KeyCode::Char('[') => app.speed_decrease(), + KeyCode::Char(']') => app.speed_increase(), KeyCode::Char('c') if ctrl => app.copy_step(), KeyCode::Char('v') if ctrl => app.paste_step(), _ => {} @@ -268,9 +278,81 @@ fn main() -> io::Result<()> { } }, }, + Page::Patterns => { + use app::PatternsViewLevel; + match key.code { + KeyCode::Left => { + app.patterns_cursor = (app.patterns_cursor + 15) % 16; + } + KeyCode::Right => { + app.patterns_cursor = (app.patterns_cursor + 1) % 16; + } + KeyCode::Up => { + app.patterns_cursor = (app.patterns_cursor + 12) % 16; + } + KeyCode::Down => { + app.patterns_cursor = (app.patterns_cursor + 4) % 16; + } + KeyCode::Esc | KeyCode::Backspace => { + match app.patterns_view_level { + PatternsViewLevel::Banks => { + app.page.down(); + } + PatternsViewLevel::Patterns { .. } => { + app.patterns_view_level = PatternsViewLevel::Banks; + app.patterns_cursor = 0; + } + } + } + KeyCode::Enter => { + match app.patterns_view_level { + PatternsViewLevel::Banks => { + let bank = app.patterns_cursor; + app.patterns_view_level = + PatternsViewLevel::Patterns { bank }; + app.patterns_cursor = 0; + } + PatternsViewLevel::Patterns { bank } => { + let pattern = app.patterns_cursor; + app.select_edit_bank(bank); + app.select_edit_pattern(pattern); + app.patterns_view_level = PatternsViewLevel::Banks; + app.patterns_cursor = 0; + app.page.down(); + } + } + } + KeyCode::Char(' ') => { + if let PatternsViewLevel::Patterns { bank } = + app.patterns_view_level + { + let pattern = app.patterns_cursor; + app.toggle_pattern_playback(bank, pattern); + } + } + KeyCode::Char('q') => { + app.modal = Modal::ConfirmQuit { selected: false }; + } + KeyCode::Char('r') => { + match app.patterns_view_level { + PatternsViewLevel::Banks => { + let bank = app.patterns_cursor; + let current_name = app.project.banks[bank].name.clone().unwrap_or_default(); + app.modal = Modal::RenameBank { bank, name: current_name }; + } + PatternsViewLevel::Patterns { bank } => { + let pattern = app.patterns_cursor; + let current_name = app.project.banks[bank].patterns[pattern].name.clone().unwrap_or_default(); + app.modal = Modal::RenamePattern { bank, pattern, name: current_name }; + } + } + } + _ => {} + } + } Page::Audio => match key.code { KeyCode::Char('q') => { - app.modal = Modal::ConfirmQuit; + app.modal = Modal::ConfirmQuit { selected: false }; } KeyCode::Char('h') => { engine.lock().unwrap().hush(); @@ -290,6 +372,29 @@ fn main() -> io::Result<()> { } _ => {} }, + Page::Doc => { + let topic_count = views::doc_view::topic_count(); + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + app.doc_topic = (app.doc_topic + 1) % topic_count; + app.doc_scroll = 0; + } + KeyCode::Char('k') | KeyCode::Up => { + app.doc_topic = (app.doc_topic + topic_count - 1) % topic_count; + app.doc_scroll = 0; + } + KeyCode::PageDown => { + app.doc_scroll = app.doc_scroll.saturating_add(10); + } + KeyCode::PageUp => { + app.doc_scroll = app.doc_scroll.saturating_sub(10); + } + KeyCode::Char('q') => { + app.modal = Modal::ConfirmQuit { selected: false }; + } + _ => {} + } + } } } } diff --git a/seq/src/model.rs b/seq/src/model.rs index 9adae82..c3e9e9e 100644 --- a/seq/src/model.rs +++ b/seq/src/model.rs @@ -1,5 +1,67 @@ use serde::{Deserialize, Serialize}; +#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq)] +pub enum PatternSpeed { + Eighth, // 1/8x + Quarter, // 1/4x + Half, // 1/2x + #[default] + Normal, // 1x + Double, // 2x + Quad, // 4x + Octo, // 8x +} + +impl PatternSpeed { + pub fn multiplier(&self) -> f64 { + match self { + Self::Eighth => 0.125, + Self::Quarter => 0.25, + Self::Half => 0.5, + Self::Normal => 1.0, + Self::Double => 2.0, + Self::Quad => 4.0, + Self::Octo => 8.0, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Eighth => "1/8x", + Self::Quarter => "1/4x", + Self::Half => "1/2x", + Self::Normal => "1x", + Self::Double => "2x", + Self::Quad => "4x", + Self::Octo => "8x", + } + } + + pub fn next(&self) -> Self { + match self { + Self::Eighth => Self::Quarter, + Self::Quarter => Self::Half, + Self::Half => Self::Normal, + Self::Normal => Self::Double, + Self::Double => Self::Quad, + Self::Quad => Self::Octo, + Self::Octo => Self::Octo, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Eighth => Self::Eighth, + Self::Quarter => Self::Eighth, + Self::Half => Self::Quarter, + Self::Normal => Self::Half, + Self::Double => Self::Normal, + Self::Quad => Self::Double, + Self::Octo => Self::Quad, + } + } +} + #[derive(Clone, Serialize, Deserialize)] pub struct Step { pub active: bool, @@ -22,13 +84,19 @@ impl Default for Step { pub struct Pattern { pub steps: Vec, pub length: usize, + #[serde(default)] + pub speed: PatternSpeed, + #[serde(default)] + pub name: Option, } impl Default for Pattern { fn default() -> Self { Self { - steps: (0..16).map(|_| Step::default()).collect(), + steps: (0..32).map(|_| Step::default()).collect(), length: 16, + speed: PatternSpeed::default(), + name: None, } } } @@ -42,9 +110,8 @@ impl Pattern { self.steps.get_mut(index) } - #[allow(dead_code)] pub fn set_length(&mut self, length: usize) { - let length = length.clamp(1, 64); + let length = length.clamp(2, 32); while self.steps.len() < length { self.steps.push(Step::default()); } @@ -55,12 +122,15 @@ impl Pattern { #[derive(Clone, Serialize, Deserialize)] pub struct Bank { pub patterns: Vec, + #[serde(default)] + pub name: Option, } impl Default for Bank { fn default() -> Self { Self { patterns: (0..16).map(|_| Pattern::default()).collect(), + name: None, } } } diff --git a/seq/src/page.rs b/seq/src/page.rs index 0274642..3a34029 100644 --- a/seq/src/page.rs +++ b/seq/src/page.rs @@ -2,21 +2,37 @@ pub enum Page { #[default] Main, + Patterns, Audio, + Doc, } impl Page { pub fn left(&mut self) { *self = match self { - Page::Main => Page::Audio, - Page::Audio => Page::Audio, + Page::Main | Page::Patterns => Page::Doc, + Page::Audio => Page::Main, + Page::Doc => Page::Audio, } } pub fn right(&mut self) { *self = match self { - Page::Main => Page::Main, - Page::Audio => Page::Main, + Page::Main | Page::Patterns => Page::Audio, + Page::Audio => Page::Doc, + Page::Doc => Page::Main, + } + } + + pub fn up(&mut self) { + if *self == Page::Main { + *self = Page::Patterns; + } + } + + pub fn down(&mut self) { + if *self == Page::Patterns { + *self = Page::Main; } } } diff --git a/seq/src/script.rs b/seq/src/script.rs index 44ad098..8a11ae9 100644 --- a/seq/src/script.rs +++ b/seq/src/script.rs @@ -1,4 +1,11 @@ -use rhai::{Engine, Scope}; +use rand::rngs::StdRng; +use rand::{Rng as RngTrait, SeedableRng}; +use rhai::{Dynamic, Engine, Scope}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +pub type Variables = Arc>>; +pub type Rng = Arc>; #[derive(Clone, Debug)] pub struct Cmd { @@ -16,6 +23,18 @@ impl Cmd { cmd } + fn with_dur_f(sound: &str, dur: f64) -> Self { + let mut cmd = Self::with(sound); + cmd.pairs.push(("dur".into(), dur.to_string())); + cmd + } + + fn with_dur_i(sound: &str, dur: i64) -> Self { + let mut cmd = Self::with(sound); + cmd.pairs.push(("dur".into(), dur.to_string())); + cmd + } + fn set(&mut self, key: &str, val: &str) -> Self { self.pairs.push((key.into(), val.into())); self.clone() @@ -40,21 +59,23 @@ pub struct StepContext { pub pattern: usize, pub tempo: f64, pub phase: f64, + pub slot: usize, } -pub struct ScriptEngine { - engine: Engine, -} +pub struct ScriptEngine; impl ScriptEngine { pub fn new() -> Self { - let mut engine = Engine::new(); - engine.set_max_expr_depths(64, 32); - register_cmd(&mut engine); - Self { engine } + Self } - pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result { + pub fn evaluate( + &self, + script: &str, + ctx: &StepContext, + vars: &Variables, + rng: &Rng, + ) -> Result { if script.trim().is_empty() { return Err("empty script".to_string()); } @@ -66,12 +87,60 @@ impl ScriptEngine { scope.push("pattern", ctx.pattern as i64); scope.push("tempo", ctx.tempo); scope.push("phase", ctx.phase); + scope.push("slot", ctx.slot as i64); - if let Ok(cmd) = self.engine.eval_with_scope::(&mut scope, script) { + let vars_for_set = Arc::clone(vars); + let vars_for_get = Arc::clone(vars); + + let mut engine = Engine::new(); + engine.set_max_expr_depths(64, 32); + register_cmd(&mut engine); + + engine.register_fn("set", move |name: &str, value: Dynamic| { + vars_for_set + .lock() + .unwrap() + .insert(name.to_string(), value); + }); + + engine.register_fn("get", move |name: &str| -> Dynamic { + vars_for_get + .lock() + .unwrap() + .get(name) + .cloned() + .unwrap_or(Dynamic::UNIT) + }); + + let rng_rand_ff = Arc::clone(rng); + let rng_rand_ii = Arc::clone(rng); + let rng_rrand_ff = Arc::clone(rng); + let rng_rrand_ii = Arc::clone(rng); + let rng_seed = Arc::clone(rng); + + engine.register_fn("rand", move |min: f64, max: f64| -> f64 { + rng_rand_ff.lock().unwrap().gen_range(min..max) + }); + engine.register_fn("rand", move |min: i64, max: i64| -> f64 { + rng_rand_ii.lock().unwrap().gen_range(min as f64..max as f64) + }); + + engine.register_fn("rrand", move |min: f64, max: f64| -> i64 { + rng_rrand_ff.lock().unwrap().gen_range(min as i64..=max as i64) + }); + engine.register_fn("rrand", move |min: i64, max: i64| -> i64 { + rng_rrand_ii.lock().unwrap().gen_range(min..=max) + }); + + engine.register_fn("seed", move |s: i64| { + *rng_seed.lock().unwrap() = StdRng::seed_from_u64(s as u64); + }); + + if let Ok(cmd) = engine.eval_with_scope::(&mut scope, script) { return Ok(cmd.to_string()); } - self.engine + engine .eval_with_scope::(&mut scope, script) .map_err(|e| e.to_string()) } @@ -80,6 +149,11 @@ impl ScriptEngine { fn register_cmd(engine: &mut Engine) { engine.register_type_with_name::("Cmd"); engine.register_fn("sound", Cmd::with); + engine.register_fn("sound", Cmd::with_dur_f); + engine.register_fn("sound", Cmd::with_dur_i); + engine.register_fn("s", Cmd::with); + engine.register_fn("s", Cmd::with_dur_f); + engine.register_fn("s", Cmd::with_dur_i); macro_rules! reg_both { ($($name:expr),*) => { @@ -99,17 +173,18 @@ fn register_cmd(engine: &mut Engine) { "lpf", "lpq", "lpe", "lpa", "lpd", "lps", "lpr", "hpf", "hpq", "hpe", "hpa", "hpd", "hps", "hpr", "bpf", "bpq", "bpe", "bpa", "bpd", "bps", "bpr", + "ftype", "penv", "patt", "pdec", "psus", "prel", - "vib", "vibmod", - "fm", "fmh", "fme", "fma", "fmd", "fms", "fmr", - "am", "amdepth", - "rm", "rmdepth", + "vib", "vibmod", "vibshape", + "fm", "fmh", "fmshape", "fme", "fma", "fmd", "fms", "fmr", + "am", "amdepth", "amshape", + "rm", "rmdepth", "rmshape", "phaser", "phaserdepth", "phasersweep", "phasercenter", "flanger", "flangerdepth", "flangerfeedback", "chorus", "chorusdepth", "chorusdelay", "comb", "combfreq", "combfeedback", "combdamp", "coarse", "crush", "fold", "wrap", "distort", "distortvol", - "delay", "delaytime", "delayfeedback", + "delay", "delaytime", "delayfeedback", "delaytype", "verb", "verbdecay", "verbdamp", "verbpredelay", "verbdiff", "voice", "orbit", "note", "size", "n", "cut" ); diff --git a/seq/src/ui.rs b/seq/src/ui.rs index ccc21d2..e607b29 100644 --- a/seq/src/ui.rs +++ b/seq/src/ui.rs @@ -6,45 +6,33 @@ use ratatui::Frame; use crate::app::{App, Modal}; use crate::page::Page; -use crate::views::{audio_view, main_view}; +use crate::views::{audio_view, doc_view, main_view, patterns_view}; pub fn render(frame: &mut Frame, app: &mut App) { - let [header_area, scope_area, body_area, footer_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(2), + let [header_area, body_area, footer_area] = Layout::vertical([ + Constraint::Length(1), Constraint::Fill(1), Constraint::Length(3), ]) .areas(frame.area()); render_header(frame, app, header_area); - render_scope(frame, app, scope_area); match app.page { Page::Main => main_view::render(frame, app, body_area), + Page::Patterns => patterns_view::render(frame, app, body_area), Page::Audio => audio_view::render(frame, app, body_area), + Page::Doc => doc_view::render(frame, app, body_area), } render_footer(frame, app, footer_area); render_modal(frame, app); } -fn render_scope(frame: &mut Frame, app: &App, area: Rect) { - let scope_chars: String = app - .scope - .iter() - .map(|&s| { - let level = (s.abs() * 8.0).min(7.0) as usize; - ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level] - }) - .collect(); - - let scope = Paragraph::new(scope_chars).style(Style::new().fg(Color::Green)); - - frame.render_widget(scope, area); -} - fn render_header(frame: &mut Frame, app: &App, area: Rect) { + let [left_area, right_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area); + let play_symbol = if app.playing { "▶" } else { "■" }; let play_color = if app.playing { Color::Green @@ -61,57 +49,43 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { Color::Green }; - let mut spans = vec![ + let left_spans = vec![ Span::styled("EDIT ", Style::new().fg(Color::Cyan)), Span::styled( format!("B{:02}:P{:02}", app.edit_bank + 1, app.edit_pattern + 1), Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::styled("PLAY ", Style::new().fg(play_color)), - Span::styled( - format!( - "B{:02}:P{:02} {}", - app.playback_bank + 1, - app.playback_pattern + 1, - play_symbol - ), - Style::new().fg(play_color).add_modifier(Modifier::BOLD), - ), + Span::styled(play_symbol, Style::new().fg(play_color)), ]; - if app.queued_bank.is_some() { - spans.push(Span::raw(" ")); - spans.push(Span::styled("QUEUE ", Style::new().fg(Color::Yellow))); - spans.push(Span::styled( - format!( - "B{:02}:P{:02}", - app.queued_bank.unwrap() + 1, - app.queued_pattern.unwrap() + 1 - ), - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD), - )); - } + frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area); - spans.extend([ + let pattern = app.project.pattern_at(app.edit_bank, app.edit_pattern); + let right_spans = vec![ + Span::styled(format!("L:{:02}", pattern.length), Style::new().fg(Color::Rgb(180, 140, 90))), + Span::raw(" "), + Span::styled(format!("S:{}", pattern.speed.label()), Style::new().fg(Color::Rgb(180, 140, 90))), Span::raw(" "), Span::styled(format!("{:.1} BPM", app.tempo), Style::new().fg(Color::Magenta)), Span::raw(" "), Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)), Span::raw(" "), Span::styled(format!("V:{}", app.active_voices), Style::new().fg(Color::Cyan)), - ]); + ]; - let header = Paragraph::new(Line::from(spans)) - .block(Block::default().borders(Borders::ALL).title("seq")); - - frame.render_widget(header, area); + frame.render_widget( + Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), + right_area, + ); } fn render_footer(frame: &mut Frame, app: &App, area: Rect) { let page_indicator = match app.page { Page::Main => "[MAIN] ", + Page::Patterns => "[PATTERNS] ", Page::Audio => "[AUDIO] ", + Page::Doc => "[DOC] ", }; let content = if let Some(ref msg) = app.status_message { @@ -125,18 +99,27 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)), Span::styled("←→↑↓", Style::new().fg(Color::Yellow)), Span::raw(":nav "), - Span::styled("p", Style::new().fg(Color::Yellow)), - Span::raw(":pat "), - Span::styled("b", Style::new().fg(Color::Yellow)), - Span::raw(":bank "), - Span::styled("g", Style::new().fg(Color::Yellow)), - Span::raw(":go "), - Span::styled("Enter", Style::new().fg(Color::Yellow)), - Span::raw(":toggle "), + Span::styled("<>", Style::new().fg(Color::Yellow)), + Span::raw(":len "), + Span::styled("[]", Style::new().fg(Color::Yellow)), + Span::raw(":spd "), Span::styled("Tab", Style::new().fg(Color::Yellow)), Span::raw(":focus "), Span::styled("s/l", Style::new().fg(Color::Yellow)), - Span::raw(":save/load"), + Span::raw(":save/load "), + Span::styled("C-↑", Style::new().fg(Color::Yellow)), + Span::raw(":patterns"), + ]), + Page::Patterns => Line::from(vec![ + Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)), + Span::styled("←→↑↓", Style::new().fg(Color::Yellow)), + Span::raw(":nav "), + Span::styled("Enter", Style::new().fg(Color::Yellow)), + Span::raw(":select "), + Span::styled("Space", Style::new().fg(Color::Yellow)), + Span::raw(":play "), + Span::styled("Esc", Style::new().fg(Color::Yellow)), + Span::raw(":back"), ]), Page::Audio => Line::from(vec![ Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)), @@ -153,6 +136,15 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { Span::styled("C-←→", Style::new().fg(Color::Yellow)), Span::raw(":page"), ]), + Page::Doc => Line::from(vec![ + Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)), + Span::styled("j/k", Style::new().fg(Color::Yellow)), + Span::raw(":topic "), + Span::styled("PgUp/Dn", Style::new().fg(Color::Yellow)), + Span::raw(":scroll "), + Span::styled("C-←→", Style::new().fg(Color::Yellow)), + Span::raw(":page"), + ]), } }; @@ -170,20 +162,47 @@ fn render_modal(frame: &mut Frame, app: &App) { let term = frame.area(); match &app.modal { Modal::None => {} - Modal::ConfirmQuit => { + Modal::ConfirmQuit { selected } => { let width = 30.min(term.width.saturating_sub(4)); let height = 5.min(term.height.saturating_sub(4)); let area = centered_rect(width, height, term); frame.render_widget(Clear, area); - let modal = Paragraph::new(Line::from("Quit? (y/n)")) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title("Confirm") - .border_style(Style::new().fg(Color::Yellow)), - ); - frame.render_widget(modal, area); + + let block = Block::default() + .borders(Borders::ALL) + .title("Confirm") + .border_style(Style::new().fg(Color::Yellow)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner); + + frame.render_widget( + Paragraph::new("Quit?").alignment(Alignment::Center), + rows[0], + ); + + let yes_style = if *selected { + Style::new().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + let no_style = if !*selected { + Style::new().fg(Color::Black).bg(Color::Yellow) + } else { + Style::default() + }; + + let buttons = Line::from(vec![ + Span::styled(" Yes ", yes_style), + Span::raw(" "), + Span::styled(" No ", no_style), + ]); + frame.render_widget( + Paragraph::new(buttons).alignment(Alignment::Center), + rows[1], + ); } Modal::SaveAs(path) => { let width = (term.width * 60 / 100).clamp(40, 70).min(term.width.saturating_sub(4)); @@ -221,92 +240,41 @@ fn render_modal(frame: &mut Frame, app: &App) { ); frame.render_widget(modal, area); } - Modal::PatternPicker { cursor } => { - render_picker_modal( - frame, - &format!("Select Pattern (Bank {:02})", app.edit_bank + 1), - *cursor, - app.edit_pattern, - app.playback_pattern, - app.edit_bank == app.playback_bank, + Modal::RenameBank { bank, name } => { + let width = 40.min(term.width.saturating_sub(4)); + let height = 5.min(term.height.saturating_sub(4)); + let area = centered_rect(width, height, term); + frame.render_widget(Clear, area); + let modal = Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(name, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Rename Bank {:02}", bank + 1)) + .border_style(Style::new().fg(Color::Magenta)), ); + frame.render_widget(modal, area); } - Modal::BankPicker { cursor } => { - render_picker_modal( - frame, - "Select Bank", - *cursor, - app.edit_bank, - app.playback_bank, - true, + Modal::RenamePattern { bank, pattern, name } => { + let width = 40.min(term.width.saturating_sub(4)); + let height = 5.min(term.height.saturating_sub(4)); + let area = centered_rect(width, height, term); + frame.render_widget(Clear, area); + let modal = Paragraph::new(Line::from(vec![ + Span::raw("> "), + Span::styled(name, Style::new().fg(Color::Cyan)), + Span::styled("█", Style::new().fg(Color::White)), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Rename B{:02}:P{:02}", bank + 1, pattern + 1)) + .border_style(Style::new().fg(Color::Magenta)), ); + frame.render_widget(modal, area); } } } - -fn render_picker_modal( - frame: &mut Frame, - title: &str, - cursor: usize, - edit_pos: usize, - play_pos: usize, - show_play: bool, -) { - let term = frame.area(); - let width = 30.min(term.width.saturating_sub(4)); - let height = 10.min(term.height.saturating_sub(4)); - let area = centered_rect(width, height, term); - frame.render_widget(Clear, area); - - let block = Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::new().fg(Color::Rgb(100, 160, 180))); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(inner); - - for row in 0..4 { - let mut spans = Vec::new(); - for col in 0..4 { - let idx = row * 4 + col; - let num = format!(" {:02} ", idx + 1); - - let style = if idx == cursor { - Style::new().bg(Color::Cyan).fg(Color::Black) - } else if idx == edit_pos { - Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) - } else if show_play && idx == play_pos { - Style::new().fg(Color::Green).add_modifier(Modifier::BOLD) - } else { - Style::new().fg(Color::White) - }; - - spans.push(Span::styled(num, style)); - if col < 3 { - spans.push(Span::raw(" ")); - } - } - frame.render_widget(Paragraph::new(Line::from(spans)), rows[row]); - } - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("[E]", Style::new().fg(Color::Cyan)), - Span::raw("=edit "), - Span::styled("[P]", Style::new().fg(Color::Green)), - Span::raw("=play"), - ])), - rows[5], - ); -} diff --git a/seq/src/views/doc_view.rs b/seq/src/views/doc_view.rs new file mode 100644 index 0000000..eb8d485 --- /dev/null +++ b/seq/src/views/doc_view.rs @@ -0,0 +1,134 @@ +use minimad::{Composite, CompositeStyle, Compound, Line}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line as RLine, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::Frame; + +use crate::app::App; + +const DOCS: &[(&str, &str)] = &[ + ("Keybindings", include_str!("../../docs/keybindings.md")), + ("Scripting", include_str!("../../docs/scripting.md")), + ("Sequencer", include_str!("../../docs/sequencer.md")), +]; + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + let [topics_area, content_area] = + Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area); + + render_topics(frame, app, topics_area); + render_content(frame, app, content_area); +} + +fn render_topics(frame: &mut Frame, app: &App, area: Rect) { + let items: Vec = DOCS + .iter() + .enumerate() + .map(|(i, (name, _))| { + let style = if i == app.doc_topic { + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::White) + }; + let prefix = if i == app.doc_topic { "> " } else { " " }; + ListItem::new(format!("{prefix}{name}")).style(style) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Topics")); + frame.render_widget(list, area); +} + +fn render_content(frame: &mut Frame, app: &App, area: Rect) { + let (title, md) = DOCS[app.doc_topic]; + let lines = parse_markdown(md); + + let visible_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + let max_scroll = total_lines.saturating_sub(visible_height); + let scroll = app.doc_scroll.min(max_scroll); + + let visible: Vec = lines.into_iter().skip(scroll).take(visible_height).collect(); + + let para = Paragraph::new(visible) + .block(Block::default().borders(Borders::ALL).title(title)); + frame.render_widget(para, area); +} + +fn parse_markdown(md: &str) -> Vec> { + let text = minimad::Text::from(md); + let mut lines = Vec::new(); + + for line in text.lines { + match line { + Line::Normal(composite) => { + lines.push(composite_to_line(composite)); + } + Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => { + lines.push(RLine::from("")); + } + } + } + + lines +} + +fn composite_to_line(composite: Composite) -> RLine<'static> { + let base_style = match composite.style { + CompositeStyle::Header(1) => Style::new() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + CompositeStyle::Header(2) => Style::new() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + CompositeStyle::Header(_) => Style::new() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + CompositeStyle::ListItem(_) => Style::new().fg(Color::White), + CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)), + CompositeStyle::Code => Style::new().fg(Color::Green), + CompositeStyle::Paragraph => Style::new().fg(Color::White), + }; + + let prefix = match composite.style { + CompositeStyle::ListItem(_) => " • ", + CompositeStyle::Quote => " │ ", + _ => "", + }; + + let mut spans: Vec> = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled(prefix.to_string(), base_style)); + } + + for compound in composite.compounds { + spans.push(compound_to_span(compound, base_style)); + } + + RLine::from(spans) +} + +fn compound_to_span(compound: Compound, base: Style) -> Span<'static> { + let mut style = base; + + if compound.bold { + style = style.add_modifier(Modifier::BOLD); + } + if compound.italic { + style = style.add_modifier(Modifier::ITALIC); + } + if compound.code { + style = Style::new().fg(Color::Green); + } + if compound.strikeout { + style = style.add_modifier(Modifier::CROSSED_OUT); + } + + Span::styled(compound.src.to_string(), style) +} + +pub fn topic_count() -> usize { + DOCS.len() +} diff --git a/seq/src/views/main_view.rs b/seq/src/views/main_view.rs index bba6064..fce0b69 100644 --- a/seq/src/views/main_view.rs +++ b/seq/src/views/main_view.rs @@ -4,13 +4,21 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use crate::app::{App, Focus}; +use crate::widgets::{Orientation, Scope}; pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + let [main_area, scope_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(10), + ]) + .areas(area); + let [seq_area, editor_area] = - Layout::vertical([Constraint::Length(9), Constraint::Fill(1)]).areas(area); + Layout::vertical([Constraint::Length(9), Constraint::Fill(1)]).areas(main_area); render_sequencer(frame, app, seq_area); render_editor(frame, app, editor_area); + render_scope(frame, app, scope_area); } fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) { @@ -42,39 +50,51 @@ fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) { return; } - let rows = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - ]) - .split(inner); + let pattern = app.current_edit_pattern(); + let length = pattern.length; + let num_rows = match length { + 0..=8 => 1, + 9..=16 => 2, + 17..=24 => 3, + _ => 4, + }; + let steps_per_row = length.div_ceil(num_rows); - let row_areas = [rows[0], rows[2]]; + let row_constraints: Vec = (0..num_rows * 2 - 1) + .map(|i| { + if i % 2 == 0 { + Constraint::Fill(1) + } else { + Constraint::Length(1) + } + }) + .collect(); + let rows = Layout::vertical(row_constraints).split(inner); - for (row_idx, row_area) in row_areas.iter().enumerate() { - let col_constraints = [ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(2), - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - ]; - let cols = Layout::horizontal(col_constraints).split(*row_area); + for row_idx in 0..num_rows { + let row_area = rows[row_idx * 2]; + let start_step = row_idx * steps_per_row; + let end_step = (start_step + steps_per_row).min(length); + let cols_in_row = end_step - start_step; - let tile_indices = [0, 2, 4, 6, 8, 10, 12, 14]; - for (col_idx, &col_layout_idx) in tile_indices.iter().enumerate() { - let step_idx = row_idx * 8 + col_idx; - render_tile(frame, cols[col_layout_idx], app, step_idx); + let col_constraints: Vec = (0..cols_in_row * 2 - 1) + .map(|i| { + if i % 2 == 0 { + Constraint::Fill(1) + } else if i == cols_in_row - 1 { + Constraint::Length(2) + } else { + Constraint::Length(1) + } + }) + .collect(); + let cols = Layout::horizontal(col_constraints).split(row_area); + + for col_idx in 0..cols_in_row { + let step_idx = start_step + col_idx; + if step_idx < length { + render_tile(frame, cols[col_idx * 2], app, step_idx); + } } } } @@ -85,16 +105,27 @@ fn render_tile(frame: &mut Frame, area: Rect, app: &App, step_idx: usize) { let is_active = step.map(|s| s.active).unwrap_or(false); let is_selected = step_idx == app.current_step; - let same_pattern = - app.edit_bank == app.playback_bank && app.edit_pattern == app.playback_pattern; - let is_playing = app.playing && same_pattern && step_idx == app.playback_step; + // Check if any slot is playing this step on the current edit pattern + let playing_slot = if app.playing { + (0..8).find(|&i| { + let (slot_active, bank, pat) = app.slot_data[i]; + slot_active + && bank == app.edit_bank + && pat == app.edit_pattern + && app.slot_steps[i] == step_idx + }) + } else { + None + }; + + let is_playing = playing_slot.is_some(); let (bg, fg) = match (is_playing, is_active, is_selected) { (true, true, _) => (Color::Rgb(195, 85, 65), Color::White), (true, false, _) => (Color::Rgb(180, 120, 45), Color::Black), - (false, true, true) => (Color::Rgb(55, 128, 115), Color::White), + (false, true, true) => (Color::Rgb(0, 220, 180), Color::Black), (false, true, false) => (Color::Rgb(45, 106, 95), Color::White), - (false, false, true) => (Color::Rgb(59, 91, 138), Color::White), + (false, false, true) => (Color::Rgb(80, 180, 255), Color::Black), (false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), }; @@ -144,3 +175,18 @@ fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(&app.editor, inner); } + +fn render_scope(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(Color::Rgb(70, 75, 85))) + .title("Scope"); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let scope = Scope::new(&app.scope) + .orientation(Orientation::Vertical) + .color(Color::Green); + frame.render_widget(scope, inner); +} diff --git a/seq/src/views/mod.rs b/seq/src/views/mod.rs index b9aba3b..94dd102 100644 --- a/seq/src/views/mod.rs +++ b/seq/src/views/mod.rs @@ -1,2 +1,4 @@ pub mod audio_view; +pub mod doc_view; pub mod main_view; +pub mod patterns_view; diff --git a/seq/src/views/patterns_view.rs b/seq/src/views/patterns_view.rs new file mode 100644 index 0000000..a140bce --- /dev/null +++ b/seq/src/views/patterns_view.rs @@ -0,0 +1,194 @@ +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::{App, PatternsViewLevel}; + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + match app.patterns_view_level { + PatternsViewLevel::Banks => render_banks(frame, app, area), + PatternsViewLevel::Patterns { bank } => render_patterns(frame, app, area, bank), + } +} + +fn render_banks(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(Color::Rgb(100, 160, 180))) + .title("Banks"); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < 50 { + let msg = Paragraph::new("Terminal too narrow") + .alignment(Alignment::Center) + .style(Style::new().fg(Color::Rgb(120, 125, 135))); + frame.render_widget(msg, inner); + return; + } + + let banks_with_playback: Vec = app + .slot_data + .iter() + .filter(|(active, _, _)| *active) + .map(|(_, bank, _)| *bank) + .collect(); + + let bank_names: Vec> = app + .project + .banks + .iter() + .map(|b| b.name.as_deref()) + .collect(); + + render_grid(frame, inner, app.patterns_cursor, app.edit_bank, &banks_with_playback, &bank_names); +} + +fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) { + let bank_name = app.project.banks[bank].name.as_deref(); + let title_text = match bank_name { + Some(name) => format!("{name} › Patterns"), + None => format!("Bank {:02} › Patterns", bank + 1), + }; + let title = Line::from(vec![ + Span::raw(title_text), + Span::styled(" [Esc]←", Style::new().fg(Color::Rgb(120, 125, 135))), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::new().fg(Color::Rgb(100, 160, 180))) + .title(title); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < 50 { + let msg = Paragraph::new("Terminal too narrow") + .alignment(Alignment::Center) + .style(Style::new().fg(Color::Rgb(120, 125, 135))); + frame.render_widget(msg, inner); + return; + } + + let playing_patterns: Vec = app + .slot_data + .iter() + .filter(|(active, b, _)| *active && *b == bank) + .map(|(_, _, pattern)| *pattern) + .collect(); + + let edit_pattern = if app.edit_bank == bank { + app.edit_pattern + } else { + usize::MAX + }; + + let pattern_names: Vec> = app.project.banks[bank] + .patterns + .iter() + .map(|p| p.name.as_deref()) + .collect(); + + render_pattern_grid(frame, app, inner, bank, app.patterns_cursor, edit_pattern, &playing_patterns, &pattern_names); +} + +fn render_grid( + frame: &mut Frame, + area: Rect, + cursor: usize, + edit_pos: usize, + playing_positions: &[usize], + names: &[Option<&str>], +) { + let rows = Layout::vertical([ + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + ]) + .split(area); + + for row in 0..4 { + let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]); + for col in 0..4 { + let idx = row * 4 + col; + let is_cursor = idx == cursor; + let is_edit = idx == edit_pos; + let is_playing = playing_positions.contains(&idx); + + let (bg, fg) = match (is_cursor, is_edit, is_playing) { + (true, _, _) => (Color::Cyan, Color::Black), + (false, true, _) => (Color::Rgb(45, 106, 95), Color::White), + (false, false, true) => (Color::Rgb(45, 80, 45), Color::Green), + (false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), + }; + + let label = names.get(idx).and_then(|n| *n).unwrap_or_else(|| ""); + let label = if label.is_empty() { + format!("{:02}", idx + 1) + } else { + label.to_string() + }; + let tile = Paragraph::new(label) + .alignment(Alignment::Center) + .style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD)); + + frame.render_widget(tile, cols[col]); + } + } +} + +fn render_pattern_grid( + frame: &mut Frame, + app: &App, + area: Rect, + bank: usize, + cursor: usize, + edit_pos: usize, + playing_positions: &[usize], + names: &[Option<&str>], +) { + let rows = Layout::vertical([ + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + Constraint::Fill(1), + ]) + .split(area); + + for row in 0..4 { + let cols = Layout::horizontal(vec![Constraint::Fill(1); 4]).split(rows[row]); + for col in 0..4 { + let idx = row * 4 + col; + let is_cursor = idx == cursor; + let is_edit = idx == edit_pos; + let is_playing = playing_positions.contains(&idx); + let queued = app.is_pattern_queued(bank, idx); + + let (bg, fg, prefix) = match (is_cursor, is_playing, queued) { + (true, _, _) => (Color::Cyan, Color::Black, ""), + (false, true, Some(false)) => (Color::Rgb(120, 90, 30), Color::Yellow, "×"), + (false, true, _) => (Color::Rgb(45, 80, 45), Color::Green, "▶"), + (false, false, Some(true)) => (Color::Rgb(80, 80, 45), Color::Yellow, "?"), + (false, false, _) if is_edit => (Color::Rgb(45, 106, 95), Color::White, ""), + (false, false, _) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135), ""), + }; + + let name = names.get(idx).and_then(|n| *n).unwrap_or(""); + let label = if name.is_empty() { + format!("{}{:02}", prefix, idx + 1) + } else { + format!("{}{}", prefix, name) + }; + let tile = Paragraph::new(label) + .alignment(Alignment::Center) + .style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD)); + + frame.render_widget(tile, cols[col]); + } + } +} diff --git a/seq/src/widgets/mod.rs b/seq/src/widgets/mod.rs new file mode 100644 index 0000000..3c59832 --- /dev/null +++ b/seq/src/widgets/mod.rs @@ -0,0 +1,3 @@ +mod scope; + +pub use scope::{Orientation, Scope}; diff --git a/seq/src/widgets/scope.rs b/seq/src/widgets/scope.rs new file mode 100644 index 0000000..4e4e558 --- /dev/null +++ b/seq/src/widgets/scope.rs @@ -0,0 +1,116 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::widgets::Widget; + +pub enum Orientation { + Horizontal, + Vertical, +} + +pub struct Scope<'a> { + data: &'a [f32], + orientation: Orientation, + color: Color, + gain: f32, +} + +impl<'a> Scope<'a> { + pub fn new(data: &'a [f32]) -> Self { + Self { + data, + orientation: Orientation::Horizontal, + color: Color::Green, + gain: 1.0, + } + } + + pub fn orientation(mut self, o: Orientation) -> Self { + self.orientation = o; + self + } + + pub fn color(mut self, c: Color) -> Self { + self.color = c; + self + } + + pub fn gain(mut self, g: f32) -> Self { + self.gain = g; + self + } +} + +impl Widget for Scope<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 || self.data.is_empty() { + return; + } + + match self.orientation { + Orientation::Horizontal => render_horizontal(self.data, area, buf, self.color, self.gain), + Orientation::Vertical => render_vertical(self.data, area, buf, self.color, self.gain), + } + } +} + +fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) { + const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + for x in 0..area.width { + let sample_idx = (x as usize * data.len()) / area.width as usize; + let sample = data.get(sample_idx).copied().unwrap_or(0.0) * gain; + let level = (sample.abs() * 8.0).min(7.0) as usize; + let ch = BLOCKS[level]; + + buf[(area.x + x, area.y)].set_char(ch).set_fg(color); + } +} + +fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gain: f32) { + let width = area.width as usize; + let height = area.height as usize; + let fine_width = width * 2; + let fine_height = height * 4; + + let mut patterns = vec![0u8; width * height]; + + for fine_y in 0..fine_height { + let sample_idx = (fine_y * data.len()) / fine_height; + let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); + + let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize; + let fine_x = fine_x.min(fine_width - 1); + + let char_x = fine_x / 2; + let char_y = fine_y / 4; + let dot_x = fine_x % 2; + let dot_y = fine_y % 4; + + let bit = match (dot_x, dot_y) { + (0, 0) => 0x01, + (0, 1) => 0x02, + (0, 2) => 0x04, + (0, 3) => 0x40, + (1, 0) => 0x08, + (1, 1) => 0x10, + (1, 2) => 0x20, + (1, 3) => 0x80, + _ => unreachable!(), + }; + + patterns[char_y * width + char_x] |= bit; + } + + for cy in 0..height { + for cx in 0..width { + let pattern = patterns[cy * width + cx]; + if pattern != 0 { + let ch = char::from_u32(0x2800 + pattern as u32).unwrap_or(' '); + buf[(area.x + cx as u16, area.y + cy as u16)] + .set_char(ch) + .set_fg(color); + } + } + } +}