seq continues

This commit is contained in:
2026-01-19 16:44:17 +01:00
parent 2900f84b7d
commit ac9e64dcb7
18 changed files with 1568 additions and 361 deletions

67
Cargo.lock generated
View File

@@ -455,7 +455,7 @@ dependencies = [
"mlua", "mlua",
"pest", "pest",
"pest_derive", "pest_derive",
"rand", "rand 0.9.2",
"rhai", "rhai",
"rmp-serde", "rmp-serde",
"rosc", "rosc",
@@ -1259,7 +1259,7 @@ source = "git+https://github.com/sourcebox/mi-plaits-dsp-rs?rev=dc55bd55e73bd6f8
dependencies = [ dependencies = [
"dyn-clone", "dyn-clone",
"num-traits", "num-traits",
"spin", "spin 0.10.0",
] ]
[[package]] [[package]]
@@ -1279,6 +1279,15 @@ dependencies = [
"windows 0.56.0", "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]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -1401,6 +1410,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1788,14 +1806,35 @@ dependencies = [
"nibble_vec", "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]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "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]] [[package]]
@@ -1805,7 +1844,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "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]] [[package]]
@@ -1895,6 +1943,7 @@ checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
dependencies = [ dependencies = [
"ahash", "ahash",
"bitflags 2.10.0", "bitflags 2.10.0",
"no-std-compat",
"num-traits", "num-traits",
"once_cell", "once_cell",
"rhai_codegen", "rhai_codegen",
@@ -2043,6 +2092,8 @@ dependencies = [
"cpal", "cpal",
"crossterm", "crossterm",
"doux", "doux",
"minimad",
"rand 0.8.5",
"ratatui", "ratatui",
"rhai", "rhai",
"rusty_link", "rusty_link",
@@ -2206,6 +2257,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "spin" name = "spin"
version = "0.10.0" version = "0.10.0"

View File

@@ -13,8 +13,10 @@ rusty_link = "0.4"
ratatui = "0.29" ratatui = "0.29"
crossterm = "0.28" crossterm = "0.28"
cpal = "0.15" cpal = "0.15"
rhai = "1.24" rhai = { version = "1.24", features = ["sync"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tui-textarea = "0.7" tui-textarea = "0.7"
arboard = "3" arboard = "3"
minimad = "0.13"

58
seq/docs/keybindings.md Normal file
View File

@@ -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

115
seq/docs/scripting.md Normal file
View File

@@ -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))
```

72
seq/docs/sequencer.md Normal file
View File

@@ -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`

View File

@@ -1,13 +1,18 @@
use rand::rngs::StdRng;
use rand::SeedableRng;
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use tui_textarea::TextArea; use tui_textarea::TextArea;
use crate::audio::{SlotChange, MAX_SLOTS};
use crate::file; use crate::file;
use crate::link::LinkState; use crate::link::LinkState;
use crate::model::{Pattern, Project}; use crate::model::{Pattern, Project};
use crate::page::Page; use crate::page::Page;
use crate::script::{ScriptEngine, StepContext}; use crate::script::{Rng, ScriptEngine, StepContext, Variables};
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Focus { pub enum Focus {
@@ -18,11 +23,18 @@ pub enum Focus {
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub enum Modal { pub enum Modal {
None, None,
ConfirmQuit, ConfirmQuit { selected: bool },
SaveAs(String), SaveAs(String),
LoadFrom(String), LoadFrom(String),
PatternPicker { cursor: usize }, RenameBank { bank: usize, name: String },
BankPicker { cursor: usize }, RenamePattern { bank: usize, pattern: usize, name: String },
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum PatternsViewLevel {
#[default]
Banks,
Patterns { bank: usize },
} }
pub struct App { pub struct App {
@@ -38,14 +50,18 @@ pub struct App {
pub focus: Focus, pub focus: Focus,
pub page: Page, pub page: Page,
pub current_step: usize, pub current_step: usize,
pub playback_step: usize,
pub edit_bank: usize, pub edit_bank: usize,
pub edit_pattern: usize, pub edit_pattern: usize,
pub playback_bank: usize,
pub playback_pattern: usize, pub patterns_view_level: PatternsViewLevel,
pub queued_bank: Option<usize>, pub patterns_cursor: usize,
pub queued_pattern: Option<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<SlotChange>,
pub event_count: usize, pub event_count: usize,
pub active_voices: usize, pub active_voices: usize,
pub peak_voices: usize, pub peak_voices: usize,
@@ -54,12 +70,16 @@ pub struct App {
pub sample_pool_mb: f32, pub sample_pool_mb: f32,
pub scope: [f32; 64], pub scope: [f32; 64],
pub script_engine: ScriptEngine, pub script_engine: ScriptEngine,
pub variables: Variables,
pub rng: Rng,
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
pub status_message: Option<String>, pub status_message: Option<String>,
pub editor: TextArea<'static>, pub editor: TextArea<'static>,
pub flash_until: Option<Instant>, pub flash_until: Option<Instant>,
pub modal: Modal, pub modal: Modal,
pub clipboard: Option<arboard::Clipboard>, pub clipboard: Option<arboard::Clipboard>,
pub doc_topic: usize,
pub doc_scroll: usize,
} }
impl App { impl App {
@@ -76,14 +96,17 @@ impl App {
focus: Focus::Sequencer, focus: Focus::Sequencer,
page: Page::default(), page: Page::default(),
current_step: 0, current_step: 0,
playback_step: 0,
edit_bank: 0, edit_bank: 0,
edit_pattern: 0, edit_pattern: 0,
playback_bank: 0,
playback_pattern: 0, patterns_view_level: PatternsViewLevel::default(),
queued_bank: None, patterns_cursor: 0,
queued_pattern: None,
slot_data: [(false, 0, 0); MAX_SLOTS],
slot_steps: [0; MAX_SLOTS],
queued_changes: Vec::new(),
event_count: 0, event_count: 0,
active_voices: 0, active_voices: 0,
peak_voices: 0, peak_voices: 0,
@@ -92,12 +115,16 @@ impl App {
sample_pool_mb: 0.0, sample_pool_mb: 0.0,
scope: [0.0; 64], scope: [0.0; 64],
script_engine: ScriptEngine::new(), script_engine: ScriptEngine::new(),
variables: Arc::new(Mutex::new(HashMap::new())),
rng: Arc::new(Mutex::new(StdRng::seed_from_u64(0))),
file_path: None, file_path: None,
status_message: None, status_message: None,
editor: TextArea::default(), editor: TextArea::default(),
flash_until: None, flash_until: None,
modal: Modal::None, modal: Modal::None,
clipboard: arboard::Clipboard::new().ok(), clipboard: arboard::Clipboard::new().ok(),
doc_topic: 0,
doc_scroll: 0,
} }
} }
@@ -155,17 +182,33 @@ impl App {
pub fn step_up(&mut self) { pub fn step_up(&mut self) {
let len = self.current_edit_pattern().length; let len = self.current_edit_pattern().length;
if self.current_step >= 8 { let num_rows = match len {
self.current_step -= 8; 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 { } 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(); self.load_step_to_editor();
} }
pub fn step_down(&mut self) { pub fn step_down(&mut self) {
let len = self.current_edit_pattern().length; 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(); 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) { fn load_step_to_editor(&mut self) {
let step_idx = self.current_step; let step_idx = self.current_step;
if let Some(step) = self.current_edit_pattern().step(step_idx) { if let Some(step) = self.current_edit_pattern().step(step_idx) {
@@ -223,9 +299,10 @@ impl App {
pattern, pattern,
tempo: self.tempo, tempo: self.tempo,
phase: self.phase, 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) => { Ok(cmd) => {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = Some(cmd); step.command = Some(cmd);
@@ -268,9 +345,10 @@ impl App {
pattern, pattern,
tempo: self.tempo, tempo: self.tempo,
phase: 0.0, 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) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = Some(cmd); step.command = Some(cmd);
} }
@@ -278,14 +356,55 @@ impl App {
} }
} }
pub fn queue_current_for_playback(&mut self) { pub fn is_pattern_queued(&self, bank: usize, pattern: usize) -> Option<bool> {
self.queued_bank = Some(self.edit_bank); self.queued_changes.iter().find_map(|c| match c {
self.queued_pattern = Some(self.edit_pattern); SlotChange::Add { slot: _, bank: b, pattern: p } if *b == bank && *p == pattern => {
self.status_message = Some(format!( Some(true)
"Queued B{:02} P{:02} (next loop)", }
self.edit_bank + 1, SlotChange::Remove { slot } => {
self.edit_pattern + 1 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) { pub fn select_edit_pattern(&mut self, pattern: usize) {

View File

@@ -6,21 +6,34 @@ use std::sync::{Arc, Mutex};
use crate::link::LinkState; use crate::link::LinkState;
use crate::model::Project; 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 { pub struct AudioState {
prev_beat: f64, prev_beat: f64,
step_index: usize, pub slots: [PatternSlot; MAX_SLOTS],
bank: usize,
pattern: usize,
} }
impl AudioState { impl AudioState {
fn new() -> Self { fn new() -> Self {
Self { Self {
prev_beat: -1.0, prev_beat: -1.0,
step_index: 0, slots: [PatternSlot::default(); MAX_SLOTS],
bank: 0,
pattern: 0,
} }
} }
} }
@@ -30,12 +43,12 @@ pub fn build_stream(
link: Arc<LinkState>, link: Arc<LinkState>,
playing: Arc<AtomicBool>, playing: Arc<AtomicBool>,
project: Arc<Mutex<Project>>, project: Arc<Mutex<Project>>,
playback_step: Arc<AtomicUsize>, slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
event_count: Arc<AtomicUsize>, event_count: Arc<AtomicUsize>,
playback_bank: Arc<AtomicUsize>, slot_data: Arc<Mutex<[(bool, usize, usize); MAX_SLOTS]>>,
playback_pattern: Arc<AtomicUsize>, slot_changes: Arc<Mutex<Vec<SlotChange>>>,
queued_bank: Arc<AtomicUsize>, variables: Variables,
queued_pattern: Arc<AtomicUsize>, rng: Rng,
) -> (Stream, f32) { ) -> (Stream, f32) {
let host = cpal::default_host(); let host = cpal::default_host();
let device = host.default_output_device().expect("no output device"); let device = host.default_output_device().expect("no output device");
@@ -50,6 +63,7 @@ pub fn build_stream(
let quantum = 4.0; let quantum = 4.0;
let audio_state = Arc::new(Mutex::new(AudioState::new())); let audio_state = Arc::new(Mutex::new(AudioState::new()));
let script_engine = ScriptEngine::new();
let sr = sample_rate; let sr = sample_rate;
let stream = device let stream = device
@@ -65,44 +79,85 @@ pub fn build_stream(
let state = link.capture_audio_state(); let state = link.capture_audio_state();
let time = link.clock_micros(); let time = link.clock_micros();
let beat = state.beat_at_time(time, quantum); let beat = state.beat_at_time(time, quantum);
let tempo = state.tempo();
let mut audio = audio_state.lock().unwrap(); 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;
if beat_int != prev_beat_int && audio.prev_beat >= 0.0 {
let proj = project.lock().unwrap(); 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); // Apply queued slot changes at bar boundaries (every 4 beats)
playback_bank.store(audio.bank, Ordering::Relaxed); let bar = (beat / quantum).floor() as i64;
playback_pattern.store(audio.pattern, Ordering::Relaxed); 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;
}
}
}
}
// Read prev_beat before the mutable borrow of slots
let prev_beat = audio.prev_beat;
// 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 let Some(step) = pattern.step(step_idx) {
if step.active { if step.active && !step.script.trim().is_empty() {
if let Some(ref cmd) = step.command { let ctx = StepContext {
engine.lock().unwrap().evaluate(cmd); 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); event_count.fetch_add(1, Ordering::Relaxed);
} }
} }
} }
let next_step = (audio.step_index + 1) % pattern.length; slot.step_index = (slot.step_index + 1) % pattern.length;
audio.step_index = next_step; }
}
if next_step == 0 { // Update shared slot data for UI
let qb = queued_bank.load(Ordering::Relaxed); {
let qp = queued_pattern.load(Ordering::Relaxed); let mut sd = slot_data.lock().unwrap();
if qb != usize::MAX && qp != usize::MAX { for (i, slot) in audio.slots.iter().enumerate() {
audio.bank = qb; sd[i] = (slot.active, slot.bank, slot.pattern);
audio.pattern = qp;
audio.step_index = 0;
queued_bank.store(usize::MAX, Ordering::Relaxed);
queued_pattern.store(usize::MAX, Ordering::Relaxed);
}
} }
} }
audio.prev_beat = beat; audio.prev_beat = beat;
} }

View File

@@ -7,6 +7,7 @@ mod page;
mod script; mod script;
mod ui; mod ui;
mod views; mod views;
mod widgets;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
@@ -24,6 +25,7 @@ use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use app::{App, Focus, Modal}; use app::{App, Focus, Modal};
use audio::{SlotChange, MAX_SLOTS};
use link::LinkState; use link::LinkState;
use model::Project; use model::Project;
use page::Page; use page::Page;
@@ -36,12 +38,14 @@ fn main() -> io::Result<()> {
link.enable(); link.enable();
let playing = Arc::new(AtomicBool::new(true)); let playing = Arc::new(AtomicBool::new(true));
let playback_step = Arc::new(AtomicUsize::new(0));
let event_count = 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)); // Slot state shared between audio thread and UI
let queued_bank = Arc::new(AtomicUsize::new(usize::MAX)); let slot_steps: [Arc<AtomicUsize>; MAX_SLOTS] = std::array::from_fn(|_| Arc::new(AtomicUsize::new(0)));
let queued_pattern = Arc::new(AtomicUsize::new(usize::MAX)); let slot_data: Arc<Mutex<[(bool, usize, usize); MAX_SLOTS]>> =
Arc::new(Mutex::new([(false, 0, 0); MAX_SLOTS]));
let slot_changes: Arc<Mutex<Vec<SlotChange>>> = Arc::new(Mutex::new(Vec::new()));
let mut app = App::new(TEMPO, QUANTUM); let mut app = App::new(TEMPO, QUANTUM);
let engine = Arc::new(Mutex::new(Engine::new(44100.0))); let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
@@ -52,12 +56,12 @@ fn main() -> io::Result<()> {
Arc::clone(&link), Arc::clone(&link),
Arc::clone(&playing), Arc::clone(&playing),
Arc::clone(&project), Arc::clone(&project),
Arc::clone(&playback_step), slot_steps.clone(),
Arc::clone(&event_count), Arc::clone(&event_count),
Arc::clone(&playback_bank), Arc::clone(&slot_data),
Arc::clone(&playback_pattern), Arc::clone(&slot_changes),
Arc::clone(&queued_bank), Arc::clone(&app.variables),
Arc::clone(&queued_pattern), Arc::clone(&app.rng),
); );
{ {
@@ -75,7 +79,6 @@ fn main() -> io::Result<()> {
loop { loop {
app.update_from_link(&link); app.update_from_link(&link);
app.playing = playing.load(Ordering::Relaxed); app.playing = playing.load(Ordering::Relaxed);
app.playback_step = playback_step.load(Ordering::Relaxed);
app.event_count = event_count.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); // Sync slot state from audio thread
app.playback_pattern = playback_pattern.load(Ordering::Relaxed); {
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() { // Push queued changes to audio thread
queued_bank.store(app.queued_bank.unwrap(), Ordering::Relaxed); if !app.queued_changes.is_empty() {
queued_pattern.store(app.queued_pattern.unwrap(), Ordering::Relaxed); let mut changes = slot_changes.lock().unwrap();
app.queued_bank = None; changes.extend(app.queued_changes.drain(..));
app.queued_pattern = None;
} }
{ {
@@ -111,11 +119,21 @@ fn main() -> io::Result<()> {
app.clear_status(); app.clear_status();
match &mut app.modal { 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('y') | KeyCode::Char('Y') => break,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.modal = Modal::None; 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 { Modal::SaveAs(path) => match key.code {
@@ -152,56 +170,42 @@ fn main() -> io::Result<()> {
} }
_ => {} _ => {}
}, },
Modal::PatternPicker { ref mut cursor } => { Modal::RenameBank { bank, name } => match key.code {
match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let selected = *cursor; 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; app.modal = Modal::None;
app.select_edit_pattern(selected);
} }
KeyCode::Esc => { KeyCode::Esc => {
app.modal = Modal::None; app.modal = Modal::None;
} }
KeyCode::Left => { KeyCode::Backspace => {
*cursor = (*cursor + 15) % 16; name.pop();
} }
KeyCode::Right => { KeyCode::Char(c) => {
*cursor = (*cursor + 1) % 16; name.push(c);
}
KeyCode::Up => {
*cursor = (*cursor + 12) % 16;
}
KeyCode::Down => {
*cursor = (*cursor + 4) % 16;
} }
_ => {} _ => {}
} },
} Modal::RenamePattern { bank, pattern, name } => match key.code {
Modal::BankPicker { ref mut cursor } => {
match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let selected = *cursor; 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; app.modal = Modal::None;
app.select_edit_bank(selected);
} }
KeyCode::Esc => { KeyCode::Esc => {
app.modal = Modal::None; app.modal = Modal::None;
} }
KeyCode::Left => { KeyCode::Backspace => {
*cursor = (*cursor + 15) % 16; name.pop();
} }
KeyCode::Right => { KeyCode::Char(c) => {
*cursor = (*cursor + 1) % 16; name.push(c);
}
KeyCode::Up => {
*cursor = (*cursor + 12) % 16;
}
KeyCode::Down => {
*cursor = (*cursor + 4) % 16;
} }
_ => {} _ => {}
} },
}
Modal::None => { Modal::None => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -213,12 +217,20 @@ fn main() -> io::Result<()> {
app.page.right(); app.page.right();
continue; continue;
} }
if ctrl && key.code == KeyCode::Up {
app.page.up();
continue;
}
if ctrl && key.code == KeyCode::Down {
app.page.down();
continue;
}
match app.page { match app.page {
Page::Main => match app.focus { Page::Main => match app.focus {
Focus::Sequencer => match key.code { Focus::Sequencer => match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.modal = Modal::ConfirmQuit; app.modal = Modal::ConfirmQuit { selected: false };
} }
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
app.toggle_playing(); app.toggle_playing();
@@ -230,16 +242,6 @@ fn main() -> io::Result<()> {
KeyCode::Up => app.step_up(), KeyCode::Up => app.step_up(),
KeyCode::Down => app.step_down(), KeyCode::Down => app.step_down(),
KeyCode::Enter => app.toggle_step(), 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') => { KeyCode::Char('s') => {
let default = app let default = app
.file_path .file_path
@@ -253,6 +255,14 @@ fn main() -> io::Result<()> {
} }
KeyCode::Char('+') | KeyCode::Char('=') => app.tempo_up(&link), KeyCode::Char('+') | KeyCode::Char('=') => app.tempo_up(&link),
KeyCode::Char('-') => app.tempo_down(&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('c') if ctrl => app.copy_step(),
KeyCode::Char('v') if ctrl => app.paste_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 { Page::Audio => match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.modal = Modal::ConfirmQuit; app.modal = Modal::ConfirmQuit { selected: false };
} }
KeyCode::Char('h') => { KeyCode::Char('h') => {
engine.lock().unwrap().hush(); 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 };
}
_ => {}
}
}
} }
} }
} }

View File

@@ -1,5 +1,67 @@
use serde::{Deserialize, Serialize}; 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)] #[derive(Clone, Serialize, Deserialize)]
pub struct Step { pub struct Step {
pub active: bool, pub active: bool,
@@ -22,13 +84,19 @@ impl Default for Step {
pub struct Pattern { pub struct Pattern {
pub steps: Vec<Step>, pub steps: Vec<Step>,
pub length: usize, pub length: usize,
#[serde(default)]
pub speed: PatternSpeed,
#[serde(default)]
pub name: Option<String>,
} }
impl Default for Pattern { impl Default for Pattern {
fn default() -> Self { fn default() -> Self {
Self { Self {
steps: (0..16).map(|_| Step::default()).collect(), steps: (0..32).map(|_| Step::default()).collect(),
length: 16, length: 16,
speed: PatternSpeed::default(),
name: None,
} }
} }
} }
@@ -42,9 +110,8 @@ impl Pattern {
self.steps.get_mut(index) self.steps.get_mut(index)
} }
#[allow(dead_code)]
pub fn set_length(&mut self, length: usize) { 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 { while self.steps.len() < length {
self.steps.push(Step::default()); self.steps.push(Step::default());
} }
@@ -55,12 +122,15 @@ impl Pattern {
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Bank { pub struct Bank {
pub patterns: Vec<Pattern>, pub patterns: Vec<Pattern>,
#[serde(default)]
pub name: Option<String>,
} }
impl Default for Bank { impl Default for Bank {
fn default() -> Self { fn default() -> Self {
Self { Self {
patterns: (0..16).map(|_| Pattern::default()).collect(), patterns: (0..16).map(|_| Pattern::default()).collect(),
name: None,
} }
} }
} }

View File

@@ -2,21 +2,37 @@
pub enum Page { pub enum Page {
#[default] #[default]
Main, Main,
Patterns,
Audio, Audio,
Doc,
} }
impl Page { impl Page {
pub fn left(&mut self) { pub fn left(&mut self) {
*self = match self { *self = match self {
Page::Main => Page::Audio, Page::Main | Page::Patterns => Page::Doc,
Page::Audio => Page::Audio, Page::Audio => Page::Main,
Page::Doc => Page::Audio,
} }
} }
pub fn right(&mut self) { pub fn right(&mut self) {
*self = match self { *self = match self {
Page::Main => Page::Main, Page::Main | Page::Patterns => Page::Audio,
Page::Audio => Page::Main, 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;
} }
} }
} }

View File

@@ -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<Mutex<HashMap<String, Dynamic>>>;
pub type Rng = Arc<Mutex<StdRng>>;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Cmd { pub struct Cmd {
@@ -16,6 +23,18 @@ impl Cmd {
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 { fn set(&mut self, key: &str, val: &str) -> Self {
self.pairs.push((key.into(), val.into())); self.pairs.push((key.into(), val.into()));
self.clone() self.clone()
@@ -40,21 +59,23 @@ pub struct StepContext {
pub pattern: usize, pub pattern: usize,
pub tempo: f64, pub tempo: f64,
pub phase: f64, pub phase: f64,
pub slot: usize,
} }
pub struct ScriptEngine { pub struct ScriptEngine;
engine: Engine,
}
impl ScriptEngine { impl ScriptEngine {
pub fn new() -> Self { pub fn new() -> Self {
let mut engine = Engine::new(); Self
engine.set_max_expr_depths(64, 32);
register_cmd(&mut engine);
Self { engine }
} }
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<String, String> { pub fn evaluate(
&self,
script: &str,
ctx: &StepContext,
vars: &Variables,
rng: &Rng,
) -> Result<String, String> {
if script.trim().is_empty() { if script.trim().is_empty() {
return Err("empty script".to_string()); return Err("empty script".to_string());
} }
@@ -66,12 +87,60 @@ impl ScriptEngine {
scope.push("pattern", ctx.pattern as i64); scope.push("pattern", ctx.pattern as i64);
scope.push("tempo", ctx.tempo); scope.push("tempo", ctx.tempo);
scope.push("phase", ctx.phase); scope.push("phase", ctx.phase);
scope.push("slot", ctx.slot as i64);
if let Ok(cmd) = self.engine.eval_with_scope::<Cmd>(&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::<Cmd>(&mut scope, script) {
return Ok(cmd.to_string()); return Ok(cmd.to_string());
} }
self.engine engine
.eval_with_scope::<String>(&mut scope, script) .eval_with_scope::<String>(&mut scope, script)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -80,6 +149,11 @@ impl ScriptEngine {
fn register_cmd(engine: &mut Engine) { fn register_cmd(engine: &mut Engine) {
engine.register_type_with_name::<Cmd>("Cmd"); engine.register_type_with_name::<Cmd>("Cmd");
engine.register_fn("sound", Cmd::with); 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 { macro_rules! reg_both {
($($name:expr),*) => { ($($name:expr),*) => {
@@ -99,17 +173,18 @@ fn register_cmd(engine: &mut Engine) {
"lpf", "lpq", "lpe", "lpa", "lpd", "lps", "lpr", "lpf", "lpq", "lpe", "lpa", "lpd", "lps", "lpr",
"hpf", "hpq", "hpe", "hpa", "hpd", "hps", "hpr", "hpf", "hpq", "hpe", "hpa", "hpd", "hps", "hpr",
"bpf", "bpq", "bpe", "bpa", "bpd", "bps", "bpr", "bpf", "bpq", "bpe", "bpa", "bpd", "bps", "bpr",
"ftype",
"penv", "patt", "pdec", "psus", "prel", "penv", "patt", "pdec", "psus", "prel",
"vib", "vibmod", "vib", "vibmod", "vibshape",
"fm", "fmh", "fme", "fma", "fmd", "fms", "fmr", "fm", "fmh", "fmshape", "fme", "fma", "fmd", "fms", "fmr",
"am", "amdepth", "am", "amdepth", "amshape",
"rm", "rmdepth", "rm", "rmdepth", "rmshape",
"phaser", "phaserdepth", "phasersweep", "phasercenter", "phaser", "phaserdepth", "phasersweep", "phasercenter",
"flanger", "flangerdepth", "flangerfeedback", "flanger", "flangerdepth", "flangerfeedback",
"chorus", "chorusdepth", "chorusdelay", "chorus", "chorusdepth", "chorusdelay",
"comb", "combfreq", "combfeedback", "combdamp", "comb", "combfreq", "combfeedback", "combdamp",
"coarse", "crush", "fold", "wrap", "distort", "distortvol", "coarse", "crush", "fold", "wrap", "distort", "distortvol",
"delay", "delaytime", "delayfeedback", "delay", "delaytime", "delayfeedback", "delaytype",
"verb", "verbdecay", "verbdamp", "verbpredelay", "verbdiff", "verb", "verbdecay", "verbdamp", "verbpredelay", "verbdiff",
"voice", "orbit", "note", "size", "n", "cut" "voice", "orbit", "note", "size", "n", "cut"
); );

View File

@@ -6,45 +6,33 @@ use ratatui::Frame;
use crate::app::{App, Modal}; use crate::app::{App, Modal};
use crate::page::Page; 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) { pub fn render(frame: &mut Frame, app: &mut App) {
let [header_area, scope_area, body_area, footer_area] = Layout::vertical([ let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(3), Constraint::Length(1),
Constraint::Length(2),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(3), Constraint::Length(3),
]) ])
.areas(frame.area()); .areas(frame.area());
render_header(frame, app, header_area); render_header(frame, app, header_area);
render_scope(frame, app, scope_area);
match app.page { match app.page {
Page::Main => main_view::render(frame, app, body_area), 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::Audio => audio_view::render(frame, app, body_area),
Page::Doc => doc_view::render(frame, app, body_area),
} }
render_footer(frame, app, footer_area); render_footer(frame, app, footer_area);
render_modal(frame, app); 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) { 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_symbol = if app.playing { "" } else { "" };
let play_color = if app.playing { let play_color = if app.playing {
Color::Green Color::Green
@@ -61,57 +49,43 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
Color::Green Color::Green
}; };
let mut spans = vec![ let left_spans = vec![
Span::styled("EDIT ", Style::new().fg(Color::Cyan)), Span::styled("EDIT ", Style::new().fg(Color::Cyan)),
Span::styled( Span::styled(
format!("B{:02}:P{:02}", app.edit_bank + 1, app.edit_pattern + 1), format!("B{:02}:P{:02}", app.edit_bank + 1, app.edit_pattern + 1),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
), ),
Span::raw(" "), Span::raw(" "),
Span::styled("PLAY ", Style::new().fg(play_color)), Span::styled(play_symbol, 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),
),
]; ];
if app.queued_bank.is_some() { frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
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),
));
}
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::raw(" "),
Span::styled(format!("{:.1} BPM", app.tempo), Style::new().fg(Color::Magenta)), Span::styled(format!("{:.1} BPM", app.tempo), Style::new().fg(Color::Magenta)),
Span::raw(" "), Span::raw(" "),
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)), Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
Span::raw(" "), Span::raw(" "),
Span::styled(format!("V:{}", app.active_voices), Style::new().fg(Color::Cyan)), Span::styled(format!("V:{}", app.active_voices), Style::new().fg(Color::Cyan)),
]); ];
let header = Paragraph::new(Line::from(spans)) frame.render_widget(
.block(Block::default().borders(Borders::ALL).title("seq")); Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right),
right_area,
frame.render_widget(header, area); );
} }
fn render_footer(frame: &mut Frame, app: &App, area: Rect) { fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let page_indicator = match app.page { let page_indicator = match app.page {
Page::Main => "[MAIN] ", Page::Main => "[MAIN] ",
Page::Patterns => "[PATTERNS] ",
Page::Audio => "[AUDIO] ", Page::Audio => "[AUDIO] ",
Page::Doc => "[DOC] ",
}; };
let content = if let Some(ref msg) = app.status_message { 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(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)), Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
Span::raw(":nav "), Span::raw(":nav "),
Span::styled("p", Style::new().fg(Color::Yellow)), Span::styled("<>", Style::new().fg(Color::Yellow)),
Span::raw(":pat "), Span::raw(":len "),
Span::styled("b", Style::new().fg(Color::Yellow)), Span::styled("[]", Style::new().fg(Color::Yellow)),
Span::raw(":bank "), Span::raw(":spd "),
Span::styled("g", Style::new().fg(Color::Yellow)),
Span::raw(":go "),
Span::styled("Enter", Style::new().fg(Color::Yellow)),
Span::raw(":toggle "),
Span::styled("Tab", Style::new().fg(Color::Yellow)), Span::styled("Tab", Style::new().fg(Color::Yellow)),
Span::raw(":focus "), Span::raw(":focus "),
Span::styled("s/l", Style::new().fg(Color::Yellow)), 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![ Page::Audio => Line::from(vec![
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)), 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::styled("C-←→", Style::new().fg(Color::Yellow)),
Span::raw(":page"), 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(); let term = frame.area();
match &app.modal { match &app.modal {
Modal::None => {} Modal::None => {}
Modal::ConfirmQuit => { Modal::ConfirmQuit { selected } => {
let width = 30.min(term.width.saturating_sub(4)); let width = 30.min(term.width.saturating_sub(4));
let height = 5.min(term.height.saturating_sub(4)); let height = 5.min(term.height.saturating_sub(4));
let area = centered_rect(width, height, term); let area = centered_rect(width, height, term);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from("Quit? (y/n)"))
.alignment(Alignment::Center) let block = Block::default()
.block(
Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title("Confirm") .title("Confirm")
.border_style(Style::new().fg(Color::Yellow)), .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],
); );
frame.render_widget(modal, area);
} }
Modal::SaveAs(path) => { Modal::SaveAs(path) => {
let width = (term.width * 60 / 100).clamp(40, 70).min(term.width.saturating_sub(4)); 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); frame.render_widget(modal, area);
} }
Modal::PatternPicker { cursor } => { Modal::RenameBank { bank, name } => {
render_picker_modal( let width = 40.min(term.width.saturating_sub(4));
frame, let height = 5.min(term.height.saturating_sub(4));
&format!("Select Pattern (Bank {:02})", app.edit_bank + 1),
*cursor,
app.edit_pattern,
app.playback_pattern,
app.edit_bank == app.playback_bank,
);
}
Modal::BankPicker { cursor } => {
render_picker_modal(
frame,
"Select Bank",
*cursor,
app.edit_bank,
app.playback_bank,
true,
);
}
}
}
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); let area = centered_rect(width, height, term);
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from(vec![
let block = Block::default() Span::raw("> "),
Span::styled(name, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(title) .title(format!("Rename Bank {:02}", bank + 1))
.border_style(Style::new().fg(Color::Rgb(100, 160, 180))); .border_style(Style::new().fg(Color::Magenta)),
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],
); );
frame.render_widget(modal, area);
}
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);
}
}
} }

134
seq/src/views/doc_view.rs Normal file
View File

@@ -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<ListItem> = 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<RLine> = 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<RLine<'static>> {
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<Span<'static>> = 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()
}

View File

@@ -4,13 +4,21 @@ use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::{App, Focus}; use crate::app::{App, Focus};
use crate::widgets::{Orientation, Scope};
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { 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] = 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_sequencer(frame, app, seq_area);
render_editor(frame, app, editor_area); render_editor(frame, app, editor_area);
render_scope(frame, app, scope_area);
} }
fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) { 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; return;
} }
let rows = Layout::vertical([ let pattern = app.current_edit_pattern();
Constraint::Fill(1), let length = pattern.length;
Constraint::Length(1), let num_rows = match length {
Constraint::Fill(1), 0..=8 => 1,
]) 9..=16 => 2,
.split(inner); 17..=24 => 3,
_ => 4,
};
let steps_per_row = length.div_ceil(num_rows);
let row_areas = [rows[0], rows[2]]; let row_constraints: Vec<Constraint> = (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() { for row_idx in 0..num_rows {
let col_constraints = [ let row_area = rows[row_idx * 2];
Constraint::Fill(1), let start_step = row_idx * steps_per_row;
Constraint::Length(1), let end_step = (start_step + steps_per_row).min(length);
Constraint::Fill(1), let cols_in_row = end_step - start_step;
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);
let tile_indices = [0, 2, 4, 6, 8, 10, 12, 14]; let col_constraints: Vec<Constraint> = (0..cols_in_row * 2 - 1)
for (col_idx, &col_layout_idx) in tile_indices.iter().enumerate() { .map(|i| {
let step_idx = row_idx * 8 + col_idx; if i % 2 == 0 {
render_tile(frame, cols[col_layout_idx], app, step_idx); 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_active = step.map(|s| s.active).unwrap_or(false);
let is_selected = step_idx == app.current_step; let is_selected = step_idx == app.current_step;
let same_pattern = // Check if any slot is playing this step on the current edit pattern
app.edit_bank == app.playback_bank && app.edit_pattern == app.playback_pattern; let playing_slot = if app.playing {
let is_playing = app.playing && same_pattern && step_idx == app.playback_step; (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) { let (bg, fg) = match (is_playing, is_active, is_selected) {
(true, true, _) => (Color::Rgb(195, 85, 65), Color::White), (true, true, _) => (Color::Rgb(195, 85, 65), Color::White),
(true, false, _) => (Color::Rgb(180, 120, 45), Color::Black), (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, 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)), (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); 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);
}

View File

@@ -1,2 +1,4 @@
pub mod audio_view; pub mod audio_view;
pub mod doc_view;
pub mod main_view; pub mod main_view;
pub mod patterns_view;

View File

@@ -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<usize> = app
.slot_data
.iter()
.filter(|(active, _, _)| *active)
.map(|(_, bank, _)| *bank)
.collect();
let bank_names: Vec<Option<&str>> = 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<usize> = 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<Option<&str>> = 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]);
}
}
}

3
seq/src/widgets/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
mod scope;
pub use scope::{Orientation, Scope};

116
seq/src/widgets/scope.rs Normal file
View File

@@ -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);
}
}
}
}