12 Commits

Author SHA1 Message Date
772d21a8ed Feat: update CHANGELOG.md
Some checks failed
Deploy Website / deploy (push) Failing after 4m47s
2026-02-02 13:42:42 +01:00
4396147a8b Euclidean + hue rotation
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 13:25:27 +01:00
c396c39b6b Fix layout 2026-02-02 12:18:22 +01:00
f6b43cb021 Add double-stack words (2dup, 2drop, 2swap, 2over) and forget
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-02 07:46:39 +01:00
60d1d7ca74 Feat: update website to prevent ugliness
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:38:21 +01:00
9864cc6d61 Update changelog for v0.0.3 2026-02-02 01:12:49 +01:00
985ab687d7 chore: Release
Some checks failed
Deploy Website / deploy (push) Failing after 4m46s
CI / build (cagire-linux-x86_64, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 12m15s
CI / build (cagire-macos-aarch64, macos-14, aarch64-apple-darwin) (push) Has been cancelled
CI / build (cagire-macos-x86_64, macos-15-intel, x86_64-apple-darwin) (push) Has been cancelled
CI / build (cagire-windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / release (push) Has been cancelled
2026-02-02 01:09:13 +01:00
9b925d881e Feat: update changelog
Some checks failed
Deploy Website / deploy (push) Has been cancelled
2026-02-02 01:08:33 +01:00
71146c7cea Feat: more predictable projet load behavior
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 01:01:01 +01:00
6b95f31afd Feat: polyphony + iterator reset
Some checks failed
Deploy Website / deploy (push) Failing after 4m48s
2026-02-02 00:33:46 +01:00
adee8d0d57 Feat: adding some basic music theory
Some checks failed
Deploy Website / deploy (push) Failing after 4m49s
2026-02-01 16:15:09 +01:00
f9c284effd Feat: adding logrand and exprand 2026-02-01 15:16:20 +01:00
51 changed files with 2944 additions and 425 deletions

View File

@@ -2,7 +2,39 @@
All notable changes to this project will be documented in this file.
## [Unreleased]
## [0.0.4] - 2026-02-02
### Added
- Double-stack words: `2dup`, `2drop`, `2swap`, `2over`.
- `forget` word to remove user-defined words from the dictionary.
- Active patterns panel showing playing patterns with bank, pattern, iteration count, and step position.
- Configurable visualization layout (Top/Bottom/Left/Right) for scope and spectrum placement.
- Euclidean distribution modal to spread a step's script across the pattern using Euclidean rhythms.
- Fairyfloss theme (pastel candy colors by sailorhg).
- Hot Dog Stand theme (classic Windows 3.1 red/yellow).
- Hue rotation option in Options menu to shift all theme colors (0-360°).
### Changed
- Title view now adapts to smaller terminal sizes gracefully.
### Fixed
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
## [0.0.3] - 2026-02-02
### Added
- Polyphonic parameters: param words (`note`, `freq`, `gain`, etc.) and sound words now consume the entire stack, enabling polyphony (e.g., `60 64 67 note sine s .` emits 3 voices).
- New random distribution words: `exprand` (exponential) and `logrand` (logarithmic).
- Music theory chord words: `maj`, `m`, `dim`, `aug`, `sus2`, `sus4`, `maj7`, `min7`, `dom7`, `dim7`, `m7b5`, `minmaj7`, `aug7`, `maj6`, `min6`, `dom9`, `maj9`, `min9`, `dom11`, `min11`, `dom13`, `add9`, `add11`, `madd9`, `dom7b9`, `dom7s9`, `dom7b5`, `dom7s5`.
- Playing patterns are now saved with the project and restored on load.
### Changed
- `at` now consumes the entire stack for time offsets; polyphony multiplies with deltas (2 notes × 2 times = 4 voices).
- Iterator (`iter`) now resets when a pattern restarts.
- Project loading now properly resets state: stops all patterns, clears user variables/dictionary, and clears queued changes.
### Removed
- `tcycle` word (replaced by polyphonic parameter behavior).
## [0.0.2] - 2026-02-01
- CI testing and codebase cleanup

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
[workspace.package]
version = "0.0.2"
version = "0.0.3"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"

View File

@@ -6,7 +6,7 @@
<img src="cagire_pixel.png" alt="Cagire" width="256">
</p>
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).
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.
## Build

View File

@@ -13,6 +13,11 @@ pub enum Op {
Rot,
Nip,
Tuck,
Dup2,
Drop2,
Swap2,
Over2,
Forget,
Add,
Sub,
Mul,
@@ -53,10 +58,11 @@ pub enum Op {
Set,
GetContext(String),
Rand,
ExpRand,
LogRand,
Seed,
Cycle,
PCycle,
TCycle,
Choose,
ChanceExec,
ProbExec,
@@ -86,6 +92,7 @@ pub enum Op {
Generate,
GeomRange,
Times,
Chord(&'static [i64]),
// MIDI
MidiEmit,
GetMidiCC,

View File

@@ -0,0 +1,129 @@
pub struct Chord {
pub name: &'static str,
pub intervals: &'static [i64],
}
pub static CHORDS: &[Chord] = &[
// Triads
Chord {
name: "maj",
intervals: &[0, 4, 7],
},
Chord {
name: "m",
intervals: &[0, 3, 7],
},
Chord {
name: "dim",
intervals: &[0, 3, 6],
},
Chord {
name: "aug",
intervals: &[0, 4, 8],
},
Chord {
name: "sus2",
intervals: &[0, 2, 7],
},
Chord {
name: "sus4",
intervals: &[0, 5, 7],
},
// Seventh chords
Chord {
name: "maj7",
intervals: &[0, 4, 7, 11],
},
Chord {
name: "min7",
intervals: &[0, 3, 7, 10],
},
Chord {
name: "dom7",
intervals: &[0, 4, 7, 10],
},
Chord {
name: "dim7",
intervals: &[0, 3, 6, 9],
},
Chord {
name: "m7b5",
intervals: &[0, 3, 6, 10],
},
Chord {
name: "minmaj7",
intervals: &[0, 3, 7, 11],
},
Chord {
name: "aug7",
intervals: &[0, 4, 8, 10],
},
// Sixth chords
Chord {
name: "maj6",
intervals: &[0, 4, 7, 9],
},
Chord {
name: "min6",
intervals: &[0, 3, 7, 9],
},
// Extended chords
Chord {
name: "dom9",
intervals: &[0, 4, 7, 10, 14],
},
Chord {
name: "maj9",
intervals: &[0, 4, 7, 11, 14],
},
Chord {
name: "min9",
intervals: &[0, 3, 7, 10, 14],
},
Chord {
name: "dom11",
intervals: &[0, 4, 7, 10, 14, 17],
},
Chord {
name: "min11",
intervals: &[0, 3, 7, 10, 14, 17],
},
Chord {
name: "dom13",
intervals: &[0, 4, 7, 10, 14, 21],
},
// Add chords
Chord {
name: "add9",
intervals: &[0, 4, 7, 14],
},
Chord {
name: "add11",
intervals: &[0, 4, 7, 17],
},
Chord {
name: "madd9",
intervals: &[0, 3, 7, 14],
},
// Altered dominants
Chord {
name: "dom7b9",
intervals: &[0, 4, 7, 10, 13],
},
Chord {
name: "dom7s9",
intervals: &[0, 4, 7, 10, 15],
},
Chord {
name: "dom7b5",
intervals: &[0, 4, 6, 10],
},
Chord {
name: "dom7s5",
intervals: &[0, 4, 8, 10],
},
];
pub fn lookup(name: &str) -> Option<&'static [i64]> {
CHORDS.iter().find(|c| c.name == name).map(|c| c.intervals)
}

View File

@@ -1,3 +1,4 @@
pub mod chords;
mod scales;
pub use scales::lookup;

View File

@@ -154,6 +154,14 @@ impl CmdRegister {
&self.deltas
}
pub(super) fn sound(&self) -> Option<&Value> {
self.sound.as_ref()
}
pub(super) fn params(&self) -> &[(String, Value)] {
&self.params
}
pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
if self.sound.is_some() || !self.params.is_empty() {
Some((self.sound.as_ref(), self.params.as_slice()))

View File

@@ -152,6 +152,23 @@ impl Forth {
select_and_run(selected, stack, outputs, cmd)
};
let compute_poly_count = |cmd: &CmdRegister| -> usize {
let sound_len = match cmd.sound() {
Some(Value::CycleList(items)) => items.len(),
_ => 1,
};
let param_max = cmd
.params()
.iter()
.map(|(_, v)| match v {
Value::CycleList(items) => items.len(),
_ => 1,
})
.max()
.unwrap_or(1);
sound_len.max(param_max)
};
let emit_with_cycling = |cmd: &CmdRegister,
emit_idx: usize,
delta_secs: f64,
@@ -245,6 +262,42 @@ impl Forth {
let v = stack[len - 1].clone();
stack.insert(len - 2, v);
}
Op::Dup2 => {
let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
let a = stack[len - 2].clone();
let b = stack[len - 1].clone();
stack.push(a);
stack.push(b);
}
Op::Drop2 => {
let len = stack.len();
if len < 2 {
return Err("stack underflow".into());
}
stack.pop();
stack.pop();
}
Op::Swap2 => {
let len = stack.len();
if len < 4 {
return Err("stack underflow".into());
}
stack.swap(len - 4, len - 2);
stack.swap(len - 3, len - 1);
}
Op::Over2 => {
let len = stack.len();
if len < 4 {
return Err("stack underflow".into());
}
let a = stack[len - 4].clone();
let b = stack[len - 3].clone();
stack.push(a);
stack.push(b);
}
Op::Add => binary_op(stack, |a, b| a + b)?,
Op::Sub => binary_op(stack, |a, b| a - b)?,
@@ -363,38 +416,56 @@ impl Forth {
}
Op::NewCmd => {
let val = stack.pop().ok_or("stack underflow")?;
if stack.is_empty() {
return Err("stack underflow".into());
}
let values = std::mem::take(stack);
let val = if values.len() == 1 {
values.into_iter().next().unwrap()
} else {
Value::CycleList(values)
};
cmd.set_sound(val);
}
Op::SetParam(param) => {
let val = stack.pop().ok_or("stack underflow")?;
if stack.is_empty() {
return Err("stack underflow".into());
}
let values = std::mem::take(stack);
let val = if values.len() == 1 {
values.into_iter().next().unwrap()
} else {
Value::CycleList(values)
};
cmd.set_param(param.clone(), val);
}
Op::Emit => {
let poly_count = compute_poly_count(cmd);
let deltas = if cmd.deltas().is_empty() {
vec![Value::Float(0.0, None)]
} else {
cmd.deltas().to_vec()
};
for (emit_idx, delta_val) in deltas.iter().enumerate() {
let delta_frac = delta_val.as_float()?;
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
// Record delta span for highlighting
if let Some(span) = delta_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
if let Some(sound_val) =
emit_with_cycling(cmd, emit_idx, delta_secs, outputs)?
{
if let Some(span) = sound_val.span() {
for poly_idx in 0..poly_count {
for delta_val in deltas.iter() {
let delta_frac = delta_val.as_float()?;
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
if let Some(span) = delta_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
if let Some(sound_val) =
emit_with_cycling(cmd, poly_idx, delta_secs, outputs)?
{
if let Some(span) = sound_val.span() {
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
trace.selected_spans.push(span);
}
}
}
}
}
}
@@ -455,11 +526,37 @@ impl Forth {
let a_f = a.as_float()?;
let b_f = b.as_float()?;
let (lo, hi) = if a_f <= b_f { (a_f, b_f) } else { (b_f, a_f) };
let val = self.rng.lock().unwrap().gen_range(lo..hi);
let val = if (hi - lo).abs() < f64::EPSILON {
lo
} else {
self.rng.lock().unwrap().gen_range(lo..hi)
};
stack.push(Value::Float(val, None));
}
}
}
Op::ExpRand => {
let hi = stack.pop().ok_or("stack underflow")?.as_float()?;
let lo = stack.pop().ok_or("stack underflow")?.as_float()?;
if lo <= 0.0 || hi <= 0.0 {
return Err("exprand requires positive values".into());
}
let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
let u: f64 = self.rng.lock().unwrap().gen();
let val = lo * (hi / lo).powf(u);
stack.push(Value::Float(val, None));
}
Op::LogRand => {
let hi = stack.pop().ok_or("stack underflow")?.as_float()?;
let lo = stack.pop().ok_or("stack underflow")?.as_float()?;
if lo <= 0.0 || hi <= 0.0 {
return Err("logrand requires positive values".into());
}
let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) };
let u: f64 = self.rng.lock().unwrap().gen();
let val = hi * (lo / hi).powf(u);
stack.push(Value::Float(val, None));
}
Op::Seed => {
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
@@ -477,19 +574,6 @@ impl Forth {
drain_select_run(count, idx, stack, outputs, cmd)?;
}
Op::TCycle => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if count == 0 {
return Err("tcycle count must be > 0".into());
}
if stack.len() < count {
return Err("stack underflow".into());
}
let start = stack.len() - count;
let values: Vec<Value> = stack.drain(start..).collect();
stack.push(Value::CycleList(values));
}
Op::Choose => {
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
if count == 0 {
@@ -607,6 +691,13 @@ impl Forth {
stack.push(result);
}
Op::Chord(intervals) => {
let root = stack.pop().ok_or("stack underflow")?.as_int()?;
for &interval in *intervals {
stack.push(Value::Int(root + interval, None));
}
}
Op::Oct => {
let shift = stack.pop().ok_or("stack underflow")?;
let note = stack.pop().ok_or("stack underflow")?;
@@ -659,27 +750,10 @@ impl Forth {
}
Op::At => {
let top = stack.pop().ok_or("stack underflow")?;
let deltas = match &top {
Value::Float(..) => vec![top],
Value::Int(n, _) => {
let count = *n as usize;
if stack.len() < count {
return Err(format!(
"at: stack underflow, expected {} values but got {}",
count,
stack.len()
));
}
let mut vals = Vec::with_capacity(count);
for _ in 0..count {
vals.push(stack.pop().ok_or("stack underflow")?);
}
vals.reverse();
vals
}
_ => return Err("at expects float or int count".into()),
};
if stack.is_empty() {
return Err("stack underflow".into());
}
let deltas = std::mem::take(stack);
cmd.set_deltas(deltas);
}
@@ -877,6 +951,10 @@ impl Forth {
.unwrap_or(0);
stack.push(Value::Int(val as i64, None));
}
Op::Forget => {
let name = stack.pop().ok_or("stack underflow")?.as_str()?.to_string();
self.dict.lock().unwrap().remove(&name);
}
}
pc += 1;
}

View File

@@ -107,6 +107,46 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: "2dup",
aliases: &[],
category: "Stack",
stack: "(a b -- a b a b)",
desc: "Duplicate top two values",
example: "1 2 2dup => 1 2 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2drop",
aliases: &[],
category: "Stack",
stack: "(a b --)",
desc: "Drop top two values",
example: "1 2 3 2drop => 1",
compile: Simple,
varargs: false,
},
Word {
name: "2swap",
aliases: &[],
category: "Stack",
stack: "(a b c d -- c d a b)",
desc: "Swap top two pairs",
example: "1 2 3 4 2swap => 3 4 1 2",
compile: Simple,
varargs: false,
},
Word {
name: "2over",
aliases: &[],
category: "Stack",
stack: "(a b c d -- a b c d a b)",
desc: "Copy second pair to top",
example: "1 2 3 4 2over => 1 2 3 4 1 2",
compile: Simple,
varargs: false,
},
// Arithmetic
Word {
name: "+",
@@ -473,6 +513,26 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: "exprand",
aliases: &[],
category: "Probability",
stack: "(lo hi -- f)",
desc: "Exponential random biased toward lo. Both args must be positive",
example: "1.0 100.0 exprand => 3.7",
compile: Simple,
varargs: false,
},
Word {
name: "logrand",
aliases: &[],
category: "Probability",
stack: "(lo hi -- f)",
desc: "Exponential random biased toward hi. Both args must be positive",
example: "1.0 100.0 logrand => 87.2",
compile: Simple,
varargs: false,
},
Word {
name: "seed",
aliases: &[],
@@ -543,16 +603,6 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: true,
},
Word {
name: "tcycle",
aliases: &[],
category: "Probability",
stack: "(v1..vn n -- CycleList)",
desc: "Create cycle list for emit-time resolution",
example: "60 64 67 3 tcycle note",
compile: Simple,
varargs: true,
},
Word {
name: "every",
aliases: &[],
@@ -810,6 +860,292 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
// Chords - Triads
Word {
name: "maj",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Major triad",
example: "c4 maj => 60 64 67",
compile: Simple,
varargs: true,
},
Word {
name: "m",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Minor triad",
example: "c4 m => 60 63 67",
compile: Simple,
varargs: true,
},
Word {
name: "dim",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Diminished triad",
example: "c4 dim => 60 63 66",
compile: Simple,
varargs: true,
},
Word {
name: "aug",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth)",
desc: "Augmented triad",
example: "c4 aug => 60 64 68",
compile: Simple,
varargs: true,
},
Word {
name: "sus2",
aliases: &[],
category: "Chord",
stack: "(root -- root second fifth)",
desc: "Suspended 2nd",
example: "c4 sus2 => 60 62 67",
compile: Simple,
varargs: true,
},
Word {
name: "sus4",
aliases: &[],
category: "Chord",
stack: "(root -- root fourth fifth)",
desc: "Suspended 4th",
example: "c4 sus4 => 60 65 67",
compile: Simple,
varargs: true,
},
// Chords - Seventh
Word {
name: "maj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Major 7th",
example: "c4 maj7 => 60 64 67 71",
compile: Simple,
varargs: true,
},
Word {
name: "min7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Minor 7th",
example: "c4 min7 => 60 63 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Dominant 7th",
example: "c4 dom7 => 60 64 67 70",
compile: Simple,
varargs: true,
},
Word {
name: "dim7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Diminished 7th",
example: "c4 dim7 => 60 63 66 69",
compile: Simple,
varargs: true,
},
Word {
name: "m7b5",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Half-diminished (min7b5)",
example: "c4 m7b5 => 60 63 66 70",
compile: Simple,
varargs: true,
},
Word {
name: "minmaj7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Minor-major 7th",
example: "c4 minmaj7 => 60 63 67 71",
compile: Simple,
varargs: true,
},
Word {
name: "aug7",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh)",
desc: "Augmented 7th",
example: "c4 aug7 => 60 64 68 70",
compile: Simple,
varargs: true,
},
// Chords - Sixth
Word {
name: "maj6",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth)",
desc: "Major 6th",
example: "c4 maj6 => 60 64 67 69",
compile: Simple,
varargs: true,
},
Word {
name: "min6",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth sixth)",
desc: "Minor 6th",
example: "c4 min6 => 60 63 67 69",
compile: Simple,
varargs: true,
},
// Chords - Extended
Word {
name: "dom9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Dominant 9th",
example: "c4 dom9 => 60 64 67 70 74",
compile: Simple,
varargs: true,
},
Word {
name: "maj9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Major 9th",
example: "c4 maj9 => 60 64 67 71 74",
compile: Simple,
varargs: true,
},
Word {
name: "min9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth)",
desc: "Minor 9th",
example: "c4 min9 => 60 63 67 70 74",
compile: Simple,
varargs: true,
},
Word {
name: "dom11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Dominant 11th",
example: "c4 dom11 => 60 64 67 70 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "min11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth eleventh)",
desc: "Minor 11th",
example: "c4 min11 => 60 63 67 70 74 77",
compile: Simple,
varargs: true,
},
Word {
name: "dom13",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh ninth thirteenth)",
desc: "Dominant 13th",
example: "c4 dom13 => 60 64 67 70 74 81",
compile: Simple,
varargs: true,
},
// Chords - Add
Word {
name: "add9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth ninth)",
desc: "Major add 9",
example: "c4 add9 => 60 64 67 74",
compile: Simple,
varargs: true,
},
Word {
name: "add11",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth eleventh)",
desc: "Major add 11",
example: "c4 add11 => 60 64 67 77",
compile: Simple,
varargs: true,
},
Word {
name: "madd9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth ninth)",
desc: "Minor add 9",
example: "c4 madd9 => 60 63 67 74",
compile: Simple,
varargs: true,
},
// Chords - Altered dominants
Word {
name: "dom7b9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh flatninth)",
desc: "7th flat 9",
example: "c4 dom7b9 => 60 64 67 70 73",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s9",
aliases: &[],
category: "Chord",
stack: "(root -- root third fifth seventh sharpninth)",
desc: "7th sharp 9 (Hendrix chord)",
example: "c4 dom7s9 => 60 64 67 70 75",
compile: Simple,
varargs: true,
},
Word {
name: "dom7b5",
aliases: &[],
category: "Chord",
stack: "(root -- root third flatfifth seventh)",
desc: "7th flat 5",
example: "c4 dom7b5 => 60 64 66 70",
compile: Simple,
varargs: true,
},
Word {
name: "dom7s5",
aliases: &[],
category: "Chord",
stack: "(root -- root third sharpfifth seventh)",
desc: "7th sharp 5",
example: "c4 dom7s5 => 60 64 68 70",
compile: Simple,
varargs: true,
},
// LFO
Word {
name: "ramp",
@@ -925,9 +1261,9 @@ pub const WORDS: &[Word] = &[
name: "at",
aliases: &[],
category: "Time",
stack: "(v1..vn n --)",
stack: "(v1..vn --)",
desc: "Set delta context for emit timing",
example: "0 0.5 2 at kick s . => emits at 0 and 0.5 of step",
example: "0 0.5 at kick s . => emits at 0 and 0.5 of step",
compile: Simple,
varargs: true,
},
@@ -2275,6 +2611,16 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
Word {
name: "forget",
aliases: &[],
category: "Definitions",
stack: "(name --)",
desc: "Remove user-defined word from dictionary",
example: "\"double\" forget",
compile: Simple,
varargs: false,
},
// Generator
Word {
name: "..",
@@ -2474,6 +2820,10 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"rot" => Op::Rot,
"nip" => Op::Nip,
"tuck" => Op::Tuck,
"2dup" => Op::Dup2,
"2drop" => Op::Drop2,
"2swap" => Op::Swap2,
"2over" => Op::Over2,
"+" => Op::Add,
"-" => Op::Sub,
"*" => Op::Mul,
@@ -2508,10 +2858,11 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"sound" => Op::NewCmd,
"." => Op::Emit,
"rand" => Op::Rand,
"exprand" => Op::ExpRand,
"logrand" => Op::LogRand,
"seed" => Op::Seed,
"cycle" => Op::Cycle,
"pcycle" => Op::PCycle,
"tcycle" => Op::TCycle,
"choose" => Op::Choose,
"every" => Op::Every,
"chance" => Op::ChanceExec,
@@ -2545,6 +2896,7 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"mstart" => Op::MidiStart,
"mstop" => Op::MidiStop,
"mcont" => Op::MidiContinue,
"forget" => Op::Forget,
_ => return None,
})
}
@@ -2654,6 +3006,11 @@ pub(super) fn compile_word(
return true;
}
if let Some(intervals) = theory::chords::lookup(name) {
ops.push(Op::Chord(intervals));
return true;
}
if let Some(word) = lookup_word(name) {
match &word.compile {
Simple => {

View File

@@ -25,6 +25,8 @@ struct ProjectFile {
sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
tempo: f64,
#[serde(default)]
playing_patterns: Vec<(usize, usize)>,
}
fn default_tempo() -> f64 {
@@ -38,6 +40,7 @@ impl From<&Project> for ProjectFile {
banks: project.banks.clone(),
sample_paths: project.sample_paths.clone(),
tempo: project.tempo,
playing_patterns: project.playing_patterns.clone(),
}
}
}
@@ -48,6 +51,7 @@ impl From<ProjectFile> for Project {
banks: file.banks,
sample_paths: file.sample_paths,
tempo: file.tempo,
playing_patterns: file.playing_patterns,
};
project.normalize();
project

View File

@@ -450,6 +450,8 @@ pub struct Project {
pub sample_paths: Vec<PathBuf>,
#[serde(default = "default_tempo")]
pub tempo: f64,
#[serde(default)]
pub playing_patterns: Vec<(usize, usize)>,
}
fn default_tempo() -> f64 {
@@ -462,6 +464,7 @@ impl Default for Project {
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
sample_paths: Vec::new(),
tempo: default_tempo(),
playing_patterns: Vec::new(),
}
}
}

View File

@@ -0,0 +1,73 @@
use crate::theme;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
pub struct ActivePatterns<'a> {
patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter)
current_step: Option<(usize, usize)>, // (current_step, total_steps)
}
impl<'a> ActivePatterns<'a> {
pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self {
Self {
patterns,
current_step: None,
}
}
pub fn with_step(mut self, current: usize, total: usize) -> Self {
self.current_step = Some((current, total));
self
}
}
impl Widget for ActivePatterns<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 10 || area.height == 0 {
return;
}
let theme = theme::get();
let max_pattern_rows = if self.current_step.is_some() {
area.height.saturating_sub(1) as usize
} else {
area.height as usize
};
for (row, &(bank, pattern, iter)) in self.patterns.iter().enumerate() {
if row >= max_pattern_rows {
break;
}
let text = format!("B{:02}:{:02} ({:02})", bank + 1, pattern + 1, iter.min(99));
let y = area.y + row as u16;
let bg = if row % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
let mut chars = text.chars();
for col in 0..area.width as usize {
let ch = chars.next().unwrap_or(' ');
buf[(area.x + col as u16, y)]
.set_char(ch)
.set_fg(theme.ui.text_primary)
.set_bg(bg);
}
}
if let Some((current, total)) = self.current_step {
let text = format!("{:02}/{:02}", current + 1, total);
let y = area.y + area.height.saturating_sub(1);
let mut chars = text.chars();
for col in 0..area.width as usize {
let ch = chars.next().unwrap_or(' ');
buf[(area.x + col as u16, y)]
.set_char(ch)
.set_fg(theme.ui.text_primary)
.set_bg(theme.table.row_even);
}
}
}
}

