diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index db45ff1..5cbba13 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -129,8 +129,7 @@ impl SequencerSnapshot { pub struct SequencerHandle { pub cmd_tx: Sender, - pub audio_tx: Sender, - pub audio_rx: Receiver, + pub audio_tx: Arc>>, shared_state: Arc>, thread: JoinHandle<()>, } @@ -146,6 +145,12 @@ impl SequencerHandle { } } + pub fn swap_audio_channel(&self) -> Receiver { + let (new_tx, new_rx) = bounded::(256); + self.audio_tx.store(Arc::new(new_tx)); + new_rx + } + pub fn shutdown(self) { let _ = self.cmd_tx.send(SeqCommand::Shutdown); let _ = self.thread.join(); @@ -186,20 +191,21 @@ pub fn spawn_sequencer( rng: Rng, quantum: f64, live_keys: Arc, -) -> SequencerHandle { +) -> (SequencerHandle, Receiver) { let (cmd_tx, cmd_rx) = bounded::(64); let (audio_tx, audio_rx) = bounded::(256); + let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx)); let shared_state = Arc::new(ArcSwap::from_pointee(SharedSequencerState::default())); let shared_state_clone = Arc::clone(&shared_state); - let audio_tx_clone = audio_tx.clone(); + let audio_tx_for_thread = Arc::clone(&audio_tx); let thread = thread::Builder::new() .name("sequencer".into()) .spawn(move || { sequencer_loop( cmd_rx, - audio_tx_clone, + audio_tx_for_thread, link, playing, variables, @@ -212,13 +218,13 @@ pub fn spawn_sequencer( }) .expect("Failed to spawn sequencer thread"); - SequencerHandle { + let handle = SequencerHandle { cmd_tx, audio_tx, - audio_rx, shared_state, thread, - } + }; + (handle, audio_rx) } struct PatternCache { @@ -294,7 +300,7 @@ impl RunsCounter { #[allow(clippy::too_many_arguments)] fn sequencer_loop( cmd_rx: Receiver, - audio_tx: Sender, + audio_tx: Arc>>, link: Arc, playing: Arc, variables: Variables, @@ -430,7 +436,7 @@ fn sequencer_loop( std::mem::take(&mut trace), ); for cmd in cmds { - match audio_tx.try_send(AudioCommand::Evaluate(cmd)) { + match audio_tx.load().try_send(AudioCommand::Evaluate(cmd)) { Ok(()) => { event_count += 1; } @@ -438,7 +444,9 @@ fn sequencer_loop( dropped_events += 1; } Err(TrySendError::Disconnected(_)) => { - return; + // Channel disconnected means old stream is gone, but + // a new one will be swapped in. Don't exit - just skip. + dropped_events += 1; } } } diff --git a/src/input.rs b/src/input.rs index 0029a3d..85eb868 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,3 +1,4 @@ +use arc_swap::ArcSwap; use crossbeam_channel::Sender; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -21,7 +22,7 @@ pub struct InputContext<'a> { pub link: &'a LinkState, pub snapshot: &'a SequencerSnapshot, pub playing: &'a Arc, - pub audio_tx: &'a Sender, + pub audio_tx: &'a ArcSwap>, } impl<'a> InputContext<'a> { @@ -362,7 +363,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { if let Some(path) = sample_path { let index = doux::loader::scan_samples_dir(&path); let count = index.len(); - let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); + let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); ctx.app.audio.config.sample_count += count; ctx.app.audio.add_sample_path(path); ctx.dispatch(AppCommand::SetStatus(format!("Added {count} samples"))); @@ -515,7 +516,7 @@ fn handle_panel_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let idx = entry.index; let cmd = format!("/sound/{folder}/n/{idx}/gain/0.5/dur/1"); - let _ = ctx.audio_tx.send(AudioCommand::Evaluate(cmd)); + let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate(cmd)); } _ => state.toggle_expand(), } @@ -846,15 +847,16 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } } KeyCode::Char('h') => { - let _ = ctx.audio_tx.send(AudioCommand::Hush); + let _ = ctx.audio_tx.load().send(AudioCommand::Hush); } KeyCode::Char('p') => { - let _ = ctx.audio_tx.send(AudioCommand::Panic); + let _ = ctx.audio_tx.load().send(AudioCommand::Panic); } KeyCode::Char('r') => ctx.app.metrics.peak_voices = 0, KeyCode::Char('t') => { let _ = ctx .audio_tx + .load() .send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into())); } _ => {} @@ -986,7 +988,7 @@ fn load_project_samples(ctx: &mut InputContext) { let index = doux::loader::scan_samples_dir(path); let count = index.len(); total_count += count; - let _ = ctx.audio_tx.send(AudioCommand::LoadSamples(index)); + let _ = ctx.audio_tx.load().send(AudioCommand::LoadSamples(index)); } } diff --git a/src/main.rs b/src/main.rs index 25da3be..771be52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,7 +99,7 @@ fn main() -> io::Result<()> { initial_samples.extend(index); } - let sequencer = spawn_sequencer( + let (sequencer, initial_audio_rx) = spawn_sequencer( Arc::clone(&link), Arc::clone(&playing), Arc::clone(&app.variables), @@ -118,7 +118,7 @@ fn main() -> io::Result<()> { let (mut _stream, mut _analysis_handle) = match build_stream( &stream_config, - sequencer.audio_rx.clone(), + initial_audio_rx, Arc::clone(&scope_buffer), Arc::clone(&spectrum_buffer), Arc::clone(&metrics), @@ -149,6 +149,8 @@ fn main() -> io::Result<()> { _stream = None; _analysis_handle = None; + let new_audio_rx = sequencer.swap_audio_channel(); + let new_config = AudioStreamConfig { output_device: app.audio.config.output_device.clone(), channels: app.audio.config.channels, @@ -165,7 +167,7 @@ fn main() -> io::Result<()> { match build_stream( &new_config, - sequencer.audio_rx.clone(), + new_audio_rx, Arc::clone(&scope_buffer), Arc::clone(&spectrum_buffer), Arc::clone(&metrics), diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 22ccbac..8971956 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -9,6 +9,9 @@ use crate::app::App; use crate::state::{DeviceKind, EngineSection, SettingKind}; use crate::widgets::{Orientation, Scope, Spectrum}; +const HEADER_COLOR: Color = Color::Rgb(100, 160, 180); +const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70); + pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [left_col, _, right_col] = Layout::horizontal([ Constraint::Percentage(55), @@ -42,9 +45,9 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ Constraint::Length(devices_height), Constraint::Length(1), - Constraint::Length(6), + Constraint::Length(7), Constraint::Length(1), - Constraint::Min(5), + Constraint::Min(6), ]) .areas(padded); @@ -109,23 +112,39 @@ fn list_height(item_count: usize) -> u16 { fn devices_section_height(app: &App) -> u16 { let output_h = list_height(app.audio.output_devices.len()); let input_h = list_height(app.audio.input_devices.len()); - 2 + output_h.max(input_h) + 3 + output_h.max(input_h) +} + +fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) { + let [header_area, divider_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + ]).areas(area); + + let header_style = if focused { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD) + }; + + frame.render_widget(Paragraph::new(title).style(header_style), header_area); + + let divider = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)), + divider_area, + ); } fn render_devices(frame: &mut Frame, app: &App, area: Rect) { let section_focused = app.audio.section == EngineSection::Devices; - let header_style = if section_focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD) - }; let [header_area, content_area] = Layout::vertical([ - Constraint::Length(1), + Constraint::Length(2), Constraint::Min(1), ]).areas(area); - frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); + render_section_header(frame, "DEVICES", section_focused, header_area); let [output_col, separator, input_col] = Layout::horizontal([ Constraint::Percentage(48), @@ -206,16 +225,11 @@ fn render_device_column( fn render_settings(frame: &mut Frame, app: &App, area: Rect) { let section_focused = app.audio.section == EngineSection::Settings; - let header_style = if section_focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD) - }; let [header_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area); - frame.render_widget(Paragraph::new("Settings").style(header_style), header_area); + render_section_header(frame, "SETTINGS", section_focused, header_area); let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); let normal = Style::new().fg(Color::White); @@ -269,14 +283,9 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) { fn render_samples(frame: &mut Frame, app: &App, area: Rect) { let section_focused = app.audio.section == EngineSection::Samples; - let header_style = if section_focused { - Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) - } else { - Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD) - }; let [header_area, content_area, _, hint_area] = Layout::vertical([ - Constraint::Length(1), + Constraint::Length(2), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), @@ -285,8 +294,8 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) { let path_count = app.audio.config.sample_paths.len(); let sample_count = app.audio.config.sample_count; - let header_text = format!("Samples {path_count} paths · {sample_count} indexed"); - frame.render_widget(Paragraph::new(header_text).style(header_style), header_area); + let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed"); + render_section_header(frame, &header_text, section_focused, header_area); let dim = Style::new().fg(Color::Rgb(80, 85, 95)); let path_style = Style::new().fg(Color::Rgb(120, 125, 135));