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_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, mono_10x20_atlas, }; use soft_ratatui::{EmbeddedGraphics, SoftBackend}; use cagire::app::App; use cagire::engine::{ build_stream, spawn_sequencer, AnalysisHandle, AudioStreamConfig, LinkState, 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; #[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 soft = SoftBackend::::new(80, 24, regular, bold, italic); Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal") } struct CagireDesktop { app: App, terminal: TerminalType, link: Arc, sequencer: Option, playing: Arc, nudge_us: Arc, lookahead_ms: Arc, metrics: Arc, scope_buffer: Arc, spectrum_buffer: Arc, audio_sample_pos: Arc, sample_rate_shared: Arc, _stream: Option, _analysis_handle: Option, current_font: FontChoice, pending_font: Option, } 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; app.ui.flash_brightness = settings.display.flash_brightness; 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 seq_config = SequencerConfig { audio_sample_pos: Arc::clone(&audio_sample_pos), sample_rate: Arc::clone(&sample_rate_shared), lookahead_ms: Arc::clone(&lookahead_ms), }; let (sequencer, initial_audio_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, current_font, pending_font: None, } } 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(); 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::Relaxed); 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) { if let Some(font) = self.pending_font.take() { self.terminal = create_terminal(font); self.current_font = font; let mut settings = Settings::load(); settings.display.font = font.to_setting().to_string(); settings.save(); } self.handle_audio_restart(); self.update_metrics(); 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.ui.event_flash = (self.app.ui.event_flash - 0.1).max(0.0); let new_events = self .app .metrics .event_count .saturating_sub(self.app.ui.last_event_count); if new_events > 0 { self.app.ui.event_flash = (new_events as f32 * 0.4).min(1.0); } self.app.ui.last_event_count = self.app.metrics.event_count; self.app.flush_queued_changes(&sequencer.cmd_tx); self.app.flush_dirty_patterns(&sequencer.cmd_tx); 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 pending_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()); // Create a click-sensing overlay for context menu 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() { pending_font = Some(choice); ui.close(); } } }); }); }); if pending_font.is_some() { self.pending_font = pending_font; } 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 { let size = 64u32; let mut rgba = vec![0u8; (size * size * 4) as usize]; for y in 0..size { for x in 0..size { let idx = ((y * size + x) * 4) as usize; let cx = x as f32 - size as f32 / 2.0; let cy = y as f32 - size as f32 / 2.0; let dist = (cx * cx + cy * cy).sqrt(); let radius = size as f32 / 2.0 - 2.0; if dist < radius { let angle = cy.atan2(cx); let normalized = (angle + std::f32::consts::PI) / (2.0 * std::f32::consts::PI); if normalized > 0.1 && normalized < 0.9 { let inner_radius = radius * 0.5; if dist > inner_radius { rgba[idx] = 80; rgba[idx + 1] = 160; rgba[idx + 2] = 200; rgba[idx + 3] = 255; } else { rgba[idx] = 30; rgba[idx + 1] = 60; rgba[idx + 2] = 80; rgba[idx + 3] = 255; } } else { rgba[idx] = 30; rgba[idx + 1] = 30; rgba[idx + 2] = 40; rgba[idx + 3] = 255; } } else { rgba[idx + 3] = 0; } } } egui::IconData { rgba, width: size, height: size, } } fn main() -> eframe::Result<()> { 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)))), ) }