seq
This commit is contained in:
375
seq/src/app.rs
Normal file
375
seq/src/app.rs
Normal 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
120
seq/src/audio.rs
Normal 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
75
seq/src/file.rs
Normal 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
46
seq/src/link.rs
Normal 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
304
seq/src/main.rs
Normal 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
89
seq/src/model.rs
Normal 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
22
seq/src/page.rs
Normal 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
120
seq/src/script.rs
Normal 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
312
seq/src/ui.rs
Normal 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],
|
||||
);
|
||||
}
|
||||
81
seq/src/views/audio_view.rs
Normal file
81
seq/src/views/audio_view.rs
Normal 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
146
seq/src/views/main_view.rs
Normal 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
2
seq/src/views/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod audio_view;
|
||||
pub mod main_view;
|
||||
Reference in New Issue
Block a user