mod app; mod audio; mod file; mod link; mod model; mod page; mod script; mod ui; mod views; mod widgets; use std::io; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use clap::Parser; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use crossterm::ExecutableCommand; use doux::Engine; use ratatui::prelude::CrosstermBackend; use ratatui::Terminal; use app::{App, AudioFocus, Focus, Modal, PatternField}; use audio::{AudioStreamConfig, SlotChange, MAX_SLOTS}; use link::LinkState; use model::Project; use page::Page; #[derive(Parser)] #[command(name = "seq", about = "A step sequencer with Ableton Link support")] struct Args { /// Directory containing audio samples to load (can be specified multiple times) #[arg(short, long)] samples: Vec, /// Output audio device (name or index) #[arg(short, long)] output: Option, /// Input audio device (name or index) #[arg(short, long)] input: Option, /// Number of output channels #[arg(short, long, default_value = "2")] channels: u16, /// Audio buffer size in samples #[arg(short, long, default_value = "512")] buffer: u32, } const TEMPO: f64 = 120.0; const QUANTUM: f64 = 4.0; fn main() -> io::Result<()> { let args = Args::parse(); let link = Arc::new(LinkState::new(TEMPO, QUANTUM)); link.enable(); let playing = Arc::new(AtomicBool::new(true)); let event_count = Arc::new(AtomicUsize::new(0)); // Slot state shared between audio thread and UI let slot_steps: [Arc; MAX_SLOTS] = std::array::from_fn(|_| Arc::new(AtomicUsize::new(0))); let slot_data: Arc> = Arc::new(Mutex::new([(false, 0, 0); MAX_SLOTS])); let slot_changes: Arc>> = Arc::new(Mutex::new(Vec::new())); let mut app = App::new(TEMPO, QUANTUM); // Apply CLI args to audio config app.audio_config.output_device = args.output; app.audio_config.input_device = args.input; app.audio_config.channels = args.channels; app.audio_config.buffer_size = args.buffer; app.audio_config.sample_paths = args.samples; let engine = Arc::new(Mutex::new(Engine::new(44100.0))); let project = Arc::new(Mutex::new(Project::default())); // Load sample directories for path in &app.audio_config.sample_paths { let index = doux::loader::scan_samples_dir(path); let count = index.len(); engine.lock().unwrap().sample_index.extend(index); app.audio_config.sample_count += count; } let stream_config = AudioStreamConfig { output_device: app.audio_config.output_device.clone(), channels: app.audio_config.channels, buffer_size: app.audio_config.buffer_size, }; let (mut stream, sample_rate) = audio::build_stream( &stream_config, Arc::clone(&engine), Arc::clone(&link), Arc::clone(&playing), Arc::clone(&project), slot_steps.clone(), Arc::clone(&event_count), Arc::clone(&slot_data), Arc::clone(&slot_changes), Arc::clone(&app.variables), Arc::clone(&app.rng), ) .expect("Failed to start audio"); app.audio_config.sample_rate = sample_rate; { let mut eng = engine.lock().unwrap(); eng.sr = sample_rate; eng.isr = 1.0 / sample_rate; } enable_raw_mode()?; io::stdout().execute(EnterAlternateScreen)?; let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; loop { if app.restart_pending { app.restart_pending = false; drop(stream); let new_config = AudioStreamConfig { output_device: app.audio_config.output_device.clone(), channels: app.audio_config.channels, buffer_size: app.audio_config.buffer_size, }; { let mut eng = engine.lock().unwrap(); *eng = Engine::new_with_channels(eng.sr, new_config.channels as usize); } match audio::build_stream( &new_config, Arc::clone(&engine), Arc::clone(&link), Arc::clone(&playing), Arc::clone(&project), slot_steps.clone(), Arc::clone(&event_count), Arc::clone(&slot_data), Arc::clone(&slot_changes), Arc::clone(&app.variables), Arc::clone(&app.rng), ) { Ok((new_stream, sr)) => { stream = new_stream; app.audio_config.sample_rate = sr; { let mut eng = engine.lock().unwrap(); eng.sr = sr; eng.isr = 1.0 / sr; } app.status_message = Some("Audio restarted".to_string()); } Err(e) => { app.status_message = Some(format!("Restart failed: {e}")); let (fallback_stream, _) = audio::build_stream( &AudioStreamConfig { output_device: None, channels: 2, buffer_size: 512, }, Arc::clone(&engine), Arc::clone(&link), Arc::clone(&playing), Arc::clone(&project), slot_steps.clone(), Arc::clone(&event_count), Arc::clone(&slot_data), Arc::clone(&slot_changes), Arc::clone(&app.variables), Arc::clone(&app.rng), ) .expect("Failed to restart with defaults"); stream = fallback_stream; } } } app.update_from_link(&link); app.playing = playing.load(Ordering::Relaxed); app.event_count = event_count.load(Ordering::Relaxed); { let eng = engine.lock().unwrap(); app.active_voices = eng.active_voices; app.peak_voices = app.peak_voices.max(eng.active_voices); app.cpu_load = eng.metrics.load.get_load(); app.schedule_depth = eng.schedule.len(); for (i, s) in app.scope.iter_mut().enumerate() { *s = eng.output.get(i * 2).copied().unwrap_or(0.0); } } // Sync slot state from audio thread { let sd = slot_data.lock().unwrap(); app.slot_data = *sd; } for (i, step_atomic) in slot_steps.iter().enumerate() { app.slot_steps[i] = step_atomic.load(Ordering::Relaxed); } // Push queued changes to audio thread if !app.queued_changes.is_empty() { let mut changes = slot_changes.lock().unwrap(); changes.extend(app.queued_changes.drain(..)); } { let mut proj = project.lock().unwrap(); proj.banks = app.project.banks.clone(); } terminal.draw(|frame| ui::render(frame, &mut app))?; if event::poll(Duration::from_millis(16))? { if let Event::Key(key) = event::read()? { app.clear_status(); match &mut app.modal { Modal::ConfirmQuit { ref mut selected } => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => break, KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Left | KeyCode::Right => { *selected = !*selected; } KeyCode::Enter => { if *selected { break; } else { app.modal = Modal::None; } } _ => {} }, Modal::SaveAs(path) => match key.code { KeyCode::Enter => { let save_path = PathBuf::from(path.as_str()); app.modal = Modal::None; app.save(save_path); } KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Backspace => { path.pop(); } KeyCode::Char(c) => { path.push(c); } _ => {} }, Modal::LoadFrom(path) => match key.code { KeyCode::Enter => { let load_path = PathBuf::from(path.as_str()); app.modal = Modal::None; app.load(load_path); } KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Backspace => { path.pop(); } KeyCode::Char(c) => { path.push(c); } _ => {} }, Modal::RenameBank { bank, name } => match key.code { KeyCode::Enter => { let bank_idx = *bank; let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; app.project.banks[bank_idx].name = new_name; app.modal = Modal::None; } KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Backspace => { name.pop(); } KeyCode::Char(c) => { name.push(c); } _ => {} }, Modal::RenamePattern { bank, pattern, name } => match key.code { KeyCode::Enter => { let (bank_idx, pattern_idx) = (*bank, *pattern); let new_name = if name.trim().is_empty() { None } else { Some(name.clone()) }; app.project.banks[bank_idx].patterns[pattern_idx].name = new_name; app.modal = Modal::None; } KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Backspace => { name.pop(); } KeyCode::Char(c) => { name.push(c); } _ => {} }, Modal::SetPattern { field, input } => match key.code { KeyCode::Enter => { let field = *field; let (bank, pattern) = (app.edit_bank, app.edit_pattern); match field { PatternField::Length => { if let Ok(len) = input.parse::() { app.project.pattern_at_mut(bank, pattern).set_length(len); let new_len = app.project.pattern_at(bank, pattern).length; if app.current_step >= new_len { app.current_step = new_len - 1; } app.status_message = Some(format!("Length set to {new_len}")); } else { app.status_message = Some("Invalid length".to_string()); } } PatternField::Speed => { if let Some(speed) = model::PatternSpeed::from_label(input) { app.project.pattern_at_mut(bank, pattern).speed = speed; app.status_message = Some(format!("Speed set to {}", speed.label())); } else { app.status_message = Some("Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string()); } } } app.modal = Modal::None; } KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Backspace => { input.pop(); } KeyCode::Char(c) => { input.push(c); } _ => {} }, Modal::AddSamplePath(path) => match key.code { KeyCode::Enter => { let sample_path = PathBuf::from(path.as_str()); if sample_path.is_dir() { let index = doux::loader::scan_samples_dir(&sample_path); let count = index.len(); engine.lock().unwrap().sample_index.extend(index); app.audio_config.sample_count += count; app.add_sample_path(sample_path); app.status_message = Some(format!("Added {count} samples")); } else { app.status_message = Some("Path is not a directory".to_string()); } app.modal = Modal::None; } KeyCode::Esc => { app.modal = Modal::None; } KeyCode::Backspace => { path.pop(); } KeyCode::Char(c) => { path.push(c); } _ => {} }, Modal::None => { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if ctrl && key.code == KeyCode::Left { app.page.left(); continue; } if ctrl && key.code == KeyCode::Right { app.page.right(); continue; } if ctrl && key.code == KeyCode::Up { app.page.up(); continue; } if ctrl && key.code == KeyCode::Down { app.page.down(); continue; } match app.page { Page::Main => match app.focus { Focus::Sequencer => match key.code { KeyCode::Char('q') => { app.modal = Modal::ConfirmQuit { selected: false }; } KeyCode::Char(' ') => { app.toggle_playing(); playing.store(app.playing, Ordering::Relaxed); } KeyCode::Tab => app.toggle_focus(), KeyCode::Left => app.prev_step(), KeyCode::Right => app.next_step(), KeyCode::Up => app.step_up(), KeyCode::Down => app.step_down(), KeyCode::Enter => app.toggle_step(), KeyCode::Char('s') => { let default = app .file_path .as_ref() .map(|p| p.display().to_string()) .unwrap_or_else(|| "project.buboseq".to_string()); app.modal = Modal::SaveAs(default); } KeyCode::Char('l') => { app.modal = Modal::LoadFrom(String::new()); } KeyCode::Char('+') | KeyCode::Char('=') => app.tempo_up(&link), KeyCode::Char('-') => app.tempo_down(&link), KeyCode::Char('<') | KeyCode::Char(',') => { app.length_decrease() } KeyCode::Char('>') | KeyCode::Char('.') => { app.length_increase() } KeyCode::Char('[') => app.speed_decrease(), KeyCode::Char(']') => app.speed_increase(), KeyCode::Char('L') => app.open_pattern_modal(PatternField::Length), KeyCode::Char('S') => app.open_pattern_modal(PatternField::Speed), KeyCode::Char('c') if ctrl => app.copy_step(), KeyCode::Char('v') if ctrl => app.paste_step(), _ => {} }, Focus::Editor => match key.code { KeyCode::Tab | KeyCode::Esc => app.toggle_focus(), KeyCode::Char('e') if ctrl => { app.save_editor_to_step(); app.compile_current_step(); } _ => { app.editor.input(Event::Key(key)); } }, }, Page::Patterns => { use app::PatternsViewLevel; match key.code { KeyCode::Left => { app.patterns_cursor = (app.patterns_cursor + 15) % 16; } KeyCode::Right => { app.patterns_cursor = (app.patterns_cursor + 1) % 16; } KeyCode::Up => { app.patterns_cursor = (app.patterns_cursor + 12) % 16; } KeyCode::Down => { app.patterns_cursor = (app.patterns_cursor + 4) % 16; } KeyCode::Esc | KeyCode::Backspace => { match app.patterns_view_level { PatternsViewLevel::Banks => { app.page.down(); } PatternsViewLevel::Patterns { .. } => { app.patterns_view_level = PatternsViewLevel::Banks; app.patterns_cursor = 0; } } } KeyCode::Enter => { match app.patterns_view_level { PatternsViewLevel::Banks => { let bank = app.patterns_cursor; app.patterns_view_level = PatternsViewLevel::Patterns { bank }; app.patterns_cursor = 0; } PatternsViewLevel::Patterns { bank } => { let pattern = app.patterns_cursor; app.select_edit_bank(bank); app.select_edit_pattern(pattern); app.patterns_view_level = PatternsViewLevel::Banks; app.patterns_cursor = 0; app.page.down(); } } } KeyCode::Char(' ') => { if let PatternsViewLevel::Patterns { bank } = app.patterns_view_level { let pattern = app.patterns_cursor; app.toggle_pattern_playback(bank, pattern); } } KeyCode::Char('q') => { app.modal = Modal::ConfirmQuit { selected: false }; } KeyCode::Char('r') => { match app.patterns_view_level { PatternsViewLevel::Banks => { let bank = app.patterns_cursor; let current_name = app.project.banks[bank].name.clone().unwrap_or_default(); app.modal = Modal::RenameBank { bank, name: current_name }; } PatternsViewLevel::Patterns { bank } => { let pattern = app.patterns_cursor; let current_name = app.project.banks[bank].patterns[pattern].name.clone().unwrap_or_default(); app.modal = Modal::RenamePattern { bank, pattern, name: current_name }; } } } _ => {} } } Page::Audio => match key.code { KeyCode::Char('q') => { app.modal = Modal::ConfirmQuit { selected: false }; } KeyCode::Up | KeyCode::Char('k') => { app.prev_audio_focus(); } KeyCode::Down | KeyCode::Char('j') => { app.next_audio_focus(); } KeyCode::Left => match app.audio_focus { AudioFocus::OutputDevice => app.prev_output_device(), AudioFocus::InputDevice => app.prev_input_device(), AudioFocus::Channels => app.adjust_channels(-1), AudioFocus::BufferSize => app.adjust_buffer_size(-64), AudioFocus::SamplePaths => app.remove_last_sample_path(), }, KeyCode::Right => match app.audio_focus { AudioFocus::OutputDevice => app.next_output_device(), AudioFocus::InputDevice => app.next_input_device(), AudioFocus::Channels => app.adjust_channels(1), AudioFocus::BufferSize => app.adjust_buffer_size(64), AudioFocus::SamplePaths => {} }, KeyCode::Char('R') => { app.trigger_restart(); // Reload samples on restart let mut eng = engine.lock().unwrap(); eng.sample_index.clear(); app.audio_config.sample_count = 0; for path in &app.audio_config.sample_paths { let index = doux::loader::scan_samples_dir(path); app.audio_config.sample_count += index.len(); eng.sample_index.extend(index); } } KeyCode::Char('A') => { app.modal = Modal::AddSamplePath(String::new()); } KeyCode::Char('D') => { app.refresh_audio_devices(); let out_count = app.available_output_devices.len(); let in_count = app.available_input_devices.len(); app.status_message = Some(format!("Found {out_count} output, {in_count} input devices")); } KeyCode::Char('h') => { engine.lock().unwrap().hush(); } KeyCode::Char('p') => { engine.lock().unwrap().panic(); } KeyCode::Char('r') => { app.peak_voices = 0; } KeyCode::Char('t') => { engine.lock().unwrap().evaluate("sin 440 * 0.3"); } KeyCode::Char(' ') => { app.toggle_playing(); playing.store(app.playing, Ordering::Relaxed); } _ => {} }, Page::Doc => { let topic_count = views::doc_view::topic_count(); match key.code { KeyCode::Char('j') | KeyCode::Down => { app.doc_topic = (app.doc_topic + 1) % topic_count; app.doc_scroll = 0; } KeyCode::Char('k') | KeyCode::Up => { app.doc_topic = (app.doc_topic + topic_count - 1) % topic_count; app.doc_scroll = 0; } KeyCode::PageDown => { app.doc_scroll = app.doc_scroll.saturating_add(10); } KeyCode::PageUp => { app.doc_scroll = app.doc_scroll.saturating_sub(10); } KeyCode::Char('q') => { app.modal = Modal::ConfirmQuit { selected: false }; } _ => {} } } } } } } } } disable_raw_mode()?; io::stdout().execute(LeaveAlternateScreen)?; Ok(()) }