seq continues
This commit is contained in:
67
Cargo.lock
generated
67
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
58
seq/docs/keybindings.md
Normal file
58
seq/docs/keybindings.md
Normal 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
115
seq/docs/scripting.md
Normal 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
72
seq/docs/sequencer.md
Normal 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`
|
||||
175
seq/src/app.rs
175
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<usize>,
|
||||
pub queued_pattern: Option<usize>,
|
||||
|
||||
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<SlotChange>,
|
||||
|
||||
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<PathBuf>,
|
||||
pub status_message: Option<String>,
|
||||
pub editor: TextArea<'static>,
|
||||
pub flash_until: Option<Instant>,
|
||||
pub modal: Modal,
|
||||
pub clipboard: Option<arboard::Clipboard>,
|
||||
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<bool> {
|
||||
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) {
|
||||
|
||||
125
seq/src/audio.rs
125
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<LinkState>,
|
||||
playing: Arc<AtomicBool>,
|
||||
project: Arc<Mutex<Project>>,
|
||||
playback_step: Arc<AtomicUsize>,
|
||||
slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
|
||||
event_count: Arc<AtomicUsize>,
|
||||
playback_bank: Arc<AtomicUsize>,
|
||||
playback_pattern: Arc<AtomicUsize>,
|
||||
queued_bank: Arc<AtomicUsize>,
|
||||
queued_pattern: Arc<AtomicUsize>,
|
||||
slot_data: Arc<Mutex<[(bool, usize, usize); MAX_SLOTS]>>,
|
||||
slot_changes: Arc<Mutex<Vec<SlotChange>>>,
|
||||
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;
|
||||
|
||||
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);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 step.active {
|
||||
if let Some(ref cmd) = step.command {
|
||||
engine.lock().unwrap().evaluate(cmd);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let next_step = (audio.step_index + 1) % pattern.length;
|
||||
audio.step_index = next_step;
|
||||
slot.step_index = (slot.step_index + 1) % pattern.length;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
231
seq/src/main.rs
231
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<AtomicUsize>; MAX_SLOTS] = std::array::from_fn(|_| Arc::new(AtomicUsize::new(0)));
|
||||
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 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 {
|
||||
Modal::RenameBank { bank, name } => match key.code {
|
||||
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.select_edit_pattern(selected);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.modal = Modal::None;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
*cursor = (*cursor + 15) % 16;
|
||||
KeyCode::Backspace => {
|
||||
name.pop();
|
||||
}
|
||||
KeyCode::Right => {
|
||||
*cursor = (*cursor + 1) % 16;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
*cursor = (*cursor + 12) % 16;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
*cursor = (*cursor + 4) % 16;
|
||||
KeyCode::Char(c) => {
|
||||
name.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Modal::BankPicker { ref mut cursor } => {
|
||||
match key.code {
|
||||
},
|
||||
Modal::RenamePattern { bank, pattern, name } => match key.code {
|
||||
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.select_edit_bank(selected);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
app.modal = Modal::None;
|
||||
}
|
||||
KeyCode::Left => {
|
||||
*cursor = (*cursor + 15) % 16;
|
||||
KeyCode::Backspace => {
|
||||
name.pop();
|
||||
}
|
||||
KeyCode::Right => {
|
||||
*cursor = (*cursor + 1) % 16;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
*cursor = (*cursor + 12) % 16;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
*cursor = (*cursor + 4) % 16;
|
||||
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 };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Step>,
|
||||
pub length: usize,
|
||||
#[serde(default)]
|
||||
pub speed: PatternSpeed,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
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<Pattern>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Bank {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
patterns: (0..16).map(|_| Pattern::default()).collect(),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
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<String, String> {
|
||||
pub fn evaluate(
|
||||
&self,
|
||||
script: &str,
|
||||
ctx: &StepContext,
|
||||
vars: &Variables,
|
||||
rng: &Rng,
|
||||
) -> Result<String, String> {
|
||||
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::<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());
|
||||
}
|
||||
|
||||
self.engine
|
||||
engine
|
||||
.eval_with_scope::<String>(&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>("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"
|
||||
);
|
||||
|
||||
264
seq/src/ui.rs
264
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::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()
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.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) => {
|
||||
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::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));
|
||||
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 block = Block::default()
|
||||
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(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],
|
||||
.title(format!("Rename Bank {:02}", bank + 1))
|
||||
.border_style(Style::new().fg(Color::Magenta)),
|
||||
);
|
||||
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
134
seq/src/views/doc_view.rs
Normal 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()
|
||||
}
|
||||
@@ -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<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() {
|
||||
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<Constraint> = (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);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod audio_view;
|
||||
pub mod doc_view;
|
||||
pub mod main_view;
|
||||
pub mod patterns_view;
|
||||
|
||||
194
seq/src/views/patterns_view.rs
Normal file
194
seq/src/views/patterns_view.rs
Normal 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
3
seq/src/widgets/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod scope;
|
||||
|
||||
pub use scope::{Orientation, Scope};
|
||||
116
seq/src/widgets/scope.rs
Normal file
116
seq/src/widgets/scope.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user