View File

@@ -1,3 +1,4 @@
mod active_patterns;
mod confirm;
mod editor;
mod file_browser;
@@ -12,6 +13,7 @@ mod text_input;
pub mod theme;
mod vu_meter;
pub use active_patterns::ActivePatterns;
pub use confirm::ConfirmModal;
pub use editor::{CompletionCandidate, Editor};
pub use file_browser::FileBrowserModal;

View File

@@ -0,0 +1,277 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let bg = Color::Rgb(90, 84, 117);
let bg_light = Color::Rgb(113, 103, 153);
let bg_lighter = Color::Rgb(130, 120, 165);
let fg = Color::Rgb(248, 248, 240);
let fg_dim = Color::Rgb(197, 163, 255);
let muted = Color::Rgb(168, 164, 177);
let dark = Color::Rgb(55, 51, 72);
let purple = Color::Rgb(174, 129, 255);
let pink = Color::Rgb(255, 184, 209);
let coral = Color::Rgb(255, 133, 127);
let yellow = Color::Rgb(255, 243, 82);
let gold = Color::Rgb(230, 192, 0);
let mint = Color::Rgb(194, 255, 223);
let lavender = Color::Rgb(197, 163, 255);
ThemeColors {
ui: UiColors {
bg,
bg_rgb: (90, 84, 117),
text_primary: fg,
text_muted: fg_dim,
text_dim: muted,
border: bg_lighter,
header: mint,
unfocused: muted,
accent: pink,
surface: bg_light,
},
status: StatusColors {
playing_bg: Color::Rgb(70, 95, 85),
playing_fg: mint,
stopped_bg: Color::Rgb(100, 70, 85),
stopped_fg: coral,
fill_on: mint,
fill_off: muted,
fill_bg: bg_light,
},
selection: SelectionColors {
cursor_bg: pink,
cursor_fg: dark,
selected_bg: Color::Rgb(120, 90, 130),
selected_fg: pink,
in_range_bg: Color::Rgb(100, 95, 125),
in_range_fg: fg,
cursor: pink,
selected: Color::Rgb(120, 90, 130),
in_range: Color::Rgb(100, 95, 125),
},
tile: TileColors {
playing_active_bg: Color::Rgb(100, 85, 60),
playing_active_fg: gold,
playing_inactive_bg: Color::Rgb(95, 90, 70),
playing_inactive_fg: yellow,
active_bg: Color::Rgb(70, 100, 100),
active_fg: mint,
inactive_bg: bg_light,
inactive_fg: fg_dim,
active_selected_bg: Color::Rgb(120, 90, 130),
active_in_range_bg: Color::Rgb(100, 95, 125),
link_bright: [
(255, 184, 209),
(174, 129, 255),
(255, 133, 127),
(194, 255, 223),
(255, 243, 82),
],
link_dim: [
(100, 75, 90),
(85, 70, 105),
(100, 65, 65),
(75, 100, 95),
(100, 95, 55),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(100, 75, 95),
tempo_fg: pink,
bank_bg: Color::Rgb(70, 95, 95),
bank_fg: mint,
pattern_bg: Color::Rgb(85, 75, 110),
pattern_fg: purple,
stats_bg: bg_light,
stats_fg: fg_dim,
},
modal: ModalColors {
border: mint,
border_accent: pink,
border_warn: coral,
border_dim: muted,
confirm: coral,
rename: purple,
input: mint,
editor: mint,
preview: muted,
},
flash: FlashColors {
error_bg: Color::Rgb(100, 65, 70),
error_fg: coral,
success_bg: Color::Rgb(65, 95, 85),
success_fg: mint,
info_bg: bg_light,
info_fg: fg,
event_rgb: (100, 85, 110),
},
list: ListColors {
playing_bg: Color::Rgb(65, 95, 85),
playing_fg: mint,
staged_play_bg: Color::Rgb(95, 80, 120),
staged_play_fg: purple,
staged_stop_bg: Color::Rgb(105, 70, 85),
staged_stop_fg: pink,
edit_bg: Color::Rgb(70, 95, 100),
edit_fg: mint,
hover_bg: bg_lighter,
hover_fg: fg,
},
link_status: LinkStatusColors {
disabled: coral,
connected: mint,
listening: yellow,
},
syntax: SyntaxColors {
gap_bg: dark,
executed_bg: Color::Rgb(80, 75, 100),
selected_bg: Color::Rgb(110, 100, 70),
emit: (fg, Color::Rgb(110, 80, 100)),
number: (purple, Color::Rgb(85, 75, 110)),
string: (yellow, Color::Rgb(100, 95, 60)),
comment: (muted, dark),
keyword: (pink, Color::Rgb(105, 75, 90)),
stack_op: (mint, Color::Rgb(70, 100, 95)),
operator: (pink, Color::Rgb(105, 75, 90)),
sound: (mint, Color::Rgb(70, 100, 95)),
param: (coral, Color::Rgb(105, 70, 70)),
context: (coral, Color::Rgb(105, 70, 70)),
note: (lavender, Color::Rgb(85, 75, 110)),
interval: (Color::Rgb(220, 190, 255), Color::Rgb(85, 75, 100)),
variable: (lavender, Color::Rgb(85, 75, 110)),
vary: (yellow, Color::Rgb(100, 95, 60)),
generator: (mint, Color::Rgb(70, 95, 95)),
default: (fg_dim, dark),
},
table: TableColors {
row_even: dark,
row_odd: bg,
},
values: ValuesColors {
tempo: coral,
value: fg_dim,
},
hint: HintColors {
key: coral,
text: muted,
},
view_badge: ViewBadgeColors { bg: fg, fg: bg },
nav: NavColors {
selected_bg: Color::Rgb(110, 85, 120),
selected_fg: fg,
unselected_bg: bg_light,
unselected_fg: muted,
},
editor_widget: EditorWidgetColors {
cursor_bg: fg,
cursor_fg: bg,
selection_bg: Color::Rgb(105, 95, 125),
completion_bg: bg_light,
completion_fg: fg,
completion_selected: coral,
completion_example: mint,
},
browser: BrowserColors {
directory: mint,
project_file: purple,
selected: coral,
file: fg,
focused_border: coral,
unfocused_border: muted,
root: fg,
file_icon: muted,
folder_icon: mint,
empty_text: muted,
},
input: InputColors {
text: mint,
cursor: fg,
hint: muted,
},
search: SearchColors {
active: coral,
inactive: muted,
match_bg: yellow,
match_fg: dark,
},
markdown: MarkdownColors {
h1: mint,
h2: coral,
h3: purple,
code: lavender,
code_border: Color::Rgb(120, 115, 140),
link: pink,
link_url: Color::Rgb(150, 145, 165),
quote: muted,
text: fg,
list: fg,
},
engine: EngineColors {
header: mint,
header_focused: yellow,
divider: Color::Rgb(110, 105, 130),
scroll_indicator: Color::Rgb(125, 120, 145),
label: Color::Rgb(175, 170, 190),
label_focused: Color::Rgb(210, 205, 225),
label_dim: Color::Rgb(145, 140, 160),
value: Color::Rgb(230, 225, 240),
focused: yellow,
normal: fg,
dim: Color::Rgb(125, 120, 145),
path: Color::Rgb(175, 170, 190),
border_magenta: pink,
border_green: mint,
border_cyan: lavender,
separator: Color::Rgb(110, 105, 130),
hint_active: Color::Rgb(240, 230, 120),
hint_inactive: Color::Rgb(110, 105, 130),
},
dict: DictColors {
word_name: lavender,
word_bg: Color::Rgb(75, 85, 105),
alias: muted,
stack_sig: purple,
description: fg,
example: Color::Rgb(175, 170, 190),
category_focused: yellow,
category_selected: mint,
category_normal: fg,
category_dimmed: Color::Rgb(125, 120, 145),
border_focused: yellow,
border_normal: Color::Rgb(110, 105, 130),
header_desc: Color::Rgb(195, 190, 210),
},
title: TitleColors {
big_title: pink,
author: mint,
link: lavender,
license: coral,
prompt: Color::Rgb(195, 190, 210),
subtitle: fg,
},
meter: MeterColors {
low: mint,
mid: yellow,
high: coral,
low_rgb: (194, 255, 223),
mid_rgb: (255, 243, 82),
high_rgb: (255, 133, 127),
},
sparkle: SparkleColors {
colors: [
(194, 255, 223),
(255, 133, 127),
(255, 243, 82),
(255, 184, 209),
(174, 129, 255),
],
},
confirm: ConfirmColors {
border: coral,
button_selected_bg: coral,
button_selected_fg: dark,
},
}
}

