#![windows_subsystem = "windows"] 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 cagire::engine::{ build_stream, AnalysisHandle, AudioStreamConfig, LinkState, ScopeBuffer, SequencerHandle, SpectrumBuffer, }; use cagire::terminal::{create_terminal, FontChoice, TerminalType}; use cagire::init::{init, InitArgs}; use cagire::input::{handle_key, handle_mouse, InputContext, InputResult}; use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState}; use cagire::settings::Settings; 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, } 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, _input_stream: Option, _analysis_handle: Option, device_lost: Arc, stream_error_rx: crossbeam_channel::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, egui_mouse: EguiMouseState, } 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, _input_stream: b.input_stream, _analysis_handle: b.analysis_handle, device_lost: b.device_lost, stream_error_rx: b.stream_error_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(), egui_mouse: EguiMouseState::default(), } } fn handle_audio_restart(&mut self) { if !self.app.audio.restart_pending { return; } self.app.audio.restart_pending = false; self._stream = None; self._input_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(), input_device: self.app.audio.config.input_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 (new_error_tx, new_error_rx) = crossbeam_channel::bounded(16); self.stream_error_rx = new_error_rx; let mut restart_samples = Vec::new(); self.app.audio.config.sample_counts.clear(); for path in &self.app.audio.config.sample_paths { if path.is_dir() { let index = doux::sampling::scan_samples_dir(path); self.app.audio.config.sample_counts.push(index.len()); restart_samples.extend(index); } else { self.app.audio.config.sample_counts.push(0); } } 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), new_error_tx, &self.app.audio.config.sample_paths, Arc::clone(&self.device_lost), ) { Ok((new_stream, new_input, info, new_analysis, registry)) => { self._stream = Some(new_stream); self._input_stream = new_input; 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.app.audio.config.input_sample_rate = info.input_sample_rate; 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::engine::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.scope_right = self.scope_buffer.read_right(); (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(); let mouse_events = convert_egui_mouse(ctx, widget_rect, term, &mut self.egui_mouse); let key_events = 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, }; for mouse in mouse_events { handle_mouse(&mut input_ctx, mouse, term); } for key in key_events { 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(); if self.device_lost.load(Ordering::Acquire) { self.device_lost.store(false, Ordering::Release); self.app.audio.restart_pending = true; } while let Ok(err) = self.stream_error_rx.try_recv() { self.app.ui.flash(&err, 3000, cagire::state::FlashKind::Error); } 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); self.app.flush_dirty_script(&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 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(); if self.app.playback.has_armed() { let rate = std::f32::consts::TAU; self.app.ui.pulse_phase = (self.app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU; } 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<()> { 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)))), ) }