Files
Cagire/src/bin/desktop.rs
2026-02-03 03:08:13 +01:00

577 lines
19 KiB
Rust

use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use doux::EngineMetrics;
use eframe::NativeOptions;
use egui_ratatui::RataguiBackend;
use ratatui::Terminal;
use soft_ratatui::embedded_graphics_unicodefonts::{
mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas,
mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas,
mono_9x18_atlas, mono_9x18_bold_atlas,
};
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::app::App;
use cagire::engine::{
build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand,
ScopeBuffer, SequencerConfig, SequencerHandle, SpectrumBuffer,
};
use cagire::input::{handle_key, InputContext, InputResult};
use cagire::input_egui::convert_egui_events;
use cagire::settings::Settings;
use cagire::state::audio::RefreshRate;
use cagire::views;
use crossbeam_channel::Receiver;
#[derive(Parser)]
#[command(name = "cagire-desktop", about = "Cagire desktop application")]
struct Args {
#[arg(short, long)]
samples: Vec<std::path::PathBuf>,
#[arg(short, long)]
output: Option<String>,
#[arg(short, long)]
input: Option<String>,
#[arg(short, long)]
channels: Option<u16>,
#[arg(short, long)]
buffer: Option<u32>,
}
#[derive(Clone, Copy, PartialEq)]
enum FontChoice {
Size6x13,
Size7x13,
Size8x13,
Size9x15,
Size9x18,
Size10x20,
}
impl FontChoice {
fn from_setting(s: &str) -> Self {
match s {
"6x13" => Self::Size6x13,
"7x13" => Self::Size7x13,
"9x15" => Self::Size9x15,
"9x18" => Self::Size9x18,
"10x20" => Self::Size10x20,
_ => Self::Size8x13,
}
}
fn to_setting(self) -> &'static str {
match self {
Self::Size6x13 => "6x13",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20",
}
}
fn label(self) -> &'static str {
match self {
Self::Size6x13 => "6x13 (Compact)",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13 (Default)",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20 (Large)",
}
}
const ALL: [Self; 6] = [
Self::Size6x13,
Self::Size7x13,
Self::Size8x13,
Self::Size9x15,
Self::Size9x18,
Self::Size10x20,
];
}
type TerminalType = Terminal<RataguiBackend<EmbeddedGraphics>>;
fn create_terminal(font: FontChoice) -> TerminalType {
let (regular, bold, italic) = match font {
FontChoice::Size6x13 => (
mono_6x13_atlas(),
Some(mono_6x13_bold_atlas()),
Some(mono_6x13_italic_atlas()),
),
FontChoice::Size7x13 => (
mono_7x13_atlas(),
Some(mono_7x13_bold_atlas()),
Some(mono_7x13_italic_atlas()),
),
FontChoice::Size8x13 => (
mono_8x13_atlas(),
Some(mono_8x13_bold_atlas()),
Some(mono_8x13_italic_atlas()),
),
FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None),
FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None),
FontChoice::Size10x20 => (mono_10x20_atlas(), None, None),
};
let soft = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
}
struct CagireDesktop {
app: App,
terminal: TerminalType,
link: Arc<LinkState>,
sequencer: Option<SequencerHandle>,
playing: Arc<AtomicBool>,
nudge_us: Arc<AtomicI64>,
lookahead_ms: Arc<AtomicU32>,
metrics: Arc<EngineMetrics>,
scope_buffer: Arc<ScopeBuffer>,
spectrum_buffer: Arc<SpectrumBuffer>,
audio_sample_pos: Arc<AtomicU64>,
sample_rate_shared: Arc<AtomicU32>,
_stream: Option<cpal::Stream>,
_analysis_handle: Option<AnalysisHandle>,
midi_rx: Receiver<MidiCommand>,
current_font: FontChoice,
mouse_x: Arc<AtomicU32>,
mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>,
}
impl CagireDesktop {
fn new(cc: &eframe::CreationContext<'_>, args: Args) -> Self {
let settings = Settings::load();
let link = Arc::new(LinkState::new(settings.link.tempo, settings.link.quantum));
if settings.link.enabled {
link.enable();
}
let playing = Arc::new(AtomicBool::new(true));
let nudge_us = Arc::new(AtomicI64::new(0));
let mut app = App::new();
app.playback
.queued_changes
.push(cagire::state::StagedChange {
change: cagire::engine::PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: cagire::model::LaunchQuantization::Immediate,
sync_mode: cagire::model::SyncMode::Reset,
});
app.audio.config.output_device = args.output.or(settings.audio.output_device);
app.audio.config.input_device = args.input.or(settings.audio.input_device);
app.audio.config.channels = args.channels.unwrap_or(settings.audio.channels);
app.audio.config.buffer_size = args.buffer.unwrap_or(settings.audio.buffer_size);
app.audio.config.max_voices = settings.audio.max_voices;
app.audio.config.lookahead_ms = settings.audio.lookahead_ms;
app.audio.config.sample_paths = args.samples;
app.audio.config.refresh_rate = RefreshRate::from_fps(settings.display.fps);
app.ui.runtime_highlight = settings.display.runtime_highlight;
app.audio.config.show_scope = settings.display.show_scope;
app.audio.config.show_spectrum = settings.display.show_spectrum;
app.ui.show_completion = settings.display.show_completion;
let metrics = Arc::new(EngineMetrics::default());
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let audio_sample_pos = Arc::new(AtomicU64::new(0));
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let lookahead_ms = Arc::new(AtomicU32::new(settings.audio.lookahead_ms));
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
app.audio.config.sample_count += index.len();
initial_samples.extend(index);
}
let mouse_x = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
let mouse_y = Arc::new(AtomicU32::new(0.5_f32.to_bits()));
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let seq_config = SequencerConfig {
audio_sample_pos: Arc::clone(&audio_sample_pos),
sample_rate: Arc::clone(&sample_rate_shared),
lookahead_ms: Arc::clone(&lookahead_ms),
cc_access: Some(
Arc::new(app.midi.cc_memory.clone()) as Arc<dyn cagire::model::CcAccess>
),
mouse_x: Arc::clone(&mouse_x),
mouse_y: Arc::clone(&mouse_y),
mouse_down: Arc::clone(&mouse_down),
};
let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&app.variables),
Arc::clone(&app.dict),
Arc::clone(&app.rng),
settings.link.quantum,
Arc::clone(&app.live_keys),
Arc::clone(&nudge_us),
seq_config,
);
let stream_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(),
channels: app.audio.config.channels,
buffer_size: app.audio.config.buffer_size,
max_voices: app.audio.config.max_voices,
};
let (stream, analysis_handle) = match build_stream(
&stream_config,
initial_audio_rx,
Arc::clone(&scope_buffer),
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
initial_samples,
Arc::clone(&audio_sample_pos),
) {
Ok((s, sample_rate, analysis)) => {
app.audio.config.sample_rate = sample_rate;
sample_rate_shared.store(sample_rate as u32, Ordering::Relaxed);
(Some(s), Some(analysis))
}
Err(e) => {
app.ui.set_status(format!("Audio failed: {e}"));
app.audio.error = Some(e);
(None, None)
}
};
app.mark_all_patterns_dirty();
let current_font = FontChoice::from_setting(&settings.display.font);
let terminal = create_terminal(current_font);
cc.egui_ctx.set_visuals(egui::Visuals::dark());
Self {
app,
terminal,
link,
sequencer: Some(sequencer),
playing,
nudge_us,
lookahead_ms,
metrics,
scope_buffer,
spectrum_buffer,
audio_sample_pos,
sample_rate_shared,
_stream: stream,
_analysis_handle: analysis_handle,
midi_rx,
current_font,
mouse_x,
mouse_y,
mouse_down,
}
}
fn handle_audio_restart(&mut self) {
if !self.app.audio.restart_pending {
return;
}
self.app.audio.restart_pending = false;
self._stream = None;
self._analysis_handle = None;
let Some(ref sequencer) = self.sequencer else {
return;
};
let new_audio_rx = sequencer.swap_audio_channel();
self.midi_rx = sequencer.swap_midi_channel();
let new_config = AudioStreamConfig {
output_device: self.app.audio.config.output_device.clone(),
channels: self.app.audio.config.channels,
buffer_size: self.app.audio.config.buffer_size,
max_voices: self.app.audio.config.max_voices,
};
let mut restart_samples = Vec::new();
for path in &self.app.audio.config.sample_paths {
let index = doux::sampling::scan_samples_dir(path);
restart_samples.extend(index);
}
self.app.audio.config.sample_count = restart_samples.len();
self.audio_sample_pos.store(0, Ordering::Release);
match build_stream(
&new_config,
new_audio_rx,
Arc::clone(&self.scope_buffer),
Arc::clone(&self.spectrum_buffer),
Arc::clone(&self.metrics),
restart_samples,
Arc::clone(&self.audio_sample_pos),
) {
Ok((new_stream, sr, new_analysis)) => {
self._stream = Some(new_stream);
self._analysis_handle = Some(new_analysis);
self.app.audio.config.sample_rate = sr;
self.sample_rate_shared.store(sr as u32, Ordering::Relaxed);
self.app.audio.error = None;
self.app.ui.set_status("Audio restarted".to_string());
}
Err(e) => {
self.app.audio.error = Some(e.clone());
self.app.ui.set_status(format!("Audio failed: {e}"));
}
}
}
fn update_metrics(&mut self) {
self.app.playback.playing = self.playing.load(Ordering::Relaxed);
self.app.metrics.active_voices =
self.metrics.active_voices.load(Ordering::Relaxed) as usize;
self.app.metrics.peak_voices = self
.app
.metrics
.peak_voices
.max(self.app.metrics.active_voices);
self.app.metrics.cpu_load = self.metrics.load.get_load();
self.app.metrics.schedule_depth =
self.metrics.schedule_depth.load(Ordering::Relaxed) as usize;
self.app.metrics.scope = self.scope_buffer.read();
(self.app.metrics.peak_left, self.app.metrics.peak_right) = self.scope_buffer.peaks();
self.app.metrics.spectrum = self.spectrum_buffer.read();
self.app.metrics.nudge_ms = self.nudge_us.load(Ordering::Relaxed) as f64 / 1000.0;
}
fn handle_input(&mut self, ctx: &egui::Context) -> bool {
let Some(ref sequencer) = self.sequencer else {
return false;
};
let seq_snapshot = sequencer.snapshot();
for key in convert_egui_events(ctx) {
let mut input_ctx = InputContext {
app: &mut self.app,
link: &self.link,
snapshot: &seq_snapshot,
playing: &self.playing,
audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &self.nudge_us,
lookahead_ms: &self.lookahead_ms,
};
if let InputResult::Quit = handle_key(&mut input_ctx, key) {
return true;
}
}
false
}
}
impl eframe::App for CagireDesktop {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.handle_audio_restart();
self.update_metrics();
ctx.input(|i| {
if let Some(pos) = i.pointer.latest_pos() {
let screen = i.viewport_rect();
let nx = (pos.x / screen.width()).clamp(0.0, 1.0);
let ny = (pos.y / screen.height()).clamp(0.0, 1.0);
self.mouse_x.store(nx.to_bits(), Ordering::Relaxed);
self.mouse_y.store(ny.to_bits(), Ordering::Relaxed);
}
let down = if i.pointer.primary_down() {
1.0_f32
} else {
0.0_f32
};
self.mouse_down.store(down.to_bits(), Ordering::Relaxed);
});
let Some(ref sequencer) = self.sequencer else {
return;
};
let seq_snapshot = sequencer.snapshot();
self.app.metrics.event_count = seq_snapshot.event_count;
self.app.metrics.dropped_events = seq_snapshot.dropped_events;
self.app.flush_queued_changes(&sequencer.cmd_tx);
self.app.flush_dirty_patterns(&sequencer.cmd_tx);
while let Ok(midi_cmd) = self.midi_rx.try_recv() {
match midi_cmd {
MidiCommand::NoteOn {
device,
channel,
note,
velocity,
} => {
self.app.midi.send_note_on(device, channel, note, velocity);
}
MidiCommand::NoteOff {
device,
channel,
note,
} => {
self.app.midi.send_note_off(device, channel, note);
}
MidiCommand::CC {
device,
channel,
cc,
value,
} => {
self.app.midi.send_cc(device, channel, cc, value);
}
MidiCommand::PitchBend {
device,
channel,
value,
} => {
self.app.midi.send_pitch_bend(device, channel, value);
}
MidiCommand::Pressure {
device,
channel,
value,
} => {
self.app.midi.send_pressure(device, channel, value);
}
MidiCommand::ProgramChange {
device,
channel,
program,
} => {
self.app.midi.send_program_change(device, channel, program);
}
MidiCommand::Clock { device } => self.app.midi.send_realtime(device, 0xF8),
MidiCommand::Start { device } => self.app.midi.send_realtime(device, 0xFA),
MidiCommand::Stop { device } => self.app.midi.send_realtime(device, 0xFC),
MidiCommand::Continue { device } => self.app.midi.send_realtime(device, 0xFB),
}
}
let should_quit = self.handle_input(ctx);
if should_quit {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
return;
}
let current_font = self.current_font;
let mut new_font = None;
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
.show(ctx, |ui| {
if self.app.ui.show_title {
self.app.ui.sparkles.tick(self.terminal.get_frame().area());
}
let link = &self.link;
let app = &self.app;
self.terminal
.draw(|frame| views::render(frame, app, link, &seq_snapshot))
.expect("Failed to draw");
ui.add(self.terminal.backend_mut());
let response = ui.interact(
ui.max_rect(),
egui::Id::new("terminal_context"),
egui::Sense::click(),
);
response.context_menu(|ui| {
ui.menu_button("Font", |ui| {
for choice in FontChoice::ALL {
let selected = current_font == choice;
if ui.selectable_label(selected, choice.label()).clicked() {
new_font = Some(choice);
ui.close();
}
}
});
});
});
if let Some(font) = new_font {
self.terminal = create_terminal(font);
self.current_font = font;
let mut settings = Settings::load();
settings.display.font = font.to_setting().to_string();
settings.save();
}
ctx.request_repaint_after(Duration::from_millis(
self.app.audio.config.refresh_rate.millis(),
));
}
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
if let Some(sequencer) = self.sequencer.take() {
sequencer.shutdown();
}
}
}
fn load_icon() -> egui::IconData {
const ICON_BYTES: &[u8] = include_bytes!("../../cagire_pixel.png");
let img = image::load_from_memory(ICON_BYTES)
.expect("Failed to load embedded icon")
.resize(64, 64, image::imageops::FilterType::Lanczos3)
.into_rgba8();
let (width, height) = img.dimensions();
egui::IconData {
rgba: img.into_raw(),
width,
height,
}
}
fn main() -> eframe::Result<()> {
// Lock memory BEFORE any threads are spawned to prevent page faults in RT context
#[cfg(unix)]
cagire::engine::realtime::lock_memory();
let args = Args::parse();
let options = NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Cagire")
.with_inner_size([1200.0, 800.0])
.with_icon(std::sync::Arc::new(load_icon())),
..Default::default()
};
eframe::run_native(
"Cagire",
options,
Box::new(move |cc| Ok(Box::new(CagireDesktop::new(cc, args)))),
)
}