View File

@@ -0,0 +1,273 @@
use super::*;
use ratatui::style::Color;
pub fn theme() -> ThemeColors {
let red = Color::Rgb(255, 0, 0);
let dark_red = Color::Rgb(215, 0, 0);
let darker_red = Color::Rgb(175, 0, 0);
let yellow = Color::Rgb(255, 255, 0);
let light_yellow = Color::Rgb(255, 255, 95);
let gold = Color::Rgb(255, 215, 0);
let black = Color::Rgb(0, 0, 0);
let white = Color::Rgb(255, 255, 255);
let dim_yellow = Color::Rgb(180, 180, 0);
let muted_red = Color::Rgb(140, 40, 40);
ThemeColors {
ui: UiColors {
bg: red,
bg_rgb: (255, 0, 0),
text_primary: yellow,
text_muted: light_yellow,
text_dim: gold,
border: yellow,
header: yellow,
unfocused: gold,
accent: yellow,
surface: dark_red,
},
status: StatusColors {
playing_bg: Color::Rgb(180, 180, 0),
playing_fg: black,
stopped_bg: darker_red,
stopped_fg: yellow,
fill_on: yellow,
fill_off: gold,
fill_bg: dark_red,
},
selection: SelectionColors {
cursor_bg: yellow,
cursor_fg: red,
selected_bg: Color::Rgb(200, 200, 0),
selected_fg: black,
in_range_bg: Color::Rgb(170, 100, 0),
in_range_fg: yellow,
cursor: yellow,
selected: Color::Rgb(200, 200, 0),
in_range: Color::Rgb(170, 100, 0),
},
tile: TileColors {
playing_active_bg: Color::Rgb(200, 200, 0),
playing_active_fg: black,
playing_inactive_bg: Color::Rgb(180, 180, 0),
playing_inactive_fg: black,
active_bg: Color::Rgb(200, 50, 50),
active_fg: yellow,
inactive_bg: dark_red,
inactive_fg: gold,
active_selected_bg: Color::Rgb(200, 200, 0),
active_in_range_bg: Color::Rgb(170, 100, 0),
link_bright: [
(255, 255, 0),
(255, 255, 255),
(255, 215, 0),
(255, 255, 95),
(255, 255, 0),
],
link_dim: [
(140, 140, 0),
(140, 140, 140),
(140, 120, 0),
(140, 140, 60),
(140, 140, 0),
],
},
header: HeaderColors {
tempo_bg: Color::Rgb(180, 180, 0),
tempo_fg: black,
bank_bg: darker_red,
bank_fg: yellow,
pattern_bg: Color::Rgb(200, 200, 0),
pattern_fg: black,
stats_bg: dark_red,
stats_fg: yellow,
},
modal: ModalColors {
border: yellow,
border_accent: white,
border_warn: gold,
border_dim: dim_yellow,
confirm: gold,
rename: light_yellow,
input: yellow,
editor: yellow,
preview: gold,
},
flash: FlashColors {
error_bg: black,
error_fg: yellow,
success_bg: Color::Rgb(180, 180, 0),
success_fg: black,
info_bg: dark_red,
info_fg: yellow,
event_rgb: (170, 100, 0),
},
list: ListColors {
playing_bg: Color::Rgb(180, 180, 0),
playing_fg: black,
staged_play_bg: Color::Rgb(200, 200, 0),
staged_play_fg: black,
staged_stop_bg: darker_red,
staged_stop_fg: yellow,
edit_bg: Color::Rgb(200, 50, 50),
edit_fg: yellow,
hover_bg: Color::Rgb(230, 50, 50),
hover_fg: yellow,
},
link_status: LinkStatusColors {
disabled: white,
connected: yellow,
listening: gold,
},
syntax: SyntaxColors {
gap_bg: darker_red,
executed_bg: Color::Rgb(200, 50, 50),
selected_bg: Color::Rgb(180, 180, 0),
emit: (yellow, muted_red),
number: (white, muted_red),
string: (gold, muted_red),
comment: (dim_yellow, darker_red),
keyword: (light_yellow, muted_red),
stack_op: (yellow, muted_red),
operator: (light_yellow, muted_red),
sound: (yellow, muted_red),
param: (gold, muted_red),
context: (gold, muted_red),
note: (white, muted_red),
interval: (Color::Rgb(255, 240, 150), muted_red),
variable: (white, muted_red),
vary: (gold, muted_red),
generator: (yellow, muted_red),
default: (light_yellow, darker_red),
},
table: TableColors {
row_even: darker_red,
row_odd: red,
},
values: ValuesColors {
tempo: gold,
value: light_yellow,
},
hint: HintColors {
key: white,
text: gold,
},
view_badge: ViewBadgeColors { bg: yellow, fg: red },
nav: NavColors {
selected_bg: Color::Rgb(200, 200, 0),
selected_fg: black,
unselected_bg: dark_red,
unselected_fg: gold,
},
editor_widget: EditorWidgetColors {
cursor_bg: yellow,
cursor_fg: red,
selection_bg: Color::Rgb(180, 180, 0),
completion_bg: dark_red,
completion_fg: yellow,
completion_selected: white,
completion_example: gold,
},
browser: BrowserColors {
directory: yellow,
project_file: white,
selected: gold,
file: light_yellow,
focused_border: white,
unfocused_border: gold,
root: yellow,
file_icon: gold,
folder_icon: yellow,
empty_text: gold,
},
input: InputColors {
text: yellow,
cursor: white,
hint: gold,
},
search: SearchColors {
active: white,
inactive: gold,
match_bg: yellow,
match_fg: red,
},
markdown: MarkdownColors {
h1: yellow,
h2: white,
h3: gold,
code: light_yellow,
code_border: dim_yellow,
link: white,
link_url: gold,
quote: dim_yellow,
text: yellow,
list: yellow,
},
engine: EngineColors {
header: yellow,
header_focused: white,
divider: dim_yellow,
scroll_indicator: gold,
label: light_yellow,
label_focused: white,
label_dim: dim_yellow,
value: yellow,
focused: white,
normal: yellow,
dim: dim_yellow,
path: gold,
border_magenta: gold,
border_green: yellow,
border_cyan: white,
separator: dim_yellow,
hint_active: white,
hint_inactive: dim_yellow,
},
dict: DictColors {
word_name: yellow,
word_bg: darker_red,
alias: gold,
stack_sig: white,
description: yellow,
example: gold,
category_focused: white,
category_selected: yellow,
category_normal: light_yellow,
category_dimmed: dim_yellow,
border_focused: white,
border_normal: dim_yellow,
header_desc: gold,
},
title: TitleColors {
big_title: yellow,
author: white,
link: gold,
license: light_yellow,
prompt: gold,
subtitle: yellow,
},
meter: MeterColors {
low: yellow,
mid: gold,
high: white,
low_rgb: (255, 255, 0),
mid_rgb: (255, 215, 0),
high_rgb: (255, 255, 255),
},
sparkle: SparkleColors {
colors: [
(255, 255, 0),
(255, 255, 255),
(255, 215, 0),
(255, 255, 95),
(255, 255, 0),
],
},
confirm: ConfirmColors {
border: white,
button_selected_bg: yellow,
button_selected_fg: red,
},
}
}

View File

