633 lines
29 KiB
Rust
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(())
|
|
}
|