Files
Cagire/src/bin/desktop/main.rs

473 lines
16 KiB
Rust

#![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<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>,
}
struct CagireDesktop {
app: cagire::app::App,
terminal: TerminalType,
link: Arc<LinkState>,
sequencer: Option<SequencerHandle>,
playing: Arc<AtomicBool>,
nudge_us: Arc<AtomicI64>,
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>,
_input_stream: Option<cpal::Stream>,
_analysis_handle: Option<AnalysisHandle>,
device_lost: Arc<AtomicBool>,
stream_error_rx: crossbeam_channel::Receiver<String>,
current_font: FontChoice,
zoom_factor: f32,
fullscreen: bool,
decorations: bool,
always_on_top: bool,
mouse_x: Arc<AtomicU32>,
mouse_y: Arc<AtomicU32>,
mouse_down: Arc<AtomicU32>,
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(&registry));
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, &registry);
})
.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)))),
)
}