@@ -14,8 +14,8 @@ pub fn theme() -> ThemeColors {
let autumn_red = Color::Rgb(195, 64, 67);
let carp_yellow = Color::Rgb(230, 195, 132);
let spring_blue = Color::Rgb(127, 180, 202);
let wave_red = Color::Rgb(226, 109, 115);
let sakura_pink = Color::Rgb(212, 140, 149);
let wave_red = Color::Rgb(228, 104, 118);
let sakura_pink = Color::Rgb(210, 126, 153);
let darker_bg = Color::Rgb(26, 26, 34);
@@ -64,7 +64,7 @@ pub fn theme() -> ThemeColors {
active_selected_bg: Color::Rgb(65, 55, 70),
active_in_range_bg: Color::Rgb(50, 50, 60),
link_bright: [
(226, 109, 115),
(228, 104, 118),
(149, 127, 184),
(230, 195, 132),
(127, 180, 202),
@@ -258,14 +258,14 @@ pub fn theme() -> ThemeColors {
high: wave_red,
low_rgb: (118, 148, 106),
mid_rgb: (230, 195, 132),
high_rgb: (226, 109, 115),
high_rgb: (228, 104, 118),
},
sparkle: SparkleColors {
colors: [
(127, 180, 202),
(230, 195, 132),
(118, 148, 106),
(226, 109, 115),
(228, 104, 118),
(149, 127, 184),
],
},

View File

@@ -4,7 +4,9 @@
mod catppuccin_latte;
mod catppuccin_mocha;
mod dracula;
mod fairyfloss;
mod gruvbox_dark;
mod hot_dog_stand;
mod kanagawa;
mod monochrome_black;
mod monochrome_white;
@@ -13,6 +15,7 @@ mod nord;
mod pitch_black;
mod rose_pine;
mod tokyo_night;
pub mod transform;
use ratatui::style::Color;
use std::cell::RefCell;
@@ -36,6 +39,8 @@ pub const THEMES: &[ThemeEntry] = &[
ThemeEntry { id: "TokyoNight", label: "Tokyo Night", colors: tokyo_night::theme },
ThemeEntry { id: "RosePine", label: "Rosé Pine", colors: rose_pine::theme },
ThemeEntry { id: "Kanagawa", label: "Kanagawa", colors: kanagawa::theme },
ThemeEntry { id: "Fairyfloss", label: "Fairyfloss", colors: fairyfloss::theme },
ThemeEntry { id: "HotDogStand", label: "Hot Dog Stand", colors: hot_dog_stand::theme },
];
thread_local! {

View File

@@ -8,7 +8,7 @@ pub fn theme() -> ThemeColors {
let fg = Color::Rgb(224, 222, 244);
let fg_dim = Color::Rgb(144, 140, 170);
let muted = Color::Rgb(110, 106, 134);
let rose = Color::Rgb(235, 111, 146);
let rose = Color::Rgb(235, 188, 186);
let gold = Color::Rgb(246, 193, 119);
let foam = Color::Rgb(156, 207, 216);
let iris = Color::Rgb(196, 167, 231);

View File

@@ -0,0 +1,345 @@
use ratatui::style::Color;
use super::*;
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let r = r as f32 / 255.0;
let g = g as f32 / 255.0;
let b = b as f32 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let h = if delta == 0.0 {
0.0
} else if max == r {
60.0 * (((g - b) / delta) % 6.0)
} else if max == g {
60.0 * (((b - r) / delta) + 2.0)
} else {
60.0 * (((r - g) / delta) + 4.0)
};
let h = if h < 0.0 { h + 360.0 } else { h };
let s = if max == 0.0 { 0.0 } else { delta / max };
let v = max;
(h, s, v)
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
let m = v - c;
let (r, g, b) = if h < 60.0 {
(c, x, 0.0)
} else if h < 120.0 {
(x, c, 0.0)
} else if h < 180.0 {
(0.0, c, x)
} else if h < 240.0 {
(0.0, x, c)
} else if h < 300.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
(
((r + m) * 255.0) as u8,
((g + m) * 255.0) as u8,
((b + m) * 255.0) as u8,
)
}
fn rotate_hue_rgb(r: u8, g: u8, b: u8, degrees: f32) -> (u8, u8, u8) {
let (h, s, v) = rgb_to_hsv(r, g, b);
let new_h = (h + degrees) % 360.0;
let new_h = if new_h < 0.0 { new_h + 360.0 } else { new_h };
hsv_to_rgb(new_h, s, v)
}
fn rotate_color(color: Color, degrees: f32) -> Color {
match color {
Color::Rgb(r, g, b) => {
let (nr, ng, nb) = rotate_hue_rgb(r, g, b, degrees);
Color::Rgb(nr, ng, nb)
}
_ => color,
}
}
fn rotate_tuple(tuple: (u8, u8, u8), degrees: f32) -> (u8, u8, u8) {
rotate_hue_rgb(tuple.0, tuple.1, tuple.2, degrees)
}
fn rotate_color_pair(pair: (Color, Color), degrees: f32) -> (Color, Color) {
(rotate_color(pair.0, degrees), rotate_color(pair.1, degrees))
}
pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
if degrees == 0.0 {
return theme;
}
ThemeColors {
ui: UiColors {
bg: rotate_color(theme.ui.bg, degrees),
bg_rgb: rotate_tuple(theme.ui.bg_rgb, degrees),
text_primary: rotate_color(theme.ui.text_primary, degrees),
text_muted: rotate_color(theme.ui.text_muted, degrees),
text_dim: rotate_color(theme.ui.text_dim, degrees),
border: rotate_color(theme.ui.border, degrees),
header: rotate_color(theme.ui.header, degrees),
unfocused: rotate_color(theme.ui.unfocused, degrees),
accent: rotate_color(theme.ui.accent, degrees),
surface: rotate_color(theme.ui.surface, degrees),
},
status: StatusColors {
playing_bg: rotate_color(theme.status.playing_bg, degrees),
playing_fg: rotate_color(theme.status.playing_fg, degrees),
stopped_bg: rotate_color(theme.status.stopped_bg, degrees),
stopped_fg: rotate_color(theme.status.stopped_fg, degrees),
fill_on: rotate_color(theme.status.fill_on, degrees),
fill_off: rotate_color(theme.status.fill_off, degrees),
fill_bg: rotate_color(theme.status.fill_bg, degrees),
},
selection: SelectionColors {
cursor_bg: rotate_color(theme.selection.cursor_bg, degrees),
cursor_fg: rotate_color(theme.selection.cursor_fg, degrees),
selected_bg: rotate_color(theme.selection.selected_bg, degrees),
selected_fg: rotate_color(theme.selection.selected_fg, degrees),
in_range_bg: rotate_color(theme.selection.in_range_bg, degrees),
in_range_fg: rotate_color(theme.selection.in_range_fg, degrees),
cursor: rotate_color(theme.selection.cursor, degrees),
selected: rotate_color(theme.selection.selected, degrees),
in_range: rotate_color(theme.selection.in_range, degrees),
},
tile: TileColors {
playing_active_bg: rotate_color(theme.tile.playing_active_bg, degrees),
playing_active_fg: rotate_color(theme.tile.playing_active_fg, degrees),
playing_inactive_bg: rotate_color(theme.tile.playing_inactive_bg, degrees),
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),
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),
active_in_range_bg: rotate_color(theme.tile.active_in_range_bg, degrees),
link_bright: [
rotate_tuple(theme.tile.link_bright[0], degrees),
rotate_tuple(theme.tile.link_bright[1], degrees),
rotate_tuple(theme.tile.link_bright[2], degrees),
rotate_tuple(theme.tile.link_bright[3], degrees),
rotate_tuple(theme.tile.link_bright[4], degrees),
],
link_dim: [
rotate_tuple(theme.tile.link_dim[0], degrees),
rotate_tuple(theme.tile.link_dim[1], degrees),
rotate_tuple(theme.tile.link_dim[2], degrees),
rotate_tuple(theme.tile.link_dim[3], degrees),
rotate_tuple(theme.tile.link_dim[4], degrees),
],
},
header: HeaderColors {
tempo_bg: rotate_color(theme.header.tempo_bg, degrees),
tempo_fg: rotate_color(theme.header.tempo_fg, degrees),
bank_bg: rotate_color(theme.header.bank_bg, degrees),
bank_fg: rotate_color(theme.header.bank_fg, degrees),
pattern_bg: rotate_color(theme.header.pattern_bg, degrees),
pattern_fg: rotate_color(theme.header.pattern_fg, degrees),
stats_bg: rotate_color(theme.header.stats_bg, degrees),
stats_fg: rotate_color(theme.header.stats_fg, degrees),
},
modal: ModalColors {
border: rotate_color(theme.modal.border, degrees),
border_accent: rotate_color(theme.modal.border_accent, degrees),
border_warn: rotate_color(theme.modal.border_warn, degrees),
border_dim: rotate_color(theme.modal.border_dim, degrees),
confirm: rotate_color(theme.modal.confirm, degrees),
rename: rotate_color(theme.modal.rename, degrees),
input: rotate_color(theme.modal.input, degrees),
editor: rotate_color(theme.modal.editor, degrees),
preview: rotate_color(theme.modal.preview, degrees),
},
flash: FlashColors {
error_bg: rotate_color(theme.flash.error_bg, degrees),
error_fg: rotate_color(theme.flash.error_fg, degrees),
success_bg: rotate_color(theme.flash.success_bg, degrees),
success_fg: rotate_color(theme.flash.success_fg, degrees),
info_bg: rotate_color(theme.flash.info_bg, degrees),
info_fg: rotate_color(theme.flash.info_fg, degrees),
event_rgb: rotate_tuple(theme.flash.event_rgb, degrees),
},
list: ListColors {
playing_bg: rotate_color(theme.list.playing_bg, degrees),
playing_fg: rotate_color(theme.list.playing_fg, degrees),
staged_play_bg: rotate_color(theme.list.staged_play_bg, degrees),
staged_play_fg: rotate_color(theme.list.staged_play_fg, degrees),
staged_stop_bg: rotate_color(theme.list.staged_stop_bg, degrees),
staged_stop_fg: rotate_color(theme.list.staged_stop_fg, degrees),
edit_bg: rotate_color(theme.list.edit_bg, degrees),
edit_fg: rotate_color(theme.list.edit_fg, degrees),
hover_bg: rotate_color(theme.list.hover_bg, degrees),
hover_fg: rotate_color(theme.list.hover_fg, degrees),
},
link_status: LinkStatusColors {
disabled: rotate_color(theme.link_status.disabled, degrees),
connected: rotate_color(theme.link_status.connected, degrees),
listening: rotate_color(theme.link_status.listening, degrees),
},
syntax: SyntaxColors {
gap_bg: rotate_color(theme.syntax.gap_bg, degrees),
executed_bg: rotate_color(theme.syntax.executed_bg, degrees),
selected_bg: rotate_color(theme.syntax.selected_bg, degrees),
emit: rotate_color_pair(theme.syntax.emit, degrees),
number: rotate_color_pair(theme.syntax.number, degrees),
string: rotate_color_pair(theme.syntax.string, degrees),
comment: rotate_color_pair(theme.syntax.comment, degrees),
keyword: rotate_color_pair(theme.syntax.keyword, degrees),
stack_op: rotate_color_pair(theme.syntax.stack_op, degrees),
operator: rotate_color_pair(theme.syntax.operator, degrees),
sound: rotate_color_pair(theme.syntax.sound, degrees),
param: rotate_color_pair(theme.syntax.param, degrees),
context: rotate_color_pair(theme.syntax.context, degrees),
note: rotate_color_pair(theme.syntax.note, degrees),
interval: rotate_color_pair(theme.syntax.interval, degrees),
variable: rotate_color_pair(theme.syntax.variable, degrees),
vary: rotate_color_pair(theme.syntax.vary, degrees),
generator: rotate_color_pair(theme.syntax.generator, degrees),
default: rotate_color_pair(theme.syntax.default, degrees),
},
table: TableColors {
row_even: rotate_color(theme.table.row_even, degrees),
row_odd: rotate_color(theme.table.row_odd, degrees),
},
values: ValuesColors {
tempo: rotate_color(theme.values.tempo, degrees),
value: rotate_color(theme.values.value, degrees),
},
hint: HintColors {
key: rotate_color(theme.hint.key, degrees),
text: rotate_color(theme.hint.text, degrees),
},
view_badge: ViewBadgeColors {
bg: rotate_color(theme.view_badge.bg, degrees),
fg: rotate_color(theme.view_badge.fg, degrees),
},
nav: NavColors {
selected_bg: rotate_color(theme.nav.selected_bg, degrees),
selected_fg: rotate_color(theme.nav.selected_fg, degrees),
unselected_bg: rotate_color(theme.nav.unselected_bg, degrees),
unselected_fg: rotate_color(theme.nav.unselected_fg, degrees),
},
editor_widget: EditorWidgetColors {
cursor_bg: rotate_color(theme.editor_widget.cursor_bg, degrees),
cursor_fg: rotate_color(theme.editor_widget.cursor_fg, degrees),
selection_bg: rotate_color(theme.editor_widget.selection_bg, degrees),
completion_bg: rotate_color(theme.editor_widget.completion_bg, degrees),
completion_fg: rotate_color(theme.editor_widget.completion_fg, degrees),
completion_selected: rotate_color(theme.editor_widget.completion_selected, degrees),
completion_example: rotate_color(theme.editor_widget.completion_example, degrees),
},
browser: BrowserColors {
directory: rotate_color(theme.browser.directory, degrees),
project_file: rotate_color(theme.browser.project_file, degrees),
selected: rotate_color(theme.browser.selected, degrees),
file: rotate_color(theme.browser.file, degrees),
focused_border: rotate_color(theme.browser.focused_border, degrees),
unfocused_border: rotate_color(theme.browser.unfocused_border, degrees),
root: rotate_color(theme.browser.root, degrees),
file_icon: rotate_color(theme.browser.file_icon, degrees),
folder_icon: rotate_color(theme.browser.folder_icon, degrees),
empty_text: rotate_color(theme.browser.empty_text, degrees),
},
input: InputColors {
text: rotate_color(theme.input.text, degrees),
cursor: rotate_color(theme.input.cursor, degrees),
hint: rotate_color(theme.input.hint, degrees),
},
search: SearchColors {
active: rotate_color(theme.search.active, degrees),
inactive: rotate_color(theme.search.inactive, degrees),
match_bg: rotate_color(theme.search.match_bg, degrees),
match_fg: rotate_color(theme.search.match_fg, degrees),
},
markdown: MarkdownColors {
h1: rotate_color(theme.markdown.h1, degrees),
h2: rotate_color(theme.markdown.h2, degrees),
h3: rotate_color(theme.markdown.h3, degrees),
code: rotate_color(theme.markdown.code, degrees),
code_border: rotate_color(theme.markdown.code_border, degrees),
link: rotate_color(theme.markdown.link, degrees),
link_url: rotate_color(theme.markdown.link_url, degrees),
quote: rotate_color(theme.markdown.quote, degrees),
text: rotate_color(theme.markdown.text, degrees),
list: rotate_color(theme.markdown.list, degrees),
},
engine: EngineColors {
header: rotate_color(theme.engine.header, degrees),
header_focused: rotate_color(theme.engine.header_focused, degrees),
divider: rotate_color(theme.engine.divider, degrees),
scroll_indicator: rotate_color(theme.engine.scroll_indicator, degrees),
label: rotate_color(theme.engine.label, degrees),
label_focused: rotate_color(theme.engine.label_focused, degrees),
label_dim: rotate_color(theme.engine.label_dim, degrees),
value: rotate_color(theme.engine.value, degrees),
focused: rotate_color(theme.engine.focused, degrees),
normal: rotate_color(theme.engine.normal, degrees),
dim: rotate_color(theme.engine.dim, degrees),
path: rotate_color(theme.engine.path, degrees),
border_magenta: rotate_color(theme.engine.border_magenta, degrees),
border_green: rotate_color(theme.engine.border_green, degrees),
border_cyan: rotate_color(theme.engine.border_cyan, degrees),
separator: rotate_color(theme.engine.separator, degrees),
hint_active: rotate_color(theme.engine.hint_active, degrees),
hint_inactive: rotate_color(theme.engine.hint_inactive, degrees),
},
dict: DictColors {
word_name: rotate_color(theme.dict.word_name, degrees),
word_bg: rotate_color(theme.dict.word_bg, degrees),
alias: rotate_color(theme.dict.alias, degrees),
stack_sig: rotate_color(theme.dict.stack_sig, degrees),
description: rotate_color(theme.dict.description, degrees),
example: rotate_color(theme.dict.example, degrees),
category_focused: rotate_color(theme.dict.category_focused, degrees),
category_selected: rotate_color(theme.dict.category_selected, degrees),
category_normal: rotate_color(theme.dict.category_normal, degrees),
category_dimmed: rotate_color(theme.dict.category_dimmed, degrees),
border_focused: rotate_color(theme.dict.border_focused, degrees),
border_normal: rotate_color(theme.dict.border_normal, degrees),
header_desc: rotate_color(theme.dict.header_desc, degrees),
},
title: TitleColors {
big_title: rotate_color(theme.title.big_title, degrees),
author: rotate_color(theme.title.author, degrees),
link: rotate_color(theme.title.link, degrees),
license: rotate_color(theme.title.license, degrees),
prompt: rotate_color(theme.title.prompt, degrees),
subtitle: rotate_color(theme.title.subtitle, degrees),
},
meter: MeterColors {
low: rotate_color(theme.meter.low, degrees),
mid: rotate_color(theme.meter.mid, degrees),
high: rotate_color(theme.meter.high, degrees),
low_rgb: rotate_tuple(theme.meter.low_rgb, degrees),
mid_rgb: rotate_tuple(theme.meter.mid_rgb, degrees),
high_rgb: rotate_tuple(theme.meter.high_rgb, degrees),
},
sparkle: SparkleColors {
colors: [
rotate_tuple(theme.sparkle.colors[0], degrees),
rotate_tuple(theme.sparkle.colors[1], degrees),
rotate_tuple(theme.sparkle.colors[2], degrees),
rotate_tuple(theme.sparkle.colors[3], degrees),
rotate_tuple(theme.sparkle.colors[4], degrees),
],
},
confirm: ConfirmColors {
border: rotate_color(theme.confirm.border, degrees),
button_selected_bg: rotate_color(theme.confirm.button_selected_bg, degrees),
button_selected_fg: rotate_color(theme.confirm.button_selected_fg, degrees),
},
}
}

View File

@@ -193,21 +193,42 @@ You can also use quotations if you need to execute code:
When the selected value is a quotation, it gets executed. When it is a plain value, it gets pushed onto the stack.
Three cycling words exist:
Two cycling words exist:
- `cycle` - selects based on `runs` (how many times this step has played)
- `pcycle` - selects based on `iter` (how many times the pattern has looped)
- `tcycle` - creates a cycle list that resolves at emit time
The difference between `cycle` and `pcycle` matters when patterns have different lengths. `cycle` counts per-step, `pcycle` counts per-pattern.
`tcycle` is special. It does not select immediately. Instead it creates a value that cycles when emitted:
## Polyphonic Parameters
Parameter words like `note`, `freq`, and `gain` consume the entire stack. If you push multiple values before a param word, you get polyphony:
```forth
0.3 0.5 0.7 3 tcycle gain
60 64 67 note sine s . ;; emits 3 voices with notes 60, 64, 67
```
If you emit multiple times in one step (using `at`), each emit gets the next value from the cycle.
This works for any param and for the sound word itself:
```forth
440 880 freq sine tri s . ;; 2 voices: sine at 440, tri at 880
```
When params have different lengths, shorter lists cycle:
```forth
60 64 67 note ;; 3 notes
0.5 1.0 gain ;; 2 gains (cycles: 0.5, 1.0, 0.5)
sine s . ;; emits 3 voices
```
Polyphony multiplies with `at` deltas:
```forth
0 0.5 at ;; 2 time points
60 64 note ;; 2 notes
sine s . ;; emits 4 voices (2 notes × 2 times)
```
## Summary

View File

@@ -2,5 +2,6 @@ allow-branch = ["main"]
sign-commit = false
sign-tag = false
push = true
push-remote = "github"
publish = false
tag-name = "v{{version}}"

View File

@@ -110,6 +110,8 @@ impl App {
show_completion: self.ui.show_completion,
flash_brightness: self.ui.flash_brightness,
color_scheme: self.ui.color_scheme,
layout: self.audio.config.layout,
hue_rotation: self.ui.hue_rotation,
..Default::default()
},
link: crate::settings::LinkSettings {
@@ -527,10 +529,15 @@ impl App {
self.load_step_to_editor();
}
pub fn save(&mut self, path: PathBuf, link: &LinkState) {
pub fn save(&mut self, path: PathBuf, link: &LinkState, snapshot: &SequencerSnapshot) {
self.save_editor_to_step();
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
self.project_state.project.tempo = link.tempo();
self.project_state.project.playing_patterns = snapshot
.active_patterns
.iter()
.map(|p| (p.bank, p.pattern))
.collect();
match model::save(&self.project_state.project, &path) {
Ok(final_path) => {
self.ui
@@ -547,12 +554,27 @@ impl App {
match model::load(&path) {
Ok(project) => {
let tempo = project.tempo;
let playing = project.playing_patterns.clone();
self.project_state.project = project;
self.editor_ctx.step = 0;
self.load_step_to_editor();
self.compile_all_steps(link);
self.mark_all_patterns_dirty();
link.set_tempo(tempo);
self.playback.clear_queues();
self.variables.lock().unwrap().clear();
self.dict.lock().unwrap().clear();
for (bank, pattern) in playing {
self.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: crate::model::LaunchQuantization::Immediate,
sync_mode: crate::model::SyncMode::Reset,
});
}
self.ui.set_status(format!("Loaded: {}", path.display()));
self.project_state.file_path = Some(path);
}
@@ -1103,7 +1125,7 @@ impl App {
}
self.project_state.mark_dirty(bank, pattern);
}
AppCommand::Save(path) => self.save(path, link),
AppCommand::Save(path) => self.save(path, link, snapshot),
AppCommand::Load(path) => self.load(path, link),
// UI
@@ -1290,7 +1312,15 @@ impl App {
}
AppCommand::SetColorScheme(scheme) => {
self.ui.color_scheme = scheme;
crate::theme::set(scheme.to_theme());
let base_theme = scheme.to_theme();
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, self.ui.hue_rotation);
crate::theme::set(rotated);
}
AppCommand::SetHueRotation(degrees) => {
self.ui.hue_rotation = degrees;
let base_theme = self.ui.color_scheme.to_theme();
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, degrees);
crate::theme::set(rotated);
}
AppCommand::ToggleRuntimeHighlight => {
self.ui.runtime_highlight = !self.ui.runtime_highlight;
@@ -1408,6 +1438,65 @@ impl App {
AppCommand::ResetPeakVoices => {
self.metrics.peak_voices = 0;
}
// Euclidean distribution
AppCommand::ApplyEuclideanDistribution {
bank,
pattern,
source_step,
pulses,
steps,
rotation,
} => {
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
let rhythm = euclidean_rhythm(pulses, steps, rotation);
let mut created_count = 0;
for (i, &is_hit) in rhythm.iter().enumerate() {
if !is_hit {
continue;
}
let target = (source_step + i) % pat_len;
if target == source_step {
continue;
}
if let Some(step) = self
.project_state
.project
.pattern_at_mut(bank, pattern)
.step_mut(target)
{
step.source = Some(source_step);
step.script.clear();
step.command = None;
step.active = true;
}
created_count += 1;
}
self.project_state.mark_dirty(bank, pattern);
for (i, &is_hit) in rhythm.iter().enumerate() {
if !is_hit || i == 0 {
continue;
}
let target = (source_step + i) % pat_len;
let saved = self.editor_ctx.step;
self.editor_ctx.step = target;
self.compile_current_step(link);
self.editor_ctx.step = saved;
}
self.load_step_to_editor();
self.ui.flash(
&format!("Created {} linked steps (E({pulses},{steps}))", created_count),
200,
FlashKind::Success,
);
}
}
}
@@ -1460,3 +1549,21 @@ impl App {
}
}
}
fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec<bool> {
if pulses == 0 || steps == 0 || pulses > steps {
return vec![false; steps];
}
let mut pattern = vec![false; steps];
for i in 0..pulses {
let pos = (i * steps) / pulses;
pattern[pos] = true;
}
if rotation > 0 {
pattern.rotate_left(rotation % steps);
}
pattern
}

View File

@@ -161,6 +161,7 @@ pub enum AppCommand {
HideTitle,
ToggleEditorStack,
SetColorScheme(ColorScheme),
SetHueRotation(f32),
ToggleRuntimeHighlight,
ToggleCompletion,
AdjustFlashBrightness(f32),
@@ -207,4 +208,13 @@ pub enum AppCommand {
// Metrics
ResetPeakVoices,
// Euclidean distribution
ApplyEuclideanDistribution {
bank: usize,
pattern: usize,
source_step: usize,
pulses: usize,
steps: usize,
rotation: usize,
},
}

View File

@@ -450,6 +450,10 @@ impl RunsCounter {
*count += 1;
current
}
fn clear_pattern(&mut self, bank: usize, pattern: usize) {
self.counts.retain(|&(b, p, _), _| b != bank || p != pattern);
}
}
pub(crate) struct TickInput {
@@ -716,6 +720,7 @@ impl SequencerState {
}
}
};
self.runs_counter.clear_pattern(pending.id.bank, pending.id.pattern);
self.audio_state.active_patterns.insert(
pending.id,
ActivePattern {

View File

@@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
use crate::model::PatternSpeed;
use crate::page::Page;
use crate::state::{
DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, PatternPropsField,
SampleBrowserState, SettingKind, SidePanel,
CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus,
PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
};
pub enum InputResult {
@@ -258,6 +258,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match mode {
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
FileBrowserMode::Load => {
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
ctx.dispatch(AppCommand::Load(path));
load_project_samples(ctx);
}
@@ -640,6 +641,79 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
_ => {}
}
}
Modal::EuclideanDistribution {
bank,
pattern,
source_step,
field,
pulses,
steps,
rotation,
} => {
let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step);
match key.code {
KeyCode::Up => *field = field.prev(),
KeyCode::Down | KeyCode::Tab => *field = field.next(),
KeyCode::Left => {
let target = match field {
EuclideanField::Pulses => pulses,
EuclideanField::Steps => steps,
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = val.saturating_sub(1).to_string();
}
}
KeyCode::Right => {
let target = match field {
EuclideanField::Pulses => pulses,
EuclideanField::Steps => steps,
EuclideanField::Rotation => rotation,
};
if let Ok(val) = target.parse::<usize>() {
*target = (val + 1).min(128).to_string();
}
}
KeyCode::Char(c) if c.is_ascii_digit() => match field {
EuclideanField::Pulses => pulses.push(c),
EuclideanField::Steps => steps.push(c),
EuclideanField::Rotation => rotation.push(c),
},
KeyCode::Backspace => match field {
EuclideanField::Pulses => {
pulses.pop();
}
EuclideanField::Steps => {
steps.pop();
}
EuclideanField::Rotation => {
rotation.pop();
}
},
KeyCode::Enter => {
let pulses_val: usize = pulses.parse().unwrap_or(0);
let steps_val: usize = steps.parse().unwrap_or(0);
let rotation_val: usize = rotation.parse().unwrap_or(0);
if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val {
ctx.dispatch(AppCommand::ApplyEuclideanDistribution {
bank: bank_val,
pattern: pattern_val,
source_step: source_step_val,
pulses: pulses_val,
steps: steps_val,
rotation: rotation_val,
});
ctx.dispatch(AppCommand::CloseModal);
} else {
ctx.dispatch(AppCommand::SetStatus(
"Invalid: pulses must be > 0 and <= steps".to_string(),
));
}
}
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
_ => {}
}
}
Modal::None => unreachable!(),
}
InputResult::Continue
@@ -955,9 +1029,31 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
name: current_name,
}));
}
KeyCode::Char('o') => {
ctx.app.audio.config.layout = ctx.app.audio.config.layout.next();
}
KeyCode::Char('?') => {
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
}
KeyCode::Char('e') | KeyCode::Char('E') => {
let (bank, pattern, step) = (
ctx.app.editor_ctx.bank,
ctx.app.editor_ctx.pattern,
ctx.app.editor_ctx.step,
);
let pattern_len = ctx.app.current_edit_pattern().length;
let default_steps = pattern_len.min(32);
let default_pulses = (default_steps / 2).max(1).min(default_steps);
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
bank,
pattern,
source_step: step,
field: EuclideanField::Pulses,
pulses: default_pulses.to_string(),
steps: default_steps.to_string(),
rotation: "0".to_string(),
}));
}
_ => {}
}
InputResult::Continue
@@ -1288,6 +1384,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
};
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
}
OptionsFocus::HueRotation => {
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
}
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
OptionsFocus::RuntimeHighlight => {
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);

