Compare commits
8 Commits
v0.0.2
...
60d1d7ca74
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d1d7ca74 | |||
| 9864cc6d61 | |||
| 985ab687d7 | |||
| 9b925d881e | |||
| 71146c7cea | |||
| 6b95f31afd | |||
| adee8d0d57 | |||
| f9c284effd |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [0.0.2] - 2026-02-01
|
||||||
- CI testing and codebase cleanup
|
- CI testing and codebase cleanup
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.2"
|
version = "0.0.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
<img src="cagire_pixel.png" alt="Cagire" width="256">
|
||||||
</p>
|
</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
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ pub enum Op {
|
|||||||
Set,
|
Set,
|
||||||
GetContext(String),
|
GetContext(String),
|
||||||
Rand,
|
Rand,
|
||||||
|
ExpRand,
|
||||||
|
LogRand,
|
||||||
Seed,
|
Seed,
|
||||||
Cycle,
|
Cycle,
|
||||||
PCycle,
|
PCycle,
|
||||||
TCycle,
|
|
||||||
Choose,
|
Choose,
|
||||||
ChanceExec,
|
ChanceExec,
|
||||||
ProbExec,
|
ProbExec,
|
||||||
@@ -86,6 +87,7 @@ pub enum Op {
|
|||||||
Generate,
|
Generate,
|
||||||
GeomRange,
|
GeomRange,
|
||||||
Times,
|
Times,
|
||||||
|
Chord(&'static [i64]),
|
||||||
// MIDI
|
// MIDI
|
||||||
MidiEmit,
|
MidiEmit,
|
||||||
GetMidiCC,
|
GetMidiCC,
|
||||||
|
|||||||
129
crates/forth/src/theory/chords.rs
Normal file
129
crates/forth/src/theory/chords.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod chords;
|
||||||
mod scales;
|
mod scales;
|
||||||
|
|
||||||
pub use scales::lookup;
|
pub use scales::lookup;
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ impl CmdRegister {
|
|||||||
&self.deltas
|
&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<'_>> {
|
pub(super) fn snapshot(&self) -> Option<CmdSnapshot<'_>> {
|
||||||
if self.sound.is_some() || !self.params.is_empty() {
|
if self.sound.is_some() || !self.params.is_empty() {
|
||||||
Some((self.sound.as_ref(), self.params.as_slice()))
|
Some((self.sound.as_ref(), self.params.as_slice()))
|
||||||
|
|||||||
@@ -152,6 +152,23 @@ impl Forth {
|
|||||||
select_and_run(selected, stack, outputs, cmd)
|
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,
|
let emit_with_cycling = |cmd: &CmdRegister,
|
||||||
emit_idx: usize,
|
emit_idx: usize,
|
||||||
delta_secs: f64,
|
delta_secs: f64,
|
||||||
@@ -363,38 +380,56 @@ impl Forth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Op::NewCmd => {
|
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);
|
cmd.set_sound(val);
|
||||||
}
|
}
|
||||||
Op::SetParam(param) => {
|
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);
|
cmd.set_param(param.clone(), val);
|
||||||
}
|
}
|
||||||
|
|
||||||
Op::Emit => {
|
Op::Emit => {
|
||||||
|
let poly_count = compute_poly_count(cmd);
|
||||||
let deltas = if cmd.deltas().is_empty() {
|
let deltas = if cmd.deltas().is_empty() {
|
||||||
vec![Value::Float(0.0, None)]
|
vec![Value::Float(0.0, None)]
|
||||||
} else {
|
} else {
|
||||||
cmd.deltas().to_vec()
|
cmd.deltas().to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
for (emit_idx, delta_val) in deltas.iter().enumerate() {
|
for poly_idx in 0..poly_count {
|
||||||
let delta_frac = delta_val.as_float()?;
|
for delta_val in deltas.iter() {
|
||||||
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
|
let delta_frac = delta_val.as_float()?;
|
||||||
// Record delta span for highlighting
|
let delta_secs = ctx.nudge_secs + delta_frac * ctx.step_duration();
|
||||||
if let Some(span) = delta_val.span() {
|
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() {
|
|
||||||
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
if let Some(trace) = trace_cell.borrow_mut().as_mut() {
|
||||||
trace.selected_spans.push(span);
|
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 +490,37 @@ impl Forth {
|
|||||||
let a_f = a.as_float()?;
|
let a_f = a.as_float()?;
|
||||||
let b_f = b.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 (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));
|
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 => {
|
Op::Seed => {
|
||||||
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
|
let s = stack.pop().ok_or("stack underflow")?.as_int()?;
|
||||||
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
*self.rng.lock().unwrap() = StdRng::seed_from_u64(s as u64);
|
||||||
@@ -477,19 +538,6 @@ impl Forth {
|
|||||||
drain_select_run(count, idx, stack, outputs, cmd)?;
|
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 => {
|
Op::Choose => {
|
||||||
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
let count = stack.pop().ok_or("stack underflow")?.as_int()? as usize;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -607,6 +655,13 @@ impl Forth {
|
|||||||
stack.push(result);
|
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 => {
|
Op::Oct => {
|
||||||
let shift = stack.pop().ok_or("stack underflow")?;
|
let shift = stack.pop().ok_or("stack underflow")?;
|
||||||
let note = stack.pop().ok_or("stack underflow")?;
|
let note = stack.pop().ok_or("stack underflow")?;
|
||||||
@@ -659,27 +714,10 @@ impl Forth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Op::At => {
|
Op::At => {
|
||||||
let top = stack.pop().ok_or("stack underflow")?;
|
if stack.is_empty() {
|
||||||
let deltas = match &top {
|
return Err("stack underflow".into());
|
||||||
Value::Float(..) => vec![top],
|
}
|
||||||
Value::Int(n, _) => {
|
let deltas = std::mem::take(stack);
|
||||||
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()),
|
|
||||||
};
|
|
||||||
cmd.set_deltas(deltas);
|
cmd.set_deltas(deltas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,26 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
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 {
|
Word {
|
||||||
name: "seed",
|
name: "seed",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -543,16 +563,6 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: true,
|
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 {
|
Word {
|
||||||
name: "every",
|
name: "every",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -810,6 +820,292 @@ pub const WORDS: &[Word] = &[
|
|||||||
compile: Simple,
|
compile: Simple,
|
||||||
varargs: false,
|
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
|
// LFO
|
||||||
Word {
|
Word {
|
||||||
name: "ramp",
|
name: "ramp",
|
||||||
@@ -925,9 +1221,9 @@ pub const WORDS: &[Word] = &[
|
|||||||
name: "at",
|
name: "at",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
category: "Time",
|
category: "Time",
|
||||||
stack: "(v1..vn n --)",
|
stack: "(v1..vn --)",
|
||||||
desc: "Set delta context for emit timing",
|
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,
|
compile: Simple,
|
||||||
varargs: true,
|
varargs: true,
|
||||||
},
|
},
|
||||||
@@ -2508,10 +2804,11 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
|
|||||||
"sound" => Op::NewCmd,
|
"sound" => Op::NewCmd,
|
||||||
"." => Op::Emit,
|
"." => Op::Emit,
|
||||||
"rand" => Op::Rand,
|
"rand" => Op::Rand,
|
||||||
|
"exprand" => Op::ExpRand,
|
||||||
|
"logrand" => Op::LogRand,
|
||||||
"seed" => Op::Seed,
|
"seed" => Op::Seed,
|
||||||
"cycle" => Op::Cycle,
|
"cycle" => Op::Cycle,
|
||||||
"pcycle" => Op::PCycle,
|
"pcycle" => Op::PCycle,
|
||||||
"tcycle" => Op::TCycle,
|
|
||||||
"choose" => Op::Choose,
|
"choose" => Op::Choose,
|
||||||
"every" => Op::Every,
|
"every" => Op::Every,
|
||||||
"chance" => Op::ChanceExec,
|
"chance" => Op::ChanceExec,
|
||||||
@@ -2654,6 +2951,11 @@ pub(super) fn compile_word(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(intervals) = theory::chords::lookup(name) {
|
||||||
|
ops.push(Op::Chord(intervals));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(word) = lookup_word(name) {
|
if let Some(word) = lookup_word(name) {
|
||||||
match &word.compile {
|
match &word.compile {
|
||||||
Simple => {
|
Simple => {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ struct ProjectFile {
|
|||||||
sample_paths: Vec<PathBuf>,
|
sample_paths: Vec<PathBuf>,
|
||||||
#[serde(default = "default_tempo")]
|
#[serde(default = "default_tempo")]
|
||||||
tempo: f64,
|
tempo: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
playing_patterns: Vec<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tempo() -> f64 {
|
fn default_tempo() -> f64 {
|
||||||
@@ -38,6 +40,7 @@ impl From<&Project> for ProjectFile {
|
|||||||
banks: project.banks.clone(),
|
banks: project.banks.clone(),
|
||||||
sample_paths: project.sample_paths.clone(),
|
sample_paths: project.sample_paths.clone(),
|
||||||
tempo: project.tempo,
|
tempo: project.tempo,
|
||||||
|
playing_patterns: project.playing_patterns.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +51,7 @@ impl From<ProjectFile> for Project {
|
|||||||
banks: file.banks,
|
banks: file.banks,
|
||||||
sample_paths: file.sample_paths,
|
sample_paths: file.sample_paths,
|
||||||
tempo: file.tempo,
|
tempo: file.tempo,
|
||||||
|
playing_patterns: file.playing_patterns,
|
||||||
};
|
};
|
||||||
project.normalize();
|
project.normalize();
|
||||||
project
|
project
|
||||||
|
|||||||
@@ -450,6 +450,8 @@ pub struct Project {
|
|||||||
pub sample_paths: Vec<PathBuf>,
|
pub sample_paths: Vec<PathBuf>,
|
||||||
#[serde(default = "default_tempo")]
|
#[serde(default = "default_tempo")]
|
||||||
pub tempo: f64,
|
pub tempo: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub playing_patterns: Vec<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_tempo() -> f64 {
|
fn default_tempo() -> f64 {
|
||||||
@@ -462,6 +464,7 @@ impl Default for Project {
|
|||||||
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
|
||||||
sample_paths: Vec::new(),
|
sample_paths: Vec::new(),
|
||||||
tempo: default_tempo(),
|
tempo: default_tempo(),
|
||||||
|
playing_patterns: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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)
|
- `cycle` - selects based on `runs` (how many times this step has played)
|
||||||
- `pcycle` - selects based on `iter` (how many times the pattern has looped)
|
- `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.
|
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
|
```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
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ allow-branch = ["main"]
|
|||||||
sign-commit = false
|
sign-commit = false
|
||||||
sign-tag = false
|
sign-tag = false
|
||||||
push = true
|
push = true
|
||||||
|
push-remote = "github"
|
||||||
publish = false
|
publish = false
|
||||||
tag-name = "v{{version}}"
|
tag-name = "v{{version}}"
|
||||||
|
|||||||
24
src/app.rs
24
src/app.rs
@@ -527,10 +527,15 @@ impl App {
|
|||||||
self.load_step_to_editor();
|
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.save_editor_to_step();
|
||||||
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
|
||||||
self.project_state.project.tempo = link.tempo();
|
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) {
|
match model::save(&self.project_state.project, &path) {
|
||||||
Ok(final_path) => {
|
Ok(final_path) => {
|
||||||
self.ui
|
self.ui
|
||||||
@@ -547,12 +552,27 @@ impl App {
|
|||||||
match model::load(&path) {
|
match model::load(&path) {
|
||||||
Ok(project) => {
|
Ok(project) => {
|
||||||
let tempo = project.tempo;
|
let tempo = project.tempo;
|
||||||
|
let playing = project.playing_patterns.clone();
|
||||||
|
|
||||||
self.project_state.project = project;
|
self.project_state.project = project;
|
||||||
self.editor_ctx.step = 0;
|
self.editor_ctx.step = 0;
|
||||||
self.load_step_to_editor();
|
self.load_step_to_editor();
|
||||||
self.compile_all_steps(link);
|
self.compile_all_steps(link);
|
||||||
self.mark_all_patterns_dirty();
|
self.mark_all_patterns_dirty();
|
||||||
link.set_tempo(tempo);
|
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.ui.set_status(format!("Loaded: {}", path.display()));
|
||||||
self.project_state.file_path = Some(path);
|
self.project_state.file_path = Some(path);
|
||||||
}
|
}
|
||||||
@@ -1103,7 +1123,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.project_state.mark_dirty(bank, pattern);
|
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),
|
AppCommand::Load(path) => self.load(path, link),
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
|
|||||||
@@ -450,6 +450,10 @@ impl RunsCounter {
|
|||||||
*count += 1;
|
*count += 1;
|
||||||
current
|
current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_pattern(&mut self, bank: usize, pattern: usize) {
|
||||||
|
self.counts.retain(|&(b, p, _), _| b != bank || p != pattern);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct TickInput {
|
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(
|
self.audio_state.active_patterns.insert(
|
||||||
pending.id,
|
pending.id,
|
||||||
ActivePattern {
|
ActivePattern {
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
match mode {
|
match mode {
|
||||||
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
|
FileBrowserMode::Save => ctx.dispatch(AppCommand::Save(path)),
|
||||||
FileBrowserMode::Load => {
|
FileBrowserMode::Load => {
|
||||||
|
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
||||||
ctx.dispatch(AppCommand::Load(path));
|
ctx.dispatch(AppCommand::Load(path));
|
||||||
load_project_samples(ctx);
|
load_project_samples(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,9 @@ impl PlaybackState {
|
|||||||
pub fn toggle(&mut self) {
|
pub fn toggle(&mut self) {
|
||||||
self.playing = !self.playing;
|
self.playing = !self.playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear_queues(&mut self) {
|
||||||
|
self.staged_changes.clear();
|
||||||
|
self.queued_changes.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,6 @@ mod generator;
|
|||||||
|
|
||||||
#[path = "forth/midi.rs"]
|
#[path = "forth/midi.rs"]
|
||||||
mod midi;
|
mod midi;
|
||||||
|
|
||||||
|
#[path = "forth/chords.rs"]
|
||||||
|
mod chords;
|
||||||
|
|||||||
178
tests/forth/chords.rs
Normal file
178
tests/forth/chords.rs
Normal 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]));
|
||||||
|
}
|
||||||
@@ -74,22 +74,6 @@ fn dupn_alias() {
|
|||||||
expect_int("5 3 ! + +", 15);
|
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]
|
#[test]
|
||||||
fn cycle_zero_count_error() {
|
fn cycle_zero_count_error() {
|
||||||
expect_error("1 2 3 0 cycle", "cycle count must be > 0");
|
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() {
|
fn choose_zero_count_error() {
|
||||||
expect_error("1 2 3 0 choose", "choose count must be > 0");
|
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,3 +105,74 @@ fn ftom_880() {
|
|||||||
fn mtof_ftom_roundtrip() {
|
fn mtof_ftom_roundtrip() {
|
||||||
expect_float("60 mtof ftom", 60.0);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,3 +106,35 @@ fn param_only_multiple_params() {
|
|||||||
assert!(outputs[0].contains("gain/0.5"));
|
assert!(outputs[0].contains("gain/0.5"));
|
||||||
assert!(!outputs[0].contains("sound/"));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,13 +42,6 @@ fn get_sounds(outputs: &[String]) -> Vec<String> {
|
|||||||
.collect()
|
.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;
|
const EPSILON: f64 = 1e-9;
|
||||||
|
|
||||||
fn approx_eq(a: f64, b: f64) -> bool {
|
fn approx_eq(a: f64, b: f64) -> bool {
|
||||||
@@ -156,7 +149,7 @@ fn at_single_delta() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_list_deltas() {
|
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 deltas = get_deltas(&outputs);
|
||||||
let step_dur = 0.125;
|
let step_dur = 0.125;
|
||||||
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
|
assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]);
|
||||||
@@ -165,7 +158,7 @@ fn at_list_deltas() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_three_deltas() {
|
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 deltas = get_deltas(&outputs);
|
||||||
let step_dur = 0.125;
|
let step_dur = 0.125;
|
||||||
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
|
assert!(approx_eq(deltas[0], 0.0), "expected delta 0");
|
||||||
@@ -175,70 +168,26 @@ fn at_three_deltas() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn at_persists_across_emits() {
|
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);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]);
|
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]
|
#[test]
|
||||||
fn at_reset_with_zero() {
|
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);
|
let sounds = get_sounds(&outputs);
|
||||||
assert_eq!(sounds, vec!["kick", "kick", "hat"]);
|
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]
|
#[test]
|
||||||
fn at_records_selected_spans() {
|
fn at_records_selected_spans() {
|
||||||
use cagire::forth::ExecutionTrace;
|
use cagire::forth::ExecutionTrace;
|
||||||
|
|
||||||
let f = forth();
|
let f = forth();
|
||||||
let mut trace = ExecutionTrace::default();
|
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();
|
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)
|
// Should have 6 selected spans: 3 for at deltas + 3 for sound (one per emit)
|
||||||
|
|||||||
54
website/public/script.js
Normal file
54
website/public/script.js
Normal 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
127
website/public/style.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -18,137 +18,12 @@
|
|||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
|
<meta name="twitter:title" content="Cagire - Forth-based live coding sequencer">
|
||||||
<meta name="twitter:description" content="Forth-based live coding music sequencer">
|
<meta name="twitter:description" content="Forth-based live coding music sequencer">
|
||||||
<style>
|
<link rel="stylesheet" href="/style.css">
|
||||||
@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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
|
||||||
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
|
|
||||||
<button id="theme-toggle" aria-label="Toggle theme">LIGHT</button>
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="downloads-table">
|
<table class="downloads-table">
|
||||||
<tr>
|
<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>
|
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64.tar.gz">.tar.gz</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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>
|
<video src="/mono_cagire.mp4" autoplay muted loop playsinline></video>
|
||||||
|
|
||||||
<h2>About</h2>
|
<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>
|
<h2>Code Examples</h2>
|
||||||
|
<br>
|
||||||
<p>A minimal script that plays a middle C note using a sine wave:</p>
|
<p>A minimal script that plays a middle C note using a sine wave:</p>
|
||||||
<pre>c4 note sine sound .</pre>
|
<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>
|
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
|
||||||
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
|
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
<p>Pitched-down kick drum sample with distortion:</p>
|
<p>Pitched-down kick drum sample with distortion:</p>
|
||||||
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
|
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
|
||||||
|
<br>
|
||||||
|
|
||||||
<h2>Features</h2>
|
<h2>Features</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>32 banks × 32 patterns × 128 steps per project</li>
|
<li>Robust synthesis engine: synthesizers, sampling, effects, live input, and more to discover.</li>
|
||||||
<li>Ableton Link synchronization</li>
|
<li>Ableton Link: jam with your friends or include other software / hardware to your setup.</li>
|
||||||
<li>Built-in synthesis engine (oscillators, samples, wavetables)</li>
|
<li>32 banks × 32 patterns × 128 steps per project: (~131.000 scripts per project).</li>
|
||||||
<li>Effects: filters, reverb, delay, distortion, chorus</li>
|
<li>Forth: objectively the coolest / minimal / hackable language to make music with!</li>
|
||||||
<li>User-defined words and shared variables</li>
|
<li>Embedded dictionary and documentation!</li>
|
||||||
<li>Embedded dictionary and documentation</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Live Coding</h2>
|
<h2>Live Coding</h2>
|
||||||
@@ -209,10 +96,8 @@
|
|||||||
|
|
||||||
<h2>Credits</h2>
|
<h2>Credits</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Forment)</li>
|
<li><a href="https://raphaelforment.fr">BuboBubo</a> (Raphaël Maurice 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>See <a href="https://doux.livecoding.fr">Doux</a> for engine credits.</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>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Links</h2>
|
<h2>Links</h2>
|
||||||
@@ -221,23 +106,8 @@
|
|||||||
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
|
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
|
||||||
</ul>
|
</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>
|
<script is:inline src="/script.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user