This commit is contained in:
2026-01-19 14:42:14 +01:00
parent 9938b356cd
commit 2900f84b7d
17 changed files with 20059 additions and 12 deletions

375
seq/src/app.rs Normal file
View File

@@ -0,0 +1,375 @@
use std::path::PathBuf;
use std::time::Instant;
use tui_textarea::TextArea;
use crate::file;
use crate::link::LinkState;
use crate::model::{Pattern, Project};
use crate::page::Page;
use crate::script::{ScriptEngine, StepContext};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Sequencer,
Editor,
}
#[derive(Clone, PartialEq, Eq)]
pub enum Modal {
None,
ConfirmQuit,
SaveAs(String),
LoadFrom(String),
PatternPicker { cursor: usize },
BankPicker { cursor: usize },
}
pub struct App {
pub tempo: f64,
pub beat: f64,
pub phase: f64,
pub peers: u64,
pub playing: bool,
#[allow(dead_code)]
pub quantum: f64,
pub project: Project,
pub focus: Focus,
pub page: Page,
pub current_step: usize,
pub playback_step: usize,
pub edit_bank: usize,
pub edit_pattern: usize,
pub playback_bank: usize,
pub playback_pattern: usize,
pub queued_bank: Option<usize>,
pub queued_pattern: Option<usize>,
pub event_count: usize,
pub active_voices: usize,
pub peak_voices: usize,
pub cpu_load: f32,
pub schedule_depth: usize,
pub sample_pool_mb: f32,
pub scope: [f32; 64],
pub script_engine: ScriptEngine,
pub file_path: Option<PathBuf>,
pub status_message: Option<String>,
pub editor: TextArea<'static>,
pub flash_until: Option<Instant>,
pub modal: Modal,
pub clipboard: Option<arboard::Clipboard>,
}
impl App {
pub fn new(tempo: f64, quantum: f64) -> Self {
Self {
tempo,
beat: 0.0,
phase: 0.0,
peers: 0,
playing: true,
quantum,
project: Project::default(),
focus: Focus::Sequencer,
page: Page::default(),
current_step: 0,
playback_step: 0,
edit_bank: 0,
edit_pattern: 0,
playback_bank: 0,
playback_pattern: 0,
queued_bank: None,
queued_pattern: None,
event_count: 0,
active_voices: 0,
peak_voices: 0,
cpu_load: 0.0,
schedule_depth: 0,
sample_pool_mb: 0.0,
scope: [0.0; 64],
script_engine: ScriptEngine::new(),
file_path: None,
status_message: None,
editor: TextArea::default(),
flash_until: None,
modal: Modal::None,
clipboard: arboard::Clipboard::new().ok(),
}
}
pub fn update_from_link(&mut self, link: &LinkState) {
let (tempo, beat, phase, peers) = link.query();
self.tempo = tempo;
self.beat = beat;
self.phase = phase;
self.peers = peers;
}
pub fn toggle_playing(&mut self) {
self.playing = !self.playing;
}
pub fn tempo_up(&mut self, link: &LinkState) {
self.tempo = (self.tempo + 1.0).min(300.0);
link.set_tempo(self.tempo);
}
pub fn tempo_down(&mut self, link: &LinkState) {
self.tempo = (self.tempo - 1.0).max(20.0);
link.set_tempo(self.tempo);
}
pub fn toggle_focus(&mut self) {
match self.focus {
Focus::Sequencer => {
self.focus = Focus::Editor;
self.load_step_to_editor();
}
Focus::Editor => {
self.save_editor_to_step();
self.compile_current_step();
self.focus = Focus::Sequencer;
}
}
}
pub fn current_edit_pattern(&self) -> &Pattern {
self.project.pattern_at(self.edit_bank, self.edit_pattern)
}
pub fn next_step(&mut self) {
let len = self.current_edit_pattern().length;
self.current_step = (self.current_step + 1) % len;
self.load_step_to_editor();
}
pub fn prev_step(&mut self) {
let len = self.current_edit_pattern().length;
self.current_step = (self.current_step + len - 1) % len;
self.load_step_to_editor();
}
pub fn step_up(&mut self) {
let len = self.current_edit_pattern().length;
if self.current_step >= 8 {
self.current_step -= 8;
} else {
self.current_step = (self.current_step + len - 8) % len;
}
self.load_step_to_editor();
}
pub fn step_down(&mut self) {
let len = self.current_edit_pattern().length;
self.current_step = (self.current_step + 8) % len;
self.load_step_to_editor();
}
pub fn toggle_step(&mut self) {
let step_idx = self.current_step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern);
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.active = !step.active;
}
}
fn load_step_to_editor(&mut self) {
let step_idx = self.current_step;
if let Some(step) = self.current_edit_pattern().step(step_idx) {
let lines: Vec<String> = if step.script.is_empty() {
vec![String::new()]
} else {
step.script.lines().map(String::from).collect()
};
self.editor = TextArea::new(lines);
}
}
pub fn save_editor_to_step(&mut self) {
let text = self.editor.lines().join("\n");
let step_idx = self.current_step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern);
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.script = text;
}
}
pub fn compile_current_step(&mut self) {
let step_idx = self.current_step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern);
let script = self
.project
.pattern_at(bank, pattern)
.step(step_idx)
.map(|s| s.script.clone())
.unwrap_or_default();
if script.trim().is_empty() {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = None;
}
return;
}
let ctx = StepContext {
step: step_idx,
beat: self.beat,
bank,
pattern,
tempo: self.tempo,
phase: self.phase,
};
match self.script_engine.evaluate(&script, &ctx) {
Ok(cmd) => {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = Some(cmd);
}
self.status_message = Some("Script compiled".to_string());
self.flash_until = Some(Instant::now() + std::time::Duration::from_millis(150));
}
Err(e) => {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = None;
}
self.status_message = Some(format!("Script error: {e}"));
}
}
}
pub fn compile_all_steps(&mut self) {
let pattern_len = self.current_edit_pattern().length;
let (bank, pattern) = (self.edit_bank, self.edit_pattern);
for step_idx in 0..pattern_len {
let script = self
.project
.pattern_at(bank, pattern)
.step(step_idx)
.map(|s| s.script.clone())
.unwrap_or_default();
if script.trim().is_empty() {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = None;
}
continue;
}
let ctx = StepContext {
step: step_idx,
beat: 0.0,
bank,
pattern,
tempo: self.tempo,
phase: 0.0,
};
if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx) {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = Some(cmd);
}
}
}
}
pub fn queue_current_for_playback(&mut self) {
self.queued_bank = Some(self.edit_bank);
self.queued_pattern = Some(self.edit_pattern);
self.status_message = Some(format!(
"Queued B{:02} P{:02} (next loop)",
self.edit_bank + 1,
self.edit_pattern + 1
));
}
pub fn select_edit_pattern(&mut self, pattern: usize) {
self.edit_pattern = pattern;
self.current_step = 0;
self.load_step_to_editor();
}
pub fn select_edit_bank(&mut self, bank: usize) {
self.edit_bank = bank;
self.edit_pattern = 0;
self.current_step = 0;
self.load_step_to_editor();
}
pub fn save(&mut self, path: PathBuf) {
self.save_editor_to_step();
match file::save(&self.project, &path) {
Ok(()) => {
self.status_message = Some(format!("Saved: {}", path.display()));
self.file_path = Some(path);
}
Err(e) => {
self.status_message = Some(format!("Save error: {e}"));
}
}
}
pub fn load(&mut self, path: PathBuf) {
match file::load(&path) {
Ok(project) => {
self.project = project;
self.current_step = 0;
self.load_step_to_editor();
self.compile_all_steps();
self.status_message = Some(format!("Loaded: {}", path.display()));
self.file_path = Some(path);
}
Err(e) => {
self.status_message = Some(format!("Load error: {e}"));
}
}
}
pub fn clear_status(&mut self) {
self.status_message = None;
}
pub fn is_flashing(&self) -> bool {
self.flash_until
.map(|t| Instant::now() < t)
.unwrap_or(false)
}
pub fn copy_step(&mut self) {
let step_idx = self.current_step;
let script = self
.current_edit_pattern()
.step(step_idx)
.map(|s| s.script.clone());
if let Some(script) = script {
if let Some(clip) = &mut self.clipboard {
if clip.set_text(&script).is_ok() {
self.status_message = Some("Copied".to_string());
}
}
}
}
pub fn paste_step(&mut self) {
let text = self
.clipboard
.as_mut()
.and_then(|clip| clip.get_text().ok());
if let Some(text) = text {
let step_idx = self.current_step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern);
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.script = text;
}
self.load_step_to_editor();
self.compile_current_step();
}
}
}