View File

@@ -100,7 +100,11 @@ fn main() -> io::Result<()> {
app.ui.show_completion = settings.display.show_completion;
app.ui.flash_brightness = settings.display.flash_brightness;
app.ui.color_scheme = settings.display.color_scheme;
theme::set(settings.display.color_scheme.to_theme());
app.ui.hue_rotation = settings.display.hue_rotation;
app.audio.config.layout = settings.display.layout;
let base_theme = settings.display.color_scheme.to_theme();
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation);
theme::set(rotated);
// Load MIDI settings
let outputs = midi::list_midi_outputs();

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::state::ColorScheme;
use crate::state::{ColorScheme, MainLayout};
const APP_NAME: &str = "cagire";
@@ -50,6 +50,10 @@ pub struct DisplaySettings {
pub font: String,
#[serde(default)]
pub color_scheme: ColorScheme,
#[serde(default)]
pub layout: MainLayout,
#[serde(default)]
pub hue_rotation: f32,
}
fn default_font() -> String {
@@ -91,6 +95,8 @@ impl Default for DisplaySettings {
flash_brightness: 1.0,
font: default_font(),
color_scheme: ColorScheme::default(),
layout: MainLayout::default(),
hue_rotation: 0.0,
}
}
}

View File

@@ -1,8 +1,22 @@
use doux::audio::AudioDeviceInfo;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use super::CyclicEnum;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum MainLayout {
#[default]
Top,
Bottom,
Left,
Right,
}
impl CyclicEnum for MainLayout {
const VARIANTS: &'static [Self] = &[Self::Top, Self::Bottom, Self::Left, Self::Right];
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum RefreshRate {
#[default]
@@ -62,6 +76,7 @@ pub struct AudioConfig {
pub show_scope: bool,
pub show_spectrum: bool,
pub lookahead_ms: u32,
pub layout: MainLayout,
}
impl Default for AudioConfig {
@@ -79,6 +94,7 @@ impl Default for AudioConfig {
show_scope: true,
show_spectrum: true,
lookahead_ms: 15,
layout: MainLayout::default(),
}
}
}

View File

@@ -41,6 +41,32 @@ impl PatternPropsField {
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum EuclideanField {
#[default]
Pulses,
Steps,
Rotation,
}
impl EuclideanField {
pub fn next(&self) -> Self {
match self {
Self::Pulses => Self::Steps,
Self::Steps => Self::Rotation,
Self::Rotation => Self::Rotation,
}
}
pub fn prev(&self) -> Self {
match self {
Self::Pulses => Self::Pulses,
Self::Steps => Self::Pulses,
Self::Rotation => Self::Steps,
}
}
}
pub struct EditorContext {
pub bank: usize,
pub pattern: usize,

View File

@@ -27,10 +27,11 @@ pub mod project;
pub mod sample_browser;
pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind};
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
pub use color_scheme::ColorScheme;
pub use editor::{
CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache,
CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField,
StackCache,
};
pub use live_keys::LiveKeyState;
pub use modal::Modal;

View File

@@ -1,5 +1,5 @@
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
use crate::state::editor::{PatternField, PatternPropsField};
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
use crate::state::file_browser::FileBrowserState;
#[derive(Clone, PartialEq, Eq)]
@@ -66,4 +66,13 @@ pub enum Modal {
KeybindingsHelp {
scroll: usize,
},
EuclideanDistribution {
bank: usize,
pattern: usize,
source_step: usize,
field: EuclideanField,
pulses: String,
steps: String,
rotation: String,
},
}

View File

@@ -4,6 +4,7 @@ use super::CyclicEnum;
pub enum OptionsFocus {
#[default]
ColorScheme,
HueRotation,
RefreshRate,
RuntimeHighlight,
ShowScope,
@@ -26,6 +27,7 @@ pub enum OptionsFocus {
impl CyclicEnum for OptionsFocus {
const VARIANTS: &'static [Self] = &[
Self::ColorScheme,
Self::HueRotation,
Self::RefreshRate,
Self::RuntimeHighlight,
Self::ShowScope,

View File

@@ -28,4 +28,9 @@ impl PlaybackState {
pub fn toggle(&mut self) {
self.playing = !self.playing;
}
pub fn clear_queues(&mut self) {
self.staged_changes.clear();
self.queued_changes.clear();
}
}

View File

@@ -50,6 +50,7 @@ pub struct UiState {
pub event_flash: f32,
pub flash_brightness: f32,
pub color_scheme: ColorScheme,
pub hue_rotation: f32,
}
impl Default for UiState {
@@ -78,6 +79,7 @@ impl Default for UiState {
event_flash: 0.0,
flash_brightness: 1.0,
color_scheme: ColorScheme::default(),
hue_rotation: 0.0,
}
}
}

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(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
}
Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));

View File

@@ -1,65 +1,117 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::engine::SequencerSnapshot;
use crate::state::MainLayout;
use crate::theme;
use crate::widgets::{Orientation, Scope, Spectrum, VuMeter};
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let [left_area, _spacer, vu_area] = Layout::horizontal([
let [patterns_area, _, main_area, _, vu_area] = Layout::horizontal([
Constraint::Length(13),
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Length(8),
Constraint::Length(10),
])
.areas(area);
let show_scope = app.audio.config.show_scope;
let show_spectrum = app.audio.config.show_spectrum;
let viz_height = if show_scope || show_spectrum { 14 } else { 0 };
let has_viz = show_scope || show_spectrum;
let layout = app.audio.config.layout;
let [viz_area, sequencer_area] = Layout::vertical([
Constraint::Length(viz_height),
Constraint::Fill(1),
])
.areas(left_area);
let (viz_area, sequencer_area) = match layout {
MainLayout::Top => {
let viz_height = if has_viz { 16 } else { 0 };
let [viz, seq] = Layout::vertical([
Constraint::Length(viz_height),
Constraint::Fill(1),
])
.areas(main_area);
(viz, seq)
}
MainLayout::Bottom => {
let viz_height = if has_viz { 16 } else { 0 };
let [seq, viz] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(viz_height),
])
.areas(main_area);
(viz, seq)
}
MainLayout::Left => {
let viz_width = if has_viz { 33 } else { 0 };
let [viz, _spacer, seq] = Layout::horizontal([
Constraint::Percentage(viz_width),
Constraint::Length(2),
Constraint::Fill(1),
])
.areas(main_area);
(viz, seq)
}
MainLayout::Right => {
let viz_width = if has_viz { 33 } else { 0 };
let [seq, _spacer, viz] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Percentage(viz_width),
])
.areas(main_area);
(viz, seq)
}
};
if show_scope && show_spectrum {
let [scope_area, _, spectrum_area] = Layout::horizontal([
Constraint::Percentage(50),
Constraint::Length(2),
Constraint::Percentage(50),
])
.areas(viz_area);
render_scope(frame, app, scope_area);
render_spectrum(frame, app, spectrum_area);
} else if show_scope {
render_scope(frame, app, viz_area);
} else if show_spectrum {
render_spectrum(frame, app, viz_area);
if has_viz {
render_viz_area(frame, app, viz_area, layout, show_scope, show_spectrum);
}
render_sequencer(frame, app, snapshot, sequencer_area);
render_vu_meter(frame, app, vu_area);
render_active_patterns(frame, app, snapshot, patterns_area);
}
// Calculate actual grid height to align VU meter
let pattern = app.current_edit_pattern();
let page = app.editor_ctx.step / STEPS_PER_PAGE;
let page_start = page * STEPS_PER_PAGE;
let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern.length) - page_start;
let num_rows = steps_on_page.div_ceil(8);
let spacing = num_rows.saturating_sub(1) as u16;
let row_height = sequencer_area.height.saturating_sub(spacing) / num_rows as u16;
let actual_grid_height = row_height * num_rows as u16 + spacing;
fn render_viz_area(
frame: &mut Frame,
app: &App,
area: Rect,
layout: MainLayout,
show_scope: bool,
show_spectrum: bool,
) {
let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right);
let aligned_vu_area = Rect {
y: sequencer_area.y,
height: actual_grid_height,
..vu_area
};
render_vu_meter(frame, app, aligned_vu_area);
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);
} 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 {
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);
}
}
const STEPS_PER_PAGE: usize = 32;
@@ -84,22 +136,15 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
let num_rows = steps_on_page.div_ceil(8);
let steps_per_row = steps_on_page.div_ceil(num_rows);
let spacing = num_rows.saturating_sub(1) as u16;
let row_height = area.height.saturating_sub(spacing) / num_rows as u16;
let row_height = area.height / num_rows as u16;
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
.map(|i| {
if i % 2 == 0 {
Constraint::Length(row_height)
} else {
Constraint::Length(1)
}
})
let row_constraints: Vec<Constraint> = (0..num_rows)
.map(|_| Constraint::Length(row_height))
.collect();
let rows = Layout::vertical(row_constraints).split(area);
for row_idx in 0..num_rows {
let row_area = rows[row_idx * 2];
let row_area = rows[row_idx];
let start_step = row_idx * steps_per_row;
let end_step = (start_step + steps_per_row).min(steps_on_page);
let cols_in_row = end_step - start_step;
@@ -174,6 +219,12 @@ fn render_tile(
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let source_idx = step.and_then(|s| s.source);
let symbol = if is_playing {
"".to_string()
@@ -191,17 +242,17 @@ fn render_tile(
};
let num_lines = if step_name.is_some() { 2u16 } else { 1u16 };
let content_height = num_lines;
let y_offset = area.height.saturating_sub(content_height) / 2;
let y_offset = inner.height.saturating_sub(content_height) / 2;
// Fill background for entire tile
// Fill background for inner area
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
frame.render_widget(bg_fill, area);
frame.render_widget(bg_fill, inner);
if let Some(name) = step_name {
let name_area = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
x: inner.x,
y: inner.y + y_offset,
width: inner.width,
height: 1,
};
let name_widget = Paragraph::new(name.as_str())
@@ -210,9 +261,9 @@ fn render_tile(
frame.render_widget(name_widget, name_area);
let symbol_area = Rect {
x: area.x,
y: area.y + y_offset + 1,
width: area.width,
x: inner.x,
y: inner.y + y_offset + 1,
width: inner.width,
height: 1,
};
let symbol_widget = Paragraph::new(symbol)
@@ -221,9 +272,9 @@ fn render_tile(
frame.render_widget(symbol_widget, symbol_area);
} else {
let centered_area = Rect {
x: area.x,
y: area.y + y_offset,
width: area.width,
x: inner.x,
y: inner.y + y_offset,
width: inner.width,
height: 1,
};
let tile = Paragraph::new(symbol)
@@ -233,21 +284,65 @@ fn render_tile(
}
}
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) {
let theme = theme::get();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Horizontal)
.orientation(orientation)
.color(theme.meter.low);
frame.render_widget(scope, area);
frame.render_widget(scope, inner);
}
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
let area = Rect { height: area.height.saturating_sub(1), ..area };
let theme = theme::get();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let spectrum = Spectrum::new(&app.metrics.spectrum);
frame.render_widget(spectrum, area);
frame.render_widget(spectrum, inner);
}
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let vu = VuMeter::new(app.metrics.peak_left, app.metrics.peak_right);
frame.render_widget(vu, area);
frame.render_widget(vu, inner);
}
fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
let theme = theme::get();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border));
let inner = block.inner(area);
frame.render_widget(block, area);
let patterns: Vec<(usize, usize, usize)> = snapshot
.active_patterns
.iter()
.map(|p| (p.bank, p.pattern, p.iter))
.collect();
let step_info = snapshot
.get_step(app.editor_ctx.bank, app.editor_ctx.pattern)
.map(|step| (step, app.current_edit_pattern().length));
let mut widget = ActivePatterns::new(&patterns);
if let Some((step, total)) = step_info {
widget = widget.with_step(step, total);
}
frame.render_widget(widget, inner);
}

