diff --git a/crates/ratatui/src/theme.rs b/crates/ratatui/src/theme.rs index e44fdb5..88e1abd 100644 --- a/crates/ratatui/src/theme.rs +++ b/crates/ratatui/src/theme.rs @@ -422,11 +422,11 @@ impl ThemeColors { (166, 227, 161), // Green ], link_dim: [ - (70, 55, 85), // Mauve dimmed - (85, 65, 80), // Pink dimmed - (85, 60, 45), // Peach dimmed - (45, 75, 80), // Sky dimmed - (55, 80, 55), // Green dimmed + (70, 55, 85), // Mauve dimmed + (85, 65, 80), // Pink dimmed + (85, 60, 45), // Peach dimmed + (45, 75, 80), // Sky dimmed + (55, 80, 55), // Green dimmed ], }, header: HeaderColors { @@ -799,10 +799,7 @@ impl ThemeColors { key: peach, text: overlay1, }, - view_badge: ViewBadgeColors { - bg: text, - fg: base, - }, + view_badge: ViewBadgeColors { bg: text, fg: base }, nav: NavColors { selected_bg: Color::Rgb(215, 205, 245), selected_fg: text, @@ -923,21 +920,21 @@ impl ThemeColors { pub fn nord() -> Self { // Nord color palette - let polar_night0 = Color::Rgb(46, 52, 64); // nord0 - let polar_night1 = Color::Rgb(59, 66, 82); // nord1 - let polar_night2 = Color::Rgb(67, 76, 94); // nord2 - let polar_night3 = Color::Rgb(76, 86, 106); // nord3 + let polar_night0 = Color::Rgb(46, 52, 64); // nord0 + let polar_night1 = Color::Rgb(59, 66, 82); // nord1 + let polar_night2 = Color::Rgb(67, 76, 94); // nord2 + let polar_night3 = Color::Rgb(76, 86, 106); // nord3 let snow_storm0 = Color::Rgb(216, 222, 233); // nord4 let _snow_storm1 = Color::Rgb(229, 233, 240); // nord5 let snow_storm2 = Color::Rgb(236, 239, 244); // nord6 - let frost0 = Color::Rgb(143, 188, 187); // nord7 (teal) - let frost1 = Color::Rgb(136, 192, 208); // nord8 (light blue) - let frost2 = Color::Rgb(129, 161, 193); // nord9 (blue) - let _frost3 = Color::Rgb(94, 129, 172); // nord10 (dark blue) - let aurora_red = Color::Rgb(191, 97, 106); // nord11 + let frost0 = Color::Rgb(143, 188, 187); // nord7 (teal) + let frost1 = Color::Rgb(136, 192, 208); // nord8 (light blue) + let frost2 = Color::Rgb(129, 161, 193); // nord9 (blue) + let _frost3 = Color::Rgb(94, 129, 172); // nord10 (dark blue) + let aurora_red = Color::Rgb(191, 97, 106); // nord11 let aurora_orange = Color::Rgb(208, 135, 112); // nord12 let aurora_yellow = Color::Rgb(235, 203, 139); // nord13 - let aurora_green = Color::Rgb(163, 190, 140); // nord14 + let aurora_green = Color::Rgb(163, 190, 140); // nord14 let aurora_purple = Color::Rgb(180, 142, 173); // nord15 Self { @@ -992,11 +989,11 @@ impl ThemeColors { (163, 190, 140), // Aurora green ], link_dim: [ - (55, 75, 85), // Frost1 dimmed - (70, 60, 70), // Purple dimmed - (75, 55, 50), // Orange dimmed - (55, 75, 75), // Frost0 dimmed - (60, 75, 55), // Green dimmed + (55, 75, 85), // Frost1 dimmed + (70, 60, 70), // Purple dimmed + (75, 55, 50), // Orange dimmed + (55, 75, 75), // Frost0 dimmed + (60, 75, 55), // Green dimmed ], }, header: HeaderColors { @@ -1270,11 +1267,11 @@ impl ThemeColors { (80, 250, 123), // Green ], link_dim: [ - (75, 60, 95), // Purple dimmed - (95, 55, 80), // Pink dimmed - (95, 70, 50), // Orange dimmed - (55, 90, 95), // Cyan dimmed - (40, 95, 55), // Green dimmed + (75, 60, 95), // Purple dimmed + (95, 55, 80), // Pink dimmed + (95, 70, 50), // Orange dimmed + (55, 90, 95), // Cyan dimmed + (40, 95, 55), // Green dimmed ], }, header: HeaderColors { @@ -1481,21 +1478,21 @@ impl ThemeColors { pub fn gruvbox_dark() -> Self { // Gruvbox Dark palette - let bg0 = Color::Rgb(40, 40, 40); // #282828 - let bg1 = Color::Rgb(60, 56, 54); // #3c3836 - let bg2 = Color::Rgb(80, 73, 69); // #504945 - let _bg3 = Color::Rgb(102, 92, 84); // #665c54 - let fg = Color::Rgb(235, 219, 178); // #ebdbb2 - let fg2 = Color::Rgb(213, 196, 161); // #d5c4a1 - let fg3 = Color::Rgb(189, 174, 147); // #bdae93 - let fg4 = Color::Rgb(168, 153, 132); // #a89984 - let red = Color::Rgb(251, 73, 52); // #fb4934 - let green = Color::Rgb(184, 187, 38); // #b8bb26 - let yellow = Color::Rgb(250, 189, 47); // #fabd2f - let blue = Color::Rgb(131, 165, 152); // #83a598 + let bg0 = Color::Rgb(40, 40, 40); // #282828 + let bg1 = Color::Rgb(60, 56, 54); // #3c3836 + let bg2 = Color::Rgb(80, 73, 69); // #504945 + let _bg3 = Color::Rgb(102, 92, 84); // #665c54 + let fg = Color::Rgb(235, 219, 178); // #ebdbb2 + let fg2 = Color::Rgb(213, 196, 161); // #d5c4a1 + let fg3 = Color::Rgb(189, 174, 147); // #bdae93 + let fg4 = Color::Rgb(168, 153, 132); // #a89984 + let red = Color::Rgb(251, 73, 52); // #fb4934 + let green = Color::Rgb(184, 187, 38); // #b8bb26 + let yellow = Color::Rgb(250, 189, 47); // #fabd2f + let blue = Color::Rgb(131, 165, 152); // #83a598 let purple = Color::Rgb(211, 134, 155); // #d3869b - let aqua = Color::Rgb(142, 192, 124); // #8ec07c - let orange = Color::Rgb(254, 128, 25); // #fe8019 + let aqua = Color::Rgb(142, 192, 124); // #8ec07c + let orange = Color::Rgb(254, 128, 25); // #fe8019 let darker_bg = Color::Rgb(29, 32, 33); // #1d2021 @@ -1551,11 +1548,11 @@ impl ThemeColors { (184, 187, 38), // Green ], link_dim: [ - (85, 55, 35), // Orange dimmed - (75, 55, 65), // Purple dimmed - (80, 70, 40), // Yellow dimmed - (50, 60, 60), // Blue dimmed - (60, 65, 35), // Green dimmed + (85, 55, 35), // Orange dimmed + (75, 55, 65), // Purple dimmed + (80, 70, 40), // Yellow dimmed + (50, 60, 60), // Blue dimmed + (60, 65, 35), // Green dimmed ], }, header: HeaderColors { @@ -1638,10 +1635,7 @@ impl ThemeColors { key: orange, text: fg4, }, - view_badge: ViewBadgeColors { - bg: fg, - fg: bg0, - }, + view_badge: ViewBadgeColors { bg: fg, fg: bg0 }, nav: NavColors { selected_bg: Color::Rgb(80, 65, 50), selected_fg: fg, @@ -1762,18 +1756,18 @@ impl ThemeColors { pub fn monokai() -> Self { // Monokai palette - let bg = Color::Rgb(39, 40, 34); // #272822 - let bg_light = Color::Rgb(53, 54, 47); // #35362f + let bg = Color::Rgb(39, 40, 34); // #272822 + let bg_light = Color::Rgb(53, 54, 47); // #35362f let bg_lighter = Color::Rgb(70, 71, 62); - let fg = Color::Rgb(248, 248, 242); // #f8f8f2 + let fg = Color::Rgb(248, 248, 242); // #f8f8f2 let fg_dim = Color::Rgb(190, 190, 180); let comment = Color::Rgb(117, 113, 94); // #75715e - let pink = Color::Rgb(249, 38, 114); // #f92672 - let green = Color::Rgb(166, 226, 46); // #a6e22e + let pink = Color::Rgb(249, 38, 114); // #f92672 + let green = Color::Rgb(166, 226, 46); // #a6e22e let yellow = Color::Rgb(230, 219, 116); // #e6db74 - let blue = Color::Rgb(102, 217, 239); // #66d9ef + let blue = Color::Rgb(102, 217, 239); // #66d9ef let purple = Color::Rgb(174, 129, 255); // #ae81ff - let orange = Color::Rgb(253, 151, 31); // #fd971f + let orange = Color::Rgb(253, 151, 31); // #fd971f let darker_bg = Color::Rgb(30, 31, 26); @@ -1829,11 +1823,11 @@ impl ThemeColors { (166, 226, 46), // Green ], link_dim: [ - (90, 40, 60), // Pink dimmed - (70, 55, 90), // Purple dimmed - (85, 60, 35), // Orange dimmed - (50, 75, 85), // Blue dimmed - (60, 80, 40), // Green dimmed + (90, 40, 60), // Pink dimmed + (70, 55, 90), // Purple dimmed + (85, 60, 35), // Orange dimmed + (50, 75, 85), // Blue dimmed + (60, 80, 40), // Green dimmed ], }, header: HeaderColors { @@ -1916,10 +1910,7 @@ impl ThemeColors { key: orange, text: comment, }, - view_badge: ViewBadgeColors { - bg: fg, - fg: bg, - }, + view_badge: ViewBadgeColors { bg: fg, fg: bg }, nav: NavColors { selected_bg: Color::Rgb(80, 60, 75), selected_fg: fg, @@ -2040,11 +2031,11 @@ impl ThemeColors { pub fn pitch_black() -> Self { // Pitch Black (OLED) palette - pure black background with high contrast - let bg = Color::Rgb(0, 0, 0); // Pure black - let surface = Color::Rgb(10, 10, 10); // Very subtle surface - let surface2 = Color::Rgb(21, 21, 21); // Slightly visible surface - let border = Color::Rgb(40, 40, 40); // Subtle borders - let fg = Color::Rgb(230, 230, 230); // Bright white text + let bg = Color::Rgb(0, 0, 0); // Pure black + let surface = Color::Rgb(10, 10, 10); // Very subtle surface + let surface2 = Color::Rgb(21, 21, 21); // Slightly visible surface + let border = Color::Rgb(40, 40, 40); // Subtle borders + let fg = Color::Rgb(230, 230, 230); // Bright white text let fg_dim = Color::Rgb(160, 160, 160); let fg_muted = Color::Rgb(100, 100, 100); @@ -2109,11 +2100,11 @@ impl ThemeColors { (80, 255, 120), // Green ], link_dim: [ - (25, 60, 60), // Cyan dimmed - (50, 35, 65), // Purple dimmed - (60, 45, 20), // Orange dimmed - (25, 50, 70), // Blue dimmed - (25, 65, 35), // Green dimmed + (25, 60, 60), // Cyan dimmed + (50, 35, 65), // Purple dimmed + (60, 45, 20), // Orange dimmed + (25, 50, 70), // Blue dimmed + (25, 65, 35), // Green dimmed ], }, header: HeaderColors { @@ -2196,10 +2187,7 @@ impl ThemeColors { key: orange, text: fg_muted, }, - view_badge: ViewBadgeColors { - bg: fg, - fg: bg, - }, + view_badge: ViewBadgeColors { bg: fg, fg: bg }, nav: NavColors { selected_bg: Color::Rgb(40, 45, 55), selected_fg: fg, @@ -2324,16 +2312,36 @@ impl ThemeColors { pub mod ui { use super::*; - pub fn bg() -> Color { get().ui.bg } - pub fn bg_rgb() -> (u8, u8, u8) { get().ui.bg_rgb } - pub fn text_primary() -> Color { get().ui.text_primary } - pub fn text_muted() -> Color { get().ui.text_muted } - pub fn text_dim() -> Color { get().ui.text_dim } - pub fn border() -> Color { get().ui.border } - pub fn header() -> Color { get().ui.header } - pub fn unfocused() -> Color { get().ui.unfocused } - pub fn accent() -> Color { get().ui.accent } - pub fn surface() -> Color { get().ui.surface } + pub fn bg() -> Color { + get().ui.bg + } + pub fn bg_rgb() -> (u8, u8, u8) { + get().ui.bg_rgb + } + pub fn text_primary() -> Color { + get().ui.text_primary + } + pub fn text_muted() -> Color { + get().ui.text_muted + } + pub fn text_dim() -> Color { + get().ui.text_dim + } + pub fn border() -> Color { + get().ui.border + } + pub fn header() -> Color { + get().ui.header + } + pub fn unfocused() -> Color { + get().ui.unfocused + } + pub fn accent() -> Color { + get().ui.accent + } + pub fn surface() -> Color { + get().ui.surface + } // Constants for backward compatibility pub const BG: Color = Color::Rgb(30, 30, 46); @@ -2385,10 +2393,18 @@ pub mod tile { pub const ACTIVE_SELECTED_BG: Color = Color::Rgb(70, 60, 80); pub const ACTIVE_IN_RANGE_BG: Color = Color::Rgb(55, 55, 70); pub const LINK_BRIGHT: [(u8, u8, u8); 5] = [ - (203, 166, 247), (245, 194, 231), (250, 179, 135), (137, 220, 235), (166, 227, 161), + (203, 166, 247), + (245, 194, 231), + (250, 179, 135), + (137, 220, 235), + (166, 227, 161), ]; pub const LINK_DIM: [(u8, u8, u8); 5] = [ - (70, 55, 85), (85, 65, 80), (85, 60, 45), (45, 75, 80), (55, 80, 55), + (70, 55, 85), + (85, 65, 80), + (85, 60, 45), + (45, 75, 80), + (55, 80, 55), ]; } @@ -2619,7 +2635,11 @@ pub mod meter { pub mod sparkle { pub const COLORS: &[(u8, u8, u8)] = &[ - (200, 220, 255), (250, 179, 135), (166, 227, 161), (245, 194, 231), (203, 166, 247), + (200, 220, 255), + (250, 179, 135), + (166, 227, 161), + (245, 194, 231), + (203, 166, 247), ]; } diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index c44c5f1..0000000 --- a/docs/about.md +++ /dev/null @@ -1,18 +0,0 @@ -# About - -Cagire is an experimental step sequencer built by BuboBubo (Raphaël Maurice Forment). It is a free and open-source project licensed under the AGPL-3.0 License. Cagire has been developed as a side project. I wanted to learn more about using Forth and needed a playground for experimentating with this audio engine! You are free to contribute to the project by making direct contributions to the codebase or by providing feedback and suggestions. - -## Credits - -- **Doux** (audio engine) is a Rust port of Dough, originally written in C by Felix Roos. -- **mi-plaits-dsp-rs** is a Rust port of the code used by the Mutable Instruments Plaits. - * _Author_: Oliver Rockstedt [info@sourcebox.de](info@sourcebox.de). - * _Original author_: Emilie Gillet [emilie.o.gillet@gmail.com](emilie.o.gillet@gmail.com). - -## About live coding - -Live coding is a technique where a programmer writes code in real-time in front of an audience. It is a way to experiment with code, to share things and thoughts openly, to express yourself through code. It can be technical, poetical, weird, preferably all at once. Live coding can be used to create music, visual art, and other forms of media. Learn more about live coding on [https://toplap.org](https://toplap.org) or [https://livecoding.fr](https://livecoding.fr). Live coding is an autotelic activity: it is an activity that is intrinsically rewarding, and the act of doing it is its own reward. There are no errors, only fun to be found by playing music. - -## About the tool - -I do not want to pair it with a DAW, I do not want to make it fit with other commercial software. I'm not interested in VSTs or other proprietary workstations. Please, try to think of Cagire as an alternative to other tools. diff --git a/docs/arithmetic.md b/docs/arithmetic.md new file mode 100644 index 0000000..3b6bcd1 --- /dev/null +++ b/docs/arithmetic.md @@ -0,0 +1,84 @@ +# Arithmetic + +Basic math operations. All arithmetic words pop their operands and push the result. + +## Basic Operations + +``` +3 4 + ( 7 ) +10 3 - ( 7 ) +3 4 * ( 12 ) +10 3 / ( 3.333... ) +10 3 mod ( 1 ) +``` + +Division always produces a float. Use `floor` if you need an integer result. + +## Negative Numbers + +``` +5 neg ( -5 ) +-3 abs ( 3 ) +``` + +## Rounding + +``` +3.7 floor ( 3 ) +3.2 ceil ( 4 ) +3.5 round ( 4 ) +``` + +## Min and Max + +``` +3 7 min ( 3 ) +3 7 max ( 7 ) +``` + +## Power and Root + +``` +2 3 pow ( 8 ) +9 sqrt ( 3 ) +``` + +## Examples + +Calculate a frequency ratio: + +``` +440 2 12 / pow * ( 440 * 2^(1/12) ≈ 466.16 ) +``` + +Clamp a value between 0 and 1: + +``` +1.5 0 max 1 min ( 1 ) +-0.5 0 max 1 min ( 0 ) +``` + +Scale a 0-1 range to 200-800: + +``` +0.5 600 * 200 + ( 500 ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `+` | (a b -- sum) | Add | +| `-` | (a b -- diff) | Subtract | +| `*` | (a b -- prod) | Multiply | +| `/` | (a b -- quot) | Divide | +| `mod` | (a b -- rem) | Modulo | +| `neg` | (a -- -a) | Negate | +| `abs` | (a -- \|a\|) | Absolute value | +| `floor` | (a -- n) | Round down | +| `ceil` | (a -- n) | Round up | +| `round` | (a -- n) | Round to nearest | +| `min` | (a b -- min) | Minimum | +| `max` | (a b -- max) | Maximum | +| `pow` | (a b -- a^b) | Power | +| `sqrt` | (a -- √a) | Square root | diff --git a/docs/banks_patterns.md b/docs/banks_patterns.md new file mode 100644 index 0000000..0fe223c --- /dev/null +++ b/docs/banks_patterns.md @@ -0,0 +1,51 @@ +# Banks & Patterns + +Cagire organizes all your patterns and data following a strict hierarchy: + +- **Projects** contain **Banks**. +- **Banks** contain **Patterns**. +- **Patterns** contain **Steps**. + +## Structure + +``` +Project +└── 32 Banks + └── 32 Patterns (per bank) + └── 128 Steps (per pattern) +``` + +A single project gives you 32 banks, each holding 32 patterns. You get 1024 patterns in each project, ~131.000 steps. + +## Patterns + +Each pattern is an independent sequence of steps with its own properties: + +| Property | Description | Default | +|----------|-------------|---------| +| Length | Steps before the pattern loops (`1`-`128`) | `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` | + +Press `e` in the patterns view to edit these settings. + +## Patterns View + +Access the patterns view with `Ctrl+Up` from the sequencer. The view shows all banks and patterns in a grid. Indicators show pattern state: + +- `>` Currently playing +- `+` Staged to play +- `-` Staged to stop + +### Keybindings + +| Key | Action | +|-----|--------| +| `Arrows` | Navigate banks and patterns | +| `Enter` | Select and return to sequencer | +| `Space` | Toggle pattern playback | +| `e` | Edit pattern properties | +| `r` | Rename bank or pattern | +| `c` / `v` | Copy / Paste | +| `Delete` | Reset to empty pattern | diff --git a/docs/chaining.md b/docs/chaining.md new file mode 100644 index 0000000..34dadc2 --- /dev/null +++ b/docs/chaining.md @@ -0,0 +1,2 @@ +# Chaining + diff --git a/docs/chords.md b/docs/chords.md new file mode 100644 index 0000000..560aa02 --- /dev/null +++ b/docs/chords.md @@ -0,0 +1,2 @@ +# Chords + diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..e154c78 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,41 @@ +# Comparison + +Compare values and produce boolean results (0 or 1). + +## Equality + +```forth +3 3 = ( 1 - equal ) +3 4 = ( 0 - not equal ) +3 4 != ( 1 - not equal ) +3 4 <> ( 1 - not equal, alternative ) +``` + +## Ordering + +```forth +2 3 lt ( 1 - less than ) +3 2 gt ( 1 - greater than ) +3 3 <= ( 1 - less or equal ) +3 3 >= ( 1 - greater or equal ) +``` + +## With Conditionals + +```forth +step 4 lt { "kick" s . } ? ( kick on first 4 steps ) + +beat 8 >= { 0.5 gain } ? ( quieter after beat 8 ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `=` | (a b -- bool) | Equal | +| `!=` | (a b -- bool) | Not equal | +| `<>` | (a b -- bool) | Not equal (alias) | +| `lt` | (a b -- bool) | Less than | +| `gt` | (a b -- bool) | Greater than | +| `<=` | (a b -- bool) | Less or equal | +| `>=` | (a b -- bool) | Greater or equal | diff --git a/docs/conditionals.md b/docs/conditionals.md new file mode 100644 index 0000000..6db3aa1 --- /dev/null +++ b/docs/conditionals.md @@ -0,0 +1,2 @@ +# Conditionals + diff --git a/docs/context.md b/docs/context.md new file mode 100644 index 0000000..8e6b9ba --- /dev/null +++ b/docs/context.md @@ -0,0 +1,2 @@ +# Context + diff --git a/docs/cycles.md b/docs/cycles.md new file mode 100644 index 0000000..c261741 --- /dev/null +++ b/docs/cycles.md @@ -0,0 +1,2 @@ +# Cycles + diff --git a/docs/definitions.md b/docs/definitions.md new file mode 100644 index 0000000..45a4d49 --- /dev/null +++ b/docs/definitions.md @@ -0,0 +1,2 @@ +# Custom Words + diff --git a/docs/delay_reverb.md b/docs/delay_reverb.md new file mode 100644 index 0000000..b6dde16 --- /dev/null +++ b/docs/delay_reverb.md @@ -0,0 +1,51 @@ +# Delay & Reverb + +Add space and depth to your sounds with time-based effects. + +## Delay + +```forth +"snare" s 0.3 delay . ( delay mix ) +"snare" s 0.25 delaytime . ( delay time in seconds ) +"snare" s 0.5 delayfeedback . ( feedback amount ) +"snare" s 1 delaytype . ( delay type ) +``` + +## Reverb + +```forth +"pad" s 0.3 verb . ( reverb mix ) +"pad" s 2 verbdecay . ( decay time ) +"pad" s 0.5 verbdamp . ( high frequency damping ) +"pad" s 0.02 verbpredelay . ( predelay time ) +"pad" s 0.7 verbdiff . ( diffusion ) +"pad" s 1 size . ( room size ) +``` + +## Combined Example + +```forth +"keys" s + c4 note + 0.2 delay + 0.375 delaytime + 0.4 delayfeedback + 0.25 verb + 1.5 verbdecay +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `delay` | (f --) | Set delay mix | +| `delaytime` | (f --) | Set delay time | +| `delayfeedback` | (f --) | Set delay feedback | +| `delaytype` | (n --) | Set delay type | +| `verb` | (f --) | Set reverb mix | +| `verbdecay` | (f --) | Set reverb decay | +| `verbdamp` | (f --) | Set reverb damping | +| `verbpredelay` | (f --) | Set reverb predelay | +| `verbdiff` | (f --) | Set reverb diffusion | +| `size` | (f --) | Set reverb size | diff --git a/docs/dictionary.md b/docs/dictionary.md new file mode 100644 index 0000000..d3d9602 --- /dev/null +++ b/docs/dictionary.md @@ -0,0 +1,43 @@ +# The Dictionary + +Cagire includes a built-in dictionary of all Forth words. Press `Ctrl+Down` repeatedly to reach the **Dict** view, or navigate directly with `Ctrl+Arrow` keys. + +## Using the Dictionary + +The dictionary shows every available word organized by category: + +- **Stack**: Manipulation words like `dup`, `swap`, `drop` +- **Arithmetic**: Math operations +- **Sound**: Sound sources and emission +- **Filter**, **Envelope**, **Effects**: Sound shaping +- **Context**: Sequencer state like `step`, `beat`, `tempo` +- And many more... + +## Navigation + +| Key | Action | +|-----|--------| +| `Tab` | Switch between categories and words | +| `↑/↓` or `j/k` | Navigate items | +| `PgUp/PgDn` | Page through lists | +| `/` or `Ctrl+F` | Search | +| `Esc` | Clear search | + +## Word Information + +Each word entry shows: + +- **Name** and aliases +- **Stack effect**: `( before -- after )` +- **Description**: What the word does +- **Example**: How to use it + +## Search + +Press `/` to search across all words. The search matches word names, aliases, and descriptions. Press `Esc` to clear and return to browsing. + +## Tips + +- Use the dictionary while writing scripts to check stack effects +- Categories group related words together +- Some words have shorter aliases (e.g., `sound` → `s`) diff --git a/docs/effects.md b/docs/effects.md new file mode 100644 index 0000000..cc55169 --- /dev/null +++ b/docs/effects.md @@ -0,0 +1,2 @@ +# Effects + diff --git a/docs/emitting.md b/docs/emitting.md new file mode 100644 index 0000000..16907e6 --- /dev/null +++ b/docs/emitting.md @@ -0,0 +1,48 @@ +# Emitting Sounds + +The core of Cagire is emitting sounds. Every step script builds up sound commands and emits them to the audio engine. + +## The Sound Register + +Before emitting, you must select a sound source using `sound` (or its alias `s`): + +```forth +"kick" sound +"kick" s ( same thing, shorter ) +``` + +This sets the current sound register. All subsequent parameter words modify this sound until you emit or clear it. + +## Emitting + +The `.` word emits the current sound: + +```forth +"kick" s . ( emit one kick ) +"kick" s . . . ( emit three kicks ) +``` + +Use `.!` to emit multiple times: + +```forth +"kick" s 4 .! ( emit four kicks ) +``` + +## Clearing + +The `clear` word resets the sound register and all parameters: + +```forth +"kick" s 0.5 gain . clear "hat" s . +``` + +This is useful when you want to emit different sounds with independent parameters in the same step. + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `sound` / `s` | (name --) | Set sound source | +| `.` | (--) | Emit current sound | +| `.!` | (n --) | Emit current sound n times | +| `clear` | (--) | Clear sound register and params | diff --git a/docs/envelopes.md b/docs/envelopes.md new file mode 100644 index 0000000..e19ce71 --- /dev/null +++ b/docs/envelopes.md @@ -0,0 +1,2 @@ +# Envelopes + diff --git a/docs/eq_stereo.md b/docs/eq_stereo.md new file mode 100644 index 0000000..cf92723 --- /dev/null +++ b/docs/eq_stereo.md @@ -0,0 +1,65 @@ +# EQ & Stereo + +Shape the frequency balance and stereo image of your sounds. + +## Three-Band EQ + +```forth +"mix" s + 3 eqlo ( boost low shelf by 3dB ) + -2 eqmid ( cut mids by 2dB ) + 1 eqhi ( boost high shelf by 1dB ) +. +``` + +## Tilt EQ + +A simple one-knob EQ that tilts the frequency balance: + +```forth +"bright" s 0.5 tilt . ( brighter ) +"dark" s -0.5 tilt . ( darker ) +``` + +Range: -1 (dark) to 1 (bright) + +## Gain and Pan + +```forth +"kick" s 0.8 gain . ( volume 0-1 ) +"hat" s 0.5 pan . ( pan right ) +"hat" s -0.5 pan . ( pan left ) +"snare" s 1.2 postgain . ( post-processing gain ) +"lead" s 100 velocity . ( velocity/dynamics ) +``` + +## Stereo Width + +```forth +"pad" s 0 width . ( mono ) +"pad" s 1 width . ( normal stereo ) +"pad" s 2 width . ( extra wide ) +``` + +## Haas Effect + +Create spatial positioning with subtle delay between channels: + +```forth +"vocal" s 8 haas . ( 8ms delay for spatial effect ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `eqlo` | (f --) | Set low shelf gain (dB) | +| `eqmid` | (f --) | Set mid peak gain (dB) | +| `eqhi` | (f --) | Set high shelf gain (dB) | +| `tilt` | (f --) | Set tilt EQ (-1 dark, 1 bright) | +| `gain` | (f --) | Set volume (0-1) | +| `postgain` | (f --) | Set post gain | +| `velocity` | (f --) | Set velocity | +| `pan` | (f --) | Set pan (-1 to 1) | +| `width` | (f --) | Set stereo width | +| `haas` | (f --) | Set Haas delay (ms) | diff --git a/docs/filters.md b/docs/filters.md new file mode 100644 index 0000000..fb31732 --- /dev/null +++ b/docs/filters.md @@ -0,0 +1,2 @@ +# Filters + diff --git a/docs/generators.md b/docs/generators.md new file mode 100644 index 0000000..59a468b --- /dev/null +++ b/docs/generators.md @@ -0,0 +1,65 @@ +# Generators + +Create sequences of values on the stack. + +## Arithmetic Range + +The `..` operator pushes an arithmetic sequence: + +```forth +1 4 .. ( pushes 1 2 3 4 ) +60 67 .. ( pushes 60 61 62 63 64 65 66 67 ) +``` + +## Geometric Range + +The `geom..` operator creates a geometric sequence: + +```forth +1 2 4 geom.. ( pushes 1 2 4 8 ) +100 0.5 4 geom.. ( pushes 100 50 25 12.5 ) +``` + +Stack: `(start ratio count -- values...)` + +## Generate + +The `gen` word executes a quotation multiple times: + +```forth +{ 1 6 rand } 4 gen ( 4 random values from 1-6 ) +{ coin } 8 gen ( 8 random 0/1 values ) +``` + +## Examples + +Random melody: + +```forth +"pluck" s + { 48 72 rand } 4 gen 4 choose note +. +``` + +Chord from range: + +```forth +"pad" s + 60 67 .. 8 choose note ( random note in octave ) +. +``` + +Euclidean-style rhythm values: + +```forth +0 1 0 0 1 0 1 0 ( manual ) +{ coin } 8 gen ( random 8-step pattern ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `..` | (start end -- values...) | Arithmetic sequence | +| `geom..` | (start ratio count -- values...) | Geometric sequence | +| `gen` | (quot n -- results...) | Execute quotation n times | diff --git a/docs/grid.md b/docs/grid.md new file mode 100644 index 0000000..2fac11c --- /dev/null +++ b/docs/grid.md @@ -0,0 +1,54 @@ +# The Sequencer Grid + +The sequencer grid is the heart of Cagire. Each cell represents a step that can contain a Forth script to generate sound. + +## Structure + +- **Steps**: Up to 128 per pattern, displayed 32 at a time +- **Patterns**: Up to 32 per bank +- **Banks**: Up to 32 per project + +## Navigation + +Use arrow keys to move between steps. The grid wraps around at pattern boundaries. + +## Selection + +Hold `Shift` while pressing arrow keys to select multiple steps. Press `Esc` to clear the selection. + +## Editing Steps + +- `Enter` - Open the script editor +- `t` - Toggle step active/inactive +- `r` - Rename a step +- `Del` - Delete selected steps + +## Copy & Paste + +- `Ctrl+C` - Copy selected steps +- `Ctrl+V` - Paste as copies +- `Ctrl+B` - Paste as linked steps +- `Ctrl+D` - Duplicate selection +- `Ctrl+H` - Harden links (convert to copies) + +Linked steps share the same script as their source. When you edit the source, all linked steps update automatically. + +## Pattern Controls + +- `<` / `>` - Decrease/increase pattern length +- `[` / `]` - Decrease/increase pattern speed +- `L` - Set length directly +- `S` - Set speed directly + +## Playback + +- `Space` - Toggle play/stop +- `+` / `-` - Adjust tempo +- `T` - Set tempo directly +- `Ctrl+R` - Run current step once (preview) + +## Visual Indicators + +- **Highlighted cell** - Currently playing step +- **Colored backgrounds** - Linked steps share colors by source +- **Arrow prefix** (→05) - Step is linked to step 05 diff --git a/docs/how_it_works.md b/docs/how_it_works.md new file mode 100644 index 0000000..41eac8c --- /dev/null +++ b/docs/how_it_works.md @@ -0,0 +1,58 @@ +# How Does It Work? + +Cagire is a step sequencer where each step contains a **Forth script** instead of the typical note data. When the sequencer reaches a step, it runs the script. A script _can do whatever it is programed to do_, such as producing sound commands sent to an internal audio engine. Everything else is similar to a step sequencer: you can `toggle` / `untoggle`, `copy` / `paste` any step or group of steps, etc. You are completely free to define what your scripts will do. It can be as simple as playing a note, or as complex as triggering random audio samples with complex effects. Scripts can also share code and data with each other. + +## Project / session organization + +Cagire can run multiple patterns concurrently. Each pattern contains a given number of steps. Every session / project is organized hierarchically: + +- **32 Banks** +- **32 Patterns** per bank +- **128 Steps** per pattern + +That's over 130,000 possible steps per project. Most of my sessions use 15-20 at best. + +## What does a script look like? + +Forth is a stack-based programming language. It is very minimalistic and emphasizes simplicity and readability. Using Forth doesn't feel like programming at all. It feels more like juggling with words and numbers or writing bad computer poetry. There is pretty much no syntax to learn, just a few rules to follow. Forth is ancient, powerful, flexible, and... super fun to live code with! Here is a minimal program that will play a middle C note using a sine wave: + +```forth +c4 note sine sound . +``` + +Read the program backwards and you will understand what it does instantly: + +- `.`: we want to play a sound. +- `sine sound`: the sound is a sinewave. +- `c4 note`: the pitch is C4 (middle-C). + +Scripts can be simple one-liners or complex programs with conditionals, loops, and randomness. They tend to look like an accumulation of words and numbers. Use space and line returns to your advantage. The Forth language can be learned... on the spot. You just need to understand the following basic rules: + +- there are `words` and `numbers`. +- they are delimited by spaces. +- everything piles up on the `stack`. + +Obviously you will need to understand what the **stack** is, but it will take you five minutes. That's it. See the **Forth** section for details. + +## The Audio Engine + +Cagire includes a complete synthesis engine. No external software is required to play music. It comes with a large number of sound sources and sound shaping tools: oscillators, sample players, effects, filters, and more. The audio engine is quite capable and versatile, and can accomodate a vast array of genres / styles. Here are a few examples : + +```forth +;; sawtooth wave with lowpass filter, chorus and reverb +saw sound 1200 lpf 0.2 chorus 0.8 verb . +``` + +```forth +;; pure sine wave with vibrato and bit crushing +0.5 vibmod 4 vib sine sound 8 crush 0.8 gain . +``` + +```forth +;; very loud and pitched-down kick drum using an audio sample +kkick sound 1.5 distort 0.9 postgain 0.8 speed . +``` + +## Timing & Synchronization + +Cagire uses **Ableton Link** to manage timing and synchronization. This means that all devices using the same protocol can be synchronized to the same tempo. Most commercial softwares support this protocol. The playback speed is defined as a BPM (beats per minute) value. Patterns can run at different speeds relative to the master tempo. Most of the durations in Cagire are defined in terms of beats. diff --git a/docs/keybindings.md b/docs/keybindings.md index 24995e0..8498677 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -32,6 +32,10 @@ - **Ctrl+C**: Copy step script - **Ctrl+V**: Paste step script +### Execution + +- **Ctrl+R**: Run current step's script immediately (one-shot) + ### Tempo - **+ / =**: Increase tempo @@ -41,6 +45,7 @@ - **Tab / Esc**: Return to sequencer focus - **Ctrl+E**: Compile current step script +- **Ctrl+R**: Run script in editor immediately (one-shot) ## Audio Page diff --git a/docs/ladder_filters.md b/docs/ladder_filters.md new file mode 100644 index 0000000..929fcd5 --- /dev/null +++ b/docs/ladder_filters.md @@ -0,0 +1,47 @@ +# Ladder Filters + +Ladder filters provide a classic analog-style filter sound with stronger resonance character than the standard SVF filters. + +## Ladder Lowpass + +```forth +"saw" s 2000 llpf . ( ladder lowpass frequency ) +"saw" s 0.7 llpq . ( ladder lowpass resonance ) +``` + +## Ladder Highpass + +```forth +"noise" s 500 lhpf . ( ladder highpass frequency ) +"noise" s 0.5 lhpq . ( ladder highpass resonance ) +``` + +## Ladder Bandpass + +```forth +"pad" s 1000 lbpf . ( ladder bandpass frequency ) +"pad" s 0.6 lbpq . ( ladder bandpass resonance ) +``` + +## Comparison with SVF + +Ladder filters have a different resonance character: +- More aggressive self-oscillation at high resonance +- Classic "squelchy" acid sound +- 24dB/octave slope + +Standard SVF filters (`lpf`, `hpf`, `bpf`) have: +- Cleaner resonance +- Full envelope control (attack, decay, sustain, release) +- 12dB/octave slope + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `llpf` | (f --) | Set ladder lowpass frequency | +| `llpq` | (f --) | Set ladder lowpass resonance | +| `lhpf` | (f --) | Set ladder highpass frequency | +| `lhpq` | (f --) | Set ladder highpass resonance | +| `lbpf` | (f --) | Set ladder bandpass frequency | +| `lbpq` | (f --) | Set ladder bandpass resonance | diff --git a/docs/lfo.md b/docs/lfo.md new file mode 100644 index 0000000..7246c0f --- /dev/null +++ b/docs/lfo.md @@ -0,0 +1,74 @@ +# LFO & Ramps + +Generate time-varying values synchronized to the beat for modulation. + +## Ramp + +The `ramp` word creates a sawtooth wave from 0 to 1: + +```forth +0.25 1.0 ramp ( one cycle per 4 beats, linear ) +1.0 2.0 ramp ( one cycle per beat, exponential curve ) +``` + +Stack: `(freq curve -- val)` + +## Shortcut Ramps + +```forth +0.5 linramp ( linear ramp, curve=1 ) +0.25 expramp ( exponential ramp, curve=3 ) +2.0 logramp ( logarithmic ramp, curve=0.3 ) +``` + +## Triangle Wave + +```forth +0.5 tri ( triangle wave 0→1→0 ) +``` + +## Perlin Noise + +Smooth random modulation: + +```forth +0.25 perlin ( smooth noise at 0.25x beat rate ) +``` + +## Range Scaling + +Scale a 0-1 value to any range: + +```forth +0.5 200 800 range ( 0.5 becomes 500 ) +``` + +## Examples + +Modulate filter cutoff: + +```forth +"saw" s + 0.25 1.0 ramp 500 2000 range lpf +. +``` + +Random panning: + +```forth +"hat" s + 0.5 perlin -1 1 range pan +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `ramp` | (freq curve -- val) | Ramp 0-1: fract(freq*beat)^curve | +| `linramp` | (freq -- val) | Linear ramp (curve=1) | +| `expramp` | (freq -- val) | Exponential ramp (curve=3) | +| `logramp` | (freq -- val) | Logarithmic ramp (curve=0.3) | +| `tri` | (freq -- val) | Triangle wave 0→1→0 | +| `perlin` | (freq -- val) | Perlin noise 0-1 | +| `range` | (val min max -- scaled) | Scale 0-1 to min-max | diff --git a/docs/link.md b/docs/link.md new file mode 100644 index 0000000..5b7068b --- /dev/null +++ b/docs/link.md @@ -0,0 +1,2 @@ +# Ableton Link + diff --git a/docs/lofi.md b/docs/lofi.md new file mode 100644 index 0000000..1d1b20b --- /dev/null +++ b/docs/lofi.md @@ -0,0 +1,58 @@ +# Lo-fi Effects + +Add grit, warmth, and character with bit crushing, folding, and distortion. + +## Bit Crush + +Reduce bit depth for digital artifacts: + +```forth +"drum" s 8 crush . ( 8-bit crush ) +"drum" s 4 crush . ( heavy 4-bit crush ) +``` + +## Wave Folding + +Fold the waveform back on itself for complex harmonics: + +```forth +"sine" s 2 fold . ( moderate folding ) +"sine" s 4 fold . ( aggressive folding ) +``` + +## Wave Wrap + +Wrap the waveform for a different flavor of distortion: + +```forth +"bass" s 0.5 wrap . +``` + +## Distortion + +Classic overdrive/distortion: + +```forth +"guitar" s 0.5 distort . ( distortion amount ) +"guitar" s 0.8 distortvol . ( output volume ) +``` + +## Combining Effects + +```forth +"drum" s + 12 crush + 1.5 fold + 0.3 distort +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `crush` | (f --) | Set bit crush depth | +| `fold` | (f --) | Set wave fold amount | +| `wrap` | (f --) | Set wave wrap amount | +| `distort` | (f --) | Set distortion amount | +| `distortvol` | (f --) | Set distortion output volume | diff --git a/docs/logic.md b/docs/logic.md new file mode 100644 index 0000000..8503abb --- /dev/null +++ b/docs/logic.md @@ -0,0 +1,89 @@ +# Logic + +Boolean operations and conditional execution. + +## Boolean Operators + +```forth +1 1 and ( 1 ) +0 1 or ( 1 ) +1 not ( 0 ) +1 0 xor ( 1 ) +1 1 nand ( 0 ) +0 0 nor ( 1 ) +``` + +## Conditional Execution + +Execute a quotation if condition is true: + +```forth +{ "kick" s . } coin ? ( 50% chance ) +{ 0.5 gain } step 0 = ? ( only on step 0 ) +``` + +Execute if false: + +```forth +{ "snare" s . } coin !? ( if NOT coin ) +``` + +## If-Else + +```forth +{ "kick" s } { "snare" s } coin ifelse . +``` + +Stack: `(true-quot false-quot bool --)` + +## Pick + +Choose from multiple quotations: + +```forth +{ 60 } { 64 } { 67 } step 3 mod pick note +``` + +Stack: `(quot1 quot2 ... quotN n -- result)` + +## Apply + +Execute a quotation unconditionally: + +```forth +{ 2 * } apply ( doubles top of stack ) +``` + +## Examples + +Conditional parameter: + +```forth +"kick" s + { 0.8 } { 0.4 } iter 2 mod = ifelse gain +. +``` + +Multi-way branching: + +```forth +"synth" s + { c4 } { e4 } { g4 } { c5 } step 4 mod pick note +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `and` | (a b -- bool) | Logical and | +| `or` | (a b -- bool) | Logical or | +| `not` | (a -- bool) | Logical not | +| `xor` | (a b -- bool) | Exclusive or | +| `nand` | (a b -- bool) | Not and | +| `nor` | (a b -- bool) | Not or | +| `?` | (quot bool --) | Execute if true | +| `!?` | (quot bool --) | Execute if false | +| `ifelse` | (t-quot f-quot bool --) | If-else | +| `pick` | (..quots n --) | Execute nth quotation | +| `apply` | (quot --) | Execute unconditionally | diff --git a/docs/mod_fx.md b/docs/mod_fx.md new file mode 100644 index 0000000..2135736 --- /dev/null +++ b/docs/mod_fx.md @@ -0,0 +1,61 @@ +# Modulation Effects + +Phaser, flanger, and chorus effects for movement and width. + +## Phaser + +```forth +"pad" s + 1 phaser ( rate in Hz ) + 0.5 phaserdepth ( depth ) + 0.5 phasersweep ( sweep range ) + 1000 phasercenter ( center frequency ) +. +``` + +## Flanger + +```forth +"guitar" s + 0.5 flanger ( rate ) + 0.5 flangerdepth ( depth ) + 0.5 flangerfeedback ( feedback for metallic sound ) +. +``` + +## Chorus + +```forth +"keys" s + 1 chorus ( rate ) + 0.5 chorusdepth ( depth ) + 0.02 chorusdelay ( base delay time ) +. +``` + +## Combining Effects + +```forth +"pad" s + c4 note + 0.3 phaserdepth + 0.5 phaser + 0.2 chorus + 0.3 chorusdepth +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `phaser` | (f --) | Set phaser rate | +| `phaserdepth` | (f --) | Set phaser depth | +| `phasersweep` | (f --) | Set phaser sweep | +| `phasercenter` | (f --) | Set phaser center frequency | +| `flanger` | (f --) | Set flanger rate | +| `flangerdepth` | (f --) | Set flanger depth | +| `flangerfeedback` | (f --) | Set flanger feedback | +| `chorus` | (f --) | Set chorus rate | +| `chorusdepth` | (f --) | Set chorus depth | +| `chorusdelay` | (f --) | Set chorus delay | diff --git a/docs/modulation.md b/docs/modulation.md new file mode 100644 index 0000000..32a53b2 --- /dev/null +++ b/docs/modulation.md @@ -0,0 +1,2 @@ +# Modulation + diff --git a/docs/navigation.md b/docs/navigation.md new file mode 100644 index 0000000..39a7671 --- /dev/null +++ b/docs/navigation.md @@ -0,0 +1,39 @@ +# Navigation + +The Cagire application is organized as a grid composed of six views: + +``` + Dict Patterns Options + Help Sequencer Engine +``` + +- `Dict` (Dictionary): A comprehensive list of all the `Forth` words used in the application. +- `Help`: Provides detailed information about the application's features and functionalities. +- `Patterns`: Pattern banks and pattern manager. Used to organize a session / project. +- `Sequencer`: The main view, where you edit sequences and play music. +- `Options`: Configuration settings for the application. +- `Engine`: Configuration settings for the internal audio engine. + +## Switching Views + +Use `Ctrl+Arrow` keys to move between views. A minimap appears briefly showing your position in the grid. + +- `Ctrl+Left` / `Ctrl+Right` - move horizontally +- `Ctrl+Up` / `Ctrl+Down` - move vertically + +The grid wraps horizontally, so you can cycle through views on the same row. + +## Getting Help + +Press `?` on any view to see its keybindings. This shows all available shortcuts for the current context. + +Press `Esc` to close the keybindings panel. + +## Common Keys + +These work on most views: + +- `Arrow keys` - move or scroll +- `Tab` - switch focus between panels +- `/` or `Ctrl+f` - search (where available) +- `q` - quit (with confirmation) diff --git a/docs/notes.md b/docs/notes.md new file mode 100644 index 0000000..d33d76d --- /dev/null +++ b/docs/notes.md @@ -0,0 +1,2 @@ +# Notes & MIDI + diff --git a/docs/oscillators.md b/docs/oscillators.md new file mode 100644 index 0000000..c047d9e --- /dev/null +++ b/docs/oscillators.md @@ -0,0 +1,75 @@ +# Oscillators + +Control synthesis oscillator parameters for synth sounds. + +## Frequency and Pitch + +```forth +"sine" s 440 freq . ( set frequency in Hz ) +"saw" s 60 note . ( set MIDI note ) +"square" s 0.01 detune . ( slight detune ) +"lead" s 12 coarse . ( coarse tune in semitones ) +"bass" s 0.1 glide . ( portamento ) +``` + +## Pulse Width + +For pulse/square oscillators: + +```forth +"pulse" s 0.3 pw . ( narrow pulse ) +"pulse" s 0.5 pw . ( square wave ) +``` + +## Stereo Spread + +```forth +"pad" s 0.5 spread . ( stereo spread amount ) +``` + +## Harmonics and Timbre + +For oscillators that support it (like mutable): + +```forth +"mutable" s 4 harmonics . ( harmonic content ) +"mutable" s 0.5 timbre . ( timbre control ) +"mutable" s 0.3 morph . ( morph parameter ) +``` + +## Multiplier and Warp + +```forth +"fm" s 2 mult . ( frequency multiplier ) +"wt" s 0.5 warp . ( warp amount ) +"wt" s 1 mirror . ( mirror waveform ) +``` + +## Sub Oscillator + +```forth +"bass" s 0.5 sub . ( sub oscillator level ) +"bass" s 2 suboct . ( sub octave down ) +"bass" s 1 subwave . ( sub waveform ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `freq` | (f --) | Set frequency (Hz) | +| `note` | (n --) | Set MIDI note | +| `detune` | (f --) | Set detune amount | +| `coarse` | (f --) | Set coarse tune | +| `glide` | (f --) | Set glide/portamento | +| `pw` | (f --) | Set pulse width | +| `spread` | (f --) | Set stereo spread | +| `mult` | (f --) | Set multiplier | +| `warp` | (f --) | Set warp amount | +| `mirror` | (f --) | Set mirror | +| `harmonics` | (f --) | Set harmonics | +| `timbre` | (f --) | Set timbre | +| `morph` | (f --) | Set morph | +| `sub` | (f --) | Set sub oscillator level | +| `suboct` | (n --) | Set sub octave | +| `subwave` | (n --) | Set sub waveform | diff --git a/docs/parameters.md b/docs/parameters.md new file mode 100644 index 0000000..d86a509 --- /dev/null +++ b/docs/parameters.md @@ -0,0 +1,2 @@ +# Parameters + diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 0000000..dbd9700 --- /dev/null +++ b/docs/patterns.md @@ -0,0 +1,2 @@ +# Pattern Management + diff --git a/docs/pitch_envelope.md b/docs/pitch_envelope.md new file mode 100644 index 0000000..88869f9 --- /dev/null +++ b/docs/pitch_envelope.md @@ -0,0 +1,44 @@ +# Pitch Envelope + +Control pitch modulation over time with a dedicated ADSR envelope. + +## Envelope Amount + +```forth +"bass" s 0.5 penv . ( pitch envelope depth ) +"bass" s -0.5 penv . ( negative envelope ) +``` + +## Envelope Shape + +```forth +"kick" s + 1.0 penv ( full envelope depth ) + 0.001 patt ( instant attack ) + 0.1 pdec ( fast decay ) + 0 psus ( no sustain ) + 0.1 prel ( short release ) +. +``` + +## Classic Kick Drum + +```forth +"sine" s + 40 note + 2.0 penv + 0.001 patt + 0.05 pdec + 0 psus +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `penv` | (f --) | Set pitch envelope amount | +| `patt` | (f --) | Set pitch attack time | +| `pdec` | (f --) | Set pitch decay time | +| `psus` | (f --) | Set pitch sustain level | +| `prel` | (f --) | Set pitch release time | diff --git a/docs/probability.md b/docs/probability.md new file mode 100644 index 0000000..67240ef --- /dev/null +++ b/docs/probability.md @@ -0,0 +1,2 @@ +# Probability + diff --git a/docs/randomness.md b/docs/randomness.md new file mode 100644 index 0000000..7152f45 --- /dev/null +++ b/docs/randomness.md @@ -0,0 +1,2 @@ +# Randomness + diff --git a/docs/samples.md b/docs/samples.md new file mode 100644 index 0000000..8201191 --- /dev/null +++ b/docs/samples.md @@ -0,0 +1,76 @@ +# Samples + +Control sample playback with timing, looping, and slice parameters. + +## Basic Playback + +```forth +"break" s . ( play sample from start ) +"break" s 0.5 speed . ( half speed ) +"break" s -1 speed . ( reverse ) +``` + +## Time and Position + +Control where in the sample to start and end: + +```forth +"break" s 0.25 begin . ( start at 25% ) +"break" s 0.5 end . ( end at 50% ) +"break" s 0.1 time . ( offset start time ) +``` + +## Duration and Gate + +```forth +"pad" s 0.5 dur . ( play for 0.5 seconds ) +"pad" s 0.8 gate . ( gate time as fraction ) +``` + +## Looping + +Fit a sample to a number of beats: + +```forth +"break" s 4 loop . ( fit to 4 beats ) +``` + +## Repetition + +```forth +"hat" s 4 repeat . ( trigger 4 times ) +``` + +## Voice and Routing + +```forth +"kick" s 1 voice . ( assign to voice 1 ) +"snare" s 0 orbit . ( route to bus 0 ) +``` + +## Sample Selection + +```forth +"kick" s 2 n . ( select sample #2 from folder ) +"kit" s "a" bank . ( use bank suffix ) +1 cut ( cut group - stops other sounds in same group ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `time` | (f --) | Set time offset | +| `repeat` | (n --) | Set repeat count | +| `dur` | (f --) | Set duration | +| `gate` | (f --) | Set gate time | +| `speed` | (f --) | Set playback speed | +| `begin` | (f --) | Set sample start (0-1) | +| `end` | (f --) | Set sample end (0-1) | +| `loop` | (n --) | Fit sample to n beats | +| `voice` | (n --) | Set voice number | +| `orbit` | (n --) | Set orbit/bus | +| `n` | (n --) | Set sample number | +| `bank` | (str --) | Set sample bank suffix | +| `cut` | (n --) | Set cut group | +| `reset` | (n --) | Reset parameter | diff --git a/docs/scales.md b/docs/scales.md new file mode 100644 index 0000000..8af8485 --- /dev/null +++ b/docs/scales.md @@ -0,0 +1,2 @@ +# Scales + diff --git a/docs/selection.md b/docs/selection.md new file mode 100644 index 0000000..78daf9c --- /dev/null +++ b/docs/selection.md @@ -0,0 +1,62 @@ +# Selection + +Cycle through values over time for evolving patterns. + +## Step Cycle + +`cycle` cycles through values based on step runs: + +```forth +60 64 67 3 cycle note ( cycle through C, E, G ) +``` + +Each time the step runs, it picks the next value. + +## Pattern Cycle + +`pcycle` cycles based on pattern iteration: + +```forth +60 64 67 3 pcycle note ( change note each pattern loop ) +``` + +## Emit-Time Cycle + +`tcycle` creates a cycle list resolved at emit time, useful with `.!`: + +```forth +60 64 67 3 tcycle note 3 .! ( emit C, E, G in sequence ) +``` + +## Examples + +Rotating bass notes: + +```forth +"bass" s + c3 e3 g3 b3 4 cycle note +. +``` + +Evolving pattern over loops: + +```forth +"lead" s + 0.5 1.0 0.75 0.25 4 pcycle gain +. +``` + +Arpeggiated chord: + +```forth +"pluck" s + c4 e4 g4 c5 4 tcycle note 4 .! +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `cycle` | (v1..vn n -- val) | Cycle by step runs | +| `pcycle` | (v1..vn n -- val) | Cycle by pattern iteration | +| `tcycle` | (v1..vn n -- list) | Create cycle list for emit-time | diff --git a/docs/sound_basics.md b/docs/sound_basics.md new file mode 100644 index 0000000..44bdd89 --- /dev/null +++ b/docs/sound_basics.md @@ -0,0 +1,2 @@ +# Sound Basics + diff --git a/docs/stack.md b/docs/stack.md new file mode 100644 index 0000000..0c52852 --- /dev/null +++ b/docs/stack.md @@ -0,0 +1,118 @@ +# The Stack + +Forth is a stack-based language. Instead of variables and expressions, you push values onto a stack and use words that consume and produce values. + +## How It Works + +The stack is a last-in, first-out (LIFO) structure. Values you type get pushed on top. Words pop values off and push results back. + +``` +3 4 + +``` + +Step by step: +1. `3` → push 3 onto stack: `[3]` +2. `4` → push 4 onto stack: `[3, 4]` +3. `+` → pop two values, add them, push result: `[7]` + +## Values + +Three types can live on the stack: + +- **Integers**: `42`, `-7`, `0` +- **Floats**: `3.14`, `0.5`, `-1.0` +- **Strings**: `"kick"`, `"hello"` + +## Stack Notation + +Documentation uses stack effect notation: + +``` +( before -- after ) +``` + +For example, `+` has effect `( a b -- sum )` meaning it takes two values and leaves one. + +## Core Words + +### dup + +Duplicate the top value. + +``` +3 dup ( 3 3 ) +``` + +### drop + +Discard the top value. + +``` +3 4 drop ( 3 ) +``` + +### swap + +Swap the top two values. + +``` +3 4 swap ( 4 3 ) +``` + +### over + +Copy the second value to the top. + +``` +3 4 over ( 3 4 3 ) +``` + +### rot + +Rotate the top three values. + +``` +1 2 3 rot ( 2 3 1 ) +``` + +### nip + +Drop the second value. + +``` +3 4 nip ( 4 ) +``` + +### tuck + +Copy top value below second. + +``` +3 4 tuck ( 4 3 4 ) +``` + +## Examples + +Build a chord by duplicating and adding: + +``` +60 dup 4 + swap 7 + ( 64 67 60 ) +``` + +Use `over` to keep a base value: + +``` +c4 over M3 swap P5 ( e4 g4 c4 ) +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `dup` | (a -- a a) | Duplicate top | +| `drop` | (a --) | Discard top | +| `swap` | (a b -- b a) | Swap top two | +| `over` | (a b -- a b a) | Copy second to top | +| `rot` | (a b c -- b c a) | Rotate three | +| `nip` | (a b -- b) | Drop second | +| `tuck` | (a b -- b a b) | Copy top below second | diff --git a/docs/staging.md b/docs/staging.md new file mode 100644 index 0000000..2cb176b --- /dev/null +++ b/docs/staging.md @@ -0,0 +1,50 @@ +# Stage / Commit + +Cagire requires you to `stage` changes you wish to make to the playback state and then `commit` it. It is way more simple than it seems. For instance, you mark pattern `04` and `05` to start playing, and _then_ you send the order to start the playback (`commit`). The same goes for stopping patterns. You mark which pattern to stop (`stage`) and then you give the order to stop them (`commit`). Why is staging useful? Here are some reasons why this design choice was made: + +- **To apply multiple changes**: Queue several patterns to start/stop, commit them together. +- **To get clean timing**: All changes happen on beat/bar boundaries. +- **To help with live performance**: Prepare the next section without affecting current playback. + +Staging is an essential feature to understand to be effective when doing live performances: + +1. Open the **Patterns** view (`Ctrl+Up` from sequencer) +2. Navigate to a pattern you wish to change/play +3. Press `Space` to stage it. The pending change is going to be displayed: + - `+` (staged to play) + - `-` (staged to stop) +4. Repeat for other patterns you want to change +5. Press `c` to commit all changes +6. Or press `Esc` to cancel + +A pattern might not start immediately depending on the sync mode you have chosen. It might wait for the next beat/bar boundary. + +## Status Indicators + +| Indicator | Meaning | +|-----------|---------| +| `>` | Currently playing | +| `+` | Staged to play | +| `-` | Staged to stop | + +A pattern can show both `>` (playing) and `-` (staged to stop). + +## Quantization + +Committed changes don't execute immediately. They wait for a quantization boundary: + +| Setting | Behavior | +|---------|----------| +| Immediate | Next sequencer tick | +| Beat | Next beat | +| 1 Bar | Next bar (default) | +| 2/4/8 Bars | Next 2, 4, or 8-bar boundary | + +Edit quantization in pattern properties (press `e` on a pattern). + +## Sync Mode + +When a pattern starts, its playback position depends on sync mode: + +- **Reset**: Always start at step 0 +- **Phase-Lock**: Start at the current beat-aligned position (stays in sync with other patterns) diff --git a/docs/tempo.md b/docs/tempo.md new file mode 100644 index 0000000..c7d1429 --- /dev/null +++ b/docs/tempo.md @@ -0,0 +1,2 @@ +# Tempo & Speed + diff --git a/docs/timing.md b/docs/timing.md new file mode 100644 index 0000000..31cae64 --- /dev/null +++ b/docs/timing.md @@ -0,0 +1,2 @@ +# Timing + diff --git a/docs/variables.md b/docs/variables.md new file mode 100644 index 0000000..5df2cd1 --- /dev/null +++ b/docs/variables.md @@ -0,0 +1,2 @@ +# Variables + diff --git a/docs/vibrato.md b/docs/vibrato.md new file mode 100644 index 0000000..53b12fa --- /dev/null +++ b/docs/vibrato.md @@ -0,0 +1,44 @@ +# Vibrato + +Add pitch vibrato to oscillator sounds. + +## Basic Vibrato + +```forth +"lead" s + 60 note + 5 vib ( vibrato rate in Hz ) + 0.5 vibmod ( vibrato depth ) +. +``` + +## Vibrato Shape + +Control the LFO waveform: + +```forth +"pad" s + c4 note + 4 vib + 0.3 vibmod + 0 vibshape ( 0=sine, other values for different shapes ) +. +``` + +## Subtle vs Expressive + +```forth +( subtle vibrato for pads ) +"pad" s 3 vib 0.1 vibmod . + +( expressive vibrato for leads ) +"lead" s 6 vib 0.8 vibmod . +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `vib` | (f --) | Set vibrato rate (Hz) | +| `vibmod` | (f --) | Set vibrato depth | +| `vibshape` | (f --) | Set vibrato LFO shape | diff --git a/docs/wavetables.md b/docs/wavetables.md new file mode 100644 index 0000000..a37c0af --- /dev/null +++ b/docs/wavetables.md @@ -0,0 +1,55 @@ +# Wavetables + +Control wavetable synthesis parameters for scanning through waveforms. + +## Scan Position + +The `scan` parameter controls which waveform in the wavetable is active: + +```forth +"wt" s 0.0 scan . ( first waveform ) +"wt" s 0.5 scan . ( middle of table ) +"wt" s 1.0 scan . ( last waveform ) +``` + +## Wavetable Length + +Set the cycle length in samples: + +```forth +"wt" s 2048 wtlen . ( standard wavetable size ) +``` + +## Scan Modulation + +Animate the scan position with an LFO: + +```forth +"wt" s 0.2 scanlfo . ( LFO rate in Hz ) +"wt" s 0.4 scandepth . ( LFO depth 0-1 ) +"wt" s "tri" scanshape . ( LFO shape ) +``` + +Available shapes: `sine`, `tri`, `saw`, `square`, `sh` (sample & hold) + +## Example + +```forth +"wavetable" s + 60 note + 0.25 scan + 0.1 scanlfo + 0.3 scandepth + "sine" scanshape +. +``` + +## Words + +| Word | Stack | Description | +|------|-------|-------------| +| `scan` | (f --) | Set wavetable scan position (0-1) | +| `wtlen` | (n --) | Set wavetable cycle length in samples | +| `scanlfo` | (f --) | Set scan LFO rate (Hz) | +| `scandepth` | (f --) | Set scan LFO depth (0-1) | +| `scanshape` | (s --) | Set scan LFO shape | diff --git a/docs/welcome.md b/docs/welcome.md index 9de1696..ecc2ed1 100644 --- a/docs/welcome.md +++ b/docs/welcome.md @@ -1,19 +1,31 @@ # Welcome to Cagire -Cagire is a terminal-based step sequencer for live coding music. Each step in a pattern contains a **Forth** script that produces sound and create events. It is made by BuboBubo (Raphaël Maurice Forment): [https://raphaelforment.fr](https://raphaelforment.fr). Cagire is open-source (AGPL-3.0 licensed) and available on GitHub : [https://github.com/BuboBubo/cagire](https://github.com/BuboBubo/cagire). This help view will teach you everything you need to know to start using Cagire and and to live code with it. To use Cagire, you will need to understand two things: +Cagire is a terminal-based step sequencer for live coding music. Each step on the sequencer is defined by a **Forth** script that produces sound and create events. The documentation you are currently reading acts both as _tutorial_ and _reference_. It contains everything you need to know to use Cagire effectively. We recommend you to dive in and explore by picking subjects that interest you before slowly learning about everything else. Here are some recommended topics to start with: -1) How the sequencer works: dealing with steps, patterns and banks. -2) How to write a script: how to make sound using code. +1) How the sequencer works? Banks, patterns and steps. + * the sequencer model, the pattern model, the step sequencer. +2) How to write a script? How to make sound using code. + * how to write simple scripts that play `musical events`. + * how to extend these scripts with `logic` and/or `randomness`. + * how define `WORDS`, `variables`, and share data between steps. +3) What can I do with the audio engine? + * audio sources: samples, oscillators, wavetables, noise generators. + * audio effects: filters, delay, reverb, distortion, modulations. +4) How far can it go? + * how to live code with Cagire. + * how fast can I break things? -## Pages +## What is live coding? -Cagire is organized in several views. Navigate between them using **Ctrl+Left/Right/Up/Down**: +Live coding is a technique where a programmer writes code in real-time in front of an audience. It is a way to experiment with code, to share things and thoughts openly, to express yourself through code. It can be technical, poetical, weird, preferably all at once. Live coding can be used to create music, visual art, and other forms of media. Learn more about live coding on [https://toplap.org](https://toplap.org) or [https://livecoding.fr](https://livecoding.fr). Live coding is an autotelic activity: it is an activity that is intrinsically rewarding, and the act of doing it is its own reward. There are no errors, only fun. -- **Sequencer**: Main view. Edit or preview patterns and scripts. Write Forth scripts. -- **Patterns**: Project patterns management. 32 banks of 32 patterns per project. Edit pattern properties (name, length, etc). -- **Engine**: Internal audio engine management: device selection, sample loading, performance options and voice / state monitoring. -- **Options**: General application settings. -- **Dict**: Forth word dictionary, organized by category. Learn about the internal programming language and its features. -- **Help**: Documentation. This is the page view you are looking at right now. +## About -Have fun with Cagire! Remember that live coding is all about experimentation and exploration! +Cagire is built by BuboBubo (Raphaël Maurice Forment, [https://raphaelforment.fr](https://raphaelforment.fr)). It is a free and open-source project licensed under the `AGPL-3.0 License`. You are free to contribute to the project by making direct contributions to the codebase or by providing feedback and suggestions. + +### Credits + +* **Doux** (audio engine) is a Rust port of Dough, originally written in C by Felix Roos. +* **mi-plaits-dsp-rs** is a Rust port of the code used by the Mutable Instruments Plaits. + * _Author_: Oliver Rockstedt [info@sourcebox.de](info@sourcebox.de). + * _Original author_: Emilie Gillet [emilie.o.gillet@gmail.com](emilie.o.gillet@gmail.com). diff --git a/src/app.rs b/src/app.rs index 6d21c37..4782ff3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -281,6 +281,45 @@ impl App { self.project_state.mark_dirty(change.bank, change.pattern); } + pub fn execute_script_oneshot( + &self, + script: &str, + link: &LinkState, + audio_tx: &arc_swap::ArcSwap>, + ) -> Result<(), String> { + let (bank, pattern) = self.current_bank_pattern(); + let step_idx = self.editor_ctx.step; + let speed = self + .project_state + .project + .pattern_at(bank, pattern) + .speed + .multiplier(); + + let ctx = StepContext { + step: step_idx, + beat: link.beat(), + bank, + pattern, + tempo: link.tempo(), + phase: link.phase(), + slot: 0, + runs: 0, + iter: 0, + speed, + fill: false, + nudge_secs: 0.0, + }; + + let cmds = self.script_engine.evaluate(script, &ctx)?; + for cmd in cmds { + let _ = audio_tx + .load() + .send(crate::engine::AudioCommand::Evaluate { cmd, time: None }); + } + Ok(()) + } + pub fn compile_current_step(&mut self, link: &LinkState) { let step_idx = self.editor_ctx.step; let (bank, pattern) = self.current_bank_pattern(); @@ -1117,12 +1156,20 @@ impl App { AppCommand::PageDown => self.page.down(), // Help navigation - AppCommand::HelpNextTopic => { - self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count(); + AppCommand::HelpToggleFocus => { + use crate::state::HelpFocus; + self.ui.help_focus = match self.ui.help_focus { + HelpFocus::Topics => HelpFocus::Content, + HelpFocus::Content => HelpFocus::Topics, + }; } - AppCommand::HelpPrevTopic => { + AppCommand::HelpNextTopic(n) => { let count = help_view::topic_count(); - self.ui.help_topic = (self.ui.help_topic + count - 1) % count; + self.ui.help_topic = (self.ui.help_topic + n) % count; + } + AppCommand::HelpPrevTopic(n) => { + let count = help_view::topic_count(); + self.ui.help_topic = (self.ui.help_topic + count - (n % count)) % count; } AppCommand::HelpScrollDown(n) => { let s = self.ui.help_scroll_mut(); diff --git a/src/commands.rs b/src/commands.rs index e514bfc..f81a99b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -138,8 +138,9 @@ pub enum AppCommand { PageDown, // Help navigation - HelpNextTopic, - HelpPrevTopic, + HelpToggleFocus, + HelpNextTopic(usize), + HelpPrevTopic(usize), HelpScrollDown(usize), HelpScrollUp(usize), HelpActivateSearch, diff --git a/src/input.rs b/src/input.rs index 631a3b0..ffaff4f 100644 --- a/src/input.rs +++ b/src/input.rs @@ -508,6 +508,13 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Char('s') if ctrl => { ctx.app.editor_ctx.show_stack = !ctx.app.editor_ctx.show_stack; } + KeyCode::Char('r') if ctrl => { + let script = ctx.app.editor_ctx.editor.lines().join("\n"); + match ctx.app.execute_script_oneshot(&script, ctx.link, ctx.audio_tx) { + Ok(()) => ctx.app.ui.flash("Executed", 100, crate::state::FlashKind::Info), + Err(e) => ctx.app.ui.flash(&format!("Error: {e}"), 200, crate::state::FlashKind::Error), + } + } KeyCode::Char('a') if ctrl => { editor.select_all(); } @@ -899,6 +906,17 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR })); } } + KeyCode::Char('r') if ctrl => { + let pattern = ctx.app.current_edit_pattern(); + if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) { + if !script.trim().is_empty() { + match ctx.app.execute_script_oneshot(script, ctx.link, ctx.audio_tx) { + Ok(()) => ctx.app.ui.flash("Executed", 100, crate::state::FlashKind::Info), + Err(e) => ctx.app.ui.flash(&format!("Error: {e}"), 200, crate::state::FlashKind::Error), + } + } + } + } KeyCode::Char('r') => { let (bank, pattern, step) = ( ctx.app.editor_ctx.bank, @@ -1269,26 +1287,43 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + use crate::state::HelpFocus; + + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctx.app.ui.help_search_active { match key.code { KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch), KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm), KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace), - KeyCode::Char(c) => ctx.dispatch(AppCommand::HelpSearchInput(c)), + KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::HelpSearchInput(c)), _ => {} } return InputResult::Continue; } match key.code { - KeyCode::Char('/') => ctx.dispatch(AppCommand::HelpActivateSearch), + KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => { + ctx.dispatch(AppCommand::HelpActivateSearch); + } KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => { ctx.dispatch(AppCommand::HelpClearSearch); } - KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)), - KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)), - KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic), - KeyCode::BackTab => ctx.dispatch(AppCommand::HelpPrevTopic), + KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus), + KeyCode::Char('j') | KeyCode::Down if ctrl => { + ctx.dispatch(AppCommand::HelpNextTopic(5)); + } + KeyCode::Char('k') | KeyCode::Up if ctrl => { + ctx.dispatch(AppCommand::HelpPrevTopic(5)); + } + KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.help_focus { + HelpFocus::Topics => ctx.dispatch(AppCommand::HelpNextTopic(1)), + HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollDown(1)), + }, + KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.help_focus { + HelpFocus::Topics => ctx.dispatch(AppCommand::HelpPrevTopic(1)), + HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollUp(1)), + }, KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)), KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)), KeyCode::Char('q') => { diff --git a/src/page.rs b/src/page.rs index 7e28560..c0ec31f 100644 --- a/src/page.rs +++ b/src/page.rs @@ -26,16 +26,16 @@ impl Page { /// Grid position (col, row) for each page /// Layout: /// col 0 col 1 col 2 - /// row 0 Options Patterns Help - /// row 1 Dict Sequencer Engine + /// row 0 Dict Patterns Options + /// row 1 Help Sequencer Engine pub const fn grid_pos(self) -> (i8, i8) { match self { - Page::Options => (0, 0), - Page::Dict => (0, 1), - Page::Main => (1, 1), + Page::Dict => (0, 0), + Page::Help => (0, 1), Page::Patterns => (1, 0), + Page::Main => (1, 1), + Page::Options => (2, 0), Page::Engine => (2, 1), - Page::Help => (2, 0), } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 5ac9962..e63fc4a 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -23,4 +23,4 @@ pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use playback::{PlaybackState, StagedChange}; pub use project::ProjectState; pub use sample_browser::SampleBrowserState; -pub use ui::{DictFocus, FlashKind, UiState}; +pub use ui::{DictFocus, FlashKind, HelpFocus, UiState}; diff --git a/src/state/ui.rs b/src/state/ui.rs index 28d4ffa..aef1d55 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -19,12 +19,20 @@ pub enum DictFocus { Words, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum HelpFocus { + #[default] + Topics, + Content, +} + pub struct UiState { pub sparkles: Sparkles, pub status_message: Option, pub flash_until: Option, pub flash_kind: FlashKind, pub modal: Modal, + pub help_focus: HelpFocus, pub help_topic: usize, pub help_scrolls: Vec, pub help_search_active: bool, @@ -52,6 +60,7 @@ impl Default for UiState { flash_until: None, flash_kind: FlashKind::Success, modal: Modal::None, + help_focus: HelpFocus::default(), help_topic: 0, help_scrolls: vec![0; crate::views::help_view::topic_count()], help_search_active: false, diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 1a636fa..ce9426e 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -1,4 +1,4 @@ -use minimad::{Composite, CompositeStyle, Compound, Line}; +use minimad::{Composite, CompositeStyle, Compound, Line, TableRow}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as RLine, Span}; @@ -7,25 +7,121 @@ use ratatui::Frame; use tui_big_text::{BigText, PixelSize}; use crate::app::App; +use crate::state::HelpFocus; use crate::theme; use crate::views::highlight; -// To add a new help topic: drop a .md file in docs/ and add one line here. -const DOCS: &[(&str, &str)] = &[ - ("Welcome", include_str!("../../docs/welcome.md")), - ("Audio Engine", include_str!("../../docs/audio_engine.md")), - ("Keybindings", include_str!("../../docs/keybindings.md")), - ("Sequencer", include_str!("../../docs/sequencer.md")), - ("About", include_str!("../../docs/about.md")), +enum DocEntry { + Section(&'static str), + Topic(&'static str, &'static str), +} + +use DocEntry::{Section, Topic}; + +const DOCS: &[DocEntry] = &[ + // Getting Started + Section("Getting Started"), + Topic("Welcome", include_str!("../../docs/welcome.md")), + Topic("Moving Around", include_str!("../../docs/navigation.md")), + Topic( + "How Does It Work?", + include_str!("../../docs/how_it_works.md"), + ), + Topic( + "Banks & Patterns", + include_str!("../../docs/banks_patterns.md"), + ), + Topic("Stage / Commit", include_str!("../../docs/staging.md")), + Topic("Using the Sequencer", include_str!("../../docs/grid.md")), + // Forth fundamentals + Section("Forth"), + Topic("The Dictionary", include_str!("../../docs/dictionary.md")), + Topic("The Stack", include_str!("../../docs/stack.md")), + Topic("Arithmetic", include_str!("../../docs/arithmetic.md")), + Topic("Comparison", include_str!("../../docs/comparison.md")), + Topic("Logic", include_str!("../../docs/logic.md")), + // Sound generation + Section("Sounds"), + Topic("Emitting", include_str!("../../docs/emitting.md")), + Topic("Samples", include_str!("../../docs/samples.md")), + Topic("Oscillators", include_str!("../../docs/oscillators.md")), + Topic("Wavetables", include_str!("../../docs/wavetables.md")), + // Sound shaping + Section("Shaping"), + Topic("Envelopes", include_str!("../../docs/envelopes.md")), + Topic( + "Pitch Envelope", + include_str!("../../docs/pitch_envelope.md"), + ), + Topic("Filters", include_str!("../../docs/filters.md")), + Topic( + "Ladder Filters", + include_str!("../../docs/ladder_filters.md"), + ), + // Movement and modulation + Section("Movement"), + Topic("LFO & Ramps", include_str!("../../docs/lfo.md")), + Topic("Modulation", include_str!("../../docs/modulation.md")), + Topic("Vibrato", include_str!("../../docs/vibrato.md")), + // Effects + Section("Effects"), + Topic("Delay & Reverb", include_str!("../../docs/delay_reverb.md")), + Topic("Mod FX", include_str!("../../docs/mod_fx.md")), + Topic("EQ & Stereo", include_str!("../../docs/eq_stereo.md")), + Topic("Lo-fi", include_str!("../../docs/lofi.md")), + // Variation and randomness + Section("Variation"), + Topic("Randomness", include_str!("../../docs/randomness.md")), + Topic("Probability", include_str!("../../docs/probability.md")), + Topic("Selection", include_str!("../../docs/selection.md")), + // Timing + Section("Timing"), + Topic("Context", include_str!("../../docs/context.md")), + Topic("Cycles", include_str!("../../docs/cycles.md")), + Topic("Timing", include_str!("../../docs/timing.md")), + Topic("Patterns", include_str!("../../docs/patterns.md")), + Topic("Chaining", include_str!("../../docs/chaining.md")), + // Music theory + Section("Music"), + Topic("Notes", include_str!("../../docs/notes.md")), + Topic("Scales", include_str!("../../docs/scales.md")), + Topic("Chords", include_str!("../../docs/chords.md")), + Topic("Generators", include_str!("../../docs/generators.md")), + // Advanced + Section("Advanced"), + Topic("Variables", include_str!("../../docs/variables.md")), + Topic("Conditionals", include_str!("../../docs/conditionals.md")), + Topic("Custom Words", include_str!("../../docs/definitions.md")), + Topic("Ableton Link", include_str!("../../docs/link.md")), + // Reference + Section("Reference"), + Topic("Audio Engine", include_str!("../../docs/audio_engine.md")), + Topic("Keybindings", include_str!("../../docs/keybindings.md")), + Topic("Sequencer", include_str!("../../docs/sequencer.md")), + // Archive - old files to sort + Section("Archive"), + Topic("Sound Basics", include_str!("../../docs/sound_basics.md")), + Topic("Parameters", include_str!("../../docs/parameters.md")), + Topic("Tempo & Speed", include_str!("../../docs/tempo.md")), + Topic("Effects (old)", include_str!("../../docs/effects.md")), ]; pub fn topic_count() -> usize { - DOCS.len() + DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count() +} + +fn get_topic(index: usize) -> Option<(&'static str, &'static str)> { + DOCS.iter() + .filter_map(|e| match e { + Topic(name, content) => Some((*name, *content)), + Section(_) => None, + }) + .nth(index) } 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); + Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area); render_topics(frame, app, topics_area); render_content(frame, app, content_area); @@ -33,25 +129,77 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { fn render_topics(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); + + let visible_height = area.height.saturating_sub(2) as usize; + let total_items = DOCS.len(); + + // Find the visual index of the selected topic (including sections) + let selected_visual_idx = { + let mut visual = 0; + let mut topic_count = 0; + for entry in DOCS.iter() { + if let Topic(_, _) = entry { + if topic_count == app.ui.help_topic { + break; + } + topic_count += 1; + } + visual += 1; + } + visual + }; + + // Calculate scroll to keep selection visible (centered when possible) + let scroll = if selected_visual_idx < visible_height / 2 { + 0 + } else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) { + total_items.saturating_sub(visible_height) + } else { + selected_visual_idx.saturating_sub(visible_height / 2) + }; + + // Count topics before the scroll offset to track topic_idx correctly + let mut topic_idx = DOCS + .iter() + .take(scroll) + .filter(|e| matches!(e, Topic(_, _))) + .count(); + let items: Vec = DOCS .iter() - .enumerate() - .map(|(i, (name, _))| { - let selected = i == app.ui.help_topic; - let style = if selected { - Style::new().fg(theme.dict.category_selected).add_modifier(Modifier::BOLD) - } else { - Style::new().fg(theme.ui.text_primary) - }; - let prefix = if selected { "> " } else { " " }; - ListItem::new(format!("{prefix}{name}")).style(style) + .skip(scroll) + .take(visible_height) + .map(|entry| match entry { + Section(name) => { + let style = Style::new().fg(theme.ui.text_dim); + ListItem::new(format!("─ {name} ─")).style(style) + } + Topic(name, _) => { + let selected = topic_idx == app.ui.help_topic; + let style = if selected { + Style::new() + .fg(theme.dict.category_selected) + .add_modifier(Modifier::BOLD) + } else { + Style::new().fg(theme.ui.text_primary) + }; + let prefix = if selected { "> " } else { " " }; + topic_idx += 1; + ListItem::new(format!("{prefix}{name}")).style(style) + } }) .collect(); + let focused = app.ui.help_focus == HelpFocus::Topics; + let border_color = if focused { + theme.dict.border_focused + } else { + theme.dict.border_normal + }; let list = List::new(items).block( Block::default() .borders(Borders::ALL) - .border_style(Style::new().fg(theme.dict.border_focused)) + .border_style(Style::new().fg(border_color)) .title("Topics"), ); frame.render_widget(list, area); @@ -62,7 +210,9 @@ const BIG_TITLE_HEIGHT: u16 = 6; fn render_content(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); - let (_, md) = DOCS[app.ui.help_topic]; + let Some((_, md)) = get_topic(app.ui.help_topic) else { + return; + }; let is_welcome = app.ui.help_topic == WELCOME_TOPIC; let md_area = if is_welcome { @@ -95,24 +245,6 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { let lines = parse_markdown(md); let has_search_bar = app.ui.help_search_active || has_query; - let search_bar_height: u16 = u16::from(has_search_bar); - let visible_height = md_area.height.saturating_sub(6 + search_bar_height) as usize; - let max_scroll = lines.len().saturating_sub(visible_height); - let scroll = app.ui.help_scroll().min(max_scroll); - - let visible: Vec = lines - .into_iter() - .skip(scroll) - .take(visible_height) - .map(|line| { - if has_query { - highlight_line(line, &query_lower) - } else { - line - } - }) - .collect(); - let content_area = if has_search_bar { let [content, search] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area); @@ -122,17 +254,55 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { md_area }; - let para = Paragraph::new(visible) + // Calculate dimensions: 2 borders + 4 padding (2 left + 2 right) + let content_width = content_area.width.saturating_sub(6) as usize; + // 2 borders + 4 padding (2 top + 2 bottom) + let visible_height = content_area.height.saturating_sub(6) as usize; + + // Calculate total wrapped line count for accurate max_scroll + let total_wrapped: usize = lines + .iter() + .map(|l| wrapped_line_count(l, content_width)) + .sum(); + let max_scroll = total_wrapped.saturating_sub(visible_height); + let scroll = app.ui.help_scroll().min(max_scroll); + + let lines: Vec = if has_query { + lines + .into_iter() + .map(|line| highlight_line(line, &query_lower)) + .collect() + } else { + lines + }; + + let focused = app.ui.help_focus == HelpFocus::Content; + let border_color = if focused { + theme.dict.border_focused + } else { + theme.dict.border_normal + }; + let para = Paragraph::new(lines) + .scroll((scroll as u16, 0)) .block( Block::default() .borders(Borders::ALL) - .border_style(Style::new().fg(theme.ui.border)) + .border_style(Style::new().fg(border_color)) .padding(Padding::new(2, 2, 2, 2)), ) .wrap(Wrap { trim: false }); frame.render_widget(para, content_area); } +fn wrapped_line_count(line: &RLine, width: usize) -> usize { + let char_count: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); + if char_count == 0 || width == 0 { + 1 + } else { + (char_count + width - 1) / width + } +} + fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let style = if app.ui.help_search_active { @@ -156,7 +326,9 @@ fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> { } let content = span.content.to_string(); let base_style = span.style; - let hl_style = base_style.bg(theme.search.match_bg).fg(theme.search.match_fg); + let hl_style = base_style + .bg(theme.search.match_bg) + .fg(theme.search.match_fg); let mut start = 0; let lower_bytes = lower.as_bytes(); let query_bytes = query.as_bytes(); @@ -185,7 +357,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { /// Find first line matching query across all topics. Returns (topic_index, line_index). pub fn find_match(query: &str) -> Option<(usize, usize)> { let query = query.to_lowercase(); - for (topic_idx, (_, content)) in DOCS.iter().enumerate() { + for (topic_idx, (_, content)) in DOCS + .iter() + .filter_map(|e| match e { + Topic(name, content) => Some((*name, *content)), + Section(_) => None, + }) + .enumerate() + { for (line_idx, line) in content.lines().enumerate() { if line.to_lowercase().contains(&query) { return Some((topic_idx, line_idx)); @@ -200,9 +379,11 @@ fn code_border_style() -> Style { Style::new().fg(theme.markdown.code_border) } -fn preprocess_underscores(md: &str) -> String { +fn preprocess_markdown(md: &str) -> String { let mut out = String::with_capacity(md.len()); for line in md.lines() { + // Convert dash list markers to asterisks (minimad only recognizes *) + let line = convert_dash_lists(line); let mut result = String::with_capacity(line.len()); let mut chars = line.char_indices().peekable(); let bytes = line.as_bytes(); @@ -243,18 +424,44 @@ fn preprocess_underscores(md: &str) -> String { out } +fn convert_dash_lists(line: &str) -> String { + let trimmed = line.trim_start(); + if trimmed.starts_with("- ") { + let indent = line.len() - trimmed.len(); + format!("{}* {}", " ".repeat(indent), &trimmed[2..]) + } else { + line.to_string() + } +} + fn parse_markdown(md: &str) -> Vec> { - let processed = preprocess_underscores(md); + let processed = preprocess_markdown(md); let text = minimad::Text::from(processed.as_str()); let mut lines = Vec::new(); let mut code_line_nr: usize = 0; + let mut table_buffer: Vec = Vec::new(); + + let flush_table = |buf: &mut Vec, out: &mut Vec>| { + if buf.is_empty() { + return; + } + let col_widths = compute_column_widths(buf); + for (row_idx, row) in buf.drain(..).enumerate() { + out.push(render_table_row(row, row_idx, &col_widths)); + } + }; for line in text.lines { match line { Line::Normal(composite) if composite.style == CompositeStyle::Code => { + flush_table(&mut table_buffer, &mut lines); code_line_nr += 1; - let raw: String = composite.compounds.iter().map(|c: &minimad::Compound| c.src).collect(); + let raw: String = composite + .compounds + .iter() + .map(|c: &minimad::Compound| c.src) + .collect(); let mut spans = vec![ Span::styled(format!(" {code_line_nr:>2} "), code_border_style()), Span::styled("│ ", code_border_style()), @@ -267,41 +474,125 @@ fn parse_markdown(md: &str) -> Vec> { lines.push(RLine::from(spans)); } Line::Normal(composite) => { + flush_table(&mut table_buffer, &mut lines); code_line_nr = 0; lines.push(composite_to_line(composite)); } + Line::TableRow(row) => { + code_line_nr = 0; + table_buffer.push(row); + } + Line::TableRule(_) => { + // Skip the separator line (---|---|---) + } _ => { + flush_table(&mut table_buffer, &mut lines); + code_line_nr = 0; lines.push(RLine::from("")); } } } + flush_table(&mut table_buffer, &mut lines); lines } +fn cell_text_width(cell: &Composite) -> usize { + cell.compounds.iter().map(|c| c.src.chars().count()).sum() +} + +fn compute_column_widths(rows: &[TableRow]) -> Vec { + let mut widths: Vec = Vec::new(); + for row in rows { + for (i, cell) in row.cells.iter().enumerate() { + let w = cell_text_width(cell); + if i >= widths.len() { + widths.push(w); + } else if w > widths[i] { + widths[i] = w; + } + } + } + widths +} + +fn render_table_row(row: TableRow, row_idx: usize, col_widths: &[usize]) -> RLine<'static> { + let theme = theme::get(); + let is_header = row_idx == 0; + let bg = if is_header { + theme.ui.surface + } else if row_idx % 2 == 0 { + theme.table.row_even + } else { + theme.table.row_odd + }; + + let base_style = if is_header { + Style::new() + .fg(theme.markdown.text) + .bg(bg) + .add_modifier(Modifier::BOLD) + } else { + Style::new().fg(theme.markdown.text).bg(bg) + }; + + let sep_style = Style::new().fg(theme.markdown.code_border).bg(bg); + let mut spans: Vec> = Vec::new(); + + for (i, cell) in row.cells.into_iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" │ ", sep_style)); + } + let target_width = col_widths.get(i).copied().unwrap_or(0); + let cell_width = cell + .compounds + .iter() + .map(|c| c.src.chars().count()) + .sum::(); + + for compound in cell.compounds { + compound_to_spans(compound, base_style, &mut spans); + } + + let padding = target_width.saturating_sub(cell_width); + if padding > 0 { + spans.push(Span::styled(" ".repeat(padding), base_style)); + } + } + + RLine::from(spans) +} + fn composite_to_line(composite: Composite) -> RLine<'static> { let theme = theme::get(); let base_style = match composite.style { CompositeStyle::Header(1) => Style::new() .fg(theme.markdown.h1) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), - CompositeStyle::Header(2) => Style::new().fg(theme.markdown.h2).add_modifier(Modifier::BOLD), - CompositeStyle::Header(_) => Style::new().fg(theme.markdown.h3).add_modifier(Modifier::BOLD), + CompositeStyle::Header(2) => Style::new() + .fg(theme.markdown.h2) + .add_modifier(Modifier::BOLD), + CompositeStyle::Header(_) => Style::new() + .fg(theme.markdown.h3) + .add_modifier(Modifier::BOLD), CompositeStyle::ListItem(_) => Style::new().fg(theme.markdown.list), CompositeStyle::Quote => Style::new().fg(theme.markdown.quote), CompositeStyle::Code => Style::new().fg(theme.markdown.code), CompositeStyle::Paragraph => Style::new().fg(theme.markdown.text), }; - let prefix = match composite.style { - CompositeStyle::ListItem(_) => " • ", - CompositeStyle::Quote => " │ ", - _ => "", + let prefix: String = match composite.style { + CompositeStyle::ListItem(depth) => { + let indent = " ".repeat(depth as usize); + format!("{indent}• ") + } + CompositeStyle::Quote => " │ ".to_string(), + _ => String::new(), }; let mut spans: Vec> = Vec::new(); if !prefix.is_empty() { - spans.push(Span::styled(prefix.to_string(), base_style)); + spans.push(Span::styled(prefix, base_style)); } for compound in composite.compounds { diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index c515702..7704a81 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -34,6 +34,8 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str bindings.push(("s", "Save", "Save project")); bindings.push(("l", "Load", "Load project")); bindings.push(("f", "Fill", "Toggle fill mode (hold)")); + bindings.push(("r", "Rename", "Rename current step")); + bindings.push(("Ctrl+R", "Run", "Run step script immediately")); } Page::Patterns => { bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));