602 lines
20 KiB
Rust
602 lines
20 KiB
Rust
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<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>,
|
|
}
|
|
|
|
#[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<RataguiBackend<BlockCharBackend>>;
|
|
|
|
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::<EmbeddedGraphics>::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<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>,
|
|
_analysis_handle: Option<AnalysisHandle>,
|
|
midi_rx: Receiver<MidiCommand>,
|
|
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,
|
|
}
|
|
|
|
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)))),
|
|
)
|
|
}
|