View File

@@ -110,6 +110,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let midi_in_2 = midi_in_display(2);
let midi_in_3 = midi_in_display(3);
let hue_str = format!("{}°", app.ui.hue_rotation as i32);
let lines: Vec<Line> = vec![
render_section_header("DISPLAY", &theme),
render_divider(content_width, &theme),
@@ -119,6 +121,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
focus == OptionsFocus::ColorScheme,
&theme,
),
render_option_line(
"Hue rotation",
&hue_str,
focus == OptionsFocus::HueRotation,
&theme,
),
render_option_line(
"Refresh rate",
app.audio.config.refresh_rate.label(),
@@ -201,23 +209,24 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let focus_line: usize = match focus {
OptionsFocus::ColorScheme => 2,
OptionsFocus::RefreshRate => 3,
OptionsFocus::RuntimeHighlight => 4,
OptionsFocus::ShowScope => 5,
OptionsFocus::ShowSpectrum => 6,
OptionsFocus::ShowCompletion => 7,
OptionsFocus::FlashBrightness => 8,
OptionsFocus::LinkEnabled => 12,
OptionsFocus::StartStopSync => 13,
OptionsFocus::Quantum => 14,
OptionsFocus::MidiOutput0 => 25,
OptionsFocus::MidiOutput1 => 26,
OptionsFocus::MidiOutput2 => 27,
OptionsFocus::MidiOutput3 => 28,
OptionsFocus::MidiInput0 => 32,
OptionsFocus::MidiInput1 => 33,
OptionsFocus::MidiInput2 => 34,
OptionsFocus::MidiInput3 => 35,
OptionsFocus::HueRotation => 3,
OptionsFocus::RefreshRate => 4,
OptionsFocus::RuntimeHighlight => 5,
OptionsFocus::ShowScope => 6,
OptionsFocus::ShowSpectrum => 7,
OptionsFocus::ShowCompletion => 8,
OptionsFocus::FlashBrightness => 9,
OptionsFocus::LinkEnabled => 13,
OptionsFocus::StartStopSync => 14,
OptionsFocus::Quantum => 15,
OptionsFocus::MidiOutput0 => 26,
OptionsFocus::MidiOutput1 => 27,
OptionsFocus::MidiOutput2 => 28,
OptionsFocus::MidiOutput3 => 29,
OptionsFocus::MidiInput0 => 33,
OptionsFocus::MidiInput1 => 34,
OptionsFocus::MidiInput2 => 35,
OptionsFocus::MidiInput3 => 36,
};
let scroll_offset = if total_lines <= max_visible {

View File

@@ -16,7 +16,9 @@ use crate::app::App;
use crate::engine::{LinkState, SequencerSnapshot};
use crate::model::{SourceSpan, StepContext, Value};
use crate::page::Page;
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache};
use crate::state::{
EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache,
};
use crate::theme;
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
use crate::widgets::{
@@ -1038,5 +1040,131 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
hint_area,
);
}
Modal::EuclideanDistribution {
source_step,
field,
pulses,
steps,
rotation,
..
} => {
let width = 50u16;
let height = 11u16;
let x = (term.width.saturating_sub(width)) / 2;
let y = (term.height.saturating_sub(height)) / 2;
let area = Rect::new(x, y, width, height);
let block = Block::bordered()
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1))
.border_style(Style::default().fg(theme.modal.input));
let inner = block.inner(area);
frame.render_widget(Clear, area);
// Fill background with theme color
let bg_fill = " ".repeat(area.width as usize);
for row in 0..area.height {
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
frame.render_widget(
Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)),
line_area,
);
}
frame.render_widget(block, area);
let fields = [
(
"Pulses",
pulses.as_str(),
*field == EuclideanField::Pulses,
),
("Steps", steps.as_str(), *field == EuclideanField::Steps),
(
"Rotation",
rotation.as_str(),
*field == EuclideanField::Rotation,
),
];
for (i, (label, value, selected)) in fields.iter().enumerate() {
let row_y = inner.y + i as u16;
if row_y >= inner.y + inner.height {
break;
}
let (label_style, value_style) = if *selected {
(
Style::default()
.fg(theme.hint.key)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(theme.ui.text_primary)
.bg(theme.ui.surface),
)
} else {
(
Style::default().fg(theme.ui.text_muted),
Style::default().fg(theme.ui.text_primary),
)
};
let label_area = Rect::new(inner.x + 1, row_y, 14, 1);
let value_area = Rect::new(inner.x + 16, row_y, inner.width.saturating_sub(18), 1);
frame.render_widget(
Paragraph::new(format!("{label}:")).style(label_style),
label_area,
);
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
}
let preview_y = inner.y + 4;
if preview_y < inner.y + inner.height {
let pulses_val: usize = pulses.parse().unwrap_or(0);
let steps_val: usize = steps.parse().unwrap_or(0);
let rotation_val: usize = rotation.parse().unwrap_or(0);
let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val);
let preview_line = Line::from(vec![
Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)),
Span::styled(preview, Style::default().fg(theme.modal.input)),
]);
let preview_area =
Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1);
frame.render_widget(Paragraph::new(preview_line), preview_area);
}
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
let hint_line = Line::from(vec![
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
Span::styled(" nav ", Style::default().fg(theme.hint.text)),
Span::styled("←→", Style::default().fg(theme.hint.key)),
Span::styled(" adjust ", Style::default().fg(theme.hint.text)),
Span::styled("Enter", Style::default().fg(theme.hint.key)),
Span::styled(" apply ", Style::default().fg(theme.hint.text)),
Span::styled("Esc", Style::default().fg(theme.hint.key)),
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
]);
frame.render_widget(Paragraph::new(hint_line), hint_area);
}
}
}
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
if pulses == 0 || steps == 0 || pulses > steps {
return "[invalid]".to_string();
}
let mut pattern = vec![false; steps];
for i in 0..pulses {
let pos = (i * steps) / pulses;
pattern[pos] = true;
}
if rotation > 0 {
pattern.rotate_left(rotation % steps);
}
let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect();
format!("[{}]", chars.join(" "))
}