120
seq/src/audio.rs Normal file
View File

@@ -0,0 +1,120 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::Stream;
use doux::Engine;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use crate::link::LinkState;
use crate::model::Project;
pub struct AudioState {
prev_beat: f64,
step_index: usize,
bank: usize,
pattern: usize,
}
impl AudioState {
fn new() -> Self {
Self {
prev_beat: -1.0,
step_index: 0,
bank: 0,
pattern: 0,
}
}
}
pub fn build_stream(
engine: Arc<Mutex<Engine>>,
link: Arc<LinkState>,
playing: Arc<AtomicBool>,
project: Arc<Mutex<Project>>,
playback_step: Arc<AtomicUsize>,
event_count: Arc<AtomicUsize>,
playback_bank: Arc<AtomicUsize>,
playback_pattern: Arc<AtomicUsize>,
queued_bank: Arc<AtomicUsize>,
queued_pattern: Arc<AtomicUsize>,
) -> (Stream, f32) {
let host = cpal::default_host();
let device = host.default_output_device().expect("no output device");
let config = device.default_output_config().expect("no default config");
let sample_rate = config.sample_rate().0 as f32;
let stream_config = cpal::StreamConfig {
channels: 2,
sample_rate: config.sample_rate(),
buffer_size: cpal::BufferSize::Default,
};
let quantum = 4.0;
let audio_state = Arc::new(Mutex::new(AudioState::new()));
let sr = sample_rate;
let stream = device
.build_output_stream(
&stream_config,
move |data: &mut [f32], _| {
let buffer_samples = data.len() / 2;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
let is_playing = playing.load(Ordering::Relaxed);
if is_playing {
let state = link.capture_audio_state();
let time = link.clock_micros();
let beat = state.beat_at_time(time, quantum);
let mut audio = audio_state.lock().unwrap();
let beat_int = (beat * 4.0).floor() as i64;
let prev_beat_int = (audio.prev_beat * 4.0).floor() as i64;
if beat_int != prev_beat_int && audio.prev_beat >= 0.0 {
let proj = project.lock().unwrap();
let pattern = proj.pattern_at(audio.bank, audio.pattern);
let step_idx = audio.step_index % pattern.length;
playback_step.store(step_idx, Ordering::Relaxed);
playback_bank.store(audio.bank, Ordering::Relaxed);
playback_pattern.store(audio.pattern, Ordering::Relaxed);
if let Some(step) = pattern.step(step_idx) {
if step.active {
if let Some(ref cmd) = step.command {
engine.lock().unwrap().evaluate(cmd);
event_count.fetch_add(1, Ordering::Relaxed);
}
}
}
let next_step = (audio.step_index + 1) % pattern.length;
audio.step_index = next_step;
if next_step == 0 {
let qb = queued_bank.load(Ordering::Relaxed);
let qp = queued_pattern.load(Ordering::Relaxed);
if qb != usize::MAX && qp != usize::MAX {
audio.bank = qb;
audio.pattern = qp;
audio.step_index = 0;
queued_bank.store(usize::MAX, Ordering::Relaxed);
queued_pattern.store(usize::MAX, Ordering::Relaxed);
}
}
}
audio.prev_beat = beat;
}
let mut eng = engine.lock().unwrap();
eng.metrics.load.set_buffer_time(buffer_time_ns);
eng.process_block(data, &[], &[]);
},
|err| eprintln!("stream error: {err}"),
None,
)
.expect("failed to build stream");
stream.play().expect("failed to play stream");
(stream, sample_rate)
}

