Feat: lots of things, preparing for live gig
Some checks failed
Deploy Website / deploy (push) Failing after 4m50s

This commit is contained in:
2026-02-15 11:23:11 +01:00
parent 10ca567ac5
commit 670ae0b6b6
59 changed files with 1414 additions and 96 deletions

View File

@@ -49,7 +49,7 @@ cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" }
doux = { git = "https://github.com/Bubobubobubobubo/doux.git", features = ["native"] }
doux = { path = "/Users/bubo/doux", features = ["native"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"

View File

@@ -80,7 +80,9 @@ pub enum Op {
Mtof,
Ftom,
SetTempo,
Every,
Every(Option<SourceSpan>),
Bjork(Option<SourceSpan>),
PBjork(Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>),
When,
Unless,

View File

@@ -838,13 +838,36 @@ impl Forth {
stack.push(Value::Int(if result { 1 } else { 0 }, None));
}
Op::Every => {
Op::Every(word_span) => {
let n = pop_int(stack)?;
let quot = pop(stack)?;
if n <= 0 {
return Err("every count must be > 0".into());
}
let result = ctx.iter as i64 % n == 0;
stack.push(Value::Int(if result { 1 } else { 0 }, None));
record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result));
if result {
run_quotation(quot, stack, outputs, cmd)?;
}
}
Op::Bjork(word_span) | Op::PBjork(word_span) => {
let n = pop_int(stack)?;
let k = pop_int(stack)?;
let quot = pop(stack)?;
if n <= 0 || k < 0 {
return Err("bjork: n must be > 0, k must be >= 0".into());
}
let counter = match &ops[pc] {
Op::Bjork(_) => ctx.runs,
_ => ctx.iter,
};
let pos = counter % n as usize;
let hit = k >= n || euclidean_hit(k as usize, n as usize, pos);
record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(hit));
if hit {
run_quotation(quot, stack, outputs, cmd)?;
}
}
Op::Quotation(quote_ops, body_span) => {
@@ -1424,6 +1447,13 @@ fn emit_output(
outputs.push(out);
}
fn euclidean_hit(k: usize, n: usize, pos: usize) -> bool {
if k == 0 {
return false;
}
((pos + 1) * k) / n != (pos * k) / n
}
fn euclidean_rhythm(k: usize, n: usize, rotation: usize) -> Vec<i64> {
if k == 0 || n == 0 {
return Vec::new();

View File

@@ -68,7 +68,9 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"choose" => Op::Choose(None),
"bounce" => Op::Bounce(None),
"wchoose" => Op::WChoose(None),
"every" => Op::Every,
"every" => Op::Every(None),
"bjork" => Op::Bjork(None),
"pbjork" => Op::PBjork(None),
"chance" => Op::ChanceExec(None),
"prob" => Op::ProbExec(None),
"coin" => Op::Coin(None),
@@ -192,7 +194,9 @@ fn attach_span(op: &mut Op, span: SourceSpan) {
match op {
Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s)
| Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s)
| Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) => *s = Some(span),
| Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s)
| Op::Every(s)
| Op::Bjork(s) | Op::PBjork(s) => *s = Some(span),
_ => {}
}
}

View File