View File

@@ -3,6 +3,7 @@ use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
#[cfg(not(feature = "desktop"))]
use tui_big_text::{BigText, PixelSize};
use crate::state::ui::UiState;
@@ -16,13 +17,21 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
let link_style = Style::new().fg(theme.title.link);
let license_style = Style::new().fg(theme.title.license);
#[cfg(not(feature = "desktop"))]
let big_title = BigText::builder()
.pixel_size(PixelSize::Quadrant)
.pixel_size(PixelSize::Full)
.style(Style::new().fg(theme.title.big_title).bold())
.lines(vec!["CAGIRE".into()])
.centered()
.build();
#[cfg(feature = "desktop")]
let big_title = Paragraph::new(Line::from(Span::styled(
"CAGIRE",
Style::new().fg(theme.title.big_title).bold(),
)))
.alignment(Alignment::Center);
let version_style = Style::new().fg(theme.title.subtitle);
let subtitle_lines = vec![
@@ -49,21 +58,43 @@ pub fn render(frame: &mut Frame, area: Rect, ui: &UiState) {
)),
];
let big_text_height = 4;
#[cfg(not(feature = "desktop"))]
let big_text_height = 8;
#[cfg(feature = "desktop")]
let big_text_height = 1;
let min_title_width = 30;
let subtitle_height = subtitle_lines.len() as u16;
let total_height = big_text_height + subtitle_height;
let vertical_padding = area.height.saturating_sub(total_height) / 2;
let [_, title_area, subtitle_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(big_text_height),
Constraint::Length(subtitle_height),
Constraint::Fill(1),
])
.areas(area);
let show_big_title =
area.height >= (big_text_height + subtitle_height) && area.width >= min_title_width;
frame.render_widget(big_title, title_area);
if show_big_title {
let total_height = big_text_height + subtitle_height;
let vertical_padding = area.height.saturating_sub(total_height) / 2;
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
frame.render_widget(subtitle, subtitle_area);
let [_, title_area, subtitle_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(big_text_height),
Constraint::Length(subtitle_height),
Constraint::Fill(1),
])
.areas(area);
frame.render_widget(big_title, title_area);
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
frame.render_widget(subtitle, subtitle_area);
} else {
let vertical_padding = area.height.saturating_sub(subtitle_height) / 2;
let [_, subtitle_area, _] = Layout::vertical([
Constraint::Length(vertical_padding),
Constraint::Length(subtitle_height),
Constraint::Fill(1),
])
.areas(area);
let subtitle = Paragraph::new(subtitle_lines).alignment(Alignment::Center);
frame.render_widget(subtitle, subtitle_area);
}
}

View File

@@ -1,4 +1,4 @@
pub use cagire_ratatui::{
ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation, SampleBrowser,
Scope, Spectrum, TextInputModal, VuMeter,
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation,
SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
};

View File

@@ -54,3 +54,6 @@ mod generator;
#[path = "forth/midi.rs"]
mod midi;
#[path = "forth/chords.rs"]
mod chords;

178
tests/forth/chords.rs Normal file
View File

@@ -0,0 +1,178 @@
use cagire::forth::Value;
use super::harness::{expect_stack, run};
fn ints(vals: &[i64]) -> Vec<Value> {
vals.iter().map(|&v| Value::Int(v, None)).collect()
}
// Triads
#[test]
fn chord_major() {
expect_stack("c4 maj", &ints(&[60, 64, 67]));
}
#[test]
fn chord_minor() {
expect_stack("c4 m", &ints(&[60, 63, 67]));
}
#[test]
fn chord_diminished() {
expect_stack("c4 dim", &ints(&[60, 63, 66]));
}
#[test]
fn chord_augmented() {
expect_stack("c4 aug", &ints(&[60, 64, 68]));
}
#[test]
fn chord_sus2() {
expect_stack("c4 sus2", &ints(&[60, 62, 67]));
}
#[test]
fn chord_sus4() {
expect_stack("c4 sus4", &ints(&[60, 65, 67]));
}
// Seventh chords
#[test]
fn chord_maj7() {
expect_stack("c4 maj7", &ints(&[60, 64, 67, 71]));
}
#[test]
fn chord_min7() {
expect_stack("c4 min7", &ints(&[60, 63, 67, 70]));
}
#[test]
fn chord_dom7() {
expect_stack("c4 dom7", &ints(&[60, 64, 67, 70]));
}
#[test]
fn chord_dim7() {
expect_stack("c4 dim7", &ints(&[60, 63, 66, 69]));
}
#[test]
fn chord_half_dim() {
expect_stack("c4 m7b5", &ints(&[60, 63, 66, 70]));
}
#[test]
fn chord_minmaj7() {
expect_stack("c4 minmaj7", &ints(&[60, 63, 67, 71]));
}
#[test]
fn chord_aug7() {
expect_stack("c4 aug7", &ints(&[60, 64, 68, 70]));
}
// Sixth chords
#[test]
fn chord_maj6() {
expect_stack("c4 maj6", &ints(&[60, 64, 67, 69]));
}
#[test]
fn chord_min6() {
expect_stack("c4 min6", &ints(&[60, 63, 67, 69]));
}
// Extended chords
#[test]
fn chord_dom9() {
expect_stack("c4 dom9", &ints(&[60, 64, 67, 70, 74]));
}
#[test]
fn chord_maj9() {
expect_stack("c4 maj9", &ints(&[60, 64, 67, 71, 74]));
}
#[test]
fn chord_min9() {
expect_stack("c4 min9", &ints(&[60, 63, 67, 70, 74]));
}
#[test]
fn chord_dom11() {
expect_stack("c4 dom11", &ints(&[60, 64, 67, 70, 74, 77]));
}
#[test]
fn chord_min11() {
expect_stack("c4 min11", &ints(&[60, 63, 67, 70, 74, 77]));
}
#[test]
fn chord_dom13() {
expect_stack("c4 dom13", &ints(&[60, 64, 67, 70, 74, 81]));
}
// Add chords
#[test]
fn chord_add9() {
expect_stack("c4 add9", &ints(&[60, 64, 67, 74]));
}
#[test]
fn chord_add11() {
expect_stack("c4 add11", &ints(&[60, 64, 67, 77]));
}
#[test]
fn chord_madd9() {
expect_stack("c4 madd9", &ints(&[60, 63, 67, 74]));
}
// Altered dominants
#[test]
fn chord_dom7b9() {
expect_stack("c4 dom7b9", &ints(&[60, 64, 67, 70, 73]));
}
#[test]
fn chord_dom7s9() {
expect_stack("c4 dom7s9", &ints(&[60, 64, 67, 70, 75]));
}
#[test]
fn chord_dom7b5() {
expect_stack("c4 dom7b5", &ints(&[60, 64, 66, 70]));
}
#[test]
fn chord_dom7s5() {
expect_stack("c4 dom7s5", &ints(&[60, 64, 68, 70]));
}
// Different roots
#[test]
fn chord_a3_min7() {
expect_stack("a3 min7", &ints(&[57, 60, 64, 67]));
}
#[test]
fn chord_e4_dom7s9() {
expect_stack("e4 dom7s9", &ints(&[64, 68, 71, 74, 79]));
}
#[test]
fn chord_with_integer_root() {
let f = run("60 maj");
let stack = f.stack();
assert_eq!(stack, ints(&[60, 64, 67]));
}

View File

