use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; use cagire::block_renderer::BlockCharBackend; 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::engine::{ build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, SequencerHandle, SpectrumBuffer, }; use cagire::init::{init, InitArgs}; use cagire::input::{handle_key, handle_mouse, InputContext, InputResult}; use cagire::input_egui::{convert_egui_events, convert_egui_mouse}; use cagire::settings::Settings; use cagire::views; use crossbeam_channel::Receiver; #[derive(Parser)] #[command(name = "cagire-desktop", about = "Cagire desktop application")] struct Args { #[arg(short, long)] samples: Vec, #[arg(short, long)] output: Option, #[arg(short, long)] input: Option, #[arg(short, long)] channels: Option, #[arg(short, long)] buffer: Option, } #[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>; 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 eg = SoftBackend::::new(80, 24, regular, bold, italic); let soft = SoftBackend { buffer: eg.buffer, cursor: eg.cursor, cursor_pos: eg.cursor_pos, char_width: eg.char_width, char_height: eg.char_height, blink_counter: eg.blink_counter, blinking_fast: eg.blinking_fast, blinking_slow: eg.blinking_slow, rgb_pixmap: eg.rgb_pixmap, always_redraw_list: eg.always_redraw_list, raster_backend: BlockCharBackend { inner: eg.raster_backend, }, }; Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") } struct CagireDesktop { app: cagire::app::App, terminal: TerminalType, link: Arc, sequencer: Option, playing: Arc, nudge_us: Arc, metrics: Arc, scope_buffer: Arc, spectrum_buffer: Arc, audio_sample_pos: Arc, sample_rate_shared: Arc, _stream: Option, _analysis_handle: Option, midi_rx: Receiver, current_font: FontChoice, zoom_factor: f32, fullscreen: bool, decorations: bool, always_on_top: bool, mouse_x: Arc, mouse_y: Arc, mouse_down: Arc, last_frame: std::time::Instant, } impl CagireDesktop { fn new(cc: &eframe::CreationContext<'_>, args: Args) -> Self { let b = init(InitArgs { samples: args.samples, output: args.output, input: args.input, channels: args.channels, buffer: args.buffer, }); let current_font = FontChoice::from_setting(&b.settings.display.font); let terminal = create_terminal(current_font); let zoom_factor = b.settings.display.zoom_factor; cc.egui_ctx.set_visuals(egui::Visuals::dark()); cc.egui_ctx.set_zoom_factor(zoom_factor); Self { app: b.app, terminal, link: b.link, sequencer: Some(b.sequencer), playing: b.playing, nudge_us: b.nudge_us, metrics: b.metrics, scope_buffer: b.scope_buffer, spectrum_buffer: b.spectrum_buffer, audio_sample_pos: b.audio_sample_pos, sample_rate_shared: b.sample_rate_shared, _stream: b.stream, _analysis_handle: b.analysis_handle, midi_rx: b.midi_rx, current_font, zoom_factor, fullscreen: false, decorations: true, always_on_top: false, mouse_x: b.mouse_x, mouse_y: b.mouse_y, mouse_down: b.mouse_down, last_frame: std::time::Instant::now(), } } 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); let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples .iter() .map(|e| (e.name.clone(), e.path.clone())) .collect(); 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, info, new_analysis, registry)) => { self._stream = Some(new_stream); self._analysis_handle = Some(new_analysis); self.app.audio.config.sample_rate = info.sample_rate; self.app.audio.config.host_name = info.host_name; self.app.audio.config.channels = info.channels; self.sample_rate_shared .store(info.sample_rate as u32, Ordering::Relaxed); self.app.audio.error = None; self.app.audio.sample_registry = Some(std::sync::Arc::clone(®istry)); self.app.ui.set_status("Audio restarted".to_string()); if !preload_entries.is_empty() { let sr = info.sample_rate; std::thread::Builder::new() .name("sample-preload".into()) .spawn(move || { cagire::init::preload_sample_heads(preload_entries, sr, ®istry); }) .expect("failed to spawn preload thread"); } } 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(); let term = self.terminal.get_frame().area(); let widget_rect = ctx.content_rect(); for mouse in convert_egui_mouse(ctx, widget_rect, term) { 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, }; handle_mouse(&mut input_ctx, mouse, term); } 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, }; 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.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 current_zoom = self.zoom_factor; let current_fullscreen = self.fullscreen; let current_decorations = self.decorations; let current_always_on_top = self.always_on_top; let mut new_font = None; let mut new_zoom = None; let mut toggle_fullscreen = false; let mut toggle_decorations = false; let mut toggle_always_on_top = false; 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()); } cagire::state::effects::tick_effects(&mut self.app.ui, self.app.page); let elapsed = self.last_frame.elapsed(); self.last_frame = std::time::Instant::now(); let link = &self.link; let app = &self.app; self.terminal .draw(|frame| views::render(frame, app, link, &seq_snapshot, elapsed)) .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(); } } }); ui.menu_button("Zoom", |ui| { for &level in &[0.5_f32, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] { let selected = (current_zoom - level).abs() < 0.01; let label = format!("{:.0}%", level * 100.0); if ui.selectable_label(selected, label).clicked() { new_zoom = Some(level); ui.close(); } } }); ui.separator(); if ui .selectable_label(current_fullscreen, "Fullscreen") .clicked() { toggle_fullscreen = true; ui.close(); } if ui .selectable_label(current_always_on_top, "Always On Top") .clicked() { toggle_always_on_top = true; ui.close(); } if ui .selectable_label(!current_decorations, "Borderless") .clicked() { toggle_decorations = true; 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(); } if let Some(zoom) = new_zoom { self.zoom_factor = zoom; ctx.set_zoom_factor(zoom); let mut settings = Settings::load(); settings.display.zoom_factor = zoom; settings.save(); } if toggle_fullscreen { self.fullscreen = !self.fullscreen; ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); } if toggle_always_on_top { self.always_on_top = !self.always_on_top; let level = if self.always_on_top { egui::WindowLevel::AlwaysOnTop } else { egui::WindowLevel::Normal }; ctx.send_viewport_cmd(egui::ViewportCommand::WindowLevel(level)); } if toggle_decorations { self.decorations = !self.decorations; ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(self.decorations)); } 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!("../../../assets/Cagire.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<()> { #[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)))), ) }