@@ -839,6 +839,36 @@ pub(super) const WORDS: &[Word] = &[
compile: Param,
varargs: true,
},
Word {
name: "smear",
aliases: &[],
category: "Mod FX",
stack: "(v.. --)",
desc: "Set smear allpass chain wet/dry mix (0=bypass, 1=full wet)",
example: "0.5 smear",
compile: Param,
varargs: true,
},
Word {
name: "smearfreq",
aliases: &[],
category: "Mod FX",
stack: "(v.. --)",
desc: "Set smear allpass break frequency in Hz",
example: "800 smearfreq",
compile: Param,
varargs: true,
},
Word {
name: "smearfb",
aliases: &[],
category: "Mod FX",
stack: "(v.. --)",
desc: "Set smear feedback for resonance (0-0.95)",
example: "0.8 smearfb",
compile: Param,
varargs: true,
},
Word {
name: "chorus",
aliases: &[],

View File

@@ -198,9 +198,29 @@ pub(super) const WORDS: &[Word] = &[
name: "every",
aliases: &[],
category: "Time",
stack: "(n -- bool)",
desc: "True every nth iteration",
example: "4 every",
stack: "(quot n --)",
desc: "Execute quotation every nth iteration",
example: "{ 2 distort } 4 every",
compile: Simple,
varargs: false,
},
Word {
name: "bjork",
aliases: &[],
category: "Time",
stack: "(quot k n --)",
desc: "Execute quotation using Euclidean distribution over step runs",
example: "{ 2 distort } 3 8 bjork",
compile: Simple,
varargs: false,
},
Word {
name: "pbjork",
aliases: &[],
category: "Time",
stack: "(quot k n --)",
desc: "Execute quotation using Euclidean distribution over pattern iterations",
example: "{ 2 distort } 3 8 pbjork",
compile: Simple,
varargs: false,
},

View File

@@ -3,7 +3,7 @@ mod project;
pub const MAX_BANKS: usize = 32;
pub const MAX_PATTERNS: usize = 32;
pub const MAX_STEPS: usize = 128;
pub const MAX_STEPS: usize = 1024;
pub const DEFAULT_LENGTH: usize = 16;
pub use file::{load, save, FileError};

View File

@@ -220,6 +220,10 @@ impl Step {
pub fn is_default(&self) -> bool {
self.active && self.script.is_empty() && self.source.is_none() && self.name.is_none()
}
pub fn has_content(&self) -> bool {
!self.script.is_empty()
}
}
impl Default for Step {

View File

@@ -9,7 +9,7 @@ thread_local! {
static PATTERNS: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
#[allow(dead_code)]
#[derive(Clone, Copy)]
pub enum Orientation {
Horizontal,
Vertical,

View File

@@ -63,6 +63,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(200, 235, 235),
active_fg: teal,
content_bg: Color::Rgb(185, 225, 225),
inactive_bg: surface0,
inactive_fg: subtext0,
active_selected_bg: Color::Rgb(215, 210, 240),

View File

@@ -63,6 +63,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(40, 55, 55),
active_fg: teal,
content_bg: Color::Rgb(47, 62, 62),
inactive_bg: surface0,
inactive_fg: subtext0,
active_selected_bg: Color::Rgb(70, 60, 80),

View File

@@ -57,6 +57,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(50, 70, 70),
active_fg: cyan,
content_bg: Color::Rgb(57, 77, 77),
inactive_bg: current_line,
inactive_fg: comment,
active_selected_bg: Color::Rgb(80, 70, 95),

View File

@@ -60,6 +60,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(14, 28, 26),
active_fg: cyan,
content_bg: Color::Rgb(21, 35, 33),
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(22, 36, 28),

View File

@@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(20, 30, 30),
active_fg: cyan,
content_bg: Color::Rgb(27, 37, 37),
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(40, 30, 35),

View File

@@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(70, 100, 100),
active_fg: mint,
content_bg: Color::Rgb(77, 107, 107),
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(120, 90, 130),

View File

@@ -62,6 +62,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(0, 16, 32),
active_fg: lightblue,
content_bg: Color::Rgb(7, 23, 39),
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(10, 20, 36),

View File

@@ -59,6 +59,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(50, 65, 55),
active_fg: aqua,
content_bg: Color::Rgb(57, 72, 62),
inactive_bg: bg1,
inactive_fg: fg3,
active_selected_bg: Color::Rgb(85, 70, 60),

View File

@@ -54,6 +54,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: black,
active_bg: Color::Rgb(200, 50, 50),
active_fg: yellow,
content_bg: Color::Rgb(210, 60, 60),
inactive_bg: dark_red,
inactive_fg: gold,
active_selected_bg: Color::Rgb(200, 200, 0),

View File

@@ -59,6 +59,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: fg_dim,
active_bg: Color::Rgb(45, 55, 70),
active_fg: crystal_blue,
content_bg: Color::Rgb(52, 62, 77),
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(65, 55, 70),

View File

@@ -59,6 +59,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: Color::Rgb(180, 140, 20),
active_bg: Color::Rgb(210, 235, 240),
active_fg: function,
content_bg: Color::Rgb(195, 225, 230),
inactive_bg: surface,
inactive_fg: text_dim,
active_selected_bg: Color::Rgb(210, 215, 245),

View File

@@ -139,6 +139,7 @@ pub struct TileColors {
pub playing_inactive_fg: Color,
pub active_bg: Color,
pub active_fg: Color,
pub content_bg: Color,
pub inactive_bg: Color,
pub inactive_fg: Color,
pub active_selected_bg: Color,

View File

@@ -56,6 +56,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: medium,
active_bg: Color::Rgb(45, 45, 45),
active_fg: bright,
content_bg: Color::Rgb(55, 55, 55),
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(80, 80, 80),

View File

@@ -56,6 +56,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: medium,
active_bg: Color::Rgb(210, 210, 210),
active_fg: dark,
content_bg: Color::Rgb(195, 195, 195),
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(170, 170, 170),

View File

@@ -57,6 +57,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(55, 75, 70),
active_fg: blue,
content_bg: Color::Rgb(62, 82, 77),
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(85, 65, 80),

View File

@@ -57,6 +57,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: aurora_yellow,
active_bg: Color::Rgb(50, 65, 65),
active_fg: frost0,
content_bg: Color::Rgb(57, 72, 72),
inactive_bg: polar_night1,
inactive_fg: snow_storm0,
active_selected_bg: Color::Rgb(75, 75, 95),

View File

@@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(15, 40, 40),
active_fg: cyan,
content_bg: Color::Rgb(22, 47, 47),
inactive_bg: surface,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(45, 40, 55),

View File

@@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: subtle,
active_bg: Color::Rgb(35, 50, 60),
active_fg: foam,
content_bg: Color::Rgb(42, 57, 67),
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(60, 50, 70),

View File

@@ -58,6 +58,7 @@ pub fn theme() -> ThemeColors {
playing_inactive_fg: yellow,
active_bg: Color::Rgb(45, 60, 75),
active_fg: blue,
content_bg: Color::Rgb(52, 67, 82),
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(70, 55, 85),

View File

@@ -123,6 +123,7 @@ pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
playing_inactive_fg: rotate_color(theme.tile.playing_inactive_fg, degrees),
active_bg: rotate_color(theme.tile.active_bg, degrees),
active_fg: rotate_color(theme.tile.active_fg, degrees),
content_bg: rotate_color(theme.tile.content_bg, degrees),
inactive_bg: rotate_color(theme.tile.inactive_bg, degrees),
inactive_fg: rotate_color(theme.tile.inactive_fg, degrees),
active_selected_bg: rotate_color(theme.tile.active_selected_bg, degrees),

View File

@@ -12,10 +12,10 @@ Cagire organizes all your patterns and data following a strict hierarchy:
Project
└── 32 Banks
└── 32 Patterns (per bank)
└── 128 Steps (per pattern)
└── 1024 Steps (per pattern)
```
A single project gives you 32 banks, each holding 32 patterns. You get 1024 patterns in each project, ~131.000 steps.
A single project gives you 32 banks, each holding 32 patterns. You get 1024 patterns in each project, ~1.048.000 steps.
## Patterns
@@ -23,7 +23,7 @@ Each pattern is an independent sequence of steps with its own properties:
| Property | Description | Default |
|----------|-------------|---------|
| Length | Steps before the pattern loops (`1`-`128`) | `16` |
| Length | Steps before the pattern loops (`1`-`1024`) | `16` |
| Speed | Playback rate (`1/8x` to `8x`) | `1x` |
| Quantization | When the pattern launches | `Bar` |
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |

View File

@@ -8,9 +8,9 @@ Cagire can run multiple patterns concurrently. Each pattern contains a given num
- **32 Banks**
- **32 Patterns** per bank
- **128 Steps** per pattern
- **1024 Steps** per pattern
That's over 130,000 possible steps per project. Most of my sessions use 15-20 at best.
That's over 1,000,000 possible steps per project. Most of my sessions use 15-20 at best.
## What does a script look like?

View File

@@ -36,4 +36,5 @@ These work on most views:
- `Arrow keys` - move or scroll
- `Tab` - switch focus between panels
- `/` or `Ctrl+f` - search (where available)
- `:` - jump to step number (sequencer view)
- `q` - quit (with confirmation)

View File

@@ -175,6 +175,20 @@ Classic Forth is deterministic. Cagire has built-in randomness:
These words take a quotation and execute it probabilistically.
## Periodic Execution
Execute a quotation on specific iterations:
```forth
{ "snare" s . } 4 every ;; every 4th pattern iteration
{ "hat" s . } 3 8 bjork ;; Euclidean: 3 hits across 8 step runs
{ "hat" s . } 5 8 pbjork ;; Euclidean: 5 hits across 8 pattern iterations
```
`every` checks the pattern iteration count. On iteration 0, 4, 8, 12... the quotation runs. On all other iterations it is skipped.
`bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits as evenly as possible across n positions. `bjork` counts by step runs, `pbjork` counts by pattern iterations. Classic Euclidean rhythms: tresillo (3,8), cinquillo (5,8), son clave (5,16).
## Cycling
Cagire has built-in support for cycling through values. Push values onto the stack, then select one based on pattern state:

181
docs/tutorial_generators.md Normal file
View File

@@ -0,0 +1,181 @@
# Generators & Sequences
Sequences of values drive music: arpeggios, parameter sweeps, rhythmic patterns. Cagire has dedicated words for building sequences on the stack, transforming them, and collapsing them to single values.
## Ranges
`..` pushes an integer range onto the stack. Both endpoints are inclusive. If start exceeds end, it counts down:
```forth
1 5 .. ;; 1 2 3 4 5
5 1 .. ;; 5 4 3 2 1
0 7 .. ;; 0 1 2 3 4 5 6 7
```
`.,` adds a step parameter. Works with floats:
```forth
0 1 0.25 ., ;; 0 0.25 0.5 0.75 1
0 10 2 ., ;; 0 2 4 6 8 10
1 0 0.5 ., ;; 1 0.5 0 (descending)
```
`geom..` builds a geometric sequence. Takes start, ratio, and count:
```forth
1 2 4 geom.. ;; 1 2 4 8
100 0.5 4 geom.. ;; 100 50 25 12.5
```
Musical use -- build a harmonic series:
```forth
110 2 5 geom.. 5 rev note
```
That gives you 110, 220, 440, 880, 1760 (reversed), ready to feed into `note` or `freq`.
## Computed Sequences
`gen` executes a quotation n times and collects all results. The quotation must push exactly one value per call:
```forth
{ 1 6 rand } 4 gen ;; 4 random values between 1 and 6
{ coin } 8 gen ;; 8 random 0s and 1s
```
Contrast with `times`, which executes for side effects and does not collect. `times` sets `@i` to the current index:
```forth
4 { @i } times ;; 0 1 2 3 (pushes @i each iteration)
4 { @i 60 + note sine s . } times ;; plays 4 notes, collects nothing
```
The distinction: `gen` is for building data. `times` is for doing things.
## Euclidean Patterns
`euclid` distributes k hits evenly across n positions and pushes the hit indices:
```forth
3 8 euclid ;; 0 3 5
4 8 euclid ;; 0 2 4 6
5 8 euclid ;; 0 1 3 5 6
```
`euclidrot` adds a rotation parameter that shifts the pattern:
```forth
3 8 0 euclidrot ;; 0 3 5 (no rotation)
3 8 1 euclidrot ;; 1 4 6
3 8 2 euclidrot ;; 1 4 6
```
These give you raw indices as data on the stack. This is different from `bjork` and `pbjork` (covered in the Randomness tutorial), which execute a quotation on matching steps. `euclid` gives you numbers to work with; `bjork` triggers actions.
Use euclid indices to pick notes from a scale:
```forth
: pick ( ..vals n i -- val ) rot drop swap ;
c4 d4 e4 g4 a4 ;; pentatonic scale on the stack
3 8 euclid ;; get 3 hit positions
```
## Transforming Sequences
Four words reshape values already on the stack. All take n (the count of items to operate on) from the top:
`rev` reverses order:
```forth
1 2 3 4 4 rev ;; 4 3 2 1
c4 e4 g4 3 rev ;; g4 e4 c4 (descending arpeggio)
```
`shuffle` randomizes order:
```forth
c4 e4 g4 b4 4 shuffle ;; random permutation each time
```
`sort` and `rsort` for ascending and descending:
```forth
3 1 4 1 5 5 sort ;; 1 1 3 4 5
3 1 4 1 5 5 rsort ;; 5 4 3 1 1
```
## Reducing Sequences
`sum` and `prod` collapse n values into one:
```forth
1 2 3 4 4 sum ;; 10
1 2 3 4 4 prod ;; 24
```
Useful for computing averages or accumulating values:
```forth
{ 1 6 rand } 4 gen 4 sum ;; sum of 4 dice rolls
```
## Replication
`dupn` (alias `!`) duplicates a value n times:
```forth
440 3 dupn ;; 440 440 440
c4 4 dupn ;; c4 c4 c4 c4
```
Build a drone chord -- same note, different octaves:
```forth
c3 note 0.5 gain sine s .
c3 note 12 + 0.5 gain sine s .
c3 note 24 + 0.3 gain sine s .
```
Or replicate a value for batch processing:
```forth
0.5 4 dupn 4 sum ;; 2.0
```
## Combining Techniques
An arpeggio that shuffles every time the step plays:
```forth
c4 e4 g4 b4 4 shuffle
drop drop drop ;; keep only the first note
note sine s .
```
Parameter spread across voices -- four sines with geometrically spaced frequencies:
```forth
220 1.5 4 geom..
4 { @i 1 + pick note sine s . } times
```
Euclidean rhythm driving note selection from a generated sequence:
```forth
3 8 euclid ;; 3 hit indices
```
A chord built from a range, then sorted high to low:
```forth
60 67 .. 8 rsort
```
Rhythmic density control -- generate hits, keep only the loud ones:
```forth
{ 0.0 1.0 rand } 8 gen
```
The generator words produce raw material. The transform words shape it. Together they let you express complex musical ideas in a few words.

302
docs/tutorial_harmony.md Normal file
View File

@@ -0,0 +1,302 @@
# Notes & Harmony
Cagire speaks music theory. Notes, intervals, chords, and scales are all first-class words that compile to stack operations on MIDI values. This tutorial covers every pitch-related feature.
## MIDI Notes
Write a note name followed by an octave number. It compiles to a MIDI integer:
```forth
c4 ;; 60 (middle C)
a4 ;; 69 (concert A)
e3 ;; 52
```
Sharps use `s` or `#`. Flats use `b`:
```forth
fs4 ;; 66 (F sharp 4)
f#4 ;; 66 (same thing)
bb3 ;; 58 (B flat 3)
eb4 ;; 63
```
Octave range is -1 to 9. The formula is `(octave + 1) * 12 + base + modifier`, where C=0, D=2, E=4, F=5, G=7, A=9, B=11.
Note literals push a single integer onto the stack, just like writing `60` directly. They work everywhere an integer works:
```forth
c4 note sine s . ;; play middle C as a sine
a4 note 0.5 gain modal s . ;; concert A, quieter
```
## Intervals
An interval duplicates the top of the stack and adds semitones. This lets you build chords by stacking:
```forth
c4 M3 P5 ;; stack: 60 64 67 (C major triad)
c4 m3 P5 ;; stack: 60 63 67 (C minor triad)
a3 P5 ;; stack: 57 64 (A plus a fifth)
```
Simple intervals (within one octave):
| Interval | Semitones | Name |
|----------|-----------|------|
| `P1` / `unison` | 0 | Perfect unison |
| `m2` | 1 | Minor 2nd |
| `M2` | 2 | Major 2nd |
| `m3` | 3 | Minor 3rd |
| `M3` | 4 | Major 3rd |
| `P4` | 5 | Perfect 4th |
| `aug4` / `dim5` / `tritone` | 6 | Tritone |
| `P5` | 7 | Perfect 5th |
| `m6` | 8 | Minor 6th |
| `M6` | 9 | Major 6th |
| `m7` | 10 | Minor 7th |
| `M7` | 11 | Major 7th |
| `P8` | 12 | Octave |
Compound intervals (beyond one octave):
| Interval | Semitones |
|----------|-----------|
| `m9` | 13 |
| `M9` | 14 |
| `m10` | 15 |
| `M10` | 16 |
| `P11` | 17 |
| `aug11` | 18 |
| `P12` | 19 |
| `m13` | 20 |
| `M13` | 21 |
| `m14` | 22 |
| `M14` | 23 |
| `P15` | 24 |
## Chords
Chord words take a root note and push all the chord tones. They eat the root and replace it with the full voicing:
```forth
c4 maj ;; stack: 60 64 67
c4 min7 ;; stack: 60 63 67 70
c4 dom9 ;; stack: 60 64 67 70 74
```
**Triads:**
| Word | Intervals | Example (C4) |
|------|-----------|-------------|
| `maj` | 0 4 7 | 60 64 67 |
| `m` | 0 3 7 | 60 63 67 |
| `dim` | 0 3 6 | 60 63 66 |
| `aug` | 0 4 8 | 60 64 68 |
| `sus2` | 0 2 7 | 60 62 67 |
| `sus4` | 0 5 7 | 60 65 67 |
**Seventh chords:**
| Word | Intervals | Example (C4) |
|------|-----------|-------------|
| `maj7` | 0 4 7 11 | 60 64 67 71 |
| `min7` | 0 3 7 10 | 60 63 67 70 |
| `dom7` | 0 4 7 10 | 60 64 67 70 |
| `dim7` | 0 3 6 9 | 60 63 66 69 |
| `m7b5` | 0 3 6 10 | 60 63 66 70 |
| `minmaj7` | 0 3 7 11 | 60 63 67 71 |
| `aug7` | 0 4 8 10 | 60 64 68 70 |
**Sixth chords:**
| Word | Intervals | Example (C4) |
|------|-----------|-------------|
| `maj6` | 0 4 7 9 | 60 64 67 69 |
| `min6` | 0 3 7 9 | 60 63 67 69 |
**Extended chords:**
| Word | Intervals | Example (C4) |
|------|-----------|-------------|
| `dom9` | 0 4 7 10 14 | 60 64 67 70 74 |
| `maj9` | 0 4 7 11 14 | 60 64 67 71 74 |
| `min9` | 0 3 7 10 14 | 60 63 67 70 74 |
| `dom11` | 0 4 7 10 14 17 | 60 64 67 70 74 77 |
| `min11` | 0 3 7 10 14 17 | 60 63 67 70 74 77 |
| `dom13` | 0 4 7 10 14 21 | 60 64 67 70 74 81 |
**Add chords:**
| Word | Intervals | Example (C4) |
|------|-----------|-------------|
| `add9` | 0 4 7 14 | 60 64 67 74 |
| `add11` | 0 4 7 17 | 60 64 67 77 |
| `madd9` | 0 3 7 14 | 60 63 67 74 |
**Altered dominants:**
| Word | Intervals | Example (C4) |
|------|-----------|-------------|
| `dom7b9` | 0 4 7 10 13 | 60 64 67 70 73 |
| `dom7s9` | 0 4 7 10 15 | 60 64 67 70 75 |
| `dom7b5` | 0 4 6 10 | 60 64 66 70 |
| `dom7s5` | 0 4 8 10 | 60 64 68 70 |
Chord tones are varargs -- they eat the entire stack. So a chord word should come right after the root note:
```forth
c4 maj note sine s . ;; plays all 3 notes as one chord
```
## Scales
Scale words convert a degree index into a MIDI note. The base note is C4 (MIDI 60). Degrees wrap around with octave transposition:
```forth
0 major ;; 60 (C4 -- degree 0)
4 major ;; 67 (G4 -- degree 4)
7 major ;; 72 (C5 -- degree 7, wraps to next octave)
-1 major ;; 59 (B3 -- negative degrees go down)
```
Use scales with `cycle` or `rand` to walk through pitches:
```forth
0 1 2 3 4 5 6 7 8 cycle minor note sine s .
```
**Standard modes:**
| Word | Pattern (semitones) |
|------|-------------------|
| `major` | 0 2 4 5 7 9 11 |
| `minor` | 0 2 3 5 7 8 10 |
| `dorian` | 0 2 3 5 7 9 10 |
| `phrygian` | 0 1 3 5 7 8 10 |
| `lydian` | 0 2 4 6 7 9 11 |
| `mixolydian` | 0 2 4 5 7 9 10 |
| `aeolian` | 0 2 3 5 7 8 10 |
| `locrian` | 0 1 3 5 6 8 10 |
**Pentatonic and blues:**
| Word | Pattern |
|------|---------|
| `pentatonic` | 0 2 4 7 9 |
| `minpent` | 0 3 5 7 10 |
| `blues` | 0 3 5 6 7 10 |
**Chromatic and whole tone:**
| Word | Pattern |
|------|---------|
| `chromatic` | 0 1 2 3 4 5 6 7 8 9 10 11 |
| `wholetone` | 0 2 4 6 8 10 |
**Harmonic and melodic minor:**
| Word | Pattern |
|------|---------|
| `harmonicminor` | 0 2 3 5 7 8 11 |
| `melodicminor` | 0 2 3 5 7 9 11 |
**Jazz / Bebop:**
| Word | Pattern |
|------|---------|
| `bebop` | 0 2 4 5 7 9 10 11 |
| `bebopmaj` | 0 2 4 5 7 8 9 11 |
| `bebopmin` | 0 2 3 5 7 8 9 10 |
| `altered` | 0 1 3 4 6 8 10 |
| `lyddom` | 0 2 4 6 7 9 10 |
**Symmetric:**
| Word | Pattern |
|------|---------|
| `halfwhole` | 0 1 3 4 6 7 9 10 |
| `wholehalf` | 0 2 3 5 6 8 9 11 |
| `augmented` | 0 3 4 7 8 11 |
| `tritone` | 0 1 4 6 7 10 |
| `prometheus` | 0 2 4 6 9 10 |
**Modal variants (from melodic minor):**
| Word | Pattern |
|------|---------|
| `dorianb2` | 0 1 3 5 7 9 10 |
| `lydianaug` | 0 2 4 6 8 9 11 |
| `mixb6` | 0 2 4 5 7 8 10 |
| `locrian2` | 0 2 3 5 6 8 10 |
## Octave Shifting
`oct` transposes a note by octaves:
```forth
c4 1 oct ;; 72 (C5)
c4 -1 oct ;; 48 (C3)
c4 2 oct ;; 84 (C6)
```
Stack effect: `(note shift -- transposed)`. The shift is multiplied by 12 and added to the note.
## Frequency Conversion
`mtof` converts a MIDI note to frequency in Hz. `ftom` does the reverse:
```forth
69 mtof ;; 440.0 (A4)
60 mtof ;; 261.63 (C4)
440 ftom ;; 69.0
```
Useful when a synth parameter expects Hz rather than MIDI:
```forth
c4 mtof freq sine s .
```
## Putting It Together
A chord progression cycling every pattern iteration:
```forth
{ c3 maj7 } { f3 maj7 } { g3 dom7 } { c3 maj7 } 4 pcycle
note sine s .
```
Arpeggiate a chord across the step's time divisions:
```forth
c4 min7 arp note 0.5 decay sine s .
```
Random notes from a scale:
```forth
0 7 rand minor note sine s .
```
A bass line walking scale degrees:
```forth
0 2 4 5 7 5 4 2 8 cycle minor note
-2 oct 0.8 gain sine s .
```
Chord voicings with random inversion:
```forth
e3 min9
{ } { 1 oct } 2 choose
note modal s .
```
Stacked intervals for custom voicings:
```forth
c3 P5 P8 M10 ;; C3, G3, C4, E4
note sine s .
```

201
docs/tutorial_randomness.md Normal file
View File

@@ -0,0 +1,201 @@
# Randomness
Music needs surprise. A pattern that plays identically every time gets boring fast. Cagire has a rich set of words for injecting randomness and controlled variation into your sequences.
## Random Numbers
`coin` pushes 0 or 1 with equal probability:
```forth
coin note sine s . ;; either 0 or 1 as the note
```
`rand` takes a range and returns a random value. If both bounds are integers, the result is an integer. If either is a float, you get a float:
```forth
60 72 rand note sine s . ;; random MIDI note from 60 to 72
0.3 0.9 rand gain sine s . ;; random gain between 0.3 and 0.9
```
`exprand` and `logrand` give you weighted distributions. `exprand` is biased toward the low end, `logrand` toward the high end:
```forth
200.0 8000.0 exprand freq sine s . ;; mostly low frequencies
200.0 8000.0 logrand freq sine s . ;; mostly high frequencies
```
These are useful for parameters where perception is logarithmic, like frequency and duration.
## Conditional Execution
The probability words take a quotation and execute it with some chance. `chance` takes a float from 0.0 to 1.0, `prob` takes a percentage from 0 to 100:
```forth
{ hat s . } 0.25 chance ;; 25% chance
{ hat s . } 75 prob ;; 75% chance
```
Named probability words save you from remembering numbers:
| Word | Probability |
|------|------------|
| `always` | 100% |
| `almostAlways` | 90% |
| `often` | 75% |
| `sometimes` | 50% |
| `rarely` | 25% |
| `almostNever` | 10% |
| `never` | 0% |
```forth
{ hat s . } often ;; 75%
{ snare s . } sometimes ;; 50%
{ clap s . } rarely ;; 25%
```
`always` and `never` are useful when you want to temporarily mute or unmute a voice without deleting code. Change `sometimes` to `never` to silence it, `always` to bring it back.
Use `?` and `!?` with `coin` for quick coin-flip decisions:
```forth
{ hat s . } coin ? ;; execute if coin is 1
{ rim s . } coin !? ;; execute if coin is 0
```
## Selection
`choose` picks randomly from n items on the stack:
```forth
kick snare hat 3 choose s . ;; random drum hit
60 64 67 72 4 choose note sine s . ;; random note from a set
```
When a chosen item is a quotation, it gets executed:
```forth
{ 0.1 decay } { 0.5 decay } { 0.9 decay } 3 choose
sine s .
```
`wchoose` lets you assign weights to each option. Push value/weight pairs:
```forth
kick 0.5 snare 0.3 hat 0.2 3 wchoose s .
```
Kick plays 50% of the time, snare 30%, hat 20%. Weights don't need to sum to 1 -- they're normalized automatically.
`shuffle` randomizes the order of n items on the stack:
```forth
60 64 67 72 4 shuffle ;; stack now has the same 4 values in random order
```
Combined with `note`, this gives you a random permutation of a chord every time the step runs.
## Cycling
Cycling steps through values deterministically. No randomness -- pure rotation.
`cycle` selects based on how many times this step has played (its `runs` count):
```forth
60 64 67 3 cycle note sine s . ;; 60, 64, 67, 60, 64, 67, ...
```
`pcycle` selects based on the pattern iteration count (`iter`):
```forth
kick snare 2 pcycle s . ;; kick on even iterations, snare on odd
```
The difference matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern.
Quotations work here too:
```forth
{ c4 note } { e4 note } { g4 note } 3 cycle
sine s .
```
`bounce` ping-pongs instead of wrapping around:
```forth
60 64 67 72 4 bounce note sine s . ;; 60, 64, 67, 72, 67, 64, 60, 64, ...
```
## Periodic Execution
`every` runs a quotation once every n pattern iterations:
```forth
{ crash s . } 4 every ;; crash cymbal every 4th iteration
```
`bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits across n positions as evenly as possible. Classic Euclidean rhythms:
```forth
{ hat s . } 3 8 bjork ;; tresillo: x..x..x. (by step runs)
{ hat s . } 5 8 pbjork ;; cinquillo: x.xx.xx. (by pattern iterations)
```
`bjork` counts by step runs (how many times this particular step has played). `pbjork` counts by pattern iterations. Some classic patterns:
| k | n | Name |
|---|---|------|
| 3 | 8 | tresillo |
| 5 | 8 | cinquillo |
| 5 | 16 | bossa nova |
| 7 | 16 | samba |
## Seeding
By default, every run produces different random values. Use `seed` to make randomness reproducible:
```forth
42 seed
60 72 rand note sine s . ;; always the same "random" note
```
The seed is set at the start of the script. Same seed, same sequence. Useful when you want a specific random pattern to repeat.
## Combining Words
The real power comes from mixing techniques. A hi-hat pattern with ghost notes:
```forth
hat s
{ 0.3 0.6 rand gain } { 0.8 gain } 2 cycle
.
```
Full volume on even runs, random quiet on odd runs.
A bass line that changes every 4 bars:
```forth
{ c2 note } { e2 note } { g2 note } { a2 note } 4 pcycle
{ 0.5 decay } often
sine s .
```
Layered percussion with different densities:
```forth
{ kick s . } always
{ snare s . } 2 every
{ hat s . } 5 8 bjork
{ rim s . } rarely
```
A melodic step with weighted note selection and random timbre:
```forth
c4 0.4 e4 0.3 g4 0.2 b4 0.1 4 wchoose note
0.3 0.7 rand decay
1.0 4.0 exprand harmonics
modal s .
```
The root note plays most often. Higher chord tones are rarer. Decay and harmonics vary continuously.

View File

@@ -20,6 +20,7 @@ use crate::page::Page;
use crate::services::{clipboard, dict_nav, euclidean, help_nav, pattern_editor};
use crate::settings::Settings;
use crate::state::{
undo::{UndoEntry, UndoHistory, UndoScope},
AudioSettings, CyclicEnum, EditorContext, EditorTarget, FlashKind, LiveKeyState, Metrics,
Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
PlaybackState, ProjectState, SampleTree, StagedChange, StagedPropChange, UiState,
@@ -61,6 +62,8 @@ pub struct App {
pub copied_patterns: Option<Vec<Pattern>>,
pub copied_banks: Option<Vec<Bank>>,
pub undo: UndoHistory,
pub audio: AudioSettings,
pub options: OptionsState,
pub panel: PanelState,
@@ -103,6 +106,8 @@ impl App {
copied_patterns: None,
copied_banks: None,
undo: UndoHistory::default(),
audio: AudioSettings::default(),
options: OptionsState::default(),
panel: PanelState::default(),
@@ -124,6 +129,7 @@ impl App {
runtime_highlight: self.ui.runtime_highlight,
show_scope: self.audio.config.show_scope,
show_spectrum: self.audio.config.show_spectrum,
show_preview: self.audio.config.show_preview,
show_completion: self.ui.show_completion,
color_scheme: self.ui.color_scheme,
layout: self.audio.config.layout,
@@ -617,6 +623,7 @@ impl App {
link.set_tempo(tempo);
self.playback.clear_queues();
self.undo.clear();
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
@@ -742,6 +749,46 @@ impl App {
}
}
pub fn shift_patterns_up(&mut self) {
let bank = self.patterns_nav.bank_cursor;
let patterns = self.patterns_nav.selected_patterns();
let start = *patterns.first().unwrap();
let end = *patterns.last().unwrap();
if let Some(dirty) = clipboard::shift_patterns_up(
&mut self.project_state.project,
bank,
start..=end,
) {
for (b, p) in &dirty {
self.project_state.mark_dirty(*b, *p);
}
self.patterns_nav.pattern_cursor -= 1;
if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor {
*anchor -= 1;
}
}
}
pub fn shift_patterns_down(&mut self) {
let bank = self.patterns_nav.bank_cursor;
let patterns = self.patterns_nav.selected_patterns();
let start = *patterns.first().unwrap();
let end = *patterns.last().unwrap();
if let Some(dirty) = clipboard::shift_patterns_down(
&mut self.project_state.project,
bank,
start..=end,
) {
for (b, p) in &dirty {
self.project_state.mark_dirty(*b, *p);
}
self.patterns_nav.pattern_cursor += 1;
if let Some(ref mut anchor) = self.patterns_nav.pattern_anchor {
*anchor += 1;
}
}
}
pub fn copy_bank(&mut self, bank: usize) {
self.copied_banks = Some(vec![clipboard::copy_bank(
&self.project_state.project,
@@ -986,8 +1033,120 @@ impl App {
};
}
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
fn undoable_scope(&self, cmd: &AppCommand) -> Option<UndoScope> {
match cmd {
// Pattern-level
AppCommand::ToggleSteps
| AppCommand::LengthIncrease
| AppCommand::LengthDecrease
| AppCommand::SpeedIncrease
| AppCommand::SpeedDecrease
| AppCommand::SaveEditorToStep
| AppCommand::CompileCurrentStep
| AppCommand::HardenSteps
| AppCommand::PasteSteps
| AppCommand::LinkPasteSteps
| AppCommand::DuplicateSteps
| AppCommand::CopySteps => {
let (bank, pattern) = self.current_bank_pattern();
let data = self.project_state.project.pattern_at(bank, pattern).clone();
Some(UndoScope::Pattern { bank, pattern, data })
}
AppCommand::SetLength { bank, pattern, .. }
| AppCommand::SetSpeed { bank, pattern, .. }
| AppCommand::DeleteStep { bank, pattern, .. }
| AppCommand::DeleteSteps { bank, pattern, .. }
| AppCommand::ResetPattern { bank, pattern }
| AppCommand::PastePattern { bank, pattern }
| AppCommand::RenamePattern { bank, pattern, .. } => {
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
}
AppCommand::RenameStep { bank, pattern, .. } => {
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
}
AppCommand::ApplyEuclideanDistribution { bank, pattern, .. } => {
let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
}
// Bank-level
AppCommand::ResetBank { bank } | AppCommand::PasteBank { bank } => {
let data = self.project_state.project.banks[*bank].clone();
Some(UndoScope::Bank { bank: *bank, data })
}
AppCommand::ShiftPatternsUp | AppCommand::ShiftPatternsDown => {
let bank = self.patterns_nav.bank_cursor;
let data = self.project_state.project.banks[bank].clone();
Some(UndoScope::Bank { bank, data })
}
AppCommand::PastePatterns { bank, .. }
| AppCommand::ResetPatterns { bank, .. }
| AppCommand::RenameBank { bank, .. } => {
let data = self.project_state.project.banks[*bank].clone();
Some(UndoScope::Bank { bank: *bank, data })
}
AppCommand::PasteBanks { .. } | AppCommand::ResetBanks { .. } => None,
_ => None,
}
}
fn apply_undo_entry(&mut self, entry: UndoEntry) -> UndoEntry {
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
let reverse_scope = match entry.scope {
UndoScope::Pattern { bank, pattern, data } => {
let current = self.project_state.project.pattern_at(bank, pattern).clone();
*self.project_state.project.pattern_at_mut(bank, pattern) = data;
self.project_state.mark_dirty(bank, pattern);
UndoScope::Pattern { bank, pattern, data: current }
}
UndoScope::Bank { bank, data } => {
let current = self.project_state.project.banks[bank].clone();
let pat_count = current.patterns.len();
self.project_state.project.banks[bank] = data;
for p in 0..pat_count {
self.project_state.mark_dirty(bank, p);
}
UndoScope::Bank { bank, data: current }
}
};
self.editor_ctx.bank = entry.cursor.0;
self.editor_ctx.pattern = entry.cursor.1;
self.editor_ctx.step = entry.cursor.2;
self.load_step_to_editor();
UndoEntry { scope: reverse_scope, cursor }
}
pub fn dispatch(&mut self, cmd: AppCommand, link: &LinkState, snapshot: &SequencerSnapshot) {
// Handle undo/redo before the undoable snapshot
match cmd {
AppCommand::Undo => {
if let Some(entry) = self.undo.pop_undo() {
let reverse = self.apply_undo_entry(entry);
self.undo.push_redo(reverse);
self.ui.flash("Undo", 100, FlashKind::Info);
}
return;
}
AppCommand::Redo => {
if let Some(entry) = self.undo.pop_redo() {
let reverse = self.apply_undo_entry(entry);
self.undo.undo_stack.push(reverse);
self.ui.flash("Redo", 100, FlashKind::Info);
}
return;
}
_ => {}
}
if let Some(scope) = self.undoable_scope(&cmd) {
let cursor = (self.editor_ctx.bank, self.editor_ctx.pattern, self.editor_ctx.step);
self.undo.push(UndoEntry { scope, cursor });
}
match cmd {
AppCommand::Undo | AppCommand::Redo => unreachable!(),
// Playback
AppCommand::TogglePlaying => self.toggle_playing(link),
AppCommand::TempoUp => self.tempo_up(link),
@@ -1092,6 +1251,10 @@ impl App {
self.reset_banks(&banks);
}
// Reorder
AppCommand::ShiftPatternsUp => self.shift_patterns_up(),
AppCommand::ShiftPatternsDown => self.shift_patterns_down(),
// Clipboard
AppCommand::HardenSteps => self.harden_steps(),
AppCommand::CopySteps => self.copy_steps(),
@@ -1406,6 +1569,9 @@ impl App {
AppCommand::ToggleSpectrum => {
self.audio.config.show_spectrum = !self.audio.config.show_spectrum;
}
AppCommand::TogglePreview => {
self.audio.config.show_preview = !self.audio.config.show_preview;
}
// Metrics
AppCommand::ResetPeakVoices => {

View File

@@ -5,6 +5,10 @@ use crate::page::Page;
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind};
pub enum AppCommand {
// Undo/Redo
Undo,
Redo,
// Playback
TogglePlaying,
TempoUp,
@@ -89,6 +93,10 @@ pub enum AppCommand {
banks: Vec<usize>,
},
// Reorder
ShiftPatternsUp,
ShiftPatternsDown,
// Clipboard
HardenSteps,
CopySteps,
@@ -239,6 +247,7 @@ pub enum AppCommand {
ToggleRefreshRate,
ToggleScope,
ToggleSpectrum,
TogglePreview,
// Metrics
ResetPeakVoices,

View File

@@ -81,6 +81,7 @@ pub fn init(args: InitArgs) -> Init {
app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum;
app.audio.config.show_preview = settings.display.show_preview;
app.ui.show_completion = settings.display.show_completion;
app.ui.color_scheme = settings.display.color_scheme;
app.ui.hue_rotation = settings.display.hue_rotation;

View File

@@ -96,6 +96,12 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
let state = FileBrowserState::new_save(initial);
ctx.dispatch(AppCommand::OpenModal(Modal::FileBrowser(Box::new(state))));
}
KeyCode::Char('z') if ctrl && !shift => {
ctx.dispatch(AppCommand::Undo);
}
KeyCode::Char('Z') if ctrl => {
ctx.dispatch(AppCommand::Redo);
}
KeyCode::Char('c') if ctrl => {
ctx.dispatch(AppCommand::CopySteps);
}
@@ -134,6 +140,10 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
let current = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
}
KeyCode::Char(':') => {
let current = (ctx.app.editor_ctx.step + 1).to_string();
ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(current)));
}
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),
KeyCode::Char('[') => ctx.dispatch(AppCommand::SpeedDecrease),

View File

@@ -138,6 +138,22 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char(c) => input.push(c),
_ => {}
},
Modal::JumpToStep(input) => match key.code {
KeyCode::Enter => {
if let Ok(step) = input.parse::<usize>() {
if step > 0 {
ctx.dispatch(AppCommand::GoToStep(step - 1));
}
}
ctx.dispatch(AppCommand::CloseModal);
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => input.push(c),
_ => {}
},
Modal::SetTempo(input) => match key.code {
KeyCode::Enter => {
if let Ok(tempo) = input.parse::<f64>() {
@@ -466,7 +482,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = (val + 1).min(128).to_string();
*target = (val + 1).min(1024).to_string();
}
}
KeyCode::Char(c) if c.is_ascii_digit() => match field {

View File

@@ -312,7 +312,8 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
// Replay viz/sequencer split
let show_scope = ctx.app.audio.config.show_scope;
let show_spectrum = ctx.app.audio.config.show_spectrum;
let has_viz = show_scope || show_spectrum;
let show_preview = ctx.app.audio.config.show_preview;
let has_viz = show_scope || show_spectrum || show_preview;
let layout = ctx.app.audio.config.layout;
let sequencer_area = match layout {

View File

@@ -25,6 +25,7 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope),
OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum),
OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion),
OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview),
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
OptionsFocus::StartStopSync => ctx
.link

View File

@@ -8,8 +8,15 @@ use crate::state::{ConfirmAction, Modal, PatternsColumn, RenameTarget};
pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Up if alt && ctx.app.patterns_nav.column == PatternsColumn::Patterns => {
ctx.dispatch(AppCommand::ShiftPatternsUp);
}
KeyCode::Down if alt && ctx.app.patterns_nav.column == PatternsColumn::Patterns => {
ctx.dispatch(AppCommand::ShiftPatternsDown);
}
KeyCode::Up if shift => {
match ctx.app.patterns_nav.column {
PatternsColumn::Banks => {
@@ -94,6 +101,12 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
selected: false,
}));
}
KeyCode::Char('z') if ctrl && !shift => {
ctx.dispatch(AppCommand::Undo);
}
KeyCode::Char('Z') if ctrl => {
ctx.dispatch(AppCommand::Redo);
}
KeyCode::Char('c') if ctrl => {
let bank = ctx.app.patterns_nav.bank_cursor;
match ctx.app.patterns_nav.column {

View File

@@ -56,6 +56,17 @@ pub const DOCS: &[DocEntry] = &[
Topic("Introduction", include_str!("../../docs/midi_intro.md")),
Topic("MIDI Output", include_str!("../../docs/midi_output.md")),
Topic("MIDI Input", include_str!("../../docs/midi_input.md")),
// Tutorials
Section("Tutorials"),
Topic("Randomness", include_str!("../../docs/tutorial_randomness.md")),
Topic(
"Notes & Harmony",
include_str!("../../docs/tutorial_harmony.md"),
),
Topic(
"Generators",
include_str!("../../docs/tutorial_generators.md"),
),
];
pub fn topic_count() -> usize {

View File

@@ -9,6 +9,34 @@ fn annotate_copy_name(name: &Option<String>) -> Option<String> {
}
}
pub fn shift_patterns_up(
project: &mut Project,
bank: usize,
range: std::ops::RangeInclusive<usize>,
) -> Option<Vec<(usize, usize)>> {
if *range.start() == 0 {
return None;
}
let slice_start = range.start() - 1;
let slice_end = *range.end();
project.banks[bank].patterns[slice_start..=slice_end].rotate_left(1);
Some((slice_start..=slice_end).map(|p| (bank, p)).collect())
}
pub fn shift_patterns_down(
project: &mut Project,
bank: usize,
range: std::ops::RangeInclusive<usize>,
) -> Option<Vec<(usize, usize)>> {
if *range.end() >= crate::model::MAX_PATTERNS - 1 {
return None;
}
let slice_start = *range.start();
let slice_end = range.end() + 1;
project.banks[bank].patterns[slice_start..=slice_end].rotate_right(1);
Some((slice_start..=slice_end).map(|p| (bank, p)).collect())
}
pub fn copy_pattern(project: &Project, bank: usize, pattern: usize) -> Pattern {
project.banks[bank].patterns[pattern].clone()
}

View File

@@ -40,6 +40,8 @@ pub struct DisplaySettings {
pub show_scope: bool,
pub show_spectrum: bool,
#[serde(default = "default_true")]
pub show_preview: bool,
#[serde(default = "default_true")]
pub show_completion: bool,
#[serde(default = "default_font")]
pub font: String,
@@ -83,6 +85,7 @@ impl Default for DisplaySettings {
runtime_highlight: false,
show_scope: true,
show_spectrum: true,
show_preview: true,
show_completion: true,
font: default_font(),
color_scheme: ColorScheme::default(),

View File

@@ -83,6 +83,7 @@ pub struct AudioConfig {
pub refresh_rate: RefreshRate,
pub show_scope: bool,
pub show_spectrum: bool,
pub show_preview: bool,
pub layout: MainLayout,
}
@@ -101,6 +102,7 @@ impl Default for AudioConfig {
refresh_rate: RefreshRate::default(),
show_scope: true,
show_spectrum: true,
show_preview: true,
layout: MainLayout::default(),
}
}

View File

@@ -27,6 +27,7 @@ pub mod patterns_nav;
pub mod playback;
pub mod project;
pub mod sample_browser;
pub mod undo;
pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};

View File

@@ -64,6 +64,7 @@ pub enum Modal {
input: String,
},
SetTempo(String),
JumpToStep(String),
AddSamplePath(Box<FileBrowserState>),
Editor,
Preview,

View File

@@ -10,6 +10,7 @@ pub enum OptionsFocus {
ShowScope,
ShowSpectrum,
ShowCompletion,
ShowPreview,
LinkEnabled,
StartStopSync,
Quantum,
@@ -32,6 +33,7 @@ impl CyclicEnum for OptionsFocus {
Self::ShowScope,
Self::ShowSpectrum,
Self::ShowCompletion,
Self::ShowPreview,
Self::LinkEnabled,
Self::StartStopSync,
Self::Quantum,
@@ -54,17 +56,18 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[
(OptionsFocus::ShowScope, 6),
(OptionsFocus::ShowSpectrum, 7),
(OptionsFocus::ShowCompletion, 8),
(OptionsFocus::LinkEnabled, 12),
(OptionsFocus::StartStopSync, 13),
(OptionsFocus::Quantum, 14),
(OptionsFocus::MidiOutput0, 24),
(OptionsFocus::MidiOutput1, 25),
(OptionsFocus::MidiOutput2, 26),
(OptionsFocus::MidiOutput3, 27),
(OptionsFocus::MidiInput0, 31),
(OptionsFocus::MidiInput1, 32),
(OptionsFocus::MidiInput2, 33),
(OptionsFocus::MidiInput3, 34),
(OptionsFocus::ShowPreview, 9),
(OptionsFocus::LinkEnabled, 13),
(OptionsFocus::StartStopSync, 14),
(OptionsFocus::Quantum, 15),
(OptionsFocus::MidiOutput0, 25),
(OptionsFocus::MidiOutput1, 26),
(OptionsFocus::MidiOutput2, 27),
(OptionsFocus::MidiOutput3, 28),
(OptionsFocus::MidiInput0, 32),
(OptionsFocus::MidiInput1, 33),
(OptionsFocus::MidiInput2, 34),
(OptionsFocus::MidiInput3, 35),
];
impl OptionsFocus {

53
src/state/undo.rs Normal file
View File

@@ -0,0 +1,53 @@
use crate::model::{Bank, Pattern};
const MAX_UNDO: usize = 100;
pub enum UndoScope {
Pattern {
bank: usize,
pattern: usize,
data: Pattern,
},
Bank {
bank: usize,
data: Bank,
},
}
pub struct UndoEntry {
pub scope: UndoScope,
pub cursor: (usize, usize, usize),
}
#[derive(Default)]
pub struct UndoHistory {
pub(crate) undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>,
}
impl UndoHistory {
pub fn push(&mut self, entry: UndoEntry) {
self.redo_stack.clear();
if self.undo_stack.len() >= MAX_UNDO {
self.undo_stack.remove(0);
}
self.undo_stack.push(entry);
}
pub fn pop_undo(&mut self) -> Option<UndoEntry> {
self.undo_stack.pop()
}
pub fn push_redo(&mut self, entry: UndoEntry) {
self.redo_stack.push(entry);
}
pub fn pop_redo(&mut self) -> Option<UndoEntry> {
self.redo_stack.pop()
}
pub fn clear(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
}
}

View File

@@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
bindings.push(("r", "Rename", "Rename current step"));
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
bindings.push((":", "Jump", "Jump to step number"));
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
bindings.push(("m", "Mute", "Stage mute for current pattern"));
bindings.push(("x", "Solo", "Stage solo for current pattern"));

View File

@@ -1,12 +1,18 @@
use std::collections::HashSet;
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;
use crate::engine::SequencerSnapshot;
use crate::model::SourceSpan;
use crate::state::MainLayout;
use crate::theme;
use crate::views::highlight::highlight_line_with_runtime;
use crate::views::render::{adjust_resolved_for_line, adjust_spans_for_line};
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
pub fn layout(area: Rect) -> [Rect; 5] {
@@ -25,7 +31,8 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
let show_scope = app.audio.config.show_scope;
let show_spectrum = app.audio.config.show_spectrum;
let has_viz = show_scope || show_spectrum;
let show_preview = app.audio.config.show_preview;
let has_viz = show_scope || show_spectrum || show_preview;
let layout = app.audio.config.layout;
let (viz_area, sequencer_area) = match layout {
@@ -70,7 +77,7 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
};
if has_viz {
render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum);
render_viz_area(frame, app, snapshot, viz_area);
}
render_sequencer(frame, app, snapshot, sequencer_area);
@@ -78,43 +85,48 @@ pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area:
render_active_patterns(frame, app, snapshot, patterns_area);
}
enum VizPanel {
Scope,
Spectrum,
Preview,
}
fn render_viz_area(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
area: Rect,
layout: MainLayout,
show_scope: bool,
show_spectrum: bool,
) {
let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right);
let is_vertical_layout = matches!(app.audio.config.layout, MainLayout::Left | MainLayout::Right);
let show_scope = app.audio.config.show_scope;
let show_spectrum = app.audio.config.show_spectrum;
let show_preview = app.audio.config.show_preview;
if show_scope && show_spectrum {
if is_vertical_layout {
let [scope_area, spectrum_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Fill(1),
])
.areas(area);
render_scope(frame, app, scope_area, Orientation::Vertical);
render_spectrum(frame, app, spectrum_area);
let mut panels = Vec::new();
if show_scope { panels.push(VizPanel::Scope); }
if show_spectrum { panels.push(VizPanel::Spectrum); }
if show_preview { panels.push(VizPanel::Preview); }
let constraints: Vec<Constraint> = panels.iter().map(|_| Constraint::Fill(1)).collect();
let areas: Vec<Rect> = if is_vertical_layout {
Layout::vertical(&constraints).split(area).to_vec()
} else {
let [scope_area, spectrum_area] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Fill(1),
])
.areas(area);
render_scope(frame, app, scope_area, Orientation::Horizontal);
render_spectrum(frame, app, spectrum_area);
}
} else if show_scope {
Layout::horizontal(&constraints).split(area).to_vec()
};
let orientation = if is_vertical_layout {
Orientation::Vertical
} else {
Orientation::Horizontal
};
render_scope(frame, app, area, orientation);
} else if show_spectrum {
render_spectrum(frame, app, area);
for (panel, panel_area) in panels.iter().zip(areas.iter()) {
match panel {
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
VizPanel::Preview => render_script_preview(frame, app, snapshot, *panel_area),
}
}
}
@@ -187,6 +199,7 @@ fn render_tile(
let pattern = app.current_edit_pattern();
let step = pattern.step(step_idx);
let is_active = step.map(|s| s.active).unwrap_or(false);
let has_content = step.map(|s| s.has_content()).unwrap_or(false);
let is_linked = step.map(|s| s.source.is_some()).unwrap_or(false);
let is_selected = step_idx == app.editor_ctx.step;
let in_selection = app.editor_ctx.selection_range()
@@ -217,7 +230,10 @@ fn render_tile(
let (r, g, b) = link_color.unwrap().1;
(Color::Rgb(r, g, b), theme.tile.active_fg)
}
(false, true, false, false, _) => (theme.tile.active_bg, theme.tile.active_fg),
(false, true, false, false, _) => {
let bg = if has_content { theme.tile.content_bg } else { theme.tile.active_bg };
(bg, theme.tile.active_fg)
}
(false, false, true, _, _) => (theme.selection.selected, theme.selection.cursor_fg),
(false, false, _, _, true) => (theme.selection.in_range, theme.selection.cursor_fg),
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
@@ -234,6 +250,8 @@ fn render_tile(
"".to_string()
} else if let Some(source) = source_idx {
format!("{:02}", source + 1)
} else if has_content {
format!("·{:02}·", step_idx + 1)
} else {
format!("{:02}", step_idx + 1)
};
@@ -314,6 +332,90 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(spectrum, inner);
}
fn render_script_preview(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
area: Rect,
) {
let theme = theme::get();
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step;
let step = pattern.step(step_idx);
let source_idx = step.and_then(|s| s.source);
let step_name = step.and_then(|s| s.name.as_ref());
let title = match (source_idx, step_name) {
(Some(src), Some(name)) => format!(" {:02}: {} -> {:02} ", step_idx + 1, name, src + 1),
(None, Some(name)) => format!(" {:02}: {} ", step_idx + 1, name),
(Some(src), None) => format!(" {:02} -> {:02} ", step_idx + 1, src + 1),
(None, None) => format!(" Step {:02} ", step_idx + 1),
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::new().fg(theme.ui.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let script = pattern.resolve_script(step_idx).unwrap_or("");
if script.is_empty() {
let empty = Paragraph::new("(empty)")
.alignment(Alignment::Center)
.style(Style::new().fg(theme.ui.text_dim));
let centered = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(empty, centered);
return;
}
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = pattern.resolve_source(step_idx);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let resolved_display: Vec<(SourceSpan, String)> = trace
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let mut line_start = 0usize;
let lines: Vec<Line> = script
.lines()
.take(inner.height as usize)
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(&t.executed_spans, line_start, line_str.len());
let sel = adjust_spans_for_line(&t.selected_spans, line_start, line_str.len());
let res = adjust_resolved_for_line(&resolved_display, line_start, line_str.len());
highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words)
} else {
highlight_line_with_runtime(line_str, &[], &[], &[], &user_words)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let block = Block::default()

View File

@@ -161,6 +161,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
focus == OptionsFocus::ShowCompletion,
&theme,
),
render_option_line(
"Show preview",
if app.audio.config.show_preview { "On" } else { "Off" },
focus == OptionsFocus::ShowPreview,
&theme,
),
Line::from(""),
link_header,
render_divider(content_width, &theme),

View File

@@ -38,7 +38,7 @@ fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<Sou
})
}
fn adjust_spans_for_line(
pub fn adjust_spans_for_line(
spans: &[SourceSpan],
line_start: usize,
line_len: usize,
@@ -49,7 +49,7 @@ fn adjust_spans_for_line(
.collect()
}
fn adjust_resolved_for_line(
pub fn adjust_resolved_for_line(
resolved: &[(SourceSpan, String)],
line_start: usize,
line_len: usize,
@@ -518,7 +518,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
}
Modal::SetPattern { field, input } => {
let (title, hint) = match field {
PatternField::Length => ("Set Length (1-128)", "Enter number"),
PatternField::Length => ("Set Length (1-1024)", "Enter number"),
PatternField::Speed => ("Set Speed", "e.g. 1/3, 2/5, 1x, 2x"),
};
TextInputModal::new(title, input)
@@ -527,6 +527,15 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
.border_color(theme.modal.confirm)
.render_centered(frame, term)
}
Modal::JumpToStep(input) => {
let pattern_len = app.current_edit_pattern().length;
let title = format!("Jump to Step (1-{})", pattern_len);
TextInputModal::new(&title, input)
.hint("Enter step number")
.width(30)
.border_color(theme.modal.confirm)
.render_centered(frame, term)
}
Modal::SetTempo(input) => TextInputModal::new("Set Tempo (20-300 BPM)", input)
.hint("Enter BPM")
.width(30)

View File

@@ -59,29 +59,29 @@ fn iter() {
#[test]
fn every_true_on_zero() {
let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("4 every", &ctx);
assert_eq!(stack_int(&f), 1);
let f = run_ctx("{ 100 } 4 every", &ctx);
assert_eq!(stack_int(&f), 100);
}
#[test]
fn every_true_on_multiple() {
let ctx = ctx_with(|c| c.iter = 8);
let f = run_ctx("4 every", &ctx);
assert_eq!(stack_int(&f), 1);
let f = run_ctx("{ 100 } 4 every", &ctx);
assert_eq!(stack_int(&f), 100);
}
#[test]
fn every_false_between() {
for i in 1..4 {
let ctx = ctx_with(|c| c.iter = i);
let f = run_ctx("4 every", &ctx);
assert_eq!(stack_int(&f), 0, "iter={} should be false", i);
let f = run_ctx("{ 100 } 4 every", &ctx);
assert!(f.stack().is_empty(), "iter={} should not execute quotation", i);
}
}
#[test]
fn every_zero_count() {
expect_error("0 every", "every count must be > 0");
expect_error("{ 1 } 0 every", "every count must be > 0");
}
#[test]
@@ -97,3 +97,77 @@ fn context_in_computation() {
let f = run_ctx("60 step +", &ctx);
assert_eq!(stack_int(&f), 63);
}
// bjork (runs-based)
#[test]
fn bjork_tresillo() {
// Bresenham(3,8) hits at positions 0, 2, 5
for runs in 0..8 {
let ctx = ctx_with(|c| c.runs = runs);
let f = run_ctx("{ 100 } 3 8 bjork", &ctx);
let hit = ((runs + 1) * 3) / 8 != (runs * 3) / 8;
if hit {
assert_eq!(stack_int(&f), 100, "runs={} should hit", runs);
} else {
assert!(f.stack().is_empty(), "runs={} should miss", runs);
}
}
}
#[test]
fn bjork_hit_count() {
// Bjorklund(3,8) should produce exactly 3 hits
let mut hit_count = 0;
for runs in 0..8 {
let ctx = ctx_with(|c| c.runs = runs);
let f = run_ctx("{ 100 } 3 8 bjork", &ctx);
if !f.stack().is_empty() {
hit_count += 1;
}
}
assert_eq!(hit_count, 3);
}
#[test]
fn bjork_all_hits() {
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("{ 100 } 8 8 bjork", &ctx);
assert_eq!(stack_int(&f), 100);
}
#[test]
fn bjork_zero_hits() {
let ctx = ctx_with(|c| c.runs = 0);
let f = run_ctx("{ 100 } 0 8 bjork", &ctx);
assert!(f.stack().is_empty());
}
#[test]
fn bjork_invalid() {
expect_error("{ 1 } 3 0 bjork", "bjork");
}
// pbjork (iter-based)
#[test]
fn pbjork_cinquillo() {
let mut hit_count = 0;
for iter in 0..8 {
let ctx = ctx_with(|c| c.iter = iter);
let f = run_ctx("{ 100 } 5 8 pbjork", &ctx);
if !f.stack().is_empty() {
hit_count += 1;
}
}
assert_eq!(hit_count, 5);
}
#[test]
fn pbjork_wraps() {
let ctx0 = ctx_with(|c| c.iter = 0);
let ctx8 = ctx_with(|c| c.iter = 8);
let f0 = run_ctx("{ 100 } 3 8 pbjork", &ctx0);
let f8 = run_ctx("{ 100 } 3 8 pbjork", &ctx8);
assert_eq!(f0.stack().is_empty(), f8.stack().is_empty());
}

View File

@@ -29,14 +29,14 @@ fn when_with_arithmetic() {
#[test]
fn when_with_every() {
// iter=0, every 2 should be true
// iter=0, every 2 executes quotation
let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("{ 100 } 2 every ?", &ctx);
let f = run_ctx("{ 100 } 2 every", &ctx);
assert_eq!(stack_int(&f), 100);
// iter=1, every 2 should be false
// iter=1, every 2 skips quotation
let ctx = ctx_with(|c| c.iter = 1);
let f = run_ctx("50 { 100 } 2 every ?", &ctx);
let f = run_ctx("50 { 100 } 2 every", &ctx);
assert_eq!(stack_int(&f), 50); // quotation not executed
}
@@ -104,13 +104,12 @@ fn unexpected_close_error() {
#[test]
fn every_with_quotation_integration() {
// Simulating: { 2 distort } 2 every ?
// On even iterations, distort is applied
// { 2 distort } 2 every — on even iterations, distort is applied
for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f
.evaluate(r#""kick" s { 2 distort } 2 every ? ."#, &ctx)
.evaluate(r#""kick" s { 2 distort } 2 every ."#, &ctx)
.unwrap();
if iter % 2 == 0 {
assert!(
@@ -128,6 +127,18 @@ fn every_with_quotation_integration() {
}
}
// bjork with sound
#[test]
fn bjork_with_sound() {
let ctx = ctx_with(|c| c.runs = 2); // position 2 is a hit for (3,8)
let f = forth();
let outputs = f
.evaluate(r#""kick" s { 2 distort } 3 8 bjork ."#, &ctx)
.unwrap();
assert!(outputs[0].contains("distort/2"));
}
// Unless (!?) tests
#[test]
@@ -142,28 +153,15 @@ fn unless_true_skips() {
assert_eq!(stack_int(&f), 99);
}
#[test]
fn unless_with_every() {
// iter=0, every 2 is true, so unless skips
let ctx = ctx_with(|c| c.iter = 0);
let f = run_ctx("50 { 100 } 2 every !?", &ctx);
assert_eq!(stack_int(&f), 50);
// iter=1, every 2 is false, so unless executes
let ctx = ctx_with(|c| c.iter = 1);
let f = run_ctx("{ 100 } 2 every !?", &ctx);
assert_eq!(stack_int(&f), 100);
}
#[test]
fn when_and_unless_complementary() {
// Using both ? and !? for if-else like behavior
// Using iter mod + ?/!? for if-else behavior (every no longer pushes bool)
for iter in 0..4 {
let ctx = ctx_with(|c| c.iter = iter);
let f = forth();
let outputs = f
.evaluate(
r#""kick" s { 2 distort } 2 every ? { 4 distort } 2 every !? ."#,
r#""kick" s { 2 distort } iter 2 mod 0 = ? { 4 distort } iter 2 mod 0 = !? ."#,
&ctx,
)
.unwrap();

View File

@@ -100,7 +100,7 @@ const version = cargo.match(/\[workspace\.package\]\s*\nversion\s*=\s*"([^"]+)"/
<button data-desc="Conditional execution and probability branching built into the language. Weighted random choices, coin flips, euclidean rhythms with rotation. Multiple random distributions, Perlin noise, seeded randomness. Cagire is designed to be generative and fun.">Probability</button>
<button data-desc="Everything moves. Audio-rate LFOs, slides, ramps, envelopes, jitter, drunk walks, vibrato, ring modulation — all available as simple words you drop into any script. Shape any parameter over time with multiple curve types. Add controlled randomness or smooth motion at your heart's content.">Modulation</button>
<button data-desc="Tempo sync with any Ableton Link-enabled app or hardware on the same network. Shared tempo and phase across all connected devices. You can jam with your friends!">Ableton Link</button>
<button data-desc="32 banks * 32 patterns * 128 steps per project. That's ~131.000 scripts per project, way too much! Play as much patterns as you want simultaneously.">Projects</button>
<button data-desc="32 banks * 32 patterns * 1024 steps per project. That's ~1.048.000 scripts per project, way too much! Play as much patterns as you want simultaneously.">Projects</button>
<button data-desc="Forth is concatenative, minimal and radically extensible. No syntax to learn — just words separated by spaces. You build your own vocabulary on top of the language, growing it toward your musical ideas. Expressions are terse and compositional: small words combine into larger ones. Forth rewards exploration and improvisation, making it a natural fit for live coding.">Forth</button>
<button data-desc="Per-pattern speed control, sync modes (loop, play-once, trigger) and launch quantization (bar, beat, step or immediate). Pattern chaining for longer sequences. Scripts are time-aware: they know their beat position, phase, iteration count and tempo.">Sequencer</button>
<button data-desc="Play multiple patterns simultaneously. Mute and solo staging with quantized execution. Fill mode for live key input. Stage pattern starts and stops to land on the beat. Built for improvisation on stage.">Play Live</button>