Files
doux-copy/seq/src/main.rs
2026-01-19 18:27:07 +01:00

633 lines
29 KiB
Rust

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<PathBuf>,
/// Output audio device (name or index)
#[arg(short, long)]
output: Option<String>,
/// Input audio device (name or index)
#[arg(short, long)]
input: Option<String>,
/// 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<AtomicUsize>; MAX_SLOTS] = std::array::from_fn(|_| Arc::new(AtomicUsize::new(0)));
let slot_data: Arc<Mutex<[(bool, usize, usize); MAX_SLOTS]>> =
Arc::new(Mutex::new([(false, 0, 0); MAX_SLOTS]));
let slot_changes: Arc<Mutex<Vec<SlotChange>>> = 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::<usize>() {
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(())
}