Lots + MIDI implementation
This commit is contained in:
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -81,6 +81,12 @@ jobs:
|
||||
if: runner.os != 'Windows'
|
||||
run: cargo bundle --release --features desktop --bin cagire-desktop --target ${{ matrix.target }}
|
||||
|
||||
- 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'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -107,7 +113,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}-desktop
|
||||
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app
|
||||
path: target/${{ matrix.target }}/release/bundle/osx/Cagire.app.zip
|
||||
|
||||
- name: Upload desktop artifact (Windows exe)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -139,8 +145,8 @@ jobs:
|
||||
base="${name%-desktop}"
|
||||
if ls "$dir"/*.deb 1>/dev/null 2>&1; then
|
||||
cp "$dir"/*.deb "release/${base}-desktop.deb"
|
||||
elif [ -d "$dir/Cagire.app" ]; then
|
||||
(cd "$dir" && zip -r "../../release/${base}-desktop.app.zip" Cagire.app)
|
||||
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"
|
||||
fi
|
||||
|
||||
22
.github/workflows/pages.yml
vendored
22
.github/workflows/pages.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -86,4 +86,11 @@ pub enum Op {
|
||||
Generate,
|
||||
GeomRange,
|
||||
Times,
|
||||
// MIDI
|
||||
MidiEmit,
|
||||
GetMidiCC,
|
||||
MidiClock,
|
||||
MidiStart,
|
||||
MidiStop,
|
||||
MidiContinue,
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct StepContext {
|
||||
pub speed: f64,
|
||||
pub fill: bool,
|
||||
pub nudge_secs: f64,
|
||||
pub cc_memory: Option<CcMemory>,
|
||||
}
|
||||
|
||||
impl StepContext {
|
||||
@@ -41,6 +42,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]>>;
|
||||
pub(super) type CmdSnapshot<'a> = (Option<&'a Value>, &'a [(String, Value)]);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -124,6 +126,7 @@ pub(super) struct CmdRegister {
|
||||
deltas: Vec<Value>,
|
||||
}
|
||||
|
||||
|
||||
impl CmdRegister {
|
||||
pub(super) fn set_sound(&mut self, val: Value) {
|
||||
self.sound = Some(val);
|
||||
|
||||
@@ -790,6 +790,75 @@ 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);
|
||||
|
||||
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}"));
|
||||
} 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}"));
|
||||
} else if let Some(pressure) = get_int("pressure") {
|
||||
let pressure = pressure.clamp(0, 127) as u8;
|
||||
outputs.push(format!("/midi/pressure/{pressure}/chan/{chan}"));
|
||||
} else if let Some(program) = get_int("program") {
|
||||
let program = program.clamp(0, 127) as u8;
|
||||
outputs.push(format!("/midi/program/{program}/chan/{chan}"));
|
||||
} 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}"));
|
||||
}
|
||||
}
|
||||
Op::MidiClock => {
|
||||
outputs.push("/midi/clock".to_string());
|
||||
}
|
||||
Op::MidiStart => {
|
||||
outputs.push("/midi/start".to_string());
|
||||
}
|
||||
Op::MidiStop => {
|
||||
outputs.push("/midi/stop".to_string());
|
||||
}
|
||||
Op::MidiContinue => {
|
||||
outputs.push("/midi/continue".to_string());
|
||||
}
|
||||
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 val = ctx
|
||||
.cc_memory
|
||||
.as_ref()
|
||||
.and_then(|mem| mem.lock().ok())
|
||||
.map(|mem| mem[chan_clamped][cc_clamped])
|
||||
.unwrap_or(0);
|
||||
stack.push(Value::Int(val as i64, None));
|
||||
}
|
||||
}
|
||||
pc += 1;
|
||||
}
|
||||
|
||||
@@ -2280,6 +2280,127 @@ 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",
|
||||
example: "1 1 ccval",
|
||||
compile: Simple,
|
||||
varargs: false,
|
||||
},
|
||||
];
|
||||
|
||||
pub(super) fn simple_op(name: &str) -> Option<Op> {
|
||||
@@ -2357,6 +2478,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,
|
||||
})
|
||||
}
|
||||
|
||||
14
src/app.rs
14
src/app.rs
@@ -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,7 @@ pub struct App {
|
||||
pub audio: AudioSettings,
|
||||
pub options: OptionsState,
|
||||
pub panel: PanelState,
|
||||
pub midi: MidiState,
|
||||
}
|
||||
|
||||
impl Default for App {
|
||||
@@ -86,6 +88,7 @@ impl App {
|
||||
audio: AudioSettings::default(),
|
||||
options: OptionsState::default(),
|
||||
panel: PanelState::default(),
|
||||
midi: MidiState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +117,14 @@ impl App {
|
||||
tempo: link.tempo(),
|
||||
quantum: link.quantum(),
|
||||
},
|
||||
midi: crate::settings::MidiSettings {
|
||||
output_device: self.midi.selected_output.and_then(|idx| {
|
||||
crate::midi::list_midi_outputs().get(idx).map(|d| d.name.clone())
|
||||
}),
|
||||
input_device: self.midi.selected_input.and_then(|idx| {
|
||||
crate::midi::list_midi_inputs().get(idx).map(|d| d.name.clone())
|
||||
}),
|
||||
},
|
||||
};
|
||||
settings.save();
|
||||
}
|
||||
@@ -315,6 +326,7 @@ impl App {
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_memory: None,
|
||||
};
|
||||
|
||||
let cmds = self.script_engine.evaluate(script, &ctx)?;
|
||||
@@ -365,6 +377,7 @@ impl App {
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_memory: None,
|
||||
};
|
||||
|
||||
match self.script_engine.evaluate(&script, &ctx) {
|
||||
@@ -442,6 +455,7 @@ impl App {
|
||||
speed,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
cc_memory: None,
|
||||
};
|
||||
|
||||
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {
|
||||
|
||||
@@ -5,6 +5,6 @@ pub mod sequencer;
|
||||
pub use audio::{build_stream, AnalysisHandle, AudioStreamConfig, ScopeBuffer, SpectrumBuffer};
|
||||
pub use link::LinkState;
|
||||
pub use sequencer::{
|
||||
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand, SequencerConfig,
|
||||
SequencerHandle, SequencerSnapshot, StepSnapshot,
|
||||
spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand,
|
||||
SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot,
|
||||
};
|
||||
|
||||
@@ -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,13 @@ pub enum AudioCommand {
|
||||
ResetEngine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MidiCommand {
|
||||
NoteOn { channel: u8, note: u8, velocity: u8 },
|
||||
NoteOff { channel: u8, note: u8 },
|
||||
CC { channel: u8, cc: u8, value: u8 },
|
||||
}
|
||||
|
||||
pub enum SeqCommand {
|
||||
PatternUpdate {
|
||||
bank: usize,
|
||||
@@ -142,6 +149,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 +171,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 +205,7 @@ struct AudioState {
|
||||
active_patterns: HashMap<PatternId, ActivePattern>,
|
||||
pending_starts: Vec<PendingPattern>,
|
||||
pending_stops: Vec<PendingPattern>,
|
||||
flush_midi_notes: bool,
|
||||
}
|
||||
|
||||
impl AudioState {
|
||||
@@ -200,6 +215,7 @@ impl AudioState {
|
||||
active_patterns: HashMap::new(),
|
||||
pending_starts: Vec::new(),
|
||||
pending_stops: Vec::new(),
|
||||
flush_midi_notes: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +224,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 +238,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 +256,7 @@ pub fn spawn_sequencer(
|
||||
sequencer_loop(
|
||||
cmd_rx,
|
||||
audio_tx_for_thread,
|
||||
midi_tx_for_thread,
|
||||
link,
|
||||
playing,
|
||||
variables,
|
||||
@@ -248,6 +269,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 +277,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 +411,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 +458,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 +476,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), 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 +495,8 @@ impl SequencerState {
|
||||
speed_overrides: HashMap::new(),
|
||||
key_cache: KeyCache::new(),
|
||||
buf_audio_commands: Vec::new(),
|
||||
cc_memory,
|
||||
active_notes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +547,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 +591,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 +610,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 +738,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 +881,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 +894,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,18 +941,66 @@ fn sequencer_loop(
|
||||
let output = seq_state.tick(input);
|
||||
|
||||
for tsc in output.audio_commands {
|
||||
let cmd = AudioCommand::Evaluate {
|
||||
cmd: tsc.cmd,
|
||||
time: tsc.time,
|
||||
};
|
||||
match audio_tx.load().try_send(cmd) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
||||
seq_state.dropped_events += 1;
|
||||
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 { channel, note, .. }, Some(dur_secs)) =
|
||||
(&midi_cmd, dur)
|
||||
{
|
||||
let dur_us = (dur_secs * 1_000_000.0) as i64;
|
||||
seq_state.active_notes.insert(
|
||||
(*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,
|
||||
};
|
||||
match audio_tx.load().try_send(cmd) {
|
||||
Ok(()) => {}
|
||||
Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
|
||||
seq_state.dropped_events += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_NOTE_DURATION_US: i64 = 30_000_000; // 30 second safety timeout
|
||||
|
||||
if output.flush_midi_notes {
|
||||
for ((channel, note), _) in seq_state.active_notes.drain() {
|
||||
let _ = midi_tx.load().try_send(MidiCommand::NoteOff { channel, note });
|
||||
}
|
||||
// Send MIDI panic (CC 123 = All Notes Off) on all 16 channels
|
||||
for chan in 0..16u8 {
|
||||
let _ = midi_tx
|
||||
.load()
|
||||
.try_send(MidiCommand::CC { channel: chan, cc: 123, value: 0 });
|
||||
}
|
||||
} else {
|
||||
seq_state.active_notes.retain(|&(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 { channel, note });
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(t) = output.new_tempo {
|
||||
link.set_tempo(t);
|
||||
}
|
||||
@@ -921,6 +1011,48 @@ 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;
|
||||
}
|
||||
match parts[1] {
|
||||
"note" => {
|
||||
// /midi/note/<note>/vel/<vel>/chan/<chan>/dur/<dur>
|
||||
let note: u8 = parts.get(2)?.parse().ok()?;
|
||||
let vel: u8 = parts.get(4)?.parse().ok()?;
|
||||
let chan: u8 = parts.get(6)?.parse().ok()?;
|
||||
let dur: Option<f64> = parts.get(8).and_then(|s| s.parse().ok());
|
||||
Some((
|
||||
MidiCommand::NoteOn {
|
||||
channel: chan,
|
||||
note,
|
||||
velocity: vel,
|
||||
},
|
||||
dur,
|
||||
))
|
||||
}
|
||||
"cc" => {
|
||||
// /midi/cc/<cc>/<val>/chan/<chan>
|
||||
let cc: u8 = parts.get(2)?.parse().ok()?;
|
||||
let val: u8 = parts.get(3)?.parse().ok()?;
|
||||
let chan: u8 = parts.get(5)?.parse().ok()?;
|
||||
Some((
|
||||
MidiCommand::CC {
|
||||
channel: chan,
|
||||
cc,
|
||||
value: val,
|
||||
},
|
||||
None,
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -932,7 +1064,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 {
|
||||
|
||||
34
src/input.rs
34
src/input.rs
@@ -1270,6 +1270,40 @@ 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::MidiOutput => {
|
||||
let devices = crate::midi::list_midi_outputs();
|
||||
if !devices.is_empty() {
|
||||
let current = ctx.app.midi.selected_output.unwrap_or(0);
|
||||
let new_idx = if key.code == KeyCode::Left {
|
||||
if current == 0 { devices.len() - 1 } else { current - 1 }
|
||||
} else {
|
||||
(current + 1) % devices.len()
|
||||
};
|
||||
if ctx.app.midi.connect_output(new_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output: {}",
|
||||
devices[new_idx].name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionsFocus::MidiInput => {
|
||||
let devices = crate::midi::list_midi_inputs();
|
||||
if !devices.is_empty() {
|
||||
let current = ctx.app.midi.selected_input.unwrap_or(0);
|
||||
let new_idx = if key.code == KeyCode::Left {
|
||||
if current == 0 { devices.len() - 1 } else { current - 1 }
|
||||
} else {
|
||||
(current + 1) % devices.len()
|
||||
};
|
||||
if ctx.app.midi.connect_input(new_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input: {}",
|
||||
devices[new_idx].name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
src/main.rs
34
src/main.rs
@@ -2,6 +2,7 @@ mod app;
|
||||
mod commands;
|
||||
mod engine;
|
||||
mod input;
|
||||
mod midi;
|
||||
mod model;
|
||||
mod page;
|
||||
mod services;
|
||||
@@ -101,6 +102,20 @@ fn main() -> io::Result<()> {
|
||||
app.ui.color_scheme = settings.display.color_scheme;
|
||||
theme::set(settings.display.color_scheme.to_theme());
|
||||
|
||||
// Load MIDI settings
|
||||
if let Some(output_name) = &settings.midi.output_device {
|
||||
let outputs = midi::list_midi_outputs();
|
||||
if let Some(idx) = outputs.iter().position(|d| &d.name == output_name) {
|
||||
let _ = app.midi.connect_output(idx);
|
||||
}
|
||||
}
|
||||
if let Some(input_name) = &settings.midi.input_device {
|
||||
let inputs = midi::list_midi_inputs();
|
||||
if let Some(idx) = inputs.iter().position(|d| &d.name == input_name) {
|
||||
let _ = app.midi.connect_input(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let metrics = Arc::new(EngineMetrics::default());
|
||||
let scope_buffer = Arc::new(ScopeBuffer::new());
|
||||
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
|
||||
@@ -120,9 +135,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 +193,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 +237,21 @@ 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 { channel, note, velocity } => {
|
||||
app.midi.send_note_on(channel, note, velocity);
|
||||
}
|
||||
engine::MidiCommand::NoteOff { channel, note } => {
|
||||
app.midi.send_note_off(channel, note);
|
||||
}
|
||||
engine::MidiCommand::CC { channel, cc, value } => {
|
||||
app.midi.send_cc(channel, cc, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
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);
|
||||
|
||||
176
src/midi.rs
Normal file
176
src/midi.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use midir::{MidiInput, MidiOutput};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MidiDeviceInfo {
|
||||
pub name: String,
|
||||
pub port_index: usize,
|
||||
}
|
||||
|
||||
pub fn list_midi_outputs() -> Vec<MidiDeviceInfo> {
|
||||
let Ok(midi_out) = MidiOutput::new("cagire-probe") else {
|
||||
return Vec::new();
|
||||
};
|
||||
midi_out
|
||||
.ports()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, port)| {
|
||||
midi_out.port_name(port).ok().map(|name| MidiDeviceInfo {
|
||||
name,
|
||||
port_index: idx,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
|
||||
let Ok(midi_in) = MidiInput::new("cagire-probe") else {
|
||||
return Vec::new();
|
||||
};
|
||||
midi_in
|
||||
.ports()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, port)| {
|
||||
midi_in.port_name(port).ok().map(|name| MidiDeviceInfo {
|
||||
name,
|
||||
port_index: idx,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub type CcMemory = Arc<Mutex<[[u8; 128]; 16]>>;
|
||||
|
||||
pub struct MidiState {
|
||||
output_conn: Option<midir::MidiOutputConnection>,
|
||||
input_conn: Option<midir::MidiInputConnection<CcMemory>>,
|
||||
pub selected_output: Option<usize>,
|
||||
pub selected_input: Option<usize>,
|
||||
pub cc_memory: CcMemory,
|
||||
}
|
||||
|
||||
impl Default for MidiState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MidiState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
output_conn: None,
|
||||
input_conn: None,
|
||||
selected_output: None,
|
||||
selected_input: None,
|
||||
cc_memory: Arc::new(Mutex::new([[0u8; 128]; 16])),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect_output(&mut self, index: usize) -> Result<(), String> {
|
||||
let midi_out = MidiOutput::new("cagire-out").map_err(|e| e.to_string())?;
|
||||
let ports = midi_out.ports();
|
||||
let port = ports.get(index).ok_or("MIDI output port not found")?;
|
||||
let conn = midi_out
|
||||
.connect(port, "cagire-midi-out")
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.output_conn = Some(conn);
|
||||
self.selected_output = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect_output(&mut self) {
|
||||
if let Some(conn) = self.output_conn.take() {
|
||||
conn.close();
|
||||
}
|
||||
self.selected_output = None;
|
||||
}
|
||||
|
||||
pub fn connect_input(&mut self, index: usize) -> Result<(), String> {
|
||||
let midi_in = MidiInput::new("cagire-in").map_err(|e| e.to_string())?;
|
||||
let ports = midi_in.ports();
|
||||
let port = ports.get(index).ok_or("MIDI input port not found")?;
|
||||
|
||||
let cc_mem = Arc::clone(&self.cc_memory);
|
||||
let conn = midi_in
|
||||
.connect(
|
||||
port,
|
||||
"cagire-midi-in",
|
||||
move |_timestamp, message, cc_mem| {
|
||||
if message.len() >= 3 {
|
||||
let status = message[0];
|
||||
let data1 = message[1] as usize;
|
||||
let data2 = message[2];
|
||||
// CC message: 0xBn where n is channel 0-15
|
||||
if (status & 0xF0) == 0xB0 && data1 < 128 {
|
||||
let channel = (status & 0x0F) as usize;
|
||||
if let Ok(mut mem) = cc_mem.lock() {
|
||||
mem[channel][data1] = data2;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cc_mem,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
self.input_conn = Some(conn);
|
||||
self.selected_input = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect_input(&mut self) {
|
||||
if let Some(conn) = self.input_conn.take() {
|
||||
conn.close();
|
||||
}
|
||||
self.selected_input = None;
|
||||
}
|
||||
|
||||
pub fn send_note_on(&mut self, channel: u8, note: u8, velocity: u8) {
|
||||
if let Some(conn) = &mut self.output_conn {
|
||||
let status = 0x90 | (channel & 0x0F);
|
||||
let _ = conn.send(&[status, note & 0x7F, velocity & 0x7F]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_note_off(&mut self, channel: u8, note: u8) {
|
||||
if let Some(conn) = &mut self.output_conn {
|
||||
let status = 0x80 | (channel & 0x0F);
|
||||
let _ = conn.send(&[status, note & 0x7F, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_cc(&mut self, channel: u8, cc: u8, value: u8) {
|
||||
if let Some(conn) = &mut self.output_conn {
|
||||
let status = 0xB0 | (channel & 0x0F);
|
||||
let _ = conn.send(&[status, cc & 0x7F, value & 0x7F]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_all_notes_off(&mut self) {
|
||||
if let Some(conn) = &mut self.output_conn {
|
||||
for channel in 0..16u8 {
|
||||
let status = 0xB0 | channel;
|
||||
let _ = conn.send(&[status, 123, 0]); // CC 123 = All Notes Off
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cc(&self, channel: u8, cc: u8) -> u8 {
|
||||
let channel = (channel as usize).min(15);
|
||||
let cc = (cc as usize).min(127);
|
||||
self.cc_memory
|
||||
.lock()
|
||||
.map(|mem| mem[channel][cc])
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_output_connected(&self) -> bool {
|
||||
self.output_conn.is_some()
|
||||
}
|
||||
|
||||
pub fn is_input_connected(&self) -> bool {
|
||||
self.input_conn.is_some()
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,11 +4,19 @@ use crate::state::ColorScheme;
|
||||
|
||||
const APP_NAME: &str = "cagire";
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
pub struct MidiSettings {
|
||||
pub output_device: Option<String>,
|
||||
pub input_device: Option<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)]
|
||||
|
||||
@@ -11,6 +11,8 @@ pub enum OptionsFocus {
|
||||
LinkEnabled,
|
||||
StartStopSync,
|
||||
Quantum,
|
||||
MidiOutput,
|
||||
MidiInput,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -30,13 +32,15 @@ impl OptionsState {
|
||||
OptionsFocus::FlashBrightness => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync,
|
||||
OptionsFocus::StartStopSync => OptionsFocus::Quantum,
|
||||
OptionsFocus::Quantum => OptionsFocus::ColorScheme,
|
||||
OptionsFocus::Quantum => OptionsFocus::MidiOutput,
|
||||
OptionsFocus::MidiOutput => OptionsFocus::MidiInput,
|
||||
OptionsFocus::MidiInput => OptionsFocus::ColorScheme,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
OptionsFocus::ColorScheme => OptionsFocus::Quantum,
|
||||
OptionsFocus::ColorScheme => OptionsFocus::MidiInput,
|
||||
OptionsFocus::RefreshRate => OptionsFocus::ColorScheme,
|
||||
OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate,
|
||||
OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight,
|
||||
@@ -46,6 +50,8 @@ impl OptionsState {
|
||||
OptionsFocus::LinkEnabled => OptionsFocus::FlashBrightness,
|
||||
OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::Quantum => OptionsFocus::StartStopSync,
|
||||
OptionsFocus::MidiOutput => OptionsFocus::Quantum,
|
||||
OptionsFocus::MidiInput => OptionsFocus::MidiOutput,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -73,6 +74,32 @@ 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);
|
||||
|
||||
// MIDI device lists
|
||||
let midi_outputs = midi::list_midi_outputs();
|
||||
let midi_inputs = midi::list_midi_inputs();
|
||||
|
||||
let midi_out_display = if let Some(idx) = app.midi.selected_output {
|
||||
midi_outputs
|
||||
.get(idx)
|
||||
.map(|d| d.name.as_str())
|
||||
.unwrap_or("(disconnected)")
|
||||
} else if midi_outputs.is_empty() {
|
||||
"(none found)"
|
||||
} else {
|
||||
"(not connected)"
|
||||
};
|
||||
|
||||
let midi_in_display = if let Some(idx) = app.midi.selected_input {
|
||||
midi_inputs
|
||||
.get(idx)
|
||||
.map(|d| d.name.as_str())
|
||||
.unwrap_or("(disconnected)")
|
||||
} else if midi_inputs.is_empty() {
|
||||
"(none found)"
|
||||
} else {
|
||||
"(not connected)"
|
||||
};
|
||||
|
||||
// Build flat list of all lines
|
||||
let lines: Vec<Line> = vec![
|
||||
// DISPLAY section (lines 0-8)
|
||||
@@ -143,12 +170,19 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
|
||||
// Blank line (line 16)
|
||||
Line::from(""),
|
||||
// SESSION section (lines 17-22)
|
||||
// SESSION section (lines 17-20)
|
||||
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),
|
||||
// Blank line (line 22)
|
||||
Line::from(""),
|
||||
// MIDI section (lines 23-26)
|
||||
render_section_header("MIDI", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
render_option_line("Output", midi_out_display, focus == OptionsFocus::MidiOutput, &theme),
|
||||
render_option_line("Input", midi_in_display, focus == OptionsFocus::MidiInput, &theme),
|
||||
];
|
||||
|
||||
let total_lines = lines.len();
|
||||
@@ -166,6 +200,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
OptionsFocus::LinkEnabled => 12,
|
||||
OptionsFocus::StartStopSync => 13,
|
||||
OptionsFocus::Quantum => 14,
|
||||
OptionsFocus::MidiOutput => 25,
|
||||
OptionsFocus::MidiInput => 26,
|
||||
};
|
||||
|
||||
// Calculate scroll offset to keep focused line visible (centered when possible)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -51,3 +51,6 @@ mod ramps;
|
||||
|
||||
#[path = "forth/generator.rs"]
|
||||
mod generator;
|
||||
|
||||
#[path = "forth/midi.rs"]
|
||||
mod midi;
|
||||
|
||||
@@ -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
284
tests/forth/midi.rs
Normal 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]));
|
||||
{
|
||||
let mut mem = cc_memory.lock().unwrap();
|
||||
mem[0][1] = 64; // channel 1 (0-indexed), CC 1, value 64
|
||||
mem[5][74] = 127; // 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_start() {
|
||||
let outputs = expect_outputs("mstart", 1);
|
||||
assert_eq!(outputs[0], "/midi/start");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_stop() {
|
||||
let outputs = expect_outputs("mstop", 1);
|
||||
assert_eq!(outputs[0], "/midi/stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midi_continue() {
|
||||
let outputs = expect_outputs("mcont", 1);
|
||||
assert_eq!(outputs[0], "/midi/continue");
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
3
website/.gitignore
vendored
Normal file
3
website/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
5
website/astro.config.mjs
Normal file
5
website/astro.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
output: "static",
|
||||
});
|
||||
@@ -1,164 +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-desktop.app.zip" class="btn">macOS (ARM)</a>
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-macos-x86_64-desktop.app.zip" class="btn">macOS (Intel)</a>
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-windows-x86_64-desktop.msi" class="btn">Windows</a>
|
||||
<a href="https://github.com/Bubobubobubobubo/cagire/releases/latest/download/cagire-linux-x86_64-desktop.deb" class="btn">Linux</a>
|
||||
</div>
|
||||
<p style="margin-top: 0.75rem;">CLI binaries also available on <a href="https://github.com/Bubobubobubobubo/cagire/releases/latest">GitHub Releases</a>.</p>
|
||||
</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
12
website/package.json
Normal 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
3142
website/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
website/public/CozetteVector.ttf
Normal file
BIN
website/public/CozetteVector.ttf
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
173
website/src/pages/index.astro
Normal file
173
website/src/pages/index.astro
Normal 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>
|
||||
Reference in New Issue
Block a user