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::init::{init, InitArgs}; use cagire::engine::{ build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer, SequencerHandle, SpectrumBuffer, }; use cagire::input::{handle_key, InputContext, InputResult}; use cagire::input_egui::convert_egui_events; 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 soft = SoftBackend::::new(80, 24, regular, bold, italic); 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, lookahead_ms: 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, 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); cc.egui_ctx.set_visuals(egui::Visuals::dark()); Self { app: b.app, terminal, link: b.link, sequencer: Some(b.sequencer), playing: b.playing, nudge_us: b.nudge_us, lookahead_ms: b.lookahead_ms, 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, 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); 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)) => { 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.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.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()); } 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(); } } }); }); }); 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<()> { #[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)))), ) }