Lots + MIDI implementation

This commit is contained in:
2026-01-31 23:13:51 +01:00
parent b5fe6a1437
commit 03c0baf5b5
34 changed files with 4323 additions and 191 deletions

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,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) {

View File

@@ -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,
};

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,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 {

View File

@@ -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);
}

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,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
View 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()
}
}

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,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)]

View File

@@ -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,
};
}
}

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;
@@ -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)

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) {