75
seq/src/file.rs Normal file
View File

@@ -0,0 +1,75 @@
use std::fs;
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::model::{Bank, Project};
const VERSION: u8 = 1;
#[derive(Serialize, Deserialize)]
struct ProjectFile {
version: u8,
banks: Vec<Bank>,
}
impl From<&Project> for ProjectFile {
fn from(project: &Project) -> Self {
Self {
version: VERSION,
banks: project.banks.clone(),
}
}
}
impl From<ProjectFile> for Project {
fn from(file: ProjectFile) -> Self {
Self { banks: file.banks }
}
}
#[derive(Debug)]
pub enum FileError {
Io(io::Error),
Json(serde_json::Error),
Version(u8),
}
impl std::fmt::Display for FileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileError::Io(e) => write!(f, "IO error: {e}"),
FileError::Json(e) => write!(f, "JSON error: {e}"),
FileError::Version(v) => write!(f, "Unsupported version: {v}"),
}
}
}
impl From<io::Error> for FileError {
fn from(e: io::Error) -> Self {
FileError::Io(e)
}
}
impl From<serde_json::Error> for FileError {
fn from(e: serde_json::Error) -> Self {
FileError::Json(e)
}
}
pub fn save(project: &Project, path: &Path) -> Result<(), FileError> {
let file = ProjectFile::from(project);
let json = serde_json::to_string_pretty(&file)?;
fs::write(path, json)?;
Ok(())
}
pub fn load(path: &Path) -> Result<Project, FileError> {
let json = fs::read_to_string(path)?;
let file: ProjectFile = serde_json::from_str(&json)?;
if file.version > VERSION {
return Err(FileError::Version(file.version));
}
Ok(Project::from(file))
}

46
seq/src/link.rs Normal file
View File