@@ -1,4 +1,5 @@
use super::harness::*;
use cagire::forth::Value;
#[test]
fn define_and_use_word() {
@@ -113,3 +114,44 @@ fn define_word_with_conditional() {
f.evaluate("10 maybe-double", &ctx).unwrap();
assert_eq!(stack_int(&f), 20);
}
#[test]
fn forget_removes_word() {
let f = forth();
let ctx = default_ctx();
f.evaluate(": double 2 * ;", &ctx).unwrap();
f.evaluate("5 double", &ctx).unwrap();
assert_eq!(stack_int(&f), 10);
f.clear_stack();
f.evaluate("\"double\" forget", &ctx).unwrap();
f.evaluate("double", &ctx).unwrap();
let stack = f.stack();
assert_eq!(stack.len(), 1);
match &stack[0] {
Value::Str(s, _) => assert_eq!(s, "double"),
other => panic!("expected Str, got {:?}", other),
}
}
#[test]
fn forget_nonexistent_is_noop() {
let f = forth();
let ctx = default_ctx();
f.evaluate("\"nosuchword\" forget", &ctx).unwrap();
f.evaluate("42", &ctx).unwrap();
assert_eq!(stack_int(&f), 42);
}
#[test]
fn forget_and_redefine() {
let f = forth();
let ctx = default_ctx();
f.evaluate(": foo 10 ;", &ctx).unwrap();
f.evaluate("foo", &ctx).unwrap();
assert_eq!(stack_int(&f), 10);
f.clear_stack();
f.evaluate("\"foo\" forget", &ctx).unwrap();
f.evaluate(": foo 20 ;", &ctx).unwrap();
f.evaluate("foo", &ctx).unwrap();
assert_eq!(stack_int(&f), 20);
}

View File

@@ -74,22 +74,6 @@ fn dupn_alias() {
expect_int("5 3 ! + +", 15);
}
#[test]
fn tcycle_creates_cycle_list() {
let outputs = expect_outputs(r#"0.0 at 60 64 67 3 tcycle note sine s ."#, 1);
assert!(outputs[0].contains("note/60"));
}
#[test]
fn tcycle_with_multiple_emits() {
let f = forth();
let ctx = default_ctx();
let outputs = f.evaluate(r#"0 0.5 2 at 60 64 2 tcycle note sine s ."#, &ctx).unwrap();
assert_eq!(outputs.len(), 2);
assert!(outputs[0].contains("note/60"));
assert!(outputs[1].contains("note/64"));
}
#[test]
fn cycle_zero_count_error() {
expect_error("1 2 3 0 cycle", "cycle count must be > 0");
@@ -99,8 +83,3 @@ fn cycle_zero_count_error() {
fn choose_zero_count_error() {
expect_error("1 2 3 0 choose", "choose count must be > 0");
}
#[test]
fn tcycle_zero_count_error() {
expect_error("1 2 3 0 tcycle", "tcycle count must be > 0");
}

View File

@@ -105,3 +105,74 @@ fn ftom_880() {
fn mtof_ftom_roundtrip() {
expect_float("60 mtof ftom", 60.0);
}
#[test]
fn exprand_in_range() {
let f = forth_seeded(12345);
f.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
let val = stack_float(&f);
assert!(val >= 1.0 && val <= 100.0, "exprand {} not in [1, 100]", val);
}
#[test]
fn exprand_deterministic() {
let f1 = forth_seeded(99);
let f2 = forth_seeded(99);
f1.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
f2.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
assert_eq!(f1.stack(), f2.stack());
}
#[test]
fn exprand_swapped_args() {
let f1 = forth_seeded(42);
let f2 = forth_seeded(42);
f1.evaluate("1.0 100.0 exprand", &default_ctx()).unwrap();
f2.evaluate("100.0 1.0 exprand", &default_ctx()).unwrap();
assert_eq!(f1.stack(), f2.stack());
}
#[test]
fn exprand_requires_positive() {
expect_error("0.0 10.0 exprand", "exprand requires positive values");
expect_error("-1.0 10.0 exprand", "exprand requires positive values");
expect_error("1.0 0.0 exprand", "exprand requires positive values");
}
#[test]
fn logrand_in_range() {
let f = forth_seeded(12345);
f.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
let val = stack_float(&f);
assert!(val >= 1.0 && val <= 100.0, "logrand {} not in [1, 100]", val);
}
#[test]
fn logrand_deterministic() {
let f1 = forth_seeded(99);
let f2 = forth_seeded(99);
f1.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
f2.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
assert_eq!(f1.stack(), f2.stack());
}
#[test]
fn logrand_swapped_args() {
let f1 = forth_seeded(42);
let f2 = forth_seeded(42);
f1.evaluate("1.0 100.0 logrand", &default_ctx()).unwrap();
f2.evaluate("100.0 1.0 logrand", &default_ctx()).unwrap();
assert_eq!(f1.stack(), f2.stack());
}
#[test]
fn logrand_requires_positive() {
expect_error("0.0 10.0 logrand", "logrand requires positive values");
expect_error("-1.0 10.0 logrand", "logrand requires positive values");
expect_error("1.0 0.0 logrand", "logrand requires positive values");
}
#[test]
fn rand_equal_bounds() {
expect_float("5.0 5.0 rand", 5.0);
}

View File

@@ -106,3 +106,35 @@ fn param_only_multiple_params() {
assert!(outputs[0].contains("gain/0.5"));
assert!(!outputs[0].contains("sound/"));
}
#[test]
fn polyphonic_notes() {
let outputs = expect_outputs(r#"60 64 67 note sine s ."#, 3);
assert!(outputs[0].contains("note/60"));
assert!(outputs[1].contains("note/64"));
assert!(outputs[2].contains("note/67"));
}
#[test]
fn polyphonic_sounds() {
let outputs = expect_outputs(r#"440 freq kick hat s ."#, 2);
assert!(outputs[0].contains("sound/kick"));
assert!(outputs[1].contains("sound/hat"));
}
#[test]
fn polyphonic_cycling() {
let outputs = expect_outputs(r#"60 64 67 note 0.5 1.0 gain sine s ."#, 3);
assert!(outputs[0].contains("note/60"));
assert!(outputs[0].contains("gain/0.5"));
assert!(outputs[1].contains("note/64"));
assert!(outputs[1].contains("gain/1"));
assert!(outputs[2].contains("note/67"));
assert!(outputs[2].contains("gain/0.5"));
}
#[test]
fn polyphonic_with_at() {
let outputs = expect_outputs(r#"0 0.5 at 60 64 note sine s ."#, 4);
assert_eq!(outputs.len(), 4);
}

View File

@@ -95,6 +95,46 @@ fn tuck_underflow() {
expect_error("1 tuck", "stack underflow");
}
#[test]
fn dup2() {
expect_stack("1 2 2dup", &[int(1), int(2), int(1), int(2)]);
}
#[test]
fn dup2_underflow() {
expect_error("1 2dup", "stack underflow");
}
#[test]
fn drop2() {
expect_stack("1 2 3 2drop", &[int(1)]);
}
#[test]
fn drop2_underflow() {
expect_error("1 2drop", "stack underflow");
}
#[test]
fn swap2() {
expect_stack("1 2 3 4 2swap", &[int(3), int(4), int(1), int(2)]);
}
#[test]
fn swap2_underflow() {
expect_error("1 2 3 2swap", "stack underflow");
}
#[test]
fn over2() {
expect_stack("1 2 3 4 2over", &[int(1), int(2), int(3), int(4), int(1), int(2)]);
}
#[test]
fn over2_underflow() {
expect_error("1 2 3 2over", "stack underflow");
}
#[test]
fn stack_persists() {
let f = forth();

View File

@@ -42,13 +42,6 @@ fn get_sounds(outputs: &[String]) -> Vec<String> {
.collect()
}
fn get_param(outputs: &[String], param: &str) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get(param).copied().unwrap_or(0.0))
.collect()
}
const EPSILON: f64 = 1e-9;
fn approx_eq(a: f64, b: f64) -> bool {
@@ -156,7 +149,7 @@ fn at_single_delta() {
#[test]
fn at_list_deltas() {
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s ."#, 2);
let outputs = expect_outputs(r#"0 0.5 at "kick" s ."#, 2);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
@@ -165,7 +158,7 @@ fn at_list_deltas() {
#[test]
fn at_three_deltas() {
let outputs = expect_outputs(r#"0 0.33 0.67 3 at "kick" s ."#, 3);
let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" s ."#, 3);
let deltas = get_deltas(&outputs);
let step_dur = 0.125;
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
@@ -175,70 +168,26 @@ fn at_three_deltas() {
#[test]
fn at_persists_across_emits() {
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . "hat" s ."#, 4);
let outputs = expect_outputs(r#"0 0.5 at "kick" s . "hat" s ."#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
}
#[test]
fn tcycle_basic() {
let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note sine s ."#, 3);
let notes = get_param(&outputs, "note");
assert_eq!(notes, vec![60.0, 64.0, 67.0]);
}
#[test]
fn tcycle_wraps() {
let outputs = expect_outputs(r#"0 0.33 0.67 3 at 60 64 2 tcycle note sine s ."#, 3);
let notes = get_param(&outputs, "note");
assert_eq!(notes, vec![60.0, 64.0, 60.0]);
}
#[test]
fn tcycle_with_sound() {
let outputs = expect_outputs(r#"0 0.5 2 at kick hat 2 tcycle s ."#, 2);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "hat"]);
}
#[test]
fn tcycle_multiple_params() {
let outputs = expect_outputs(r#"0 0.5 0.75 3 at 60 64 67 3 tcycle note 0.5 1.0 2 tcycle gain sine s ."#, 3);
let notes = get_param(&outputs, "note");
let gains = get_param(&outputs, "gain");
assert_eq!(notes, vec![60.0, 64.0, 67.0]);
assert_eq!(gains, vec![0.5, 1.0, 0.5]);
}
#[test]
fn at_reset_with_zero() {
let outputs = expect_outputs(r#"0 0.5 2 at "kick" s . 0.0 at "hat" s ."#, 3);
let outputs = expect_outputs(r#"0 0.5 at "kick" s . 0.0 at "hat" s ."#, 3);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
}
#[test]
fn tcycle_records_selected_spans() {
use cagire::forth::ExecutionTrace;
let f = forth();
let mut trace = ExecutionTrace::default();
let script = r#"0 0.5 2 at kick hat 2 tcycle s ."#;
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
// Should have 4 selected spans:
// - 2 for at deltas (0 and 0.5)
// - 2 for tcycle sound values (kick and hat)
assert_eq!(trace.selected_spans.len(), 4, "expected 4 selected spans (2 at + 2 tcycle)");
}
#[test]
fn at_records_selected_spans() {
use cagire::forth::ExecutionTrace;
let f = forth();
let mut trace = ExecutionTrace::default();
let script = r#"0 0.5 0.75 3 at "kick" s ."#;
let script = r#"0 0.5 0.75 at "kick" s ."#;
f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap();
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)

54
website/public/script.js Normal file
View File

@@ -0,0 +1,54 @@
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isLight = stored ? stored === 'light' : !prefersDark;
if (isLight) {
root.classList.add('light');
}
toggle.textContent = isLight ? 'DARK' : 'LIGHT';
toggle.addEventListener('click', () => {
root.classList.toggle('light');
const light = root.classList.contains('light');
toggle.textContent = light ? 'DARK' : 'LIGHT';
localStorage.setItem('theme', light ? 'light' : 'dark');
highlightForth();
});
function highlightForth() {
const words = ['note', 'sound', 'lpf', 'hpf', 'chorus', 'verb', 'distort', 'speed'];
const notes = ['c4'];
const chords = ['min7'];
const samples = ['kkick', 'sine', 'saw'];
const isLight = document.documentElement.classList.contains('light');
const numColor = isLight ? '#a855f7' : '#e8a0e8';
const dotColor = isLight ? '#0284c7' : '#7dd3fc';
const wordColor = isLight ? '#65a30d' : '#a3e635';
const noteColor = isLight ? '#d97706' : '#fbbf24';
const chordColor = isLight ? '#15803d' : '#4ade80';
const sampleColor = isLight ? '#dc2626' : '#f87171';
document.querySelectorAll('pre').forEach(pre => {
const text = pre.dataset.source || pre.textContent;
pre.dataset.source = text;
pre.innerHTML = text
.split(/(\s+)/)
.map(t => {
if (t === '.') return `<span style="color:${dotColor}">.</span>`;
if (/^-?\d+\.?\d*$/.test(t)) return `<span style="color:${numColor}">${t}</span>`;
if (words.includes(t)) return `<span style="color:${wordColor}">${t}</span>`;
if (notes.includes(t)) return `<span style="color:${noteColor}">${t}</span>`;
if (chords.includes(t)) return `<span style="color:${chordColor}">${t}</span>`;
if (samples.includes(t)) return `<span style="color:${sampleColor}">${t}</span>`;
return t;
})
.join('');
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', highlightForth);
} else {
highlightForth();
}

127
website/public/style.css Normal file
View File

@@ -0,0 +1,127 @@
@font-face {
font-family: 'CozetteVector';
src: url('/CozetteVector.ttf') format('truetype');
}
:root {
--bg: #000;
--surface: #121212;
--text: #fff;
--text-dim: #b4b4b4;
--text-muted: #787878;
}
:root.light {
--bg: #fff;
--surface: #f0f0f0;
--text: #000;
--text-dim: #505050;
--text-muted: #8c8c8c;
}
body {
font-family: 'CozetteVector', monospace;
background: var(--bg);
color: var(--text);
max-width: 800px;
margin: 0 auto;
padding: 1rem;
line-height: 1.3;
font-size: 0.9rem;
}
h1 {
color: var(--text);
margin: 0;
}
h2 {
color: var(--text);
margin-top: 1rem;
margin-bottom: 0.25rem;
font-size: 1rem;
}
p { margin: 0.25rem 0; }
video {
max-width: 100%;
margin: 0.5rem 0;
display: block;
}
a { color: var(--text-dim); }
ul {
padding-left: 1.5rem;
margin: 0.25rem 0;
}
li { margin: 0.1rem 0; }
pre {
background: var(--surface);
padding: 0.5rem;
overflow-x: auto;
margin: 0.25rem 0;
}
.downloads-table {
border-collapse: collapse;
margin: 0.5rem 0;
width: 100%;
}
.downloads-table th,
.downloads-table td {
padding: 0.25rem 0.75rem;
text-align: left;
}
.downloads-table th {
color: var(--text);
}
.downloads-table td:first-child {
color: var(--text-muted);
}
.downloads-table tr:nth-child(even) {
background: var(--surface);
}
.note {
color: var(--text-muted);
font-size: 0.8rem;
font-style: italic;
}
.note a {
color: var(--text-muted);
}
.support {
background: var(--surface);
padding: 0.5rem;
margin: 0.5rem 0;
color: var(--text-dim);
}
.support a {
color: var(--text);
}
#theme-toggle {
font-family: 'CozetteVector', monospace;
background: none;
color: var(--text-muted);
border: none;
padding: 0;
cursor: pointer;
font-size: inherit;
text-decoration: underline;
}
#theme-toggle:hover {
color: var(--text);
}

View File

@@ -18,137 +18,12 @@
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
<meta name="twitter:description" content="Forth-based live coding music sequencer">
<style>
@font-face {
font-family: 'CozetteVector';
src: url('/CozetteVector.ttf') format('truetype');
}
:root {
--bg: #000;
--surface: #121212;
--text: #fff;
--text-dim: #b4b4b4;
--text-muted: #787878;
}
:root.light {
--bg: #fff;
--surface: #f0f0f0;
--text: #000;
--text-dim: #505050;
--text-muted: #8c8c8c;
}
body {
font-family: 'CozetteVector', monospace;
background: var(--bg);
color: var(--text);
max-width: 800px;
margin: 0 auto;
padding: 1rem;
line-height: 1.3;
font-size: 0.9rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
h1 {
color: var(--text);
margin: 0;
}
h2 {
color: var(--text);
margin-top: 1rem;
margin-bottom: 0.25rem;
font-size: 1rem;
}
p { margin: 0.25rem 0; }
video {
max-width: 100%;
margin: 0.5rem 0;
display: block;
}
a { color: var(--text-dim); }
ul {
padding-left: 1.5rem;
margin: 0.25rem 0;
}
li { margin: 0.1rem 0; }
pre {
background: var(--surface);
padding: 0.5rem;
overflow-x: auto;
margin: 0.25rem 0;
}
.downloads-table {
border-collapse: collapse;
margin: 0.5rem 0;
width: 100%;
}
.downloads-table th,
.downloads-table td {
padding: 0.25rem 0.75rem;
text-align: left;
}
.downloads-table th {
color: var(--text);
}
.downloads-table td:first-child {
color: var(--text-muted);
}
.downloads-table tr:nth-child(even) {
background: var(--surface);
}
.note {
color: var(--text-muted);
font-size: 0.8rem;
font-style: italic;
}
.note a {
color: var(--text-muted);
}
#theme-toggle {
font-family: 'CozetteVector', monospace;
background: var(--surface);
color: var(--text);
border: 1px solid var(--text-muted);
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
}
#theme-toggle:hover {
background: var(--text-muted);
color: var(--bg);
}
</style>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="header">
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
<button id="theme-toggle" aria-label="Toggle theme">LIGHT</button>
</div>
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
<p class="support">Cagire is free and open source. If you find it useful, consider <a href="https://ko-fi.com/raphaelbubo">supporting the project on Ko-fi</a>.</p>
<table class="downloads-table">
<tr>
@@ -177,31 +52,43 @@
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64.tar.gz">.tar.gz</a></td>
</tr>
</table>
<p class="note">All releases on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub</a>. You can also compile the software yourself or get it from Cargo!</p>
<p class="note">All releases are available on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub</a>. You can also compile the software yourself by getting it from Cargo!</p>
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
<h2>About</h2>
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
<p>Cagire is a step sequencer where each step contains a Forth script instead of typical note data. When the sequencer reaches a step, it runs the associated script. Scripts can produce sound, trigger samples, apply effects, or do nothing at all. You are free to define what your scripts will do. Cagire includes a built-in audio engine called <a href="https://doux.livecoding.fr">Doux</a>. No external software is needed to make sound. It comes with oscillators, sample players, filters, reverb, delay, distortion, and more.</p>
<h2>Code Examples</h2>
<br>
<p>A minimal script that plays a middle C note using a sine wave:</p>
<pre>c4 note sine sound .</pre>
<br>
<p>And now let's make it polyphonic and add different parameters per voice:</p>
<pre>c4 min7 note
sine sound
0.1 chorus
500 1000 1500 lpf
.</pre>
<br>
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
<br>
<p>Pitched-down kick drum sample with distortion:</p>
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
<br>
<h2>Features</h2>
<ul>
<li>32 banks × 32 patterns × 128 steps per project</li>
<li>Ableton Link synchronization</li>
<li>Built-in synthesis engine (oscillators, samples, wavetables)</li>
<li>Effects: filters, reverb, delay, distortion, chorus</li>
<li>User-defined words and shared variables</li>
<li>Embedded dictionary and documentation</li>
<li>Robust synthesis engine: synthesizers, sampling, effects, live input, and more to discover.</li>
<li>Ableton Link: jam with your friends or include other software / hardware to your setup.</li>
<li>32 banks × 32 patterns × 128 steps per project: (~131.000 scripts per project).</li>
<li>Forth: objectively the coolest / minimal / hackable language to make music with!</li>
<li>Embedded dictionary and documentation!</li>
</ul>
<h2>Live Coding</h2>
@@ -209,10 +96,8 @@
<h2>Credits</h2>
<ul>
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Forment)</li>
<li><a href="https://doux.livecoding.fr">Doux</a> audio engine, Rust port of Dough by <a href="https://eddyflux.cc/">Felix Roos</a></li>
<li><a href="https://github.com/sourcebox/mi-plaits-dsp-rs">mi-plaits-dsp-rs</a> by Oliver Rockstedt, based on <a href="https://mutable-instruments.net/">Mutable Instruments</a> Plaits by Emilie Gillet</li>
<li>Related: <a href="https://strudel.cc">Strudel</a>, <a href="https://tidalcycles.org">TidalCycles</a>, <a href="https://sova.livecoding.fr">Sova</a></li>
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Maurice Forment).</li>
<li>See <a href="https://doux.livecoding.fr">Doux</a> for engine credits.</li>
</ul>
<h2>Links</h2>
@@ -221,23 +106,8 @@
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
</ul>
<p style="margin-top: 2rem; color: var(--text-muted);">AGPL-3.0 License</p>
<p style="margin-top: 2rem; color: var(--text-muted);">AGPL-3.0 License · <button id="theme-toggle" aria-label="Toggle theme">LIGHT</button></p>
<script>
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;
if (localStorage.getItem('theme') === 'light') {
root.classList.add('light');
toggle.textContent = 'DARK';
}
toggle.addEventListener('click', () => {
root.classList.toggle('light');
const isLight = root.classList.contains('light');
toggle.textContent = isLight ? 'DARK' : 'LIGHT';
localStorage.setItem('theme', isLight ? 'light' : 'dark');
});
</script>
<script is:inline src="/script.js"></script>
</body>
</html>