9 Commits

Author SHA1 Message Date
2100b82dad More robust midi implementation
Some checks failed
Deploy Website / deploy (push) Failing after 4m58s
2026-01-31 23:58:57 +01:00
15a4300db5 better quality midi 2026-01-31 23:23:36 +01:00
fed39c01e8 Lots + MIDI implementation 2026-01-31 23:13:51 +01:00
0a4f1419eb Fix: continue to fix release build and CI 2026-01-31 19:58:21 +01:00
793c83e18c Fix: again CI breaks 2026-01-31 18:04:11 +01:00
20bc0ffcb4 Fixing builds and workflows 2026-01-31 17:52:44 +01:00
8e09fd106e Remove emit_n tests (feature not implemented) 2026-01-31 17:37:00 +01:00
73ca0ff096 Add Windows/Linux desktop bundles to CI 2026-01-31 17:24:41 +01:00
425f1c8627 CI build versions 2026-01-31 16:35:38 +01:00
46 changed files with 4608 additions and 461 deletions

View File

@@ -45,7 +45,6 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
@@ -58,10 +57,13 @@ jobs:
sudo apt-get update
sudo apt-get install -y build-essential cmake pkg-config libasound2-dev libclang-dev libjack-dev \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev libgl1-mesa-dev
cargo install cargo-bundle
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: brew list cmake &>/dev/null || brew install cmake
run: |
brew list cmake &>/dev/null || brew install cmake
cargo install cargo-bundle
- name: Install dependencies (Windows)
if: runner.os == 'Windows'
@@ -75,11 +77,15 @@ jobs:
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Run tests
run: cargo test --target ${{ matrix.target }}
- name: Bundle desktop app
if: runner.os != 'Windows'
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
- name: Run clippy
run: cargo clippy --target ${{ matrix.target }} -- -D warnings
- name: Zip macOS app bundle
if: runner.os == 'macOS'
run: |
cd target/${{ matrix.target }}/release/bundle/osx
zip -r Cagire.app.zip Cagire.app
- name: Upload artifact (Unix)
if: runner.os != 'Windows'
@@ -95,14 +101,21 @@ jobs:
name: ${{ matrix.artifact }}
path: target/${{ matrix.target }}/release/cagire.exe
- name: Upload desktop artifact (Unix)
if: runner.os != 'Windows'
- name: Upload desktop artifact (Linux deb)
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/cagire-desktop
path: target/${{ matrix.target }}/release/bundle/deb/*.deb
- name: Upload desktop artifact (Windows)
- name: Upload desktop artifact (macOS app bundle)
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}-desktop
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
- name: Upload desktop artifact (Windows exe)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
@@ -130,10 +143,12 @@ jobs:
name=$(basename "$dir")
if [[ "$name" == *-desktop ]]; then
base="${name%-desktop}"
if [ -f "$dir/cagire-desktop.exe" ]; then
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
cp "$dir"/*.deb "release/${base}-desktop.deb"
elif [ -f "$dir/Cagire.app.zip" ]; then
cp "$dir/Cagire.app.zip" "release/${base}-desktop.app.zip"
elif [ -f "$dir/cagire-desktop.exe" ]; then
cp "$dir/cagire-desktop.exe" "release/${base}-desktop.exe"
elif [ -f "$dir/cagire-desktop" ]; then
cp "$dir/cagire-desktop" "release/${base}-desktop"
fi
else
if [ -f "$dir/cagire.exe" ]; then

View File

@@ -25,13 +25,33 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: website/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
working-directory: website
- name: Build
run: pnpm build
working-directory: website
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: website
path: website/dist
- name: Deploy to GitHub Pages
id: deployment

View File

@@ -51,6 +51,7 @@ rustfft = "6"
thread-priority = "1"
ringbuf = "0.4"
arc-swap = "1"
midir = "0.10"
# Desktop-only dependencies (behind feature flag)
egui = { version = "0.33", optional = true }
@@ -65,3 +66,12 @@ lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[package.metadata.bundle.bin.cagire-desktop]
name = "Cagire"
identifier = "com.sova.cagire"
icon = ["assets/Cagire.icns", "assets/Cagire.ico"]
version = "0.1.0"
copyright = "Copyright (c) 2025 Cagire Contributors"
category = "Music"
short_description = "Forth-based music sequencer with Ableton Link"

BIN
assets/Cagire.icns Normal file

Binary file not shown.

BIN
assets/Cagire.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -5,6 +5,6 @@ mod types;
mod vm;
mod words;
pub use types::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
pub use types::{CcMemory, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
pub use vm::Forth;
pub use words::{Word, WordCompile, WORDS};

View File

@@ -86,4 +86,11 @@ pub enum Op {
Generate,
GeomRange,
Times,
// MIDI
MidiEmit,
GetMidiCC,
MidiClock,
MidiStart,
MidiStop,
MidiContinue,
}

View File

@@ -4,6 +4,8 @@ use std::sync::{Arc, Mutex};
use super::ops::Op;
pub const MAX_MIDI_DEVICES: usize = 4;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SourceSpan {
pub start: usize,
@@ -29,6 +31,7 @@ pub struct StepContext {
pub speed: f64,
pub fill: bool,
pub nudge_secs: f64,
pub cc_memory: Option<CcMemory>,
}
impl StepContext {
@@ -41,6 +44,7 @@ pub type Variables = Arc<Mutex<HashMap<String, Value>>>;
pub type Dictionary = Arc<Mutex<HashMap<String, Vec<Op>>>>;
pub type Rng = Arc<Mutex<StdRng>>;
pub type Stack = Arc<Mutex<Vec<Value>>>;
pub type CcMemory = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>;
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]);
#[derive(Clone, Debug)]
@@ -124,6 +128,7 @@ pub(super) struct CmdRegister {
deltas: Vec<Value>,
}
impl CmdRegister {
pub(super) fn set_sound(&mut self, val: Value) {
self.sound = Some(val);

View File

@@ -790,6 +790,118 @@ impl Forth {
val *= ratio;
}
}
// MIDI operations
Op::MidiEmit => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let get_int = |name: &str| -> Option<i64> {
params
.iter()
.rev()
.find(|(k, _)| k == name)
.and_then(|(_, v)| v.as_int().ok())
};
let get_float = |name: &str| -> Option<f64> {
params
.iter()
.rev()
.find(|(k, _)| k == name)
.and_then(|(_, v)| v.as_float().ok())
};
let chan = get_int("chan")
.map(|c| (c.clamp(1, 16) - 1) as u8)
.unwrap_or(0);
let dev = get_int("dev")
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
if let (Some(cc), Some(val)) = (get_int("ccnum"), get_int("ccout")) {
let cc = cc.clamp(0, 127) as u8;
let val = val.clamp(0, 127) as u8;
outputs.push(format!("/midi/cc/{cc}/{val}/chan/{chan}/dev/{dev}"));
} else if let Some(bend) = get_float("bend") {
let bend_clamped = bend.clamp(-1.0, 1.0);
let bend_14bit = ((bend_clamped + 1.0) * 8191.5) as u16;
outputs.push(format!("/midi/bend/{bend_14bit}/chan/{chan}/dev/{dev}"));
} else if let Some(pressure) = get_int("pressure") {
let pressure = pressure.clamp(0, 127) as u8;
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}/dev/{dev}"));
} else if let Some(program) = get_int("program") {
let program = program.clamp(0, 127) as u8;
outputs.push(format!("/midi/program/{program}/chan/{chan}/dev/{dev}"));
} else {
let note = get_int("note").unwrap_or(60).clamp(0, 127) as u8;
let velocity = get_int("velocity").unwrap_or(100).clamp(0, 127) as u8;
let dur = get_float("dur").unwrap_or(1.0);
let dur_secs = dur * ctx.step_duration();
outputs.push(format!("/midi/note/{note}/vel/{velocity}/chan/{chan}/dur/{dur_secs}/dev/{dev}"));
}
}
Op::MidiClock => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/clock/dev/{dev}"));
}
Op::MidiStart => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/start/dev/{dev}"));
}
Op::MidiStop => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/stop/dev/{dev}"));
}
Op::MidiContinue => {
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as u8)
.unwrap_or(0);
outputs.push(format!("/midi/continue/dev/{dev}"));
}
Op::GetMidiCC => {
let chan = stack.pop().ok_or("stack underflow")?.as_int()?;
let cc = stack.pop().ok_or("stack underflow")?.as_int()?;
let cc_clamped = (cc.clamp(0, 127)) as usize;
let chan_clamped = (chan.clamp(1, 16) - 1) as usize;
let (_, params) = cmd.snapshot().unwrap_or((None, &[]));
let dev = params
.iter()
.rev()
.find(|(k, _)| k == "dev")
.and_then(|(_, v)| v.as_int().ok())
.map(|d| d.clamp(0, 3) as usize)
.unwrap_or(0);
let val = ctx
.cc_memory
.as_ref()
.and_then(|mem| mem.lock().ok())
.map(|mem| mem[dev][chan_clamped][cc_clamped])
.unwrap_or(0);
stack.push(Value::Int(val as i64, None));
}
}
pc += 1;
}

View File

@@ -2280,6 +2280,137 @@ pub const WORDS: &[Word] = &[
compile: Simple,
varargs: false,
},
// MIDI
Word {
name: "chan",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI channel 1-16",
example: "1 chan",
compile: Param,
varargs: false,
},
Word {
name: "ccnum",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI CC number 0-127",
example: "1 ccnum",
compile: Param,
varargs: false,
},
Word {
name: "ccout",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI CC output value 0-127",
example: "64 ccout",
compile: Param,
varargs: false,
},
Word {
name: "bend",
aliases: &[],
category: "MIDI",
stack: "(f --)",
desc: "Set pitch bend -1.0 to 1.0 (0 = center)",
example: "0.5 bend",
compile: Param,
varargs: false,
},
Word {
name: "pressure",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set channel pressure (aftertouch) 0-127",
example: "64 pressure",
compile: Param,
varargs: false,
},
Word {
name: "program",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set program change number 0-127",
example: "0 program",
compile: Param,
varargs: false,
},
Word {
name: "m.",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Emit MIDI message from params (note/cc/bend/pressure/program)",
example: "60 note 100 velocity 1 chan m.",
compile: Simple,
varargs: false,
},
Word {
name: "mclock",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI clock pulse (24 per quarter note)",
example: "mclock",
compile: Simple,
varargs: false,
},
Word {
name: "mstart",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI start message",
example: "mstart",
compile: Simple,
varargs: false,
},
Word {
name: "mstop",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI stop message",
example: "mstop",
compile: Simple,
varargs: false,
},
Word {
name: "mcont",
aliases: &[],
category: "MIDI",
stack: "(--)",
desc: "Send MIDI continue message",
example: "mcont",
compile: Simple,
varargs: false,
},
Word {
name: "ccval",
aliases: &[],
category: "MIDI",
stack: "(cc chan -- val)",
desc: "Read CC value 0-127 from MIDI input (uses dev param for device)",
example: "1 1 ccval",
compile: Simple,
varargs: false,
},
Word {
name: "dev",
aliases: &[],
category: "MIDI",
stack: "(n --)",
desc: "Set MIDI device slot 0-3 for output/input",
example: "1 dev 60 note m.",
compile: Param,
varargs: false,
},
];
pub(super) fn simple_op(name: &str) -> Option<Op> {
@@ -2357,6 +2488,12 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"gen" => Op::Generate,
"geom.." => Op::GeomRange,
"times" => Op::Times,
"m." => Op::MidiEmit,
"ccval" => Op::GetMidiCC,
"mclock" => Op::MidiClock,
"mstart" => Op::MidiStart,
"mstop" => Op::MidiStop,
"mcont" => Op::MidiContinue,
_ => return None,
})
}

View File

@@ -1,73 +0,0 @@
# Audio Engine
Cagire uses **Doux** as its audio engine ([https://doux.livecoding.fr](https://doux.livecoding.fr)). Doux is a standalone synthesis and sampling engine that receives commands as strings and turns them into sound. Doux is a fixed graph synthesizer, which means that the structure of the sound is defined by a fixed set of nodes and connections, and the parameters of these nodes can be adjusted to create different sounds. Doux is extremely versatile and you are likely to find it useful for a wide range of musical styles and genres.
## How Sound is Produced
When the sequencer hits an active step, the Forth script is compiled and executed. Each emit operation (`.`) generates a command string that is sent to Doux. The command encodes the sound name and all accumulated parameters. The following example script:
```
"saw" sound c4 note 0.5 gain 2000 lpf .
```
will produce a command string that Doux interprets to _play a saw wave at C4 with gain 0.5 and a 2kHz lowpass filter_.
## Sound sources
Each sound needs a source. Sources are defined by typing their name followed by the `sound` keyword. Sources are raw waveforms or samples. They are shaped by passing additional parameters that will modify the characteristics of the sound: envelopes, effects, synthesis options, etc. The following example defines a source named `saw` with a frequency of 440 Hz, a gain of 0.5 and some reverb:
```
"saw" source 440 freq 0.5 gain 0.5 verb .
```
The audio engine offers a vast array (~20+) of sources including oscillators, noises, live input, and more.
## Settings
- **Channels**: Output channel count (1-64)
- **Buffer Size**: Audio buffer in samples (64-4096). Lower values reduce latency but increase CPU load.
- **Voices**: Maximum polyphony (1-128, default 32). When the limit is reached, the oldest voice is stolen.
Settings are persisted across sessions.
## Samples
Cagire scans sample directories recursively and indexes all audio files. Add sample paths on the Engine page with **a**, remove with **d**. Use samples in scripts by name:
```
"kick" s .
"hat" s 0.5 gain .
```
The sample index is shown on the Engine page with the total count.
## Visualizers
The Engine page displays two real-time visualizers:
- **Scope**: Waveform display (64 samples), updated on every audio callback
- **Spectrum**: 32-band FFT analyzer with logarithmic frequency scaling (20Hz to Nyquist), Hann window, displayed in dB
Both can be toggled on or off in the Options page.
## Monitoring
The Engine page shows live metrics:
- **Active voices**: Current polyphony count
- **Peak voices**: Highest voice count since last reset (press **r** to reset)
- **CPU load**: Audio thread utilization
- **Events**: Total emitted and dropped event counts
## Tempo Scaling
Some parameters are automatically scaled by step duration so they sound consistent across tempos. These include envelope times (attack, decay, release), filter envelopes, pitch envelopes, FM envelopes, glide, and reverb/delay times.
## Commands
On the Engine page:
- **h**: Hush (graceful fade-out of all voices)
- **p**: Panic (hard stop all voices immediately)
- **r**: Reset peak voice counter
- **t**: Test sound (plays a 440Hz sine)

View File

@@ -1,2 +0,0 @@
# Effects

View File

@@ -1,63 +0,0 @@
# Keybindings
## Navigation
- **Ctrl+Left/Right**: Switch between pages (Main, Audio, Doc)
- **q**: Quit (with confirmation)
## Main Page - Sequencer Focus
- **Arrow keys**: Navigate steps in pattern
- **Enter**: Toggle step active/inactive
- **Tab**: Switch focus to editor
- **Space**: Play/pause
### Pattern Controls
- **< / >**: Decrease/increase pattern length
- **[ / ]**: Decrease/increase pattern speed
- **p**: Open pattern picker
- **b**: Open bank picker
### Slots
- **1-8**: Toggle slot on/off
- **g**: Queue current pattern to first free slot
- **G**: Queue removal of current pattern from its slot
### Files
- **s**: Save project
- **l**: Load project
- **Ctrl+C**: Copy step script
- **Ctrl+V**: Paste step script
### Execution
- **Ctrl+R**: Run current step's script immediately (one-shot)
### Tempo
- **+ / =**: Increase tempo
- **-**: Decrease tempo
## Main Page - Editor Focus
- **Tab / Esc**: Return to sequencer focus
- **Ctrl+E**: Compile current step script
- **Ctrl+R**: Run script in editor immediately (one-shot)
## Audio Page
- **h**: Hush (stop all sounds gracefully)
- **p**: Panic (kill all sounds immediately)
- **r**: Reset peak voice counter
- **t**: Test sound (plays 440Hz sine)
- **Space**: Play/pause
## Doc Page
- **j / Down**: Next topic
- **k / Up**: Previous topic
- **PgDn**: Scroll content down
- **PgUp**: Scroll content up

View File

@@ -1,2 +0,0 @@
# Parameters

View File

@@ -1,72 +0,0 @@
# Sequencer
## Structure
The sequencer is organized into:
- **Banks**: 16 banks (B01-B16)
- **Patterns**: 16 patterns per bank (P01-P16)
- **Steps**: Up to 32 steps per pattern
- **Slots**: 8 concurrent playback slots
## Patterns
Each pattern has:
- **Length**: Number of steps (1-32)
- **Speed**: Playback rate relative to tempo
- **Steps**: Each step can have a script
### Speed Settings
- 1/4: Quarter speed
- 1/2: Half speed
- 1x: Normal speed
- 2x: Double speed
- 4x: Quadruple speed
## Slots
Slots allow multiple patterns to play simultaneously.
- Press **1-8** to toggle a slot
- Slot changes are quantized to the next bar
- A "?" indicates a slot queued to start
- A "x" indicates a slot queued to stop
### Workflow
1. Edit a pattern in the main view
2. Press **g** to queue it to the first free slot
3. It starts playing at the next bar boundary
4. Press **G** to queue its removal
## Steps
Steps are the basic unit of the sequencer:
- Navigate with arrow keys
- Toggle active with Enter
- Each step can contain a Rhai script
### Active vs Inactive
- Active steps (highlighted) execute their script
- Inactive steps are skipped during playback
- Toggle with Enter key
## Playback
The sequencer uses Ableton Link for timing:
- Syncs with other Link-enabled apps
- Bar boundaries are used for slot changes
- Phase shows position within the current bar
## Files
Projects are saved as JSON files:
- **s**: Save with dialog
- **l**: Load with dialog
- File extension: `.buboseq`

View File

@@ -1,2 +0,0 @@
# Sound Basics

View File

@@ -1,2 +0,0 @@
# Tempo & Speed

View File

@@ -10,6 +10,7 @@ use crate::commands::AppCommand;
use crate::engine::{
LinkState, PatternChange, PatternSnapshot, SeqCommand, SequencerSnapshot, StepSnapshot,
};
use crate::midi::MidiState;
use crate::model::{self, Bank, Dictionary, Pattern, Rng, ScriptEngine, StepContext, Variables};
use crate::page::Page;
use crate::services::pattern_editor;
@@ -46,6 +47,13 @@ pub struct App {
pub audio: AudioSettings,
pub options: OptionsState,
pub panel: PanelState,
pub midi: MidiState,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
@@ -80,6 +88,7 @@ impl App {
audio: AudioSettings::default(),
options: OptionsState::default(),
panel: PanelState::default(),
midi: MidiState::new(),
}
}
@@ -108,6 +117,20 @@ impl App {
tempo: link.tempo(),
quantum: link.quantum(),
},
midi: crate::settings::MidiSettings {
output_devices: {
let outputs = crate::midi::list_midi_outputs();
self.midi.selected_outputs.iter()
.map(|opt| opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone())).unwrap_or_default())
.collect()
},
input_devices: {
let inputs = crate::midi::list_midi_inputs();
self.midi.selected_inputs.iter()
.map(|opt| opt.and_then(|idx| inputs.get(idx).map(|d| d.name.clone())).unwrap_or_default())
.collect()
},
},
};
settings.save();
}
@@ -309,6 +332,7 @@ impl App {
speed,
fill: false,
nudge_secs: 0.0,
cc_memory: None,
};
let cmds = self.script_engine.evaluate(script, &ctx)?;
@@ -359,6 +383,7 @@ impl App {
speed,
fill: false,
nudge_secs: 0.0,
cc_memory: None,
};
match self.script_engine.evaluate(&script, &ctx) {
@@ -436,6 +461,7 @@ impl App {
speed,
fill: false,
nudge_secs: 0.0,
cc_memory: None,
};
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {

View File

@@ -16,6 +16,12 @@ pub struct ScopeBuffer {
peak_right: AtomicU32,
}
impl Default for ScopeBuffer {
fn default() -> Self {
Self::new()
}
}
impl ScopeBuffer {
pub fn new() -> Self {
Self {
@@ -64,6 +70,12 @@ pub struct SpectrumBuffer {
pub bands: [AtomicU32; 32],
}
impl Default for SpectrumBuffer {
fn default() -> Self {
Self::new()
}
}
impl SpectrumBuffer {
pub fn new() -> Self {
Self {

View File

@@ -2,9 +2,12 @@ mod audio;
mod link;
pub mod sequencer;
// AnalysisHandle and SequencerHandle are used by src/bin/desktop.rs
#[allow(unused_imports)]
pub use audio::{build_stream, AnalysisHandle, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
pub use link::LinkState;
#[allow(unused_imports)]
pub use sequencer::{
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig,
SequencerHandle, SequencerSnapshot, StepSnapshot,
spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand,
SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot,
};

View File

@@ -8,7 +8,7 @@ use std::time::Duration;
use thread_priority::{set_current_thread_priority, ThreadPriority};
use super::LinkState;
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
use crate::model::{CcMemory, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
use crate::state::LiveKeyState;
@@ -51,6 +51,20 @@ pub enum AudioCommand {
ResetEngine,
}
#[derive(Clone, Debug)]
pub enum MidiCommand {
NoteOn { device: u8, channel: u8, note: u8, velocity: u8 },
NoteOff { device: u8, channel: u8, note: u8 },
CC { device: u8, channel: u8, cc: u8, value: u8 },
PitchBend { device: u8, channel: u8, value: u16 },
Pressure { device: u8, channel: u8, value: u8 },
ProgramChange { device: u8, channel: u8, program: u8 },
Clock { device: u8 },
Start { device: u8 },
Stop { device: u8 },
Continue { device: u8 },
}
pub enum SeqCommand {
PatternUpdate {
bank: usize,
@@ -142,6 +156,7 @@ impl SequencerSnapshot {
pub struct SequencerHandle {
pub cmd_tx: Sender<SeqCommand>,
pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
pub midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
shared_state: Arc<ArcSwap<SharedSequencerState>>,
thread: JoinHandle<()>,
}
@@ -163,6 +178,12 @@ impl SequencerHandle {
new_rx
}
pub fn swap_midi_channel(&self) -> Receiver<MidiCommand> {
let (new_tx, new_rx) = bounded::<MidiCommand>(256);
self.midi_tx.store(Arc::new(new_tx));
new_rx
}
pub fn shutdown(self) {
let _ = self.cmd_tx.send(SeqCommand::Shutdown);
if let Err(e) = self.thread.join() {
@@ -191,6 +212,7 @@ struct AudioState {
active_patterns: HashMap<PatternId, ActivePattern>,
pending_starts: Vec<PendingPattern>,
pending_stops: Vec<PendingPattern>,
flush_midi_notes: bool,
}
impl AudioState {
@@ -200,6 +222,7 @@ impl AudioState {
active_patterns: HashMap::new(),
pending_starts: Vec::new(),
pending_stops: Vec::new(),
flush_midi_notes: false,
}
}
}
@@ -208,6 +231,7 @@ pub struct SequencerConfig {
pub audio_sample_pos: Arc<AtomicU64>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
pub cc_memory: Option<CcMemory>,
}
#[allow(clippy::too_many_arguments)]
@@ -221,14 +245,17 @@ pub fn spawn_sequencer(
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
config: SequencerConfig,
) -> (SequencerHandle, Receiver<AudioCommand>) {
) -> (SequencerHandle, Receiver<AudioCommand>, Receiver<MidiCommand>) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
let (midi_tx, midi_rx) = bounded::<MidiCommand>(256);
let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx));
let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx));
let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default()));
let shared_state_clone = Arc::clone(&shared_state);
let audio_tx_for_thread = Arc::clone(&audio_tx);
let midi_tx_for_thread = Arc::clone(&midi_tx);
let thread = thread::Builder::new()
.name("sequencer".into())
@@ -236,6 +263,7 @@ pub fn spawn_sequencer(
sequencer_loop(
cmd_rx,
audio_tx_for_thread,
midi_tx_for_thread,
link,
playing,
variables,
@@ -248,6 +276,7 @@ pub fn spawn_sequencer(
config.audio_sample_pos,
config.sample_rate,
config.lookahead_ms,
config.cc_memory,
);
})
.expect("Failed to spawn sequencer thread");
@@ -255,10 +284,11 @@ pub fn spawn_sequencer(
let handle = SequencerHandle {
cmd_tx,
audio_tx,
midi_tx,
shared_state,
thread,
};
(handle, audio_rx)
(handle, audio_rx, midi_rx)
}
struct PatternCache {
@@ -388,6 +418,7 @@ pub(crate) struct TickOutput {
pub audio_commands: Vec<TimestampedCommand>,
pub new_tempo: Option<f64>,
pub shared_state: SharedSequencerState,
pub flush_midi_notes: bool,
}
struct StepResult {
@@ -434,6 +465,12 @@ impl KeyCache {
}
}
#[derive(Clone, Copy)]
struct ActiveNote {
off_time_us: i64,
start_time_us: i64,
}
pub(crate) struct SequencerState {
audio_state: AudioState,
pattern_cache: PatternCache,
@@ -446,10 +483,12 @@ pub(crate) struct SequencerState {
speed_overrides: HashMap<(usize, usize), f64>,
key_cache: KeyCache,
buf_audio_commands: Vec<TimestampedCommand>,
cc_memory: Option<CcMemory>,
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
}
impl SequencerState {
pub fn new(variables: Variables, dict: Dictionary, rng: Rng) -> Self {
pub fn new(variables: Variables, dict: Dictionary, rng: Rng, cc_memory: Option<CcMemory>) -> Self {
let script_engine = ScriptEngine::new(Arc::clone(&variables), dict, rng);
Self {
audio_state: AudioState::new(),
@@ -463,6 +502,8 @@ impl SequencerState {
speed_overrides: HashMap::new(),
key_cache: KeyCache::new(),
buf_audio_commands: Vec::new(),
cc_memory,
active_notes: HashMap::new(),
}
}
@@ -513,6 +554,7 @@ impl SequencerState {
self.audio_state.pending_stops.clear();
Arc::make_mut(&mut self.step_traces).clear();
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
}
SeqCommand::Shutdown => {}
}
@@ -556,10 +598,12 @@ impl SequencerState {
self.audio_state.prev_beat = beat;
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
TickOutput {
audio_commands: std::mem::take(&mut self.buf_audio_commands),
new_tempo: vars.new_tempo,
shared_state: self.build_shared_state(),
flush_midi_notes: flush,
}
}
@@ -573,10 +617,12 @@ impl SequencerState {
self.audio_state.pending_starts.clear();
self.audio_state.prev_beat = -1.0;
self.buf_audio_commands.clear();
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
TickOutput {
audio_commands: std::mem::take(&mut self.buf_audio_commands),
new_tempo: None,
shared_state: self.build_shared_state(),
flush_midi_notes: flush,
}
}
@@ -699,6 +745,7 @@ impl SequencerState {
speed: speed_mult,
fill,
nudge_secs,
cc_memory: self.cc_memory.clone(),
};
if let Some(script) = resolved_script {
let mut trace = ExecutionTrace::default();
@@ -841,6 +888,7 @@ impl SequencerState {
fn sequencer_loop(
cmd_rx: Receiver<SeqCommand>,
audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
link: Arc<LinkState>,
playing: Arc<std::sync::atomic::AtomicBool>,
variables: Variables,
@@ -853,12 +901,13 @@ fn sequencer_loop(
audio_sample_pos: Arc<AtomicU64>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
lookahead_ms: Arc<std::sync::atomic::AtomicU32>,
cc_memory: Option<CcMemory>,
) {
use std::sync::atomic::Ordering;
let _ = set_current_thread_priority(ThreadPriority::Max);
let mut seq_state = SequencerState::new(variables, dict, rng);
let mut seq_state = SequencerState::new(variables, dict, rng, cc_memory);
loop {
let mut commands = Vec::new();
@@ -899,6 +948,27 @@ fn sequencer_loop(
let output = seq_state.tick(input);
for tsc in output.audio_commands {
if let Some((midi_cmd, dur)) = parse_midi_command(&tsc.cmd) {
match midi_tx.load().try_send(midi_cmd.clone()) {
Ok(()) => {
if let (MidiCommand::NoteOn { device, channel, note, .. }, Some(dur_secs)) =
(&midi_cmd, dur)
{
let dur_us = (dur_secs * 1_000_000.0) as i64;
seq_state.active_notes.insert(
(*device, *channel, *note),
ActiveNote {
off_time_us: current_time_us + dur_us,
start_time_us: current_time_us,
},
);
}
}
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
seq_state.dropped_events += 1;
}
}
} else {
let cmd = AudioCommand::Evaluate {
cmd: tsc.cmd,
time: tsc.time,
@@ -910,6 +980,35 @@ fn sequencer_loop(
}
}
}
}
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
if output.flush_midi_notes {
for ((device, channel, note), _) in seq_state.active_notes.drain() {
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { device, channel, note });
}
// Send MIDI panic (CC 123 = All Notes Off) on all 16 channels for all devices
for dev in 0..4u8 {
for chan in 0..16u8 {
let _ = midi_tx
.load()
.try_send(MidiCommand::CC { device: dev, channel: chan, cc: 123, value: 0 });
}
}
} else {
seq_state.active_notes.retain(|&(device, channel, note), active| {
let should_release = current_time_us >= active.off_time_us;
let timed_out = (current_time_us - active.start_time_us) > MAX_NOTE_DURATION_US;
if should_release || timed_out {
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { device, channel, note });
false
} else {
true
}
});
}
if let Some(t) = output.new_tempo {
link.set_tempo(t);
@@ -921,6 +1020,81 @@ fn sequencer_loop(
}
}
fn parse_midi_command(cmd: &str) -> Option<(MidiCommand, Option<f64>)> {
if !cmd.starts_with("/midi/") {
return None;
}
let parts: Vec<&str> = cmd.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() < 2 {
return None;
}
let find_param = |key: &str| -> Option<&str> {
parts.iter()
.position(|&s| s == key)
.and_then(|i| parts.get(i + 1).copied())
};
let device: u8 = find_param("dev").and_then(|s| s.parse().ok()).unwrap_or(0);
match parts[1] {
"note" => {
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>/dev/<dev>
let note: u8 = parts.get(2)?.parse().ok()?;
let vel: u8 = find_param("vel")?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
let dur: Option<f64> = find_param("dur").and_then(|s| s.parse().ok());
Some((
MidiCommand::NoteOn {
device,
channel: chan,
note,
velocity: vel,
},
dur,
))
}
"cc" => {
// /midi/cc/<cc>/<val>/chan/<chan>/dev/<dev>
let cc: u8 = parts.get(2)?.parse().ok()?;
let val: u8 = parts.get(3)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((
MidiCommand::CC {
device,
channel: chan,
cc,
value: val,
},
None,
))
}
"bend" => {
// /midi/bend/<value>/chan/<chan>/dev/<dev>
let value: u16 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((MidiCommand::PitchBend { device, channel: chan, value }, None))
}
"pressure" => {
// /midi/pressure/<value>/chan/<chan>/dev/<dev>
let value: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((MidiCommand::Pressure { device, channel: chan, value }, None))
}
"program" => {
// /midi/program/<value>/chan/<chan>/dev/<dev>
let program: u8 = parts.get(2)?.parse().ok()?;
let chan: u8 = find_param("chan")?.parse().ok()?;
Some((MidiCommand::ProgramChange { device, channel: chan, program }, None))
}
"clock" => Some((MidiCommand::Clock { device }, None)),
"start" => Some((MidiCommand::Start { device }, None)),
"stop" => Some((MidiCommand::Stop { device }, None)),
"continue" => Some((MidiCommand::Continue { device }, None)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -932,7 +1106,7 @@ mod tests {
let rng: Rng = Arc::new(Mutex::new(
<rand::rngs::StdRng as rand::SeedableRng>::seed_from_u64(0),
));
SequencerState::new(variables, dict, rng)
SequencerState::new(variables, dict, rng, None)
}
fn simple_pattern(length: usize) -> PatternSnapshot {

View File

@@ -1270,6 +1270,92 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
ctx.link.set_quantum(ctx.link.quantum() + delta);
}
OptionsFocus::MidiOutput0 | OptionsFocus::MidiOutput1 |
OptionsFocus::MidiOutput2 | OptionsFocus::MidiOutput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiOutput0 => 0,
OptionsFocus::MidiOutput1 => 1,
OptionsFocus::MidiOutput2 => 2,
OptionsFocus::MidiOutput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_outputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|| !ctx.app.midi.selected_outputs.iter().enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_outputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if key.code == KeyCode::Left {
if current_pos == 0 { total_options - 1 } else { current_pos - 1 }
} else {
(current_pos + 1) % total_options
};
if new_pos == 0 {
ctx.app.midi.disconnect_output(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {}: {}", slot, device.name
)));
}
}
}
OptionsFocus::MidiInput0 | OptionsFocus::MidiInput1 |
OptionsFocus::MidiInput2 | OptionsFocus::MidiInput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiInput0 => 0,
OptionsFocus::MidiInput1 => 1,
OptionsFocus::MidiInput2 => 2,
OptionsFocus::MidiInput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_inputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|| !ctx.app.midi.selected_inputs.iter().enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_inputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if key.code == KeyCode::Left {
if current_pos == 0 { total_options - 1 } else { current_pos - 1 }
} else {
(current_pos + 1) % total_options
};
if new_pos == 0 {
ctx.app.midi.disconnect_input(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {}: {}", slot, device.name
)));
}
}
}
}
ctx.app.save_settings(ctx.link);
}

View File

@@ -4,6 +4,7 @@ pub mod app;
pub mod commands;
pub mod engine;
pub mod input;
pub mod midi;
pub mod model;
pub mod page;
pub mod services;

View File

@@ -2,6 +2,7 @@ mod app;
mod commands;
mod engine;
mod input;
mod midi;
mod model;
mod page;
mod services;
@@ -101,6 +102,24 @@ fn main() -> io::Result<()> {
app.ui.color_scheme = settings.display.color_scheme;
theme::set(settings.display.color_scheme.to_theme());
// Load MIDI settings
let outputs = midi::list_midi_outputs();
let inputs = midi::list_midi_inputs();
for (slot, name) in settings.midi.output_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = outputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_output(slot, idx);
}
}
}
for (slot, name) in settings.midi.input_devices.iter().enumerate() {
if !name.is_empty() {
if let Some(idx) = inputs.iter().position(|d| &d.name == name) {
let _ = app.midi.connect_input(slot, idx);
}
}
}
let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
@@ -120,9 +139,10 @@ fn main() -> io::Result<()> {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
lookahead_ms: Arc::clone(&lookahead_ms),
cc_memory: Some(Arc::clone(&app.midi.cc_memory)),
};
let (sequencer, initial_audio_rx) = spawn_sequencer(
let (sequencer, initial_audio_rx, mut midi_rx) = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&app.variables),
@@ -177,6 +197,7 @@ fn main() -> io::Result<()> {
_analysis_handle = None;
let new_audio_rx = sequencer.swap_audio_channel();
midi_rx = sequencer.swap_midi_channel();
let new_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(),
@@ -220,6 +241,34 @@ fn main() -> io::Result<()> {
app.playback.playing = playing.load(Ordering::Relaxed);
// Process pending MIDI commands
while let Ok(midi_cmd) = midi_rx.try_recv() {
match midi_cmd {
engine::MidiCommand::NoteOn { device, channel, note, velocity } => {
app.midi.send_note_on(device, channel, note, velocity);
}
engine::MidiCommand::NoteOff { device, channel, note } => {
app.midi.send_note_off(device, channel, note);
}
engine::MidiCommand::CC { device, channel, cc, value } => {
app.midi.send_cc(device, channel, cc, value);
}
engine::MidiCommand::PitchBend { device, channel, value } => {
app.midi.send_pitch_bend(device, channel, value);
}
engine::MidiCommand::Pressure { device, channel, value } => {
app.midi.send_pressure(device, channel, value);
}
engine::MidiCommand::ProgramChange { device, channel, program } => {
app.midi.send_program_change(device, channel, program);
}
engine::MidiCommand::Clock { device } => app.midi.send_realtime(device, 0xF8),
engine::MidiCommand::Start { device } => app.midi.send_realtime(device, 0xFA),
engine::MidiCommand::Stop { device } => app.midi.send_realtime(device, 0xFC),
engine::MidiCommand::Continue { device } => app.midi.send_realtime(device, 0xFB),
}
}
{
app.metrics.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize;
app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices);

192
src/midi.rs Normal file
View File

@@ -0,0 +1,192 @@
use std::sync::{Arc, Mutex};
use midir::{MidiInput, MidiOutput};
use crate::model::CcMemory;
pub const MAX_MIDI_OUTPUTS: usize = 4;
pub const MAX_MIDI_INPUTS: usize = 4;
#[derive(Clone, Debug)]
pub struct MidiDeviceInfo {
pub name: String,
}
pub fn list_midi_outputs() -> Vec<MidiDeviceInfo> {
let Ok(midi_out) = MidiOutput::new("cagire-probe") else {
return Vec::new();
};
midi_out
.ports()
.iter()
.filter_map(|port| {
midi_out
.port_name(port)
.ok()
.map(|name| MidiDeviceInfo { name })
})
.collect()
}
pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
let Ok(midi_in) = MidiInput::new("cagire-probe") else {
return Vec::new();
};
midi_in
.ports()
.iter()
.filter_map(|port| {
midi_in
.port_name(port)
.ok()
.map(|name| MidiDeviceInfo { name })
})
.collect()
}
pub struct MidiState {
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
input_conns: [Option<midir::MidiInputConnection<(CcMemory, usize)>>; MAX_MIDI_INPUTS],
pub selected_outputs: [Option<usize>; MAX_MIDI_OUTPUTS],
pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS],
pub cc_memory: CcMemory,
}
impl Default for MidiState {
fn default() -> Self {
Self::new()
}
}
impl MidiState {
pub fn new() -> Self {
Self {
output_conns: [None, None, None, None],
input_conns: [None, None, None, None],
selected_outputs: [None; MAX_MIDI_OUTPUTS],
selected_inputs: [None; MAX_MIDI_INPUTS],
cc_memory: Arc::new(Mutex::new([[[0u8; 128]; 16]; MAX_MIDI_OUTPUTS])),
}
}
pub fn connect_output(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
if slot >= MAX_MIDI_OUTPUTS {
return Err("Invalid output slot".to_string());
}
let midi_out = MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?;
let ports = midi_out.ports();
let port = ports.get(port_index).ok_or("MIDI output port not found")?;
let conn = midi_out
.connect(port, &format!("cagire-midi-out-{slot}"))
.map_err(|e| e.to_string())?;
self.output_conns[slot] = Some(conn);
self.selected_outputs[slot] = Some(port_index);
Ok(())
}
pub fn disconnect_output(&mut self, slot: usize) {
if slot < MAX_MIDI_OUTPUTS {
self.output_conns[slot] = None;
self.selected_outputs[slot] = None;
}
}
pub fn connect_input(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
if slot >= MAX_MIDI_INPUTS {
return Err("Invalid input slot".to_string());
}
let midi_in = MidiInput::new(&format!("cagire-in-{slot}")).map_err(|e| e.to_string())?;
let ports = midi_in.ports();
let port = ports.get(port_index).ok_or("MIDI input port not found")?;
let cc_mem = Arc::clone(&self.cc_memory);
let conn = midi_in
.connect(
port,
&format!("cagire-midi-in-{slot}"),
move |_timestamp, message, (cc_mem, slot)| {
if message.len() >= 3 {
let status = message[0];
let data1 = message[1] as usize;
let data2 = message[2];
if (status & 0xF0) == 0xB0 && data1 < 128 {
let channel = (status & 0x0F) as usize;
if let Ok(mut mem) = cc_mem.lock() {
mem[*slot][channel][data1] = data2;
}
}
}
},
(cc_mem, slot),
)
.map_err(|e| e.to_string())?;
self.input_conns[slot] = Some(conn);
self.selected_inputs[slot] = Some(port_index);
Ok(())
}
pub fn disconnect_input(&mut self, slot: usize) {
if slot < MAX_MIDI_INPUTS {
self.input_conns[slot] = None;
self.selected_inputs[slot] = None;
}
}
pub fn send_note_on(&mut self, device: u8, channel: u8, note: u8, velocity: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0x90 | (channel & 0x0F);
let _ = conn.send(&[status, note & 0x7F, velocity & 0x7F]);
}
}
pub fn send_note_off(&mut self, device: u8, channel: u8, note: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0x80 | (channel & 0x0F);
let _ = conn.send(&[status, note & 0x7F, 0]);
}
}
pub fn send_cc(&mut self, device: u8, channel: u8, cc: u8, value: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xB0 | (channel & 0x0F);
let _ = conn.send(&[status, cc & 0x7F, value & 0x7F]);
}
}
pub fn send_pitch_bend(&mut self, device: u8, channel: u8, value: u16) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xE0 | (channel & 0x0F);
let lsb = (value & 0x7F) as u8;
let msb = ((value >> 7) & 0x7F) as u8;
let _ = conn.send(&[status, lsb, msb]);
}
}
pub fn send_pressure(&mut self, device: u8, channel: u8, value: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xD0 | (channel & 0x0F);
let _ = conn.send(&[status, value & 0x7F]);
}
}
pub fn send_program_change(&mut self, device: u8, channel: u8, program: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let status = 0xC0 | (channel & 0x0F);
let _ = conn.send(&[status, program & 0x7F]);
}
}
pub fn send_realtime(&mut self, device: u8, msg: u8) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let _ = conn.send(&[msg]);
}
}
}

View File

@@ -5,4 +5,4 @@ pub use cagire_project::{
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS,
MAX_PATTERNS,
};
pub use script::{Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables};
pub use script::{CcMemory, Dictionary, ExecutionTrace, Rng, ScriptEngine, SourceSpan, StepContext, Value, Variables};

View File

@@ -1,6 +1,6 @@
use cagire_forth::Forth;
pub use cagire_forth::{Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
pub use cagire_forth::{CcMemory, Dictionary, ExecutionTrace, Rng, SourceSpan, StepContext, Value, Variables};
pub struct ScriptEngine {
forth: Forth,

View File

@@ -4,11 +4,21 @@ use crate::state::ColorScheme;
const APP_NAME: &str = "cagire";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct MidiSettings {
#[serde(default)]
pub output_devices: Vec<String>,
#[serde(default)]
pub input_devices: Vec<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Settings {
pub audio: AudioSettings,
pub display: DisplaySettings,
pub link: LinkSettings,
#[serde(default)]
pub midi: MidiSettings,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -11,6 +11,14 @@ pub enum OptionsFocus {
LinkEnabled,
StartStopSync,
Quantum,
MidiOutput0,
MidiOutput1,
MidiOutput2,
MidiOutput3,
MidiInput0,
MidiInput1,
MidiInput2,
MidiInput3,
}
#[derive(Default)]
@@ -30,13 +38,21 @@ impl OptionsState {
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
OptionsFocus::Quantum => OptionsFocus::ColorScheme,
OptionsFocus::Quantum => OptionsFocus::MidiOutput0,
OptionsFocus::MidiOutput0 => OptionsFocus::MidiOutput1,
OptionsFocus::MidiOutput1 => OptionsFocus::MidiOutput2,
OptionsFocus::MidiOutput2 => OptionsFocus::MidiOutput3,
OptionsFocus::MidiOutput3 => OptionsFocus::MidiInput0,
OptionsFocus::MidiInput0 => OptionsFocus::MidiInput1,
OptionsFocus::MidiInput1 => OptionsFocus::MidiInput2,
OptionsFocus::MidiInput2 => OptionsFocus::MidiInput3,
OptionsFocus::MidiInput3 => OptionsFocus::ColorScheme,
};
}
pub fn prev_focus(&mut self) {
self.focus = match self.focus {
OptionsFocus::ColorScheme => OptionsFocus::Quantum,
OptionsFocus::ColorScheme => OptionsFocus::MidiInput3,
OptionsFocus::RefreshRate => OptionsFocus::ColorScheme,
OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate,
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
@@ -46,6 +62,14 @@ impl OptionsState {
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
OptionsFocus::MidiOutput0 => OptionsFocus::Quantum,
OptionsFocus::MidiOutput1 => OptionsFocus::MidiOutput0,
OptionsFocus::MidiOutput2 => OptionsFocus::MidiOutput1,
OptionsFocus::MidiOutput3 => OptionsFocus::MidiOutput2,
OptionsFocus::MidiInput0 => OptionsFocus::MidiOutput3,
OptionsFocus::MidiInput1 => OptionsFocus::MidiInput0,
OptionsFocus::MidiInput2 => OptionsFocus::MidiInput1,
OptionsFocus::MidiInput3 => OptionsFocus::MidiInput2,
};
}
}

View File

@@ -59,17 +59,6 @@ const DOCS: &[DocEntry] = &[
),
Topic("Space & Time", include_str!("../../docs/engine_space.md")),
Topic("Words & Sounds", include_str!("../../docs/engine_words.md")),
// Reference
Section("Reference"),
Topic("Audio Engine", include_str!("../../docs/audio_engine.md")),
Topic("Keybindings", include_str!("../../docs/keybindings.md")),
Topic("Sequencer", include_str!("../../docs/sequencer.md")),
// Archive - old files to sort
Section("Archive"),
Topic("Sound Basics", include_str!("../../docs/sound_basics.md")),
Topic("Parameters", include_str!("../../docs/parameters.md")),
Topic("Tempo & Speed", include_str!("../../docs/tempo.md")),
Topic("Effects (old)", include_str!("../../docs/effects.md")),
];
pub fn topic_count() -> usize {
@@ -265,7 +254,7 @@ fn wrapped_line_count(line: &RLine, width: usize) -> usize {
if char_count == 0 || width == 0 {
1
} else {
(char_count + width - 1) / width
char_count.div_ceil(width)
}
}
@@ -392,9 +381,9 @@ fn preprocess_markdown(md: &str) -> String {
fn convert_dash_lists(line: &str) -> String {
let trimmed = line.trim_start();
if trimmed.starts_with("- ") {
if let Some(rest) = trimmed.strip_prefix("- ") {
let indent = line.len() - trimmed.len();
format!("{}* {}", " ".repeat(indent), &trimmed[2..])
format!("{}* {}", " ".repeat(indent), rest)
} else {
line.to_string()
}
@@ -487,7 +476,7 @@ fn render_table_row(row: TableRow, row_idx: usize, col_widths: &[usize]) -> RLin
let is_header = row_idx == 0;
let bg = if is_header {
theme.ui.surface
} else if row_idx % 2 == 0 {
} else if row_idx.is_multiple_of(2) {
theme.table.row_even
} else {
theme.table.row_odd

View File

@@ -6,6 +6,7 @@ use ratatui::Frame;
use crate::app::App;
use crate::engine::LinkState;
use crate::midi;
use crate::state::OptionsFocus;
use crate::theme;
@@ -30,7 +31,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let focus = app.options.focus;
let content_width = padded.width as usize;
// Build link header with status
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
@@ -63,7 +63,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)),
]);
// Prepare values
let flash_str = format!("{:.0}%", app.ui.flash_brightness * 100.0);
let quantum_str = format!("{:.0}", link.quantum());
let tempo_str = format!("{:.1} BPM", link.tempo());
@@ -73,9 +72,45 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(theme.values.value);
// Build flat list of all lines
let midi_outputs = midi::list_midi_outputs();
let midi_inputs = midi::list_midi_inputs();
let midi_out_display = |slot: usize| -> String {
if let Some(idx) = app.midi.selected_outputs[slot] {
midi_outputs
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "(disconnected)".to_string())
} else if midi_outputs.is_empty() {
"(none found)".to_string()
} else {
"(not connected)".to_string()
}
};
let midi_in_display = |slot: usize| -> String {
if let Some(idx) = app.midi.selected_inputs[slot] {
midi_inputs
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "(disconnected)".to_string())
} else if midi_inputs.is_empty() {
"(none found)".to_string()
} else {
"(not connected)".to_string()
}
};
let midi_out_0 = midi_out_display(0);
let midi_out_1 = midi_out_display(1);
let midi_out_2 = midi_out_display(2);
let midi_out_3 = midi_out_display(3);
let midi_in_0 = midi_in_display(0);
let midi_in_1 = midi_in_display(1);
let midi_in_2 = midi_in_display(2);
let midi_in_3 = midi_in_display(3);
let lines: Vec<Line> = vec![
// DISPLAY section (lines 0-8)
render_section_header("DISPLAY", &theme),
render_divider(content_width, &theme),
render_option_line(
@@ -119,9 +154,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
&theme,
),
render_option_line("Flash brightness", &flash_str, focus == OptionsFocus::FlashBrightness, &theme),
// Blank line (line 9)
Line::from(""),
// ABLETON LINK section (lines 10-15)
link_header,
render_divider(content_width, &theme),
render_option_line(
@@ -141,20 +174,31 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
&theme,
),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
// Blank line (line 16)
Line::from(""),
// SESSION section (lines 17-22)
render_section_header("SESSION", &theme),
render_divider(content_width, &theme),
render_readonly_line("Tempo", &tempo_str, tempo_style, &theme),
render_readonly_line("Beat", &beat_str, value_style, &theme),
render_readonly_line("Phase", &phase_str, value_style, &theme),
Line::from(""),
render_section_header("MIDI OUTPUTS", &theme),
render_divider(content_width, &theme),
render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme),
render_option_line("Output 1", &midi_out_1, focus == OptionsFocus::MidiOutput1, &theme),
render_option_line("Output 2", &midi_out_2, focus == OptionsFocus::MidiOutput2, &theme),
render_option_line("Output 3", &midi_out_3, focus == OptionsFocus::MidiOutput3, &theme),
Line::from(""),
render_section_header("MIDI INPUTS", &theme),
render_divider(content_width, &theme),
render_option_line("Input 0", &midi_in_0, focus == OptionsFocus::MidiInput0, &theme),
render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme),
render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme),
render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme),
];
let total_lines = lines.len();
let max_visible = padded.height as usize;
// Map focus to line index
let focus_line: usize = match focus {
OptionsFocus::ColorScheme => 2,
OptionsFocus::RefreshRate => 3,
@@ -166,9 +210,16 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
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,
};
// Calculate scroll offset to keep focused line visible (centered when possible)
let scroll_offset = if total_lines <= max_visible {
0
} else {
@@ -177,7 +228,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
.min(total_lines.saturating_sub(max_visible))
};
// Render visible portion
let visible_end = (scroll_offset + max_visible).min(total_lines);
let visible_lines: Vec<Line> = lines
.into_iter()
@@ -187,7 +237,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
frame.render_widget(Paragraph::new(visible_lines), padded);
// Render scroll indicators
let indicator_style = Style::new().fg(theme.ui.text_dim);
let indicator_x = padded.x + padded.width.saturating_sub(1);

View File

@@ -70,6 +70,7 @@ fn compute_stack_display(lines: &[String], editor: &cagire_ratatui::Editor, cach
speed: 1.0,
fill: false,
nudge_secs: 0.0,
cc_memory: None,
};
match forth.evaluate(&script, &ctx) {

View File

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

View File

@@ -18,6 +18,7 @@ pub fn default_ctx() -> StepContext {
speed: 1.0,
fill: false,
nudge_secs: 0.0,
cc_memory: None,
}
}

284
tests/forth/midi.rs Normal file
View File

@@ -0,0 +1,284 @@
use crate::harness::{default_ctx, expect_outputs, forth};
use cagire::forth::{CcMemory, StepContext};
use std::sync::{Arc, Mutex};
#[test]
fn test_midi_channel_set() {
let outputs = expect_outputs("60 note 100 velocity 3 chan m.", 1);
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/2/dur/"));
}
#[test]
fn test_midi_note_default_channel() {
let outputs = expect_outputs("72 note 80 velocity m.", 1);
assert!(outputs[0].starts_with("/midi/note/72/vel/80/chan/0/dur/"));
}
#[test]
fn test_midi_cc() {
let outputs = expect_outputs("64 ccnum 127 ccout m.", 1);
assert!(outputs[0].contains("/midi/cc/64/127/chan/0"));
}
#[test]
fn test_midi_cc_with_channel() {
let outputs = expect_outputs("5 chan 1 ccnum 64 ccout m.", 1);
assert!(outputs[0].contains("/midi/cc/1/64/chan/4"));
}
#[test]
fn test_ccval_returns_zero_without_cc_memory() {
let f = forth();
let ctx = default_ctx();
let outputs = f.evaluate("1 1 ccval", &ctx).unwrap();
assert!(outputs.is_empty());
let stack = f.stack();
assert_eq!(stack.len(), 1);
match &stack[0] {
cagire::forth::Value::Int(v, _) => assert_eq!(*v, 0),
_ => panic!("expected Int"),
}
}
#[test]
fn test_ccval_reads_from_cc_memory() {
let cc_memory: CcMemory = Arc::new(Mutex::new([[[0u8; 128]; 16]; 4]));
{
let mut mem = cc_memory.lock().unwrap();
mem[0][0][1] = 64; // device 0, channel 1 (0-indexed), CC 1, value 64
mem[0][5][74] = 127; // device 0, channel 6 (0-indexed), CC 74, value 127
}
let f = forth();
let ctx = StepContext {
cc_memory: Some(Arc::clone(&cc_memory)),
..default_ctx()
};
// Test CC 1 on channel 1 (user provides 1, internally 0)
f.evaluate("1 1 ccval", &ctx).unwrap();
let stack = f.stack();
assert_eq!(stack.len(), 1);
match &stack[0] {
cagire::forth::Value::Int(v, _) => assert_eq!(*v, 64),
_ => panic!("expected Int"),
}
f.clear_stack();
// Test CC 74 on channel 6 (user provides 6, internally 5)
f.evaluate("74 6 ccval", &ctx).unwrap();
let stack = f.stack();
assert_eq!(stack.len(), 1);
match &stack[0] {
cagire::forth::Value::Int(v, _) => assert_eq!(*v, 127),
_ => panic!("expected Int"),
}
}
#[test]
fn test_midi_channel_clamping() {
// Channel should be clamped 1-16, then converted to 0-15 internally
let outputs = expect_outputs("60 note 100 velocity 0 chan m.", 1);
assert!(outputs[0].contains("/chan/0")); // 0 clamped to 1, then -1 = 0
let outputs = expect_outputs("60 note 100 velocity 17 chan m.", 1);
assert!(outputs[0].contains("/chan/15")); // 17 clamped to 16, then -1 = 15
}
#[test]
fn test_midi_note_clamping() {
let outputs = expect_outputs("-1 note 100 velocity m.", 1);
assert!(outputs[0].contains("/note/0"));
let outputs = expect_outputs("200 note 100 velocity m.", 1);
assert!(outputs[0].contains("/note/127"));
}
#[test]
fn test_midi_velocity_clamping() {
let outputs = expect_outputs("60 note -10 velocity m.", 1);
assert!(outputs[0].contains("/vel/0"));
let outputs = expect_outputs("60 note 200 velocity m.", 1);
assert!(outputs[0].contains("/vel/127"));
}
#[test]
fn test_midi_defaults() {
// With only note specified, velocity defaults to 100 and channel to 0
let outputs = expect_outputs("60 note m.", 1);
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/"));
}
#[test]
fn test_midi_full_defaults() {
// With nothing specified, defaults to note=60, velocity=100, channel=0
let outputs = expect_outputs("m.", 1);
assert!(outputs[0].starts_with("/midi/note/60/vel/100/chan/0/dur/"));
}
// Pitch bend tests
#[test]
fn test_midi_bend_center() {
let outputs = expect_outputs("0.0 bend m.", 1);
// 0.0 -> 8192 (center)
assert!(outputs[0].contains("/midi/bend/8191/chan/0") || outputs[0].contains("/midi/bend/8192/chan/0"));
}
#[test]
fn test_midi_bend_max() {
let outputs = expect_outputs("1.0 bend m.", 1);
// 1.0 -> 16383 (max)
assert!(outputs[0].contains("/midi/bend/16383/chan/0"));
}
#[test]
fn test_midi_bend_min() {
let outputs = expect_outputs("-1.0 bend m.", 1);
// -1.0 -> 0 (min)
assert!(outputs[0].contains("/midi/bend/0/chan/0"));
}
#[test]
fn test_midi_bend_with_channel() {
let outputs = expect_outputs("0.5 bend 3 chan m.", 1);
assert!(outputs[0].contains("/chan/2")); // channel 3 -> 2 (0-indexed)
assert!(outputs[0].contains("/midi/bend/"));
}
#[test]
fn test_midi_bend_clamping() {
let outputs = expect_outputs("2.0 bend m.", 1);
// 2.0 clamped to 1.0 -> 16383
assert!(outputs[0].contains("/midi/bend/16383/chan/0"));
let outputs = expect_outputs("-5.0 bend m.", 1);
// -5.0 clamped to -1.0 -> 0
assert!(outputs[0].contains("/midi/bend/0/chan/0"));
}
// Channel pressure (aftertouch) tests
#[test]
fn test_midi_pressure() {
let outputs = expect_outputs("64 pressure m.", 1);
assert!(outputs[0].contains("/midi/pressure/64/chan/0"));
}
#[test]
fn test_midi_pressure_with_channel() {
let outputs = expect_outputs("100 pressure 5 chan m.", 1);
assert!(outputs[0].contains("/midi/pressure/100/chan/4"));
}
#[test]
fn test_midi_pressure_clamping() {
let outputs = expect_outputs("-10 pressure m.", 1);
assert!(outputs[0].contains("/midi/pressure/0/chan/0"));
let outputs = expect_outputs("200 pressure m.", 1);
assert!(outputs[0].contains("/midi/pressure/127/chan/0"));
}
// Program change tests
#[test]
fn test_midi_program() {
let outputs = expect_outputs("0 program m.", 1);
assert!(outputs[0].contains("/midi/program/0/chan/0"));
}
#[test]
fn test_midi_program_with_channel() {
let outputs = expect_outputs("42 program 10 chan m.", 1);
assert!(outputs[0].contains("/midi/program/42/chan/9"));
}
#[test]
fn test_midi_program_clamping() {
let outputs = expect_outputs("-1 program m.", 1);
assert!(outputs[0].contains("/midi/program/0/chan/0"));
let outputs = expect_outputs("200 program m.", 1);
assert!(outputs[0].contains("/midi/program/127/chan/0"));
}
// MIDI real-time messages
#[test]
fn test_midi_clock() {
let outputs = expect_outputs("mclock", 1);
assert_eq!(outputs[0], "/midi/clock/dev/0");
}
#[test]
fn test_midi_start() {
let outputs = expect_outputs("mstart", 1);
assert_eq!(outputs[0], "/midi/start/dev/0");
}
#[test]
fn test_midi_stop() {
let outputs = expect_outputs("mstop", 1);
assert_eq!(outputs[0], "/midi/stop/dev/0");
}
#[test]
fn test_midi_continue() {
let outputs = expect_outputs("mcont", 1);
assert_eq!(outputs[0], "/midi/continue/dev/0");
}
// Test message type priority (first matching type wins)
#[test]
fn test_midi_message_priority_cc_over_note() {
// CC params take priority over note
let outputs = expect_outputs("60 note 1 ccnum 64 ccout m.", 1);
assert!(outputs[0].contains("/midi/cc/1/64"));
}
#[test]
fn test_midi_message_priority_bend_over_note() {
// bend takes priority over note (but not over CC)
let outputs = expect_outputs("60 note 0.5 bend m.", 1);
assert!(outputs[0].contains("/midi/bend/"));
}
// MIDI note duration tests
#[test]
fn test_midi_note_default_duration() {
// Default dur=1.0, with tempo=120 and speed=1.0, step_duration = 60/120/4/1 = 0.125
let outputs = expect_outputs("60 note m.", 1);
assert!(outputs[0].contains("/dur/0.125"));
}
#[test]
fn test_midi_note_explicit_duration() {
// dur=0.5 means half step duration = 0.0625 seconds
let outputs = expect_outputs("60 note 0.5 dur m.", 1);
assert!(outputs[0].contains("/dur/0.0625"));
}
#[test]
fn test_midi_note_long_duration() {
// dur=2.0 means two step durations = 0.25 seconds
let outputs = expect_outputs("60 note 2 dur m.", 1);
assert!(outputs[0].contains("/dur/0.25"));
}
#[test]
fn test_midi_note_duration_with_tempo() {
use crate::harness::{ctx_with, forth};
let f = forth();
// At tempo=60, step_duration = 60/60/4/1 = 0.25 seconds
let ctx = ctx_with(|c| c.tempo = 60.0);
let outputs = f.evaluate("60 note m.", &ctx).unwrap();
assert!(outputs[0].contains("/dur/0.25"));
}
#[test]
fn test_midi_note_duration_with_speed() {
use crate::harness::{ctx_with, forth};
let f = forth();
// At tempo=120 speed=2, step_duration = 60/120/4/2 = 0.0625 seconds
let ctx = ctx_with(|c| c.speed = 2.0);
let outputs = f.evaluate("60 note m.", &ctx).unwrap();
assert!(outputs[0].contains("/dur/0.0625"));
}

View File

@@ -145,25 +145,6 @@ fn cycle_with_sounds() {
}
}
#[test]
fn emit_n_basic() {
let outputs = expect_outputs(r#""kick" s 4 .!"#, 4);
let sounds = get_sounds(&outputs);
assert_eq!(sounds, vec!["kick", "kick", "kick", "kick"]);
}
#[test]
fn emit_n_zero() {
let outputs = expect_outputs(r#""kick" s 0 .!"#, 0);
assert!(outputs.is_empty());
}
#[test]
fn emit_n_negative_error() {
let f = forth();
let result = f.evaluate(r#""kick" s -1 .!"#, &default_ctx());
assert!(result.is_err());
}
#[test]
fn at_single_delta() {

3
website/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.astro/

View File

5
website/astro.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
});

View File

@@ -1,163 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cagire</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
padding: 2rem;
line-height: 1.5;
}
.container {
max-width: 70ch;
margin: 0 auto;
}
.header {
border-bottom: 1px solid #333;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
.ascii {
color: #64a0b4;
font-size: 0.7rem;
line-height: 1.1;
white-space: pre;
}
nav {
margin-top: 1rem;
}
nav a {
color: #cccc44;
text-decoration: none;
margin-right: 1.5rem;
}
nav a:hover {
text-decoration: underline;
}
section {
margin-bottom: 2rem;
}
h2 {
color: #888;
font-size: 0.9rem;
font-weight: bold;
margin-bottom: 0.75rem;
border-bottom: 1px solid #333;
padding-bottom: 0.25rem;
}
.buttons {
display: flex;
gap: 1rem;
}
.btn {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #333;
color: #e0e0e0;
text-decoration: none;
font-size: 0.9rem;
text-align: center;
}
.btn:hover {
border-color: #64a0b4;
color: #64a0b4;
}
.screenshot img {
width: 100%;
border: 1px solid #333;
margin-bottom: 0.75rem;
}
.screenshot p {
color: #888;
font-size: 0.9rem;
}
p {
color: #888;
margin-bottom: 0.5rem;
}
section a {
color: #cccc44;
text-decoration: none;
}
section a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<pre class="ascii">
██████╗ █████╗ ██████╗ ██╗██████╗ ███████╗
██╔════╝██╔══██╗██╔════╝ ██║██╔══██╗██╔════╝
██║ ███████║██║ ███╗██║██████╔╝█████╗
██║ ██╔══██║██║ ██║██║██╔══██╗██╔══╝
╚██████╗██║ ██║╚██████╔╝██║██║ ██║███████╗
╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═╝╚══════╝
</pre>
<nav>
<a href="#description">Description</a>
<a href="#releases">Releases</a>
<a href="#credits">Credits</a>
<a href="#support">Support</a>
</nav>
</div>
<section id="description" class="screenshot">
<img src="cagire.png" alt="Cagire screenshot">
<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 creates events. Synchronize with other musicians using Ableton Link. Cagire uses its own audio engine for audio synthesis and sampling!</p>
</section>
<section id="releases">
<h2>Releases</h2>
<div class="buttons">
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-aarch64" class="btn">macOS (ARM)</a>
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-x86_64" class="btn">macOS (Intel)</a>
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-windows-x86_64.exe" class="btn">Windows</a>
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64" class="btn">Linux</a>
</div>
</section>
<section id="credits">
<h2>Credits</h2>
<p>Cagire is built by BuboBubo (Raphael Maurice Forment).</p>
<p>Doux (audio engine) is a Rust port of Dough, originally written in C by Felix Roos.</p>
<p>mi-plaits-dsp-rs by Oliver Rockstedt, based on Mutable Instruments Plaits by Emilie Gillet.</p>
</section>
<section id="support">
<h2>Support</h2>
<p>Report issues and contribute on <a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a>.</p>
<p>Support the project on <a href="https://ko-fi.com/raphaelbubo">Ko-fi</a>.</p>
</section>
</div>
</body>
</html>

12
website/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "cagire-website",
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5.2.5"
}
}

3142
website/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -0,0 +1,173 @@
---
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cagire</title>
<style>
@font-face {
font-family: 'CozetteVector';
src: url('/CozetteVector.ttf') format('truetype');
}
body {
font-family: 'CozetteVector', monospace;
background: #0a0a0a;
color: #e0e0e0;
max-width: 800px;
margin: 0 auto;
padding: 1rem;
line-height: 1.3;
font-size: 0.9rem;
}
h1 {
color: #64a0b4;
margin-bottom: 0.25rem;
}
h2 {
color: #64a0b4;
margin-top: 1rem;
margin-bottom: 0.25rem;
font-size: 1rem;
}
p { margin: 0.25rem 0; }
img {
max-width: 100%;
margin: 0.5rem 0;
}
a { color: #cccc44; }
ul {
padding-left: 1.5rem;
margin: 0.25rem 0;
}
li { margin: 0.1rem 0; }
pre {
background: #1a1a1a;
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: #64a0b4;
}
.downloads-table td:first-child {
color: #888;
}
.downloads-table tr:nth-child(even) {
background: #151515;
}
.note {
color: #555;
font-size: 0.8rem;
font-style: italic;
}
.note a {
color: #777;
}
</style>
</head>
<body>
<h1>CAGIRE: LIVE CODING IN FORTH</h1>
<table class="downloads-table">
<tr>
<th>Platform</th>
<th>Desktop</th>
<th>Terminal</th>
</tr>
<tr>
<td>macOS (ARM)</td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-aarch64-desktop.app.zip">.app</a></td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-aarch64.tar.gz">.tar.gz</a></td>
</tr>
<tr>
<td>macOS (Intel)</td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-x86_64-desktop.app.zip">.app</a></td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-x86_64.tar.gz">.tar.gz</a></td>
</tr>
<tr>
<td>Windows</td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-windows-x86_64-desktop.exe">.exe</a></td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-windows-x86_64.zip">.zip</a></td>
</tr>
<tr>
<td>Linux</td>
<td><a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64-desktop.deb">.deb</a></td>
<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>
<img src="/cagire.png" alt="Cagire screenshot">
<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>
<h2>Code Examples</h2>
<p>A minimal script that plays a middle C note using a sine wave:</p>
<pre>c4 note sine sound .</pre>
<p>Sawtooth wave with lowpass filter, chorus and reverb:</p>
<pre>saw sound 1200 lpf 0.2 chorus 0.8 verb .</pre>
<p>Pitched-down kick drum sample with distortion:</p>
<pre>kkick sound 1.5 distort 0.8 speed .</pre>
<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>
</ul>
<h2>Live Coding</h2>
<p>Live coding is a technique where a programmer writes code in real-time, often in front of an audience. It can be used to create music, visual art, and other forms of media. Learn more at <a href="https://toplap.org">TOPLAP</a> or <a href="https://livecoding.fr">livecoding.fr</a>.</p>
<h2>Credits</h2>
<ul>
<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><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>
<h2>Links</h2>
<ul>
<li><a href="https://github.com/Bubobubobubobubo/cagire">GitHub</a></li>
<li><a href="https://ko-fi.com/raphaelbubo">Ko-fi</a></li>
</ul>
<p style="margin-top: 2rem; color: #666;">AGPL-3.0 License</p>
</body>
</html>