@@ -0,0 +1,46 @@
use rusty_link::{AblLink, SessionState};
pub struct LinkState {
link: AblLink,
quantum: f64,
}
impl LinkState {
pub fn new(tempo: f64, quantum: f64) -> Self {
let link = AblLink::new(tempo);
Self { link, quantum }
}
pub fn enable(&self) {
self.link.enable(true);
}
pub fn clock_micros(&self) -> i64 {
self.link.clock_micros()
}
pub fn query(&self) -> (f64, f64, f64, u64) {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);
let time = self.link.clock_micros();
let tempo = state.tempo();
let beat = state.beat_at_time(time, self.quantum);
let phase = state.phase_at_time(time, self.quantum);
let peers = self.link.num_peers();
(tempo, beat, phase, peers)
}
pub fn set_tempo(&self, tempo: f64) {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);
let time = self.link.clock_micros();
state.set_tempo(tempo, time);
self.link.commit_app_session_state(&state);
}
pub fn capture_audio_state(&self) -> SessionState {
let mut state = SessionState::new();
self.link.capture_audio_session_state(&mut state);
state
}
}

304
seq/src/main.rs Normal file
View File

@@ -0,0 +1,304 @@
mod app;
mod audio;
mod file;
mod link;
mod model;
mod page;
mod script;
mod ui;
mod views;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
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, Focus, Modal};
use link::LinkState;
use model::Project;
use page::Page;
const TEMPO: f64 = 120.0;
const QUANTUM: f64 = 4.0;
fn main() -> io::Result<()> {
let link = Arc::new(LinkState::new(TEMPO, QUANTUM));
link.enable();
let playing = Arc::new(AtomicBool::new(true));
let playback_step = Arc::new(AtomicUsize::new(0));
let event_count = Arc::new(AtomicUsize::new(0));
let playback_bank = Arc::new(AtomicUsize::new(0));
let playback_pattern = Arc::new(AtomicUsize::new(0));
let queued_bank = Arc::new(AtomicUsize::new(usize::MAX));
let queued_pattern = Arc::new(AtomicUsize::new(usize::MAX));
let mut app = App::new(TEMPO, QUANTUM);
let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
let project = Arc::new(Mutex::new(Project::default()));
let (_stream, sample_rate) = audio::build_stream(
Arc::clone(&engine),
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&project),
Arc::clone(&playback_step),
Arc::clone(&event_count),
Arc::clone(&playback_bank),
Arc::clone(&playback_pattern),
Arc::clone(&queued_bank),
Arc::clone(&queued_pattern),
);
{
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 {
app.update_from_link(&link);
app.playing = playing.load(Ordering::Relaxed);
app.playback_step = playback_step.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);
}
}
app.playback_bank = playback_bank.load(Ordering::Relaxed);
app.playback_pattern = playback_pattern.load(Ordering::Relaxed);
if app.queued_bank.is_some() {
queued_bank.store(app.queued_bank.unwrap(), Ordering::Relaxed);
queued_pattern.store(app.queued_pattern.unwrap(), Ordering::Relaxed);
app.queued_bank = None;
app.queued_pattern = None;
}
{
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 => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => break,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
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::PatternPicker { ref mut cursor } => {
match key.code {
KeyCode::Enter => {
let selected = *cursor;
app.modal = Modal::None;
app.select_edit_pattern(selected);
}
KeyCode::Esc => {
app.modal = Modal::None;
}
KeyCode::Left => {
*cursor = (*cursor + 15) % 16;
}
KeyCode::Right => {
*cursor = (*cursor + 1) % 16;
}
KeyCode::Up => {
*cursor = (*cursor + 12) % 16;
}
KeyCode::Down => {
*cursor = (*cursor + 4) % 16;
}
_ => {}
}
}
Modal::BankPicker { ref mut cursor } => {
match key.code {
KeyCode::Enter => {
let selected = *cursor;
app.modal = Modal::None;
app.select_edit_bank(selected);
}
KeyCode::Esc => {
app.modal = Modal::None;
}
KeyCode::Left => {
*cursor = (*cursor + 15) % 16;
}
KeyCode::Right => {
*cursor = (*cursor + 1) % 16;
}
KeyCode::Up => {
*cursor = (*cursor + 12) % 16;
}
KeyCode::Down => {
*cursor = (*cursor + 4) % 16;
}
_ => {}
}
}
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;
}
match app.page {
Page::Main => match app.focus {
Focus::Sequencer => match key.code {
KeyCode::Char('q') => {
app.modal = Modal::ConfirmQuit;
}
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('p') => {
app.modal =
Modal::PatternPicker { cursor: app.edit_pattern };
}
KeyCode::Char('b') => {
app.modal = Modal::BankPicker { cursor: app.edit_bank };
}
KeyCode::Char('g') => {
app.queue_current_for_playback();
}
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('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::Audio => match key.code {
KeyCode::Char('q') => {
app.modal = Modal::ConfirmQuit;
}
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);
}
_ => {}
},
}
}
}
}
}
}
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
Ok(())
}

89
seq/src/model.rs Normal file
View File

@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub struct Step {
pub active: bool,
pub script: String,
#[serde(skip)]
pub command: Option<String>,
}
impl Default for Step {
fn default() -> Self {
Self {
active: true,
script: String::new(),
command: None,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Pattern {
pub steps: Vec<Step>,
pub length: usize,
}
impl Default for Pattern {
fn default() -> Self {
Self {
steps: (0..16).map(|_| Step::default()).collect(),
length: 16,
}
}
}
impl Pattern {
pub fn step(&self, index: usize) -> Option<&Step> {
self.steps.get(index)
}
pub fn step_mut(&mut self, index: usize) -> Option<&mut Step> {
self.steps.get_mut(index)
}
#[allow(dead_code)]
pub fn set_length(&mut self, length: usize) {
let length = length.clamp(1, 64);
while self.steps.len() < length {
self.steps.push(Step::default());
}
self.length = length;
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Bank {
pub patterns: Vec<Pattern>,
}
impl Default for Bank {
fn default() -> Self {
Self {
patterns: (0..16).map(|_| Pattern::default()).collect(),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Project {
pub banks: Vec<Bank>,
}
impl Default for Project {
fn default() -> Self {
Self {
banks: (0..16).map(|_| Bank::default()).collect(),
}
}
}
impl Project {
pub fn pattern_at(&self, bank: usize, pattern: usize) -> &Pattern {
&self.banks[bank].patterns[pattern]
}
pub fn pattern_at_mut(&mut self, bank: usize, pattern: usize) -> &mut Pattern {
&mut self.banks[bank].patterns[pattern]
}
}

22
seq/src/page.rs Normal file
View File

@@ -0,0 +1,22 @@
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Page {
#[default]
Main,
Audio,
}
impl Page {
pub fn left(&mut self) {
*self = match self {
Page::Main => Page::Audio,
Page::Audio => Page::Audio,
}
}
pub fn right(&mut self) {
*self = match self {
Page::Main => Page::Main,
Page::Audio => Page::Main,
}
}
}

120
seq/src/script.rs Normal file
View File

@@ -0,0 +1,120 @@
use rhai::{Engine, Scope};
#[derive(Clone, Debug)]
pub struct Cmd {
pairs: Vec<(String, String)>,
}
impl Cmd {
fn new() -> Self {
Self { pairs: vec![] }
}
fn with(sound: &str) -> Self {
let mut cmd = Self::new();
cmd.pairs.push(("sound".into(), sound.into()));
cmd
}
fn set(&mut self, key: &str, val: &str) -> Self {
self.pairs.push((key.into(), val.into()));
self.clone()
}
}
impl std::fmt::Display for Cmd {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts: Vec<String> = self
.pairs
.iter()
.map(|(k, v)| format!("{k}/{v}"))
.collect();
write!(f, "/{}", parts.join("/"))
}
}
pub struct StepContext {
pub step: usize,
pub beat: f64,
pub bank: usize,
pub pattern: usize,
pub tempo: f64,
pub phase: f64,
}
pub struct ScriptEngine {
engine: Engine,
}
impl ScriptEngine {
pub fn new() -> Self {
let mut engine = Engine::new();
engine.set_max_expr_depths(64, 32);
register_cmd(&mut engine);
Self { engine }
}
pub fn evaluate(&self, script: &str, ctx: &StepContext) -> Result<String, String> {
if script.trim().is_empty() {
return Err("empty script".to_string());
}
let mut scope = Scope::new();
scope.push("step", ctx.step as i64);
scope.push("beat", ctx.beat);
scope.push("bank", ctx.bank as i64);
scope.push("pattern", ctx.pattern as i64);
scope.push("tempo", ctx.tempo);
scope.push("phase", ctx.phase);
if let Ok(cmd) = self.engine.eval_with_scope::<Cmd>(&mut scope, script) {
return Ok(cmd.to_string());
}
self.engine
.eval_with_scope::<String>(&mut scope, script)
.map_err(|e| e.to_string())
}
}
fn register_cmd(engine: &mut Engine) {
engine.register_type_with_name::<Cmd>("Cmd");
engine.register_fn("sound", Cmd::with);
macro_rules! reg_both {
($($name:expr),*) => {
$(
engine.register_fn($name, |c: &mut Cmd, v: f64| c.set($name, &v.to_string()));
engine.register_fn($name, |c: &mut Cmd, v: i64| c.set($name, &v.to_string()));
)*
};
}
reg_both!(
"time", "repeat", "dur", "gate",
"freq", "detune", "speed", "glide",
"pw", "spread", "mult", "warp", "mirror", "harmonics", "timbre", "morph", "begin", "end",
"gain", "postgain", "velocity", "pan",
"attack", "decay", "sustain", "release",
"lpf", "lpq", "lpe", "lpa", "lpd", "lps", "lpr",
"hpf", "hpq", "hpe", "hpa", "hpd", "hps", "hpr",
"bpf", "bpq", "bpe", "bpa", "bpd", "bps", "bpr",
"penv", "patt", "pdec", "psus", "prel",
"vib", "vibmod",
"fm", "fmh", "fme", "fma", "fmd", "fms", "fmr",
"am", "amdepth",
"rm", "rmdepth",
"phaser", "phaserdepth", "phasersweep", "phasercenter",
"flanger", "flangerdepth", "flangerfeedback",
"chorus", "chorusdepth", "chorusdelay",
"comb", "combfreq", "combfeedback", "combdamp",
"coarse", "crush", "fold", "wrap", "distort", "distortvol",
"delay", "delaytime", "delayfeedback",
"verb", "verbdecay", "verbdamp", "verbpredelay", "verbdiff",
"voice", "orbit", "note", "size", "n", "cut"
);
engine.register_fn("reset", |c: &mut Cmd, v: bool| {
c.set("reset", if v { "1" } else { "0" })
});
}

312
seq/src/ui.rs Normal file
View File

@@ -0,0 +1,312 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::{App, Modal};
use crate::page::Page;
use crate::views::{audio_view, main_view};
pub fn render(frame: &mut Frame, app: &mut App) {
let [header_area, scope_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(3),
])
.areas(frame.area());
render_header(frame, app, header_area);
render_scope(frame, app, scope_area);
match app.page {
Page::Main => main_view::render(frame, app, body_area),
Page::Audio => audio_view::render(frame, app, body_area),
}
render_footer(frame, app, footer_area);
render_modal(frame, app);
}
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let scope_chars: String = app
.scope
.iter()
.map(|&s| {
let level = (s.abs() * 8.0).min(7.0) as usize;
['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'][level]
})
.collect();
let scope = Paragraph::new(scope_chars).style(Style::new().fg(Color::Green));
frame.render_widget(scope, area);
}
fn render_header(frame: &mut Frame, app: &App, area: Rect) {
let play_symbol = if app.playing { "" } else { "" };
let play_color = if app.playing {
Color::Green
} else {
Color::Red
};
let cpu_pct = (app.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 {
Color::Red
} else if cpu_pct > 50.0 {
Color::Yellow
} else {
Color::Green
};
let mut spans = vec![
Span::styled("EDIT ", Style::new().fg(Color::Cyan)),
Span::styled(
format!("B{:02}:P{:02}", app.edit_bank + 1, app.edit_pattern + 1),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("PLAY ", Style::new().fg(play_color)),
Span::styled(
format!(
"B{:02}:P{:02} {}",
app.playback_bank + 1,
app.playback_pattern + 1,
play_symbol
),
Style::new().fg(play_color).add_modifier(Modifier::BOLD),
),
];
if app.queued_bank.is_some() {
spans.push(Span::raw(" "));
spans.push(Span::styled("QUEUE ", Style::new().fg(Color::Yellow)));
spans.push(Span::styled(
format!(
"B{:02}:P{:02}",
app.queued_bank.unwrap() + 1,
app.queued_pattern.unwrap() + 1
),
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
));
}
spans.extend([
Span::raw(" "),
Span::styled(format!("{:.1} BPM", app.tempo), Style::new().fg(Color::Magenta)),
Span::raw(" "),
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
Span::raw(" "),
Span::styled(format!("V:{}", app.active_voices), Style::new().fg(Color::Cyan)),
]);
let header = Paragraph::new(Line::from(spans))
.block(Block::default().borders(Borders::ALL).title("seq"));
frame.render_widget(header, area);
}
fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let page_indicator = match app.page {
Page::Main => "[MAIN] ",
Page::Audio => "[AUDIO] ",
};
let content = if let Some(ref msg) = app.status_message {
Line::from(vec![
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
Span::styled(msg.clone(), Style::new().fg(Color::Yellow)),
])
} else {
match app.page {
Page::Main => Line::from(vec![
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
Span::styled("←→↑↓", Style::new().fg(Color::Yellow)),
Span::raw(":nav "),
Span::styled("p", Style::new().fg(Color::Yellow)),
Span::raw(":pat "),
Span::styled("b", Style::new().fg(Color::Yellow)),
Span::raw(":bank "),
Span::styled("g", Style::new().fg(Color::Yellow)),
Span::raw(":go "),
Span::styled("Enter", Style::new().fg(Color::Yellow)),
Span::raw(":toggle "),
Span::styled("Tab", Style::new().fg(Color::Yellow)),
Span::raw(":focus "),
Span::styled("s/l", Style::new().fg(Color::Yellow)),
Span::raw(":save/load"),
]),
Page::Audio => Line::from(vec![
Span::styled(page_indicator, Style::new().fg(Color::White).add_modifier(Modifier::DIM)),
Span::styled("q", Style::new().fg(Color::Yellow)),
Span::raw(":quit "),
Span::styled("h", Style::new().fg(Color::Yellow)),
Span::raw(":hush "),
Span::styled("p", Style::new().fg(Color::Yellow)),
Span::raw(":panic "),
Span::styled("r", Style::new().fg(Color::Yellow)),
Span::raw(":reset "),
Span::styled("t", Style::new().fg(Color::Yellow)),
Span::raw(":test "),
Span::styled("C-←→", Style::new().fg(Color::Yellow)),
Span::raw(":page"),
]),
}
};
let footer = Paragraph::new(content).block(Block::default().borders(Borders::ALL));
frame.render_widget(footer, area);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
fn render_modal(frame: &mut Frame, app: &App) {
let term = frame.area();
match &app.modal {
Modal::None => {}
Modal::ConfirmQuit => {
let width = 30.min(term.width.saturating_sub(4));
let height = 5.min(term.height.saturating_sub(4));
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from("Quit? (y/n)"))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Confirm")
.border_style(Style::new().fg(Color::Yellow)),
);
frame.render_widget(modal, area);
}
Modal::SaveAs(path) => {
let width = (term.width * 60 / 100).clamp(40, 70).min(term.width.saturating_sub(4));
let height = 5.min(term.height.saturating_sub(4));
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(path, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title("Save As (Enter to confirm, Esc to cancel)")
.border_style(Style::new().fg(Color::Green)),
);
frame.render_widget(modal, area);
}
Modal::LoadFrom(path) => {
let width = (term.width * 60 / 100).clamp(40, 70).min(term.width.saturating_sub(4));
let height = 5.min(term.height.saturating_sub(4));
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let modal = Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(path, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
]))
.block(
Block::default()
.borders(Borders::ALL)
.title("Load From (Enter to confirm, Esc to cancel)")
.border_style(Style::new().fg(Color::Blue)),
);
frame.render_widget(modal, area);
}
Modal::PatternPicker { cursor } => {
render_picker_modal(
frame,
&format!("Select Pattern (Bank {:02})", app.edit_bank + 1),
*cursor,
app.edit_pattern,
app.playback_pattern,
app.edit_bank == app.playback_bank,
);
}
Modal::BankPicker { cursor } => {
render_picker_modal(
frame,
"Select Bank",
*cursor,
app.edit_bank,
app.playback_bank,
true,
);
}
}
}
fn render_picker_modal(
frame: &mut Frame,
title: &str,
cursor: usize,
edit_pos: usize,
play_pos: usize,
show_play: bool,
) {
let term = frame.area();
let width = 30.min(term.width.saturating_sub(4));
let height = 10.min(term.height.saturating_sub(4));
let area = centered_rect(width, height, term);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::new().fg(Color::Rgb(100, 160, 180)));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
for row in 0..4 {
let mut spans = Vec::new();
for col in 0..4 {
let idx = row * 4 + col;
let num = format!(" {:02} ", idx + 1);
let style = if idx == cursor {
Style::new().bg(Color::Cyan).fg(Color::Black)
} else if idx == edit_pos {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else if show_play && idx == play_pos {
Style::new().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
spans.push(Span::styled(num, style));
if col < 3 {
spans.push(Span::raw(" "));
}
}
frame.render_widget(Paragraph::new(Line::from(spans)), rows[row]);
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("[E]", Style::new().fg(Color::Cyan)),
Span::raw("=edit "),
Span::styled("[P]", Style::new().fg(Color::Green)),
Span::raw("=play"),
])),
rows[5],
);
}

View File

@@ -0,0 +1,81 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
use ratatui::Frame;
use crate::app::App;
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
render_stats(frame, app, area);
}
fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title("Engine Stats")
.border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let [cpu_area, voices_area, extra_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(2),
Constraint::Min(1),
])
.areas(inner);
let cpu_pct = (app.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 {
Color::Red
} else if cpu_pct > 50.0 {
Color::Yellow
} else {
Color::Green
};
let gauge = Gauge::default()
.block(Block::default().title("CPU"))
.gauge_style(Style::new().fg(cpu_color).bg(Color::DarkGray))
.percent(cpu_pct as u16)
.label(format!("{cpu_pct:.1}%"));
frame.render_widget(gauge, cpu_area);
let voice_color = if app.active_voices > 24 {
Color::Red
} else if app.active_voices > 16 {
Color::Yellow
} else {
Color::Cyan
};
let voices = Paragraph::new(Line::from(vec![
Span::raw("Active: "),
Span::styled(
format!("{:3}", app.active_voices),
Style::new().fg(voice_color).add_modifier(Modifier::BOLD),
),
Span::raw(" Peak: "),
Span::styled(
format!("{:3}", app.peak_voices),
Style::new().fg(Color::Yellow),
),
]));
frame.render_widget(voices, voices_area);
let extra = Paragraph::new(vec![
Line::from(vec![
Span::raw("Schedule: "),
Span::styled(format!("{}", app.schedule_depth), Style::new().fg(Color::White)),
]),
Line::from(vec![
Span::raw("Pool: "),
Span::styled(format!("{:.1} MB", app.sample_pool_mb), Style::new().fg(Color::White)),
]),
]);
frame.render_widget(extra, extra_area);
}

146
seq/src/views/main_view.rs Normal file
View File

@@ -0,0 +1,146 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::{App, Focus};
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
let [seq_area, editor_area] =
Layout::vertical([Constraint::Length(9), Constraint::Fill(1)]).areas(area);
render_sequencer(frame, app, seq_area);
render_editor(frame, app, editor_area);
}
fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) {
let focus_indicator = if app.focus == Focus::Sequencer {
"*"
} else {
" "
};
let border_style = if app.focus == Focus::Sequencer {
Style::new().fg(Color::Rgb(100, 160, 180))
} else {
Style::new().fg(Color::Rgb(70, 75, 85))
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(format!("Sequencer{focus_indicator}"));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 50 {
let msg = Paragraph::new("Terminal too narrow")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Rgb(120, 125, 135)));
frame.render_widget(msg, inner);
return;
}
let rows = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(inner);
let row_areas = [rows[0], rows[2]];
for (row_idx, row_area) in row_areas.iter().enumerate() {
let col_constraints = [
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
];
let cols = Layout::horizontal(col_constraints).split(*row_area);
let tile_indices = [0, 2, 4, 6, 8, 10, 12, 14];
for (col_idx, &col_layout_idx) in tile_indices.iter().enumerate() {
let step_idx = row_idx * 8 + col_idx;
render_tile(frame, cols[col_layout_idx], app, step_idx);
}
}
}
fn render_tile(frame: &mut Frame, area: Rect, app: &App, step_idx: usize) {
let pattern = app.current_edit_pattern();
let step = pattern.step(step_idx);
let is_active = step.map(|s| s.active).unwrap_or(false);
let is_selected = step_idx == app.current_step;
let same_pattern =
app.edit_bank == app.playback_bank && app.edit_pattern == app.playback_pattern;
let is_playing = app.playing && same_pattern && step_idx == app.playback_step;
let (bg, fg) = match (is_playing, is_active, is_selected) {
(true, true, _) => (Color::Rgb(195, 85, 65), Color::White),
(true, false, _) => (Color::Rgb(180, 120, 45), Color::Black),
(false, true, true) => (Color::Rgb(55, 128, 115), Color::White),
(false, true, false) => (Color::Rgb(45, 106, 95), Color::White),
(false, false, true) => (Color::Rgb(59, 91, 138), Color::White),
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
};
let symbol = if is_playing {
"".to_string()
} else {
format!("{:02}", step_idx + 1)
};
let tile = Paragraph::new(symbol)
.alignment(Alignment::Center)
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
frame.render_widget(tile, area);
}
fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
let focus_indicator = if app.focus == Focus::Editor {
"*"
} else {
" "
};
let border_style = if app.is_flashing() {
Style::new().fg(Color::Green)
} else if app.focus == Focus::Editor {
Style::new().fg(Color::Rgb(100, 160, 180))
} else {
Style::new().fg(Color::Rgb(70, 75, 85))
};
let step_num = app.current_step + 1;
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(format!("Step {step_num:02} Script{focus_indicator}"));
let inner = block.inner(area);
frame.render_widget(block, area);
let cursor_style = if app.focus == Focus::Editor {
Style::new().bg(Color::White).fg(Color::Black)
} else {
Style::default()
};
app.editor.set_cursor_style(cursor_style);
frame.render_widget(&app.editor, inner);
}

2
seq/src/views/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod audio_view;
pub mod main_view;