This commit is contained in:
2026-01-20 02:11:51 +01:00
parent 4391995eae
commit ce0014020f
16 changed files with 941 additions and 595 deletions

1
Cargo.lock generated
View File

@@ -2091,6 +2091,7 @@ dependencies = [
"arboard", "arboard",
"clap", "clap",
"cpal", "cpal",
"crossbeam-channel",
"crossterm", "crossterm",
"doux", "doux",
"minimad", "minimad",

View File

@@ -21,3 +21,4 @@ serde_json = "1"
tui-textarea = "0.7" tui-textarea = "0.7"
arboard = "3" arboard = "3"
minimad = "0.13" minimad = "0.13"
crossbeam-channel = "0.5"

View File

@@ -1,19 +1,26 @@
use doux::audio::AudioDeviceInfo; use doux::audio::AudioDeviceInfo;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::SeedableRng; use rand::SeedableRng;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use tui_textarea::TextArea; use tui_textarea::TextArea;
use crate::audio::{SlotChange, MAX_SLOTS}; use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
use crate::file; use crate::file;
use crate::link::LinkState; use crate::link::LinkState;
use crate::model::{Pattern, Project}; use crate::model::{Pattern, Project};
use crate::page::Page; use crate::page::Page;
use crate::script::{Rng, ScriptEngine, StepContext, Variables}; use crate::script::{ScriptEngine, StepContext, Variables, Rng};
use crate::sequencer::SlotState;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SlotChange {
Add { slot: usize, bank: usize, pattern: usize },
Remove { slot: usize },
}
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum Focus { pub enum Focus {
@@ -81,6 +88,209 @@ pub enum AudioFocus {
SamplePaths, SamplePaths,
} }
pub struct Metrics {
pub event_count: usize,
pub active_voices: usize,
pub peak_voices: usize,
pub cpu_load: f32,
pub schedule_depth: usize,
pub scope: [f32; 64],
}
impl Default for Metrics {
fn default() -> Self {
Self {
event_count: 0,
active_voices: 0,
peak_voices: 0,
cpu_load: 0.0,
schedule_depth: 0,
scope: [0.0; 64],
}
}
}
pub struct EditorContext {
pub bank: usize,
pub pattern: usize,
pub step: usize,
pub focus: Focus,
pub text: TextArea<'static>,
}
impl Default for EditorContext {
fn default() -> Self {
Self {
bank: 0,
pattern: 0,
step: 0,
focus: Focus::Sequencer,
text: TextArea::default(),
}
}
}
pub struct AudioSettings {
pub config: AudioConfig,
pub focus: AudioFocus,
pub output_devices: Vec<AudioDeviceInfo>,
pub input_devices: Vec<AudioDeviceInfo>,
pub restart_pending: bool,
}
impl Default for AudioSettings {
fn default() -> Self {
Self {
config: AudioConfig::default(),
focus: AudioFocus::default(),
output_devices: doux::audio::list_output_devices(),
input_devices: doux::audio::list_input_devices(),
restart_pending: false,
}
}
}
impl AudioSettings {
pub fn refresh_devices(&mut self) {
self.output_devices = doux::audio::list_output_devices();
self.input_devices = doux::audio::list_input_devices();
}
pub fn next_focus(&mut self) {
self.focus = match self.focus {
AudioFocus::OutputDevice => AudioFocus::InputDevice,
AudioFocus::InputDevice => AudioFocus::Channels,
AudioFocus::Channels => AudioFocus::BufferSize,
AudioFocus::BufferSize => AudioFocus::SamplePaths,
AudioFocus::SamplePaths => AudioFocus::OutputDevice,
};
}
pub fn prev_focus(&mut self) {
self.focus = match self.focus {
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
AudioFocus::InputDevice => AudioFocus::OutputDevice,
AudioFocus::Channels => AudioFocus::InputDevice,
AudioFocus::BufferSize => AudioFocus::Channels,
AudioFocus::SamplePaths => AudioFocus::BufferSize,
};
}
pub fn next_output_device(&mut self) {
if self.output_devices.is_empty() {
return;
}
let current_idx = self.current_output_device_index();
let next_idx = (current_idx + 1) % self.output_devices.len();
self.config.output_device = Some(self.output_devices[next_idx].name.clone());
}
pub fn prev_output_device(&mut self) {
if self.output_devices.is_empty() {
return;
}
let current_idx = self.current_output_device_index();
let prev_idx = (current_idx + self.output_devices.len() - 1) % self.output_devices.len();
self.config.output_device = Some(self.output_devices[prev_idx].name.clone());
}
fn current_output_device_index(&self) -> usize {
match &self.config.output_device {
Some(name) => self
.output_devices
.iter()
.position(|d| &d.name == name)
.unwrap_or(0),
None => self
.output_devices
.iter()
.position(|d| d.is_default)
.unwrap_or(0),
}
}
pub fn next_input_device(&mut self) {
if self.input_devices.is_empty() {
return;
}
let current_idx = self.current_input_device_index();
let next_idx = (current_idx + 1) % self.input_devices.len();
self.config.input_device = Some(self.input_devices[next_idx].name.clone());
}
pub fn prev_input_device(&mut self) {
if self.input_devices.is_empty() {
return;
}
let current_idx = self.current_input_device_index();
let prev_idx = (current_idx + self.input_devices.len() - 1) % self.input_devices.len();
self.config.input_device = Some(self.input_devices[prev_idx].name.clone());
}
fn current_input_device_index(&self) -> usize {
match &self.config.input_device {
Some(name) => self
.input_devices
.iter()
.position(|d| &d.name == name)
.unwrap_or(0),
None => self
.input_devices
.iter()
.position(|d| d.is_default)
.unwrap_or(0),
}
}
pub fn adjust_channels(&mut self, delta: i16) {
let new_val = (self.config.channels as i16 + delta).clamp(1, 64) as u16;
self.config.channels = new_val;
}
pub fn adjust_buffer_size(&mut self, delta: i32) {
let new_val = (self.config.buffer_size as i32 + delta).clamp(64, 4096) as u32;
self.config.buffer_size = new_val;
}
pub fn current_output_device_name(&self) -> &str {
match &self.config.output_device {
Some(name) => name,
None => self
.output_devices
.iter()
.find(|d| d.is_default)
.map(|d| d.name.as_str())
.unwrap_or("Default"),
}
}
pub fn current_input_device_name(&self) -> &str {
match &self.config.input_device {
Some(name) => name,
None => self
.input_devices
.iter()
.find(|d| d.is_default)
.map(|d| d.name.as_str())
.unwrap_or("None"),
}
}
pub fn add_sample_path(&mut self, path: PathBuf) {
if !self.config.sample_paths.contains(&path) {
self.config.sample_paths.push(path);
}
}
pub fn remove_last_sample_path(&mut self) {
self.config.sample_paths.pop();
}
pub fn trigger_restart(&mut self) {
self.restart_pending = true;
}
}
pub struct App { pub struct App {
pub tempo: f64, pub tempo: f64,
pub beat: f64, pub beat: f64,
@@ -91,49 +301,39 @@ pub struct App {
pub quantum: f64, pub quantum: f64,
pub project: Project, pub project: Project,
pub focus: Focus,
pub page: Page, pub page: Page,
pub current_step: usize, pub editor_ctx: EditorContext,
pub edit_bank: usize,
pub edit_pattern: usize,
pub patterns_view_level: PatternsViewLevel, pub patterns_view_level: PatternsViewLevel,
pub patterns_cursor: usize, pub patterns_cursor: usize,
// Slot playback state (synced from audio thread) pub slot_data: [SlotState; MAX_SLOTS],
pub slot_data: [(bool, usize, usize); MAX_SLOTS], // (active, bank, pattern)
pub slot_steps: [usize; MAX_SLOTS], pub slot_steps: [usize; MAX_SLOTS],
pub queued_changes: Vec<SlotChange>, pub queued_changes: Vec<SlotChange>,
pub event_count: usize, pub metrics: Metrics,
pub active_voices: usize,
pub peak_voices: usize,
pub cpu_load: f32,
pub schedule_depth: usize,
pub sample_pool_mb: f32, pub sample_pool_mb: f32,
pub scope: [f32; 64],
pub script_engine: ScriptEngine, pub script_engine: ScriptEngine,
pub variables: Variables, pub variables: Variables,
pub rng: Rng, pub rng: Rng,
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
pub status_message: Option<String>, pub status_message: Option<String>,
pub editor: TextArea<'static>,
pub flash_until: Option<Instant>, pub flash_until: Option<Instant>,
pub modal: Modal, pub modal: Modal,
pub clipboard: Option<arboard::Clipboard>, pub clipboard: Option<arboard::Clipboard>,
pub doc_topic: usize, pub doc_topic: usize,
pub doc_scroll: usize, pub doc_scroll: usize,
pub audio_config: AudioConfig, pub audio: AudioSettings,
pub audio_focus: AudioFocus, pub dirty_patterns: HashSet<(usize, usize)>,
pub available_output_devices: Vec<AudioDeviceInfo>,
pub available_input_devices: Vec<AudioDeviceInfo>,
pub restart_pending: bool,
} }
impl App { impl App {
pub fn new(tempo: f64, quantum: f64) -> Self { pub fn new(tempo: f64, quantum: f64) -> Self {
let variables = Arc::new(Mutex::new(HashMap::new()));
let rng = Arc::new(Mutex::new(StdRng::seed_from_u64(0)));
let script_engine = ScriptEngine::new(Arc::clone(&variables), Arc::clone(&rng));
Self { Self {
tempo, tempo,
beat: 0.0, beat: 0.0,
@@ -143,44 +343,39 @@ impl App {
quantum, quantum,
project: Project::default(), project: Project::default(),
focus: Focus::Sequencer,
page: Page::default(), page: Page::default(),
current_step: 0, editor_ctx: EditorContext::default(),
edit_bank: 0,
edit_pattern: 0,
patterns_view_level: PatternsViewLevel::default(), patterns_view_level: PatternsViewLevel::default(),
patterns_cursor: 0, patterns_cursor: 0,
slot_data: [(false, 0, 0); MAX_SLOTS], slot_data: [SlotState::default(); MAX_SLOTS],
slot_steps: [0; MAX_SLOTS], slot_steps: [0; MAX_SLOTS],
queued_changes: Vec::new(), queued_changes: Vec::new(),
event_count: 0, metrics: Metrics::default(),
active_voices: 0,
peak_voices: 0,
cpu_load: 0.0,
schedule_depth: 0,
sample_pool_mb: 0.0, sample_pool_mb: 0.0,
scope: [0.0; 64], variables,
script_engine: ScriptEngine::new(), rng,
variables: Arc::new(Mutex::new(HashMap::new())), script_engine,
rng: Arc::new(Mutex::new(StdRng::seed_from_u64(0))),
file_path: None, file_path: None,
status_message: None, status_message: None,
editor: TextArea::default(),
flash_until: None, flash_until: None,
modal: Modal::None, modal: Modal::None,
clipboard: arboard::Clipboard::new().ok(), clipboard: arboard::Clipboard::new().ok(),
doc_topic: 0, doc_topic: 0,
doc_scroll: 0, doc_scroll: 0,
audio_config: AudioConfig::default(), audio: AudioSettings::default(),
audio_focus: AudioFocus::default(), dirty_patterns: HashSet::new(),
available_output_devices: doux::audio::list_output_devices(), }
available_input_devices: doux::audio::list_input_devices(), }
restart_pending: false,
pub fn mark_all_patterns_dirty(&mut self) {
for bank in 0..MAX_BANKS {
for pattern in 0..MAX_PATTERNS {
self.dirty_patterns.insert((bank, pattern));
}
} }
} }
@@ -207,32 +402,32 @@ impl App {
} }
pub fn toggle_focus(&mut self) { pub fn toggle_focus(&mut self) {
match self.focus { match self.editor_ctx.focus {
Focus::Sequencer => { Focus::Sequencer => {
self.focus = Focus::Editor; self.editor_ctx.focus = Focus::Editor;
self.load_step_to_editor(); self.load_step_to_editor();
} }
Focus::Editor => { Focus::Editor => {
self.save_editor_to_step(); self.save_editor_to_step();
self.compile_current_step(); self.compile_current_step();
self.focus = Focus::Sequencer; self.editor_ctx.focus = Focus::Sequencer;
} }
} }
} }
pub fn current_edit_pattern(&self) -> &Pattern { pub fn current_edit_pattern(&self) -> &Pattern {
self.project.pattern_at(self.edit_bank, self.edit_pattern) self.project.pattern_at(self.editor_ctx.bank, self.editor_ctx.pattern)
} }
pub fn next_step(&mut self) { pub fn next_step(&mut self) {
let len = self.current_edit_pattern().length; let len = self.current_edit_pattern().length;
self.current_step = (self.current_step + 1) % len; self.editor_ctx.step = (self.editor_ctx.step + 1) % len;
self.load_step_to_editor(); self.load_step_to_editor();
} }
pub fn prev_step(&mut self) { pub fn prev_step(&mut self) {
let len = self.current_edit_pattern().length; let len = self.current_edit_pattern().length;
self.current_step = (self.current_step + len - 1) % len; self.editor_ctx.step = (self.editor_ctx.step + len - 1) % len;
self.load_step_to_editor(); self.load_step_to_editor();
} }
@@ -246,10 +441,10 @@ impl App {
}; };
let steps_per_row = len.div_ceil(num_rows); let steps_per_row = len.div_ceil(num_rows);
if self.current_step >= steps_per_row { if self.editor_ctx.step >= steps_per_row {
self.current_step -= steps_per_row; self.editor_ctx.step -= steps_per_row;
} else { } else {
self.current_step = (self.current_step + len - steps_per_row) % len; self.editor_ctx.step = (self.editor_ctx.step + len - steps_per_row) % len;
} }
self.load_step_to_editor(); self.load_step_to_editor();
} }
@@ -264,75 +459,81 @@ impl App {
}; };
let steps_per_row = len.div_ceil(num_rows); let steps_per_row = len.div_ceil(num_rows);
self.current_step = (self.current_step + steps_per_row) % len; self.editor_ctx.step = (self.editor_ctx.step + steps_per_row) % len;
self.load_step_to_editor(); self.load_step_to_editor();
} }
pub fn toggle_step(&mut self) { pub fn toggle_step(&mut self) {
let step_idx = self.current_step; let step_idx = self.editor_ctx.step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.active = !step.active; step.active = !step.active;
} }
self.dirty_patterns.insert((bank, pattern));
} }
pub fn length_increase(&mut self) { pub fn length_increase(&mut self) {
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
let current_len = self.project.pattern_at(bank, pattern).length; let current_len = self.project.pattern_at(bank, pattern).length;
self.project self.project
.pattern_at_mut(bank, pattern) .pattern_at_mut(bank, pattern)
.set_length(current_len + 1); .set_length(current_len + 1);
self.dirty_patterns.insert((bank, pattern));
} }
pub fn length_decrease(&mut self) { pub fn length_decrease(&mut self) {
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
let current_len = self.project.pattern_at(bank, pattern).length; let current_len = self.project.pattern_at(bank, pattern).length;
self.project self.project
.pattern_at_mut(bank, pattern) .pattern_at_mut(bank, pattern)
.set_length(current_len.saturating_sub(1)); .set_length(current_len.saturating_sub(1));
let new_len = self.project.pattern_at(bank, pattern).length; let new_len = self.project.pattern_at(bank, pattern).length;
if self.current_step >= new_len { if self.editor_ctx.step >= new_len {
self.current_step = new_len - 1; self.editor_ctx.step = new_len - 1;
self.load_step_to_editor(); self.load_step_to_editor();
} }
self.dirty_patterns.insert((bank, pattern));
} }
pub fn speed_increase(&mut self) { pub fn speed_increase(&mut self) {
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
let pat = self.project.pattern_at_mut(bank, pattern); let pat = self.project.pattern_at_mut(bank, pattern);
pat.speed = pat.speed.next(); pat.speed = pat.speed.next();
self.dirty_patterns.insert((bank, pattern));
} }
pub fn speed_decrease(&mut self) { pub fn speed_decrease(&mut self) {
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
let pat = self.project.pattern_at_mut(bank, pattern); let pat = self.project.pattern_at_mut(bank, pattern);
pat.speed = pat.speed.prev(); pat.speed = pat.speed.prev();
self.dirty_patterns.insert((bank, pattern));
} }
fn load_step_to_editor(&mut self) { fn load_step_to_editor(&mut self) {
let step_idx = self.current_step; let step_idx = self.editor_ctx.step;
if let Some(step) = self.current_edit_pattern().step(step_idx) { if let Some(step) = self.current_edit_pattern().step(step_idx) {
let lines: Vec<String> = if step.script.is_empty() { let lines: Vec<String> = if step.script.is_empty() {
vec![String::new()] vec![String::new()]
} else { } else {
step.script.lines().map(String::from).collect() step.script.lines().map(String::from).collect()
}; };
self.editor = TextArea::new(lines); self.editor_ctx.text = TextArea::new(lines);
} }
} }
pub fn save_editor_to_step(&mut self) { pub fn save_editor_to_step(&mut self) {
let text = self.editor.lines().join("\n"); let text = self.editor_ctx.text.lines().join("\n");
let step_idx = self.current_step; let step_idx = self.editor_ctx.step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.script = text; step.script = text;
} }
self.dirty_patterns.insert((bank, pattern));
} }
pub fn compile_current_step(&mut self) { pub fn compile_current_step(&mut self) {
let step_idx = self.current_step; let step_idx = self.editor_ctx.step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
let script = self let script = self
.project .project
@@ -358,7 +559,7 @@ impl App {
slot: 0, slot: 0,
}; };
match self.script_engine.evaluate(&script, &ctx, &self.variables, &self.rng) { match self.script_engine.evaluate(&script, &ctx) {
Ok(cmd) => { Ok(cmd) => {
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = Some(cmd); step.command = Some(cmd);
@@ -377,7 +578,7 @@ impl App {
pub fn compile_all_steps(&mut self) { pub fn compile_all_steps(&mut self) {
let pattern_len = self.current_edit_pattern().length; let pattern_len = self.current_edit_pattern().length;
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
for step_idx in 0..pattern_len { for step_idx in 0..pattern_len {
let script = self let script = self
@@ -404,7 +605,7 @@ impl App {
slot: 0, slot: 0,
}; };
if let Ok(cmd) = self.script_engine.evaluate(&script, &ctx, &self.variables, &self.rng) { 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) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.command = Some(cmd); step.command = Some(cmd);
} }
@@ -418,8 +619,8 @@ impl App {
Some(true) Some(true)
} }
SlotChange::Remove { slot } => { SlotChange::Remove { slot } => {
let (active, b, p) = self.slot_data[*slot]; let s = self.slot_data[*slot];
if active && b == bank && p == pattern { if s.active && s.bank == bank && s.pattern == pattern {
Some(false) Some(false)
} else { } else {
None None
@@ -430,8 +631,8 @@ impl App {
} }
pub fn toggle_pattern_playback(&mut self, bank: usize, pattern: usize) { pub fn toggle_pattern_playback(&mut self, bank: usize, pattern: usize) {
let playing_slot = self.slot_data.iter().enumerate().find_map(|(i, (active, b, p))| { let playing_slot = self.slot_data.iter().enumerate().find_map(|(i, s)| {
if *active && *b == bank && *p == pattern { if s.active && s.bank == bank && s.pattern == pattern {
Some(i) Some(i)
} else { } else {
None None
@@ -441,8 +642,8 @@ impl App {
let pending = self.queued_changes.iter().position(|c| match c { let pending = self.queued_changes.iter().position(|c| match c {
SlotChange::Add { bank: b, pattern: p, .. } => *b == bank && *p == pattern, SlotChange::Add { bank: b, pattern: p, .. } => *b == bank && *p == pattern,
SlotChange::Remove { slot } => { SlotChange::Remove { slot } => {
let (_, b, p) = self.slot_data[*slot]; let s = self.slot_data[*slot];
b == bank && p == pattern s.bank == bank && s.pattern == pattern
} }
}); });
@@ -453,7 +654,7 @@ impl App {
self.queued_changes.push(SlotChange::Remove { slot: slot_idx }); self.queued_changes.push(SlotChange::Remove { slot: slot_idx });
self.status_message = Some(format!("B{:02}:P{:02} queued to stop", bank + 1, pattern + 1)); self.status_message = Some(format!("B{:02}:P{:02} queued to stop", bank + 1, pattern + 1));
} else { } else {
let free_slot = (0..MAX_SLOTS).find(|&i| !self.slot_data[i].0); let free_slot = (0..MAX_SLOTS).find(|&i| !self.slot_data[i].active);
if let Some(slot_idx) = free_slot { if let Some(slot_idx) = free_slot {
self.queued_changes.push(SlotChange::Add { slot: slot_idx, bank, pattern }); self.queued_changes.push(SlotChange::Add { slot: slot_idx, bank, pattern });
self.status_message = Some(format!("B{:02}:P{:02} queued to play", bank + 1, pattern + 1)); self.status_message = Some(format!("B{:02}:P{:02} queued to play", bank + 1, pattern + 1));
@@ -464,15 +665,15 @@ impl App {
} }
pub fn select_edit_pattern(&mut self, pattern: usize) { pub fn select_edit_pattern(&mut self, pattern: usize) {
self.edit_pattern = pattern; self.editor_ctx.pattern = pattern;
self.current_step = 0; self.editor_ctx.step = 0;
self.load_step_to_editor(); self.load_step_to_editor();
} }
pub fn select_edit_bank(&mut self, bank: usize) { pub fn select_edit_bank(&mut self, bank: usize) {
self.edit_bank = bank; self.editor_ctx.bank = bank;
self.edit_pattern = 0; self.editor_ctx.pattern = 0;
self.current_step = 0; self.editor_ctx.step = 0;
self.load_step_to_editor(); self.load_step_to_editor();
} }
@@ -493,9 +694,10 @@ impl App {
match file::load(&path) { match file::load(&path) {
Ok(project) => { Ok(project) => {
self.project = project; self.project = project;
self.current_step = 0; self.editor_ctx.step = 0;
self.load_step_to_editor(); self.load_step_to_editor();
self.compile_all_steps(); self.compile_all_steps();
self.mark_all_patterns_dirty();
self.status_message = Some(format!("Loaded: {}", path.display())); self.status_message = Some(format!("Loaded: {}", path.display()));
self.file_path = Some(path); self.file_path = Some(path);
} }
@@ -516,7 +718,7 @@ impl App {
} }
pub fn copy_step(&mut self) { pub fn copy_step(&mut self) {
let step_idx = self.current_step; let step_idx = self.editor_ctx.step;
let script = self let script = self
.current_edit_pattern() .current_edit_pattern()
.step(step_idx) .step(step_idx)
@@ -538,11 +740,12 @@ impl App {
.and_then(|clip| clip.get_text().ok()); .and_then(|clip| clip.get_text().ok());
if let Some(text) = text { if let Some(text) = text {
let step_idx = self.current_step; let step_idx = self.editor_ctx.step;
let (bank, pattern) = (self.edit_bank, self.edit_pattern); let (bank, pattern) = (self.editor_ctx.bank, self.editor_ctx.pattern);
if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) { if let Some(step) = self.project.pattern_at_mut(bank, pattern).step_mut(step_idx) {
step.script = text; step.script = text;
} }
self.dirty_patterns.insert((bank, pattern));
self.load_step_to_editor(); self.load_step_to_editor();
self.compile_current_step(); self.compile_current_step();
} }
@@ -556,142 +759,4 @@ impl App {
self.modal = Modal::SetPattern { field, input: current }; self.modal = Modal::SetPattern { field, input: current };
} }
pub fn refresh_audio_devices(&mut self) {
self.available_output_devices = doux::audio::list_output_devices();
self.available_input_devices = doux::audio::list_input_devices();
}
pub fn next_audio_focus(&mut self) {
self.audio_focus = match self.audio_focus {
AudioFocus::OutputDevice => AudioFocus::InputDevice,
AudioFocus::InputDevice => AudioFocus::Channels,
AudioFocus::Channels => AudioFocus::BufferSize,
AudioFocus::BufferSize => AudioFocus::SamplePaths,
AudioFocus::SamplePaths => AudioFocus::OutputDevice,
};
}
pub fn prev_audio_focus(&mut self) {
self.audio_focus = match self.audio_focus {
AudioFocus::OutputDevice => AudioFocus::SamplePaths,
AudioFocus::InputDevice => AudioFocus::OutputDevice,
AudioFocus::Channels => AudioFocus::InputDevice,
AudioFocus::BufferSize => AudioFocus::Channels,
AudioFocus::SamplePaths => AudioFocus::BufferSize,
};
}
pub fn next_output_device(&mut self) {
if self.available_output_devices.is_empty() {
return;
}
let current_idx = self.current_output_device_index();
let next_idx = (current_idx + 1) % self.available_output_devices.len();
self.audio_config.output_device = Some(self.available_output_devices[next_idx].name.clone());
}
pub fn prev_output_device(&mut self) {
if self.available_output_devices.is_empty() {
return;
}
let current_idx = self.current_output_device_index();
let prev_idx = (current_idx + self.available_output_devices.len() - 1) % self.available_output_devices.len();
self.audio_config.output_device = Some(self.available_output_devices[prev_idx].name.clone());
}
fn current_output_device_index(&self) -> usize {
match &self.audio_config.output_device {
Some(name) => self
.available_output_devices
.iter()
.position(|d| &d.name == name)
.unwrap_or(0),
None => self
.available_output_devices
.iter()
.position(|d| d.is_default)
.unwrap_or(0),
}
}
pub fn next_input_device(&mut self) {
if self.available_input_devices.is_empty() {
return;
}
let current_idx = self.current_input_device_index();
let next_idx = (current_idx + 1) % self.available_input_devices.len();
self.audio_config.input_device = Some(self.available_input_devices[next_idx].name.clone());
}
pub fn prev_input_device(&mut self) {
if self.available_input_devices.is_empty() {
return;
}
let current_idx = self.current_input_device_index();
let prev_idx = (current_idx + self.available_input_devices.len() - 1) % self.available_input_devices.len();
self.audio_config.input_device = Some(self.available_input_devices[prev_idx].name.clone());
}
fn current_input_device_index(&self) -> usize {
match &self.audio_config.input_device {
Some(name) => self
.available_input_devices
.iter()
.position(|d| &d.name == name)
.unwrap_or(0),
None => self
.available_input_devices
.iter()
.position(|d| d.is_default)
.unwrap_or(0),
}
}
pub fn adjust_channels(&mut self, delta: i16) {
let new_val = (self.audio_config.channels as i16 + delta).clamp(1, 64) as u16;
self.audio_config.channels = new_val;
}
pub fn adjust_buffer_size(&mut self, delta: i32) {
let new_val = (self.audio_config.buffer_size as i32 + delta).clamp(64, 4096) as u32;
self.audio_config.buffer_size = new_val;
}
pub fn trigger_restart(&mut self) {
self.restart_pending = true;
}
pub fn current_output_device_name(&self) -> &str {
match &self.audio_config.output_device {
Some(name) => name,
None => self
.available_output_devices
.iter()
.find(|d| d.is_default)
.map(|d| d.name.as_str())
.unwrap_or("Default"),
}
}
pub fn current_input_device_name(&self) -> &str {
match &self.audio_config.input_device {
Some(name) => name,
None => self
.available_input_devices
.iter()
.find(|d| d.is_default)
.map(|d| d.name.as_str())
.unwrap_or("None"),
}
}
pub fn add_sample_path(&mut self, path: PathBuf) {
if !self.audio_config.sample_paths.contains(&path) {
self.audio_config.sample_paths.push(path);
}
}
pub fn remove_last_sample_path(&mut self) {
self.audio_config.sample_paths.pop();
}
} }

View File

@@ -1,12 +1,34 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::Stream; use cpal::Stream;
use doux::Engine; use crossbeam_channel::Receiver;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use doux::{Engine, EngineMetrics};
use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use crate::link::LinkState; use crate::sequencer::AudioCommand;
use crate::model::Project;
use crate::script::{Rng, ScriptEngine, StepContext, Variables}; pub struct ScopeBuffer {
pub samples: [AtomicU32; 64],
}
impl ScopeBuffer {
pub fn new() -> Self {
Self {
samples: std::array::from_fn(|_| AtomicU32::new(0)),
}
}
pub fn write(&self, data: &[f32]) {
for (i, atom) in self.samples.iter().enumerate() {
let val = data.get(i * 2).copied().unwrap_or(0.0);
atom.store(val.to_bits(), Ordering::Relaxed);
}
}
pub fn read(&self) -> [f32; 64] {
std::array::from_fn(|i| f32::from_bits(self.samples[i].load(Ordering::Relaxed)))
}
}
pub struct AudioStreamConfig { pub struct AudioStreamConfig {
pub output_device: Option<String>, pub output_device: Option<String>,
@@ -14,8 +36,6 @@ pub struct AudioStreamConfig {
pub buffer_size: u32, pub buffer_size: u32,
} }
pub const MAX_SLOTS: usize = 8;
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
pub struct PatternSlot { pub struct PatternSlot {
pub bank: usize, pub bank: usize,
@@ -24,38 +44,12 @@ pub struct PatternSlot {
pub active: bool, pub active: bool,
} }
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SlotChange {
Add { slot: usize, bank: usize, pattern: usize },
Remove { slot: usize },
}
pub struct AudioState {
prev_beat: f64,
pub slots: [PatternSlot; MAX_SLOTS],
}
impl AudioState {
fn new() -> Self {
Self {
prev_beat: -1.0,
slots: [PatternSlot::default(); MAX_SLOTS],
}
}
}
pub fn build_stream( pub fn build_stream(
config: &AudioStreamConfig, config: &AudioStreamConfig,
engine: Arc<Mutex<Engine>>, audio_rx: Receiver<AudioCommand>,
link: Arc<LinkState>, scope_buffer: Arc<ScopeBuffer>,
playing: Arc<AtomicBool>, metrics: Arc<EngineMetrics>,
project: Arc<Mutex<Project>>, initial_samples: Vec<doux::sample::SampleEntry>,
slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
event_count: Arc<AtomicUsize>,
slot_data: Arc<Mutex<[(bool, usize, usize); MAX_SLOTS]>>,
slot_changes: Arc<Mutex<Vec<SlotChange>>>,
variables: Variables,
rng: Rng,
) -> Result<(Stream, f32), String> { ) -> Result<(Stream, f32), String> {
let host = cpal::default_host(); let host = cpal::default_host();
@@ -80,12 +74,13 @@ pub fn build_stream(
buffer_size, buffer_size,
}; };
let quantum = 4.0;
let audio_state = Arc::new(Mutex::new(AudioState::new()));
let script_engine = ScriptEngine::new();
let sr = sample_rate; let sr = sample_rate;
let channels = config.channels as usize; let channels = config.channels as usize;
let metrics_clone = Arc::clone(&metrics);
let mut engine = Engine::new_with_metrics(sample_rate, channels, Arc::clone(&metrics));
engine.sample_index = initial_samples;
let stream = device let stream = device
.build_output_stream( .build_output_stream(
&stream_config, &stream_config,
@@ -93,97 +88,31 @@ pub fn build_stream(
let buffer_samples = data.len() / channels; let buffer_samples = data.len() / channels;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64; let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
let is_playing = playing.load(Ordering::Relaxed); while let Ok(cmd) = audio_rx.try_recv() {
match cmd {
if is_playing { AudioCommand::Evaluate(s) => {
let state = link.capture_audio_state(); engine.evaluate(&s);
let time = link.clock_micros();
let beat = state.beat_at_time(time, quantum);
let tempo = state.tempo();
let mut audio = audio_state.lock().unwrap();
let proj = project.lock().unwrap();
// Apply queued slot changes at bar boundaries (every 4 beats)
let bar = (beat / quantum).floor() as i64;
let prev_bar = (audio.prev_beat / quantum).floor() as i64;
if bar != prev_bar && audio.prev_beat >= 0.0 {
let mut changes = slot_changes.lock().unwrap();
for change in changes.drain(..) {
match change {
SlotChange::Add { slot, bank, pattern } => {
audio.slots[slot] = PatternSlot {
bank,
pattern,
step_index: 0,
active: true,
};
} }
SlotChange::Remove { slot } => { AudioCommand::Hush => {
audio.slots[slot].active = false; engine.hush();
} }
AudioCommand::Panic => {
engine.panic();
} }
AudioCommand::LoadSamples(samples) => {
engine.sample_index.extend(samples);
} }
} AudioCommand::ResetEngine => {
let old_samples = std::mem::take(&mut engine.sample_index);
// Read prev_beat before the mutable borrow of slots engine = Engine::new_with_metrics(sr, channels, Arc::clone(&metrics_clone));
let prev_beat = audio.prev_beat; engine.sample_index = old_samples;
// Iterate all active slots
for (slot_idx, slot) in audio.slots.iter_mut().enumerate() {
if !slot.active {
continue;
}
let pattern = proj.pattern_at(slot.bank, slot.pattern);
let speed_mult = pattern.speed.multiplier();
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64;
if beat_int != prev_beat_int && prev_beat >= 0.0 {
let step_idx = slot.step_index % pattern.length;
slot_steps[slot_idx].store(step_idx, Ordering::Relaxed);
if let Some(step) = pattern.step(step_idx) {
if step.active && !step.script.trim().is_empty() {
let ctx = StepContext {
step: step_idx,
beat,
bank: slot.bank,
pattern: slot.pattern,
tempo,
phase: beat % quantum,
slot: slot_idx,
};
if let Ok(cmd) =
script_engine.evaluate(&step.script, &ctx, &variables, &rng)
{
engine.lock().unwrap().evaluate(&cmd);
event_count.fetch_add(1, Ordering::Relaxed);
} }
} }
} }
slot.step_index = (slot.step_index + 1) % pattern.length; engine.metrics.load.set_buffer_time(buffer_time_ns);
} engine.process_block(data, &[], &[]);
} scope_buffer.write(&engine.output);
// Update shared slot data for UI
{
let mut sd = slot_data.lock().unwrap();
for (i, slot) in audio.slots.iter().enumerate() {
sd[i] = (slot.active, slot.bank, slot.pattern);
}
}
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}"), |err| eprintln!("stream error: {err}"),
None, None,

7
seq/src/config.rs Normal file
View File

@@ -0,0 +1,7 @@
pub const MAX_SLOTS: usize = 8;
pub const MAX_BANKS: usize = 16;
pub const MAX_PATTERNS: usize = 16;
pub const MAX_STEPS: usize = 32;
pub const DEFAULT_LENGTH: usize = 16;
pub const DEFAULT_TEMPO: f64 = 120.0;
pub const DEFAULT_QUANTUM: f64 = 4.0;

View File

@@ -38,9 +38,9 @@ impl LinkState {
self.link.commit_app_session_state(&state); self.link.commit_app_session_state(&state);
} }
pub fn capture_audio_state(&self) -> SessionState { pub fn capture_app_state(&self) -> SessionState {
let mut state = SessionState::new(); let mut state = SessionState::new();
self.link.capture_audio_session_state(&mut state); self.link.capture_app_session_state(&mut state);
state state
} }
} }

View File

@@ -1,18 +1,20 @@
mod app; mod app;
mod audio; mod audio;
mod config;
mod file; mod file;
mod link; mod link;
mod model; mod model;
mod page; mod page;
mod script; mod script;
mod sequencer;
mod ui; mod ui;
mod views; mod views;
mod widgets; mod widgets;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use clap::Parser; use clap::Parser;
@@ -21,15 +23,15 @@ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
}; };
use crossterm::ExecutableCommand; use crossterm::ExecutableCommand;
use doux::Engine; use doux::EngineMetrics;
use ratatui::prelude::CrosstermBackend; use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use app::{App, AudioFocus, Focus, Modal, PatternField}; use app::{App, AudioFocus, Focus, Modal, PatternField};
use audio::{AudioStreamConfig, SlotChange, MAX_SLOTS}; use audio::{AudioStreamConfig, ScopeBuffer};
use link::LinkState; use link::LinkState;
use model::Project;
use page::Page; use page::Page;
use sequencer::{spawn_sequencer, AudioCommand, PatternSnapshot, SeqCommand, StepSnapshot};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "seq", about = "A step sequencer with Ableton Link support")] #[command(name = "seq", about = "A step sequencer with Ableton Link support")]
@@ -55,72 +57,59 @@ struct Args {
buffer: u32, buffer: u32,
} }
const TEMPO: f64 = 120.0; use config::{DEFAULT_QUANTUM, DEFAULT_TEMPO};
const QUANTUM: f64 = 4.0;
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
let args = Args::parse(); let args = Args::parse();
let link = Arc::new(LinkState::new(TEMPO, QUANTUM)); let link = Arc::new(LinkState::new(DEFAULT_TEMPO, DEFAULT_QUANTUM));
link.enable(); link.enable();
let playing = Arc::new(AtomicBool::new(true)); let playing = Arc::new(AtomicBool::new(true));
let event_count = Arc::new(AtomicUsize::new(0));
// Slot state shared between audio thread and UI let mut app = App::new(DEFAULT_TEMPO, DEFAULT_QUANTUM);
let slot_steps: [Arc<AtomicUsize>; MAX_SLOTS] = std::array::from_fn(|_| Arc::new(AtomicUsize::new(0)));
let slot_data: Arc<Mutex<[(bool, usize, usize); MAX_SLOTS]>> =
Arc::new(Mutex::new([(false, 0, 0); MAX_SLOTS]));
let slot_changes: Arc<Mutex<Vec<SlotChange>>> = Arc::new(Mutex::new(Vec::new()));
let mut app = App::new(TEMPO, QUANTUM); app.audio.config.output_device = args.output;
app.audio.config.input_device = args.input;
app.audio.config.channels = args.channels;
app.audio.config.buffer_size = args.buffer;
app.audio.config.sample_paths = args.samples;
// Apply CLI args to audio config let metrics = Arc::new(EngineMetrics::default());
app.audio_config.output_device = args.output; let scope_buffer = Arc::new(ScopeBuffer::new());
app.audio_config.input_device = args.input;
app.audio_config.channels = args.channels;
app.audio_config.buffer_size = args.buffer;
app.audio_config.sample_paths = args.samples;
let engine = Arc::new(Mutex::new(Engine::new(44100.0))); let mut initial_samples = Vec::new();
let project = Arc::new(Mutex::new(Project::default())); for path in &app.audio.config.sample_paths {
// Load sample directories
for path in &app.audio_config.sample_paths {
let index = doux::loader::scan_samples_dir(path); let index = doux::loader::scan_samples_dir(path);
let count = index.len(); app.audio.config.sample_count += index.len();
engine.lock().unwrap().sample_index.extend(index); initial_samples.extend(index);
app.audio_config.sample_count += count;
} }
let sequencer = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&app.variables),
Arc::clone(&app.rng),
DEFAULT_QUANTUM,
);
let stream_config = AudioStreamConfig { let stream_config = AudioStreamConfig {
output_device: app.audio_config.output_device.clone(), output_device: app.audio.config.output_device.clone(),
channels: app.audio_config.channels, channels: app.audio.config.channels,
buffer_size: app.audio_config.buffer_size, buffer_size: app.audio.config.buffer_size,
}; };
let (mut stream, sample_rate) = audio::build_stream( let (mut stream, sample_rate) = audio::build_stream(
&stream_config, &stream_config,
Arc::clone(&engine), sequencer.audio_rx.clone(),
Arc::clone(&link), Arc::clone(&scope_buffer),
Arc::clone(&playing), Arc::clone(&metrics),
Arc::clone(&project), initial_samples,
slot_steps.clone(),
Arc::clone(&event_count),
Arc::clone(&slot_data),
Arc::clone(&slot_changes),
Arc::clone(&app.variables),
Arc::clone(&app.rng),
) )
.expect("Failed to start audio"); .expect("Failed to start audio");
app.audio_config.sample_rate = sample_rate; app.audio.config.sample_rate = sample_rate;
app.mark_all_patterns_dirty();
{
let mut eng = engine.lock().unwrap();
eng.sr = sample_rate;
eng.isr = 1.0 / sample_rate;
}
enable_raw_mode()?; enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?; io::stdout().execute(EnterAlternateScreen)?;
@@ -129,62 +118,52 @@ fn main() -> io::Result<()> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
loop { loop {
if app.restart_pending { if app.audio.restart_pending {
app.restart_pending = false; app.audio.restart_pending = false;
drop(stream); drop(stream);
let new_config = AudioStreamConfig { let new_config = AudioStreamConfig {
output_device: app.audio_config.output_device.clone(), output_device: app.audio.config.output_device.clone(),
channels: app.audio_config.channels, channels: app.audio.config.channels,
buffer_size: app.audio_config.buffer_size, buffer_size: app.audio.config.buffer_size,
}; };
{ let mut restart_samples = Vec::new();
let mut eng = engine.lock().unwrap(); for path in &app.audio.config.sample_paths {
*eng = Engine::new_with_channels(eng.sr, new_config.channels as usize); let index = doux::loader::scan_samples_dir(path);
restart_samples.extend(index);
} }
app.audio.config.sample_count = restart_samples.len();
match audio::build_stream( match audio::build_stream(
&new_config, &new_config,
Arc::clone(&engine), sequencer.audio_rx.clone(),
Arc::clone(&link), Arc::clone(&scope_buffer),
Arc::clone(&playing), Arc::clone(&metrics),
Arc::clone(&project), restart_samples,
slot_steps.clone(),
Arc::clone(&event_count),
Arc::clone(&slot_data),
Arc::clone(&slot_changes),
Arc::clone(&app.variables),
Arc::clone(&app.rng),
) { ) {
Ok((new_stream, sr)) => { Ok((new_stream, sr)) => {
stream = new_stream; stream = new_stream;
app.audio_config.sample_rate = sr; app.audio.config.sample_rate = sr;
{
let mut eng = engine.lock().unwrap();
eng.sr = sr;
eng.isr = 1.0 / sr;
}
app.status_message = Some("Audio restarted".to_string()); app.status_message = Some("Audio restarted".to_string());
} }
Err(e) => { Err(e) => {
app.status_message = Some(format!("Restart failed: {e}")); app.status_message = Some(format!("Restart failed: {e}"));
let mut fallback_samples = Vec::new();
for path in &app.audio.config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
fallback_samples.extend(index);
}
let (fallback_stream, _) = audio::build_stream( let (fallback_stream, _) = audio::build_stream(
&AudioStreamConfig { &AudioStreamConfig {
output_device: None, output_device: None,
channels: 2, channels: 2,
buffer_size: 512, buffer_size: 512,
}, },
Arc::clone(&engine), sequencer.audio_rx.clone(),
Arc::clone(&link), Arc::clone(&scope_buffer),
Arc::clone(&playing), Arc::clone(&metrics),
Arc::clone(&project), fallback_samples,
slot_steps.clone(),
Arc::clone(&event_count),
Arc::clone(&slot_data),
Arc::clone(&slot_changes),
Arc::clone(&app.variables),
Arc::clone(&app.rng),
) )
.expect("Failed to restart with defaults"); .expect("Failed to restart with defaults");
stream = fallback_stream; stream = fallback_stream;
@@ -194,37 +173,42 @@ fn main() -> io::Result<()> {
app.update_from_link(&link); app.update_from_link(&link);
app.playing = playing.load(Ordering::Relaxed); app.playing = playing.load(Ordering::Relaxed);
app.event_count = event_count.load(Ordering::Relaxed);
{ {
let eng = engine.lock().unwrap(); app.metrics.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize;
app.active_voices = eng.active_voices; app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices);
app.peak_voices = app.peak_voices.max(eng.active_voices); app.metrics.cpu_load = metrics.load.get_load();
app.cpu_load = eng.metrics.load.get_load(); app.metrics.schedule_depth = metrics.schedule_depth.load(Ordering::Relaxed) as usize;
app.schedule_depth = eng.schedule.len(); app.metrics.scope = scope_buffer.read();
for (i, s) in app.scope.iter_mut().enumerate() { }
*s = eng.output.get(i * 2).copied().unwrap_or(0.0);
let seq_snapshot = sequencer.snapshot();
app.slot_data = seq_snapshot.slot_data;
app.slot_steps = seq_snapshot.slot_steps;
app.metrics.event_count = seq_snapshot.event_count;
for change in app.queued_changes.drain(..) {
match change {
app::SlotChange::Add { slot, bank, pattern } => {
let _ = sequencer.cmd_tx.send(SeqCommand::SlotAdd { slot, bank, pattern });
}
app::SlotChange::Remove { slot } => {
let _ = sequencer.cmd_tx.send(SeqCommand::SlotRemove { slot });
}
} }
} }
// Sync slot state from audio thread for (bank, pattern) in app.dirty_patterns.drain() {
{ let pat = app.project.pattern_at(bank, pattern);
let sd = slot_data.lock().unwrap(); let snapshot = PatternSnapshot {
app.slot_data = *sd; speed: pat.speed,
} length: pat.length,
for (i, step_atomic) in slot_steps.iter().enumerate() { steps: pat.steps.iter().take(pat.length).map(|s| StepSnapshot {
app.slot_steps[i] = step_atomic.load(Ordering::Relaxed); active: s.active,
} script: s.script.clone(),
}).collect(),
// Push queued changes to audio thread };
if !app.queued_changes.is_empty() { let _ = sequencer.cmd_tx.send(SeqCommand::PatternUpdate { bank, pattern, data: snapshot });
let mut changes = slot_changes.lock().unwrap();
changes.extend(app.queued_changes.drain(..));
}
{
let mut proj = project.lock().unwrap();
proj.banks = app.project.banks.clone();
} }
terminal.draw(|frame| ui::render(frame, &mut app))?; terminal.draw(|frame| ui::render(frame, &mut app))?;
@@ -324,15 +308,16 @@ fn main() -> io::Result<()> {
Modal::SetPattern { field, input } => match key.code { Modal::SetPattern { field, input } => match key.code {
KeyCode::Enter => { KeyCode::Enter => {
let field = *field; let field = *field;
let (bank, pattern) = (app.edit_bank, app.edit_pattern); let (bank, pattern) = (app.editor_ctx.bank, app.editor_ctx.pattern);
match field { match field {
PatternField::Length => { PatternField::Length => {
if let Ok(len) = input.parse::<usize>() { if let Ok(len) = input.parse::<usize>() {
app.project.pattern_at_mut(bank, pattern).set_length(len); app.project.pattern_at_mut(bank, pattern).set_length(len);
let new_len = app.project.pattern_at(bank, pattern).length; let new_len = app.project.pattern_at(bank, pattern).length;
if app.current_step >= new_len { if app.editor_ctx.step >= new_len {
app.current_step = new_len - 1; app.editor_ctx.step = new_len - 1;
} }
app.dirty_patterns.insert((bank, pattern));
app.status_message = Some(format!("Length set to {new_len}")); app.status_message = Some(format!("Length set to {new_len}"));
} else { } else {
app.status_message = Some("Invalid length".to_string()); app.status_message = Some("Invalid length".to_string());
@@ -341,6 +326,7 @@ fn main() -> io::Result<()> {
PatternField::Speed => { PatternField::Speed => {
if let Some(speed) = model::PatternSpeed::from_label(input) { if let Some(speed) = model::PatternSpeed::from_label(input) {
app.project.pattern_at_mut(bank, pattern).speed = speed; app.project.pattern_at_mut(bank, pattern).speed = speed;
app.dirty_patterns.insert((bank, pattern));
app.status_message = Some(format!("Speed set to {}", speed.label())); app.status_message = Some(format!("Speed set to {}", speed.label()));
} else { } else {
app.status_message = Some("Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string()); app.status_message = Some("Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string());
@@ -366,9 +352,9 @@ fn main() -> io::Result<()> {
if sample_path.is_dir() { if sample_path.is_dir() {
let index = doux::loader::scan_samples_dir(&sample_path); let index = doux::loader::scan_samples_dir(&sample_path);
let count = index.len(); let count = index.len();
engine.lock().unwrap().sample_index.extend(index); let _ = sequencer.audio_tx.send(AudioCommand::LoadSamples(index));
app.audio_config.sample_count += count; app.audio.config.sample_count += count;
app.add_sample_path(sample_path); app.audio.add_sample_path(sample_path);
app.status_message = Some(format!("Added {count} samples")); app.status_message = Some(format!("Added {count} samples"));
} else { } else {
app.status_message = Some("Path is not a directory".to_string()); app.status_message = Some("Path is not a directory".to_string());
@@ -407,7 +393,7 @@ fn main() -> io::Result<()> {
} }
match app.page { match app.page {
Page::Main => match app.focus { Page::Main => match app.editor_ctx.focus {
Focus::Sequencer => match key.code { Focus::Sequencer => match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.modal = Modal::ConfirmQuit { selected: false }; app.modal = Modal::ConfirmQuit { selected: false };
@@ -456,7 +442,7 @@ fn main() -> io::Result<()> {
app.compile_current_step(); app.compile_current_step();
} }
_ => { _ => {
app.editor.input(Event::Key(key)); app.editor_ctx.text.input(Event::Key(key));
} }
}, },
}, },
@@ -537,57 +523,48 @@ fn main() -> io::Result<()> {
app.modal = Modal::ConfirmQuit { selected: false }; app.modal = Modal::ConfirmQuit { selected: false };
} }
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
app.prev_audio_focus(); app.audio.prev_focus();
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
app.next_audio_focus(); app.audio.next_focus();
} }
KeyCode::Left => match app.audio_focus { KeyCode::Left => match app.audio.focus {
AudioFocus::OutputDevice => app.prev_output_device(), AudioFocus::OutputDevice => app.audio.prev_output_device(),
AudioFocus::InputDevice => app.prev_input_device(), AudioFocus::InputDevice => app.audio.prev_input_device(),
AudioFocus::Channels => app.adjust_channels(-1), AudioFocus::Channels => app.audio.adjust_channels(-1),
AudioFocus::BufferSize => app.adjust_buffer_size(-64), AudioFocus::BufferSize => app.audio.adjust_buffer_size(-64),
AudioFocus::SamplePaths => app.remove_last_sample_path(), AudioFocus::SamplePaths => app.audio.remove_last_sample_path(),
}, },
KeyCode::Right => match app.audio_focus { KeyCode::Right => match app.audio.focus {
AudioFocus::OutputDevice => app.next_output_device(), AudioFocus::OutputDevice => app.audio.next_output_device(),
AudioFocus::InputDevice => app.next_input_device(), AudioFocus::InputDevice => app.audio.next_input_device(),
AudioFocus::Channels => app.adjust_channels(1), AudioFocus::Channels => app.audio.adjust_channels(1),
AudioFocus::BufferSize => app.adjust_buffer_size(64), AudioFocus::BufferSize => app.audio.adjust_buffer_size(64),
AudioFocus::SamplePaths => {} AudioFocus::SamplePaths => {}
}, },
KeyCode::Char('R') => { KeyCode::Char('R') => {
app.trigger_restart(); app.audio.trigger_restart();
// Reload samples on restart
let mut eng = engine.lock().unwrap();
eng.sample_index.clear();
app.audio_config.sample_count = 0;
for path in &app.audio_config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
app.audio_config.sample_count += index.len();
eng.sample_index.extend(index);
}
} }
KeyCode::Char('A') => { KeyCode::Char('A') => {
app.modal = Modal::AddSamplePath(String::new()); app.modal = Modal::AddSamplePath(String::new());
} }
KeyCode::Char('D') => { KeyCode::Char('D') => {
app.refresh_audio_devices(); app.audio.refresh_devices();
let out_count = app.available_output_devices.len(); let out_count = app.audio.output_devices.len();
let in_count = app.available_input_devices.len(); let in_count = app.audio.input_devices.len();
app.status_message = Some(format!("Found {out_count} output, {in_count} input devices")); app.status_message = Some(format!("Found {out_count} output, {in_count} input devices"));
} }
KeyCode::Char('h') => { KeyCode::Char('h') => {
engine.lock().unwrap().hush(); let _ = sequencer.audio_tx.send(AudioCommand::Hush);
} }
KeyCode::Char('p') => { KeyCode::Char('p') => {
engine.lock().unwrap().panic(); let _ = sequencer.audio_tx.send(AudioCommand::Panic);
} }
KeyCode::Char('r') => { KeyCode::Char('r') => {
app.peak_voices = 0; app.metrics.peak_voices = 0;
} }
KeyCode::Char('t') => { KeyCode::Char('t') => {
engine.lock().unwrap().evaluate("sin 440 * 0.3"); let _ = sequencer.audio_tx.send(AudioCommand::Evaluate("sin 440 * 0.3".into()));
} }
KeyCode::Char(' ') => { KeyCode::Char(' ') => {
app.toggle_playing(); app.toggle_playing();
@@ -628,5 +605,7 @@ fn main() -> io::Result<()> {
disable_raw_mode()?; disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?; io::stdout().execute(LeaveAlternateScreen)?;
sequencer.shutdown();
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::{DEFAULT_LENGTH, MAX_BANKS, MAX_PATTERNS, MAX_STEPS};
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq)] #[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq)]
pub enum PatternSpeed { pub enum PatternSpeed {
Eighth, // 1/8x Eighth, // 1/8x
@@ -106,8 +108,8 @@ pub struct Pattern {
impl Default for Pattern { impl Default for Pattern {
fn default() -> Self { fn default() -> Self {
Self { Self {
steps: (0..32).map(|_| Step::default()).collect(), steps: (0..MAX_STEPS).map(|_| Step::default()).collect(),
length: 16, length: DEFAULT_LENGTH,
speed: PatternSpeed::default(), speed: PatternSpeed::default(),
name: None, name: None,
} }
@@ -124,7 +126,7 @@ impl Pattern {
} }
pub fn set_length(&mut self, length: usize) { pub fn set_length(&mut self, length: usize) {
let length = length.clamp(2, 32); let length = length.clamp(2, MAX_STEPS);
while self.steps.len() < length { while self.steps.len() < length {
self.steps.push(Step::default()); self.steps.push(Step::default());
} }
@@ -142,7 +144,7 @@ pub struct Bank {
impl Default for Bank { impl Default for Bank {
fn default() -> Self { fn default() -> Self {
Self { Self {
patterns: (0..16).map(|_| Pattern::default()).collect(), patterns: (0..MAX_PATTERNS).map(|_| Pattern::default()).collect(),
name: None, name: None,
} }
} }
@@ -156,7 +158,7 @@ pub struct Project {
impl Default for Project { impl Default for Project {
fn default() -> Self { fn default() -> Self {
Self { Self {
banks: (0..16).map(|_| Bank::default()).collect(), banks: (0..MAX_BANKS).map(|_| Bank::default()).collect(),
} }
} }
} }

View File

@@ -62,40 +62,20 @@ pub struct StepContext {
pub slot: usize, pub slot: usize,
} }
pub struct ScriptEngine; pub struct ScriptEngine {
engine: Engine,
}
impl ScriptEngine { impl ScriptEngine {
pub fn new() -> Self { pub fn new(vars: Variables, rng: Rng) -> Self {
Self
}
pub fn evaluate(
&self,
script: &str,
ctx: &StepContext,
vars: &Variables,
rng: &Rng,
) -> 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);
scope.push("slot", ctx.slot as i64);
let vars_for_set = Arc::clone(vars);
let vars_for_get = Arc::clone(vars);
let mut engine = Engine::new(); let mut engine = Engine::new();
engine.set_max_expr_depths(64, 32); engine.set_max_expr_depths(64, 32);
register_cmd(&mut engine); register_cmd(&mut engine);
let vars_for_set = Arc::clone(&vars);
let vars_for_get = Arc::clone(&vars);
engine.register_fn("set", move |name: &str, value: Dynamic| { engine.register_fn("set", move |name: &str, value: Dynamic| {
vars_for_set vars_for_set
.lock() .lock()
@@ -112,11 +92,11 @@ impl ScriptEngine {
.unwrap_or(Dynamic::UNIT) .unwrap_or(Dynamic::UNIT)
}); });
let rng_rand_ff = Arc::clone(rng); let rng_rand_ff = Arc::clone(&rng);
let rng_rand_ii = Arc::clone(rng); let rng_rand_ii = Arc::clone(&rng);
let rng_rrand_ff = Arc::clone(rng); let rng_rrand_ff = Arc::clone(&rng);
let rng_rrand_ii = Arc::clone(rng); let rng_rrand_ii = Arc::clone(&rng);
let rng_seed = Arc::clone(rng); let rng_seed = Arc::clone(&rng);
engine.register_fn("rand", move |min: f64, max: f64| -> f64 { engine.register_fn("rand", move |min: f64, max: f64| -> f64 {
rng_rand_ff.lock().unwrap().gen_range(min..max) rng_rand_ff.lock().unwrap().gen_range(min..max)
@@ -136,11 +116,28 @@ impl ScriptEngine {
*rng_seed.lock().unwrap() = StdRng::seed_from_u64(s as u64); *rng_seed.lock().unwrap() = StdRng::seed_from_u64(s as u64);
}); });
if let Ok(cmd) = engine.eval_with_scope::<Cmd>(&mut scope, script) { 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);
scope.push("slot", ctx.slot as i64);
if let Ok(cmd) = self.engine.eval_with_scope::<Cmd>(&mut scope, script) {
return Ok(cmd.to_string()); return Ok(cmd.to_string());
} }
engine self.engine
.eval_with_scope::<String>(&mut scope, script) .eval_with_scope::<String>(&mut scope, script)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

328
seq/src/sequencer.rs Normal file
View File

@@ -0,0 +1,328 @@
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use crate::audio::PatternSlot;
use crate::config::{MAX_BANKS, MAX_PATTERNS, MAX_SLOTS};
use crate::link::LinkState;
use crate::script::{ScriptEngine, StepContext, Variables, Rng};
pub enum AudioCommand {
Evaluate(String),
Hush,
Panic,
LoadSamples(Vec<doux::sample::SampleEntry>),
#[allow(dead_code)]
ResetEngine,
}
pub enum SeqCommand {
PatternUpdate { bank: usize, pattern: usize, data: PatternSnapshot },
SlotAdd { slot: usize, bank: usize, pattern: usize },
SlotRemove { slot: usize },
Shutdown,
}
#[derive(Clone)]
pub struct PatternSnapshot {
pub speed: crate::model::PatternSpeed,
pub length: usize,
pub steps: Vec<StepSnapshot>,
}
#[derive(Clone)]
pub struct StepSnapshot {
pub active: bool,
pub script: String,
}
#[derive(Clone, Copy, Default)]
pub struct SlotState {
pub active: bool,
pub bank: usize,
pub pattern: usize,
}
pub struct AtomicSlotData {
active: AtomicBool,
bank: AtomicUsize,
pattern: AtomicUsize,
}
impl AtomicSlotData {
pub fn new() -> Self {
Self {
active: AtomicBool::new(false),
bank: AtomicUsize::new(0),
pattern: AtomicUsize::new(0),
}
}
pub fn load(&self) -> SlotState {
SlotState {
active: self.active.load(Ordering::Relaxed),
bank: self.bank.load(Ordering::Relaxed),
pattern: self.pattern.load(Ordering::Relaxed),
}
}
pub fn store(&self, state: SlotState) {
self.active.store(state.active, Ordering::Relaxed);
self.bank.store(state.bank, Ordering::Relaxed);
self.pattern.store(state.pattern, Ordering::Relaxed);
}
}
pub struct SequencerSnapshot {
pub slot_data: [SlotState; MAX_SLOTS],
pub slot_steps: [usize; MAX_SLOTS],
pub event_count: usize,
}
pub struct SequencerHandle {
pub cmd_tx: Sender<SeqCommand>,
pub audio_tx: Sender<AudioCommand>,
pub audio_rx: Receiver<AudioCommand>,
slot_data: Arc<[AtomicSlotData; MAX_SLOTS]>,
slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
event_count: Arc<AtomicUsize>,
thread: JoinHandle<()>,
}
impl SequencerHandle {
pub fn snapshot(&self) -> SequencerSnapshot {
SequencerSnapshot {
slot_data: std::array::from_fn(|i| self.slot_data[i].load()),
slot_steps: std::array::from_fn(|i| self.slot_steps[i].load(Ordering::Relaxed)),
event_count: self.event_count.load(Ordering::Relaxed),
}
}
pub fn shutdown(self) {
let _ = self.cmd_tx.send(SeqCommand::Shutdown);
let _ = self.thread.join();
}
}
struct AudioState {
prev_beat: f64,
slots: [PatternSlot; MAX_SLOTS],
pending_changes: Vec<PendingChange>,
}
enum PendingChange {
Add { slot: usize, bank: usize, pattern: usize },
Remove { slot: usize },
}
impl AudioState {
fn new() -> Self {
Self {
prev_beat: -1.0,
slots: [PatternSlot::default(); MAX_SLOTS],
pending_changes: Vec::new(),
}
}
}
pub fn spawn_sequencer(
link: Arc<LinkState>,
playing: Arc<AtomicBool>,
variables: Variables,
rng: Rng,
quantum: f64,
) -> SequencerHandle {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
let slot_data: Arc<[AtomicSlotData; MAX_SLOTS]> =
Arc::new(std::array::from_fn(|_| AtomicSlotData::new()));
let slot_steps: [Arc<AtomicUsize>; MAX_SLOTS] =
std::array::from_fn(|_| Arc::new(AtomicUsize::new(0)));
let event_count = Arc::new(AtomicUsize::new(0));
let slot_data_clone = Arc::clone(&slot_data);
let slot_steps_clone = slot_steps.clone();
let event_count_clone = Arc::clone(&event_count);
let audio_tx_clone = audio_tx.clone();
let thread = thread::Builder::new()
.name("sequencer".into())
.spawn(move || {
sequencer_loop(
cmd_rx,
audio_tx_clone,
link,
playing,
variables,
rng,
quantum,
slot_data_clone,
slot_steps_clone,
event_count_clone,
);
})
.expect("Failed to spawn sequencer thread");
SequencerHandle {
cmd_tx,
audio_tx,
audio_rx,
slot_data,
slot_steps,
event_count,
thread,
}
}
struct PatternCache {
patterns: [[Option<PatternSnapshot>; MAX_PATTERNS]; MAX_BANKS],
}
impl PatternCache {
fn new() -> Self {
Self {
patterns: std::array::from_fn(|_| std::array::from_fn(|_| None)),
}
}
fn get(&self, bank: usize, pattern: usize) -> Option<&PatternSnapshot> {
self.patterns.get(bank).and_then(|b| b.get(pattern)).and_then(|p| p.as_ref())
}
fn set(&mut self, bank: usize, pattern: usize, data: PatternSnapshot) {
if bank < MAX_BANKS && pattern < MAX_PATTERNS {
self.patterns[bank][pattern] = Some(data);
}
}
}
fn sequencer_loop(
cmd_rx: Receiver<SeqCommand>,
audio_tx: Sender<AudioCommand>,
link: Arc<LinkState>,
playing: Arc<AtomicBool>,
variables: Variables,
rng: Rng,
quantum: f64,
slot_data: Arc<[AtomicSlotData; MAX_SLOTS]>,
slot_steps: [Arc<AtomicUsize>; MAX_SLOTS],
event_count: Arc<AtomicUsize>,
) {
let script_engine = ScriptEngine::new(variables, rng);
let mut audio_state = AudioState::new();
let mut pattern_cache = PatternCache::new();
loop {
while let Ok(cmd) = cmd_rx.try_recv() {
match cmd {
SeqCommand::PatternUpdate { bank, pattern, data } => {
pattern_cache.set(bank, pattern, data);
}
SeqCommand::SlotAdd { slot, bank, pattern } => {
audio_state.pending_changes.push(PendingChange::Add { slot, bank, pattern });
}
SeqCommand::SlotRemove { slot } => {
audio_state.pending_changes.push(PendingChange::Remove { slot });
}
SeqCommand::Shutdown => {
return;
}
}
}
if !playing.load(Ordering::Relaxed) {
thread::sleep(Duration::from_micros(500));
continue;
}
let state = link.capture_app_state();
let time = link.clock_micros();
let beat = state.beat_at_time(time, quantum);
let tempo = state.tempo();
let bar = (beat / quantum).floor() as i64;
let prev_bar = (audio_state.prev_beat / quantum).floor() as i64;
if bar != prev_bar && audio_state.prev_beat >= 0.0 {
for change in audio_state.pending_changes.drain(..) {
match change {
PendingChange::Add { slot, bank, pattern } => {
audio_state.slots[slot] = PatternSlot {
bank,
pattern,
step_index: 0,
active: true,
};
}
PendingChange::Remove { slot } => {
audio_state.slots[slot].active = false;
}
}
}
}
let prev_beat = audio_state.prev_beat;
for (slot_idx, slot) in audio_state.slots.iter_mut().enumerate() {
if !slot.active {
continue;
}
let Some(pattern) = pattern_cache.get(slot.bank, slot.pattern) else {
continue;
};
let speed_mult = pattern.speed.multiplier();
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64;
if beat_int != prev_beat_int && prev_beat >= 0.0 {
let step_idx = slot.step_index % pattern.length;
slot_steps[slot_idx].store(step_idx, Ordering::Relaxed);
if let Some(step) = pattern.steps.get(step_idx) {
if step.active && !step.script.trim().is_empty() {
let ctx = StepContext {
step: step_idx,
beat,
bank: slot.bank,
pattern: slot.pattern,
tempo,
phase: beat % quantum,
slot: slot_idx,
};
if let Ok(cmd) = script_engine.evaluate(&step.script, &ctx)
{
match audio_tx.try_send(AudioCommand::Evaluate(cmd)) {
Ok(()) => {
event_count.fetch_add(1, Ordering::Relaxed);
}
Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => {
return;
}
}
}
}
}
slot.step_index = (slot.step_index + 1) % pattern.length;
}
}
for (i, slot) in audio_state.slots.iter().enumerate() {
slot_data[i].store(SlotState {
active: slot.active,
bank: slot.bank,
pattern: slot.pattern,
});
}
audio_state.prev_beat = beat;
thread::sleep(Duration::from_micros(500));
}
}

View File

@@ -40,7 +40,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
Color::Red Color::Red
}; };
let cpu_pct = (app.cpu_load * 100.0).min(100.0); let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 { let cpu_color = if cpu_pct > 80.0 {
Color::Red Color::Red
} else if cpu_pct > 50.0 { } else if cpu_pct > 50.0 {
@@ -52,7 +52,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
let left_spans = vec![ let left_spans = vec![
Span::styled("EDIT ", Style::new().fg(Color::Cyan)), Span::styled("EDIT ", Style::new().fg(Color::Cyan)),
Span::styled( Span::styled(
format!("B{:02}:P{:02}", app.edit_bank + 1, app.edit_pattern + 1), format!("B{:02}:P{:02}", app.editor_ctx.bank + 1, app.editor_ctx.pattern + 1),
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
), ),
Span::raw(" "), Span::raw(" "),
@@ -61,7 +61,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area); frame.render_widget(Paragraph::new(Line::from(left_spans)), left_area);
let pattern = app.project.pattern_at(app.edit_bank, app.edit_pattern); let pattern = app.project.pattern_at(app.editor_ctx.bank, app.editor_ctx.pattern);
let right_spans = vec![ let right_spans = vec![
Span::styled(format!("L:{:02}", pattern.length), Style::new().fg(Color::Rgb(180, 140, 90))), Span::styled(format!("L:{:02}", pattern.length), Style::new().fg(Color::Rgb(180, 140, 90))),
Span::raw(" "), Span::raw(" "),
@@ -71,7 +71,7 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) {
Span::raw(" "), Span::raw(" "),
Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)), Span::styled(format!("CPU:{cpu_pct:.0}%"), Style::new().fg(cpu_color)),
Span::raw(" "), Span::raw(" "),
Span::styled(format!("V:{}", app.active_voices), Style::new().fg(Color::Cyan)), Span::styled(format!("V:{}", app.metrics.active_voices), Style::new().fg(Color::Cyan)),
]; ];
frame.render_widget( frame.render_widget(

View File

@@ -48,8 +48,8 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
let normal = Style::new().fg(Color::White); let normal = Style::new().fg(Color::White);
let dim = Style::new().fg(Color::DarkGray); let dim = Style::new().fg(Color::DarkGray);
let output_name = truncate_name(app.current_output_device_name(), 25); let output_name = truncate_name(app.audio.current_output_device_name(), 25);
let output_style = if app.audio_focus == AudioFocus::OutputDevice { let output_style = if app.audio.focus == AudioFocus::OutputDevice {
highlight highlight
} else { } else {
normal normal
@@ -62,8 +62,8 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
]); ]);
frame.render_widget(Paragraph::new(output_line), output_area); frame.render_widget(Paragraph::new(output_line), output_area);
let input_name = truncate_name(app.current_input_device_name(), 25); let input_name = truncate_name(app.audio.current_input_device_name(), 25);
let input_style = if app.audio_focus == AudioFocus::InputDevice { let input_style = if app.audio.focus == AudioFocus::InputDevice {
highlight highlight
} else { } else {
normal normal
@@ -76,7 +76,7 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
]); ]);
frame.render_widget(Paragraph::new(input_line), input_area); frame.render_widget(Paragraph::new(input_line), input_area);
let channels_style = if app.audio_focus == AudioFocus::Channels { let channels_style = if app.audio.focus == AudioFocus::Channels {
highlight highlight
} else { } else {
normal normal
@@ -84,12 +84,12 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
let channels_line = Line::from(vec![ let channels_line = Line::from(vec![
Span::styled("Channels ", dim), Span::styled("Channels ", dim),
Span::styled("< ", channels_style), Span::styled("< ", channels_style),
Span::styled(format!("{:2}", app.audio_config.channels), channels_style), Span::styled(format!("{:2}", app.audio.config.channels), channels_style),
Span::styled(" >", channels_style), Span::styled(" >", channels_style),
]); ]);
frame.render_widget(Paragraph::new(channels_line), channels_area); frame.render_widget(Paragraph::new(channels_line), channels_area);
let buffer_style = if app.audio_focus == AudioFocus::BufferSize { let buffer_style = if app.audio.focus == AudioFocus::BufferSize {
highlight highlight
} else { } else {
normal normal
@@ -97,18 +97,18 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
let buffer_line = Line::from(vec![ let buffer_line = Line::from(vec![
Span::styled("Buffer ", dim), Span::styled("Buffer ", dim),
Span::styled("< ", buffer_style), Span::styled("< ", buffer_style),
Span::styled(format!("{:4}", app.audio_config.buffer_size), buffer_style), Span::styled(format!("{:4}", app.audio.config.buffer_size), buffer_style),
Span::styled(" >", buffer_style), Span::styled(" >", buffer_style),
]); ]);
frame.render_widget(Paragraph::new(buffer_line), buffer_area); frame.render_widget(Paragraph::new(buffer_line), buffer_area);
let rate_line = Line::from(vec![ let rate_line = Line::from(vec![
Span::styled("Rate ", dim), Span::styled("Rate ", dim),
Span::styled(format!("{:.0} Hz", app.audio_config.sample_rate), normal), Span::styled(format!("{:.0} Hz", app.audio.config.sample_rate), normal),
]); ]);
frame.render_widget(Paragraph::new(rate_line), rate_area); frame.render_widget(Paragraph::new(rate_line), rate_area);
let samples_style = if app.audio_focus == AudioFocus::SamplePaths { let samples_style = if app.audio.focus == AudioFocus::SamplePaths {
highlight highlight
} else { } else {
normal normal
@@ -117,12 +117,12 @@ fn render_config(frame: &mut Frame, app: &App, area: Rect) {
let mut sample_lines = vec![Line::from(vec![ let mut sample_lines = vec![Line::from(vec![
Span::styled("Samples ", dim), Span::styled("Samples ", dim),
Span::styled( Span::styled(
format!("{} paths, {} indexed", app.audio_config.sample_paths.len(), app.audio_config.sample_count), format!("{} paths, {} indexed", app.audio.config.sample_paths.len(), app.audio.config.sample_count),
samples_style, samples_style,
), ),
])]; ])];
for (i, path) in app.audio_config.sample_paths.iter().take(2).enumerate() { for (i, path) in app.audio.config.sample_paths.iter().take(2).enumerate() {
let path_str = path.to_string_lossy(); let path_str = path.to_string_lossy();
let display = truncate_name(&path_str, 35); let display = truncate_name(&path_str, 35);
sample_lines.push(Line::from(vec![ sample_lines.push(Line::from(vec![
@@ -157,7 +157,7 @@ fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
]) ])
.areas(inner); .areas(inner);
let cpu_pct = (app.cpu_load * 100.0).min(100.0); let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
let cpu_color = if cpu_pct > 80.0 { let cpu_color = if cpu_pct > 80.0 {
Color::Red Color::Red
} else if cpu_pct > 50.0 { } else if cpu_pct > 50.0 {
@@ -174,9 +174,9 @@ fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(gauge, cpu_area); frame.render_widget(gauge, cpu_area);
let voice_color = if app.active_voices > 24 { let voice_color = if app.metrics.active_voices > 24 {
Color::Red Color::Red
} else if app.active_voices > 16 { } else if app.metrics.active_voices > 16 {
Color::Yellow Color::Yellow
} else { } else {
Color::Cyan Color::Cyan
@@ -185,12 +185,12 @@ fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
let voices = Paragraph::new(Line::from(vec![ let voices = Paragraph::new(Line::from(vec![
Span::raw("Active: "), Span::raw("Active: "),
Span::styled( Span::styled(
format!("{:3}", app.active_voices), format!("{:3}", app.metrics.active_voices),
Style::new().fg(voice_color).add_modifier(Modifier::BOLD), Style::new().fg(voice_color).add_modifier(Modifier::BOLD),
), ),
Span::raw(" Peak: "), Span::raw(" Peak: "),
Span::styled( Span::styled(
format!("{:3}", app.peak_voices), format!("{:3}", app.metrics.peak_voices),
Style::new().fg(Color::Yellow), Style::new().fg(Color::Yellow),
), ),
])); ]));
@@ -200,7 +200,7 @@ fn render_stats(frame: &mut Frame, app: &App, area: Rect) {
let extra = Paragraph::new(vec![ let extra = Paragraph::new(vec![
Line::from(vec![ Line::from(vec![
Span::raw("Schedule: "), Span::raw("Schedule: "),
Span::styled(format!("{}", app.schedule_depth), Style::new().fg(Color::White)), Span::styled(format!("{}", app.metrics.schedule_depth), Style::new().fg(Color::White)),
]), ]),
Line::from(vec![ Line::from(vec![
Span::raw("Pool: "), Span::raw("Pool: "),

View File

@@ -22,13 +22,13 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
} }
fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) { fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) {
let focus_indicator = if app.focus == Focus::Sequencer { let focus_indicator = if app.editor_ctx.focus == Focus::Sequencer {
"*" "*"
} else { } else {
" " " "
}; };
let border_style = if app.focus == Focus::Sequencer { let border_style = if app.editor_ctx.focus == Focus::Sequencer {
Style::new().fg(Color::Rgb(100, 160, 180)) Style::new().fg(Color::Rgb(100, 160, 180))
} else { } else {
Style::new().fg(Color::Rgb(70, 75, 85)) Style::new().fg(Color::Rgb(70, 75, 85))
@@ -106,15 +106,14 @@ fn render_tile(frame: &mut Frame, area: Rect, app: &App, step_idx: usize) {
let pattern = app.current_edit_pattern(); let pattern = app.current_edit_pattern();
let step = pattern.step(step_idx); let step = pattern.step(step_idx);
let is_active = step.map(|s| s.active).unwrap_or(false); let is_active = step.map(|s| s.active).unwrap_or(false);
let is_selected = step_idx == app.current_step; let is_selected = step_idx == app.editor_ctx.step;
// Check if any slot is playing this step on the current edit pattern
let playing_slot = if app.playing { let playing_slot = if app.playing {
(0..8).find(|&i| { (0..8).find(|&i| {
let (slot_active, bank, pat) = app.slot_data[i]; let s = app.slot_data[i];
slot_active s.active
&& bank == app.edit_bank && s.bank == app.editor_ctx.bank
&& pat == app.edit_pattern && s.pattern == app.editor_ctx.pattern
&& app.slot_steps[i] == step_idx && app.slot_steps[i] == step_idx
}) })
} else { } else {
@@ -146,7 +145,7 @@ fn render_tile(frame: &mut Frame, area: Rect, app: &App, step_idx: usize) {
} }
fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) { fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
let focus_indicator = if app.focus == Focus::Editor { let focus_indicator = if app.editor_ctx.focus == Focus::Editor {
"*" "*"
} else { } else {
" " " "
@@ -154,13 +153,13 @@ fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
let border_style = if app.is_flashing() { let border_style = if app.is_flashing() {
Style::new().fg(Color::Green) Style::new().fg(Color::Green)
} else if app.focus == Focus::Editor { } else if app.editor_ctx.focus == Focus::Editor {
Style::new().fg(Color::Rgb(100, 160, 180)) Style::new().fg(Color::Rgb(100, 160, 180))
} else { } else {
Style::new().fg(Color::Rgb(70, 75, 85)) Style::new().fg(Color::Rgb(70, 75, 85))
}; };
let step_num = app.current_step + 1; let step_num = app.editor_ctx.step + 1;
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(border_style) .border_style(border_style)
@@ -169,14 +168,14 @@ fn render_editor(frame: &mut Frame, app: &mut App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let cursor_style = if app.focus == Focus::Editor { let cursor_style = if app.editor_ctx.focus == Focus::Editor {
Style::new().bg(Color::White).fg(Color::Black) Style::new().bg(Color::White).fg(Color::Black)
} else { } else {
Style::default() Style::default()
}; };
app.editor.set_cursor_style(cursor_style); app.editor_ctx.text.set_cursor_style(cursor_style);
frame.render_widget(&app.editor, inner); frame.render_widget(&app.editor_ctx.text, inner);
} }
fn render_scope(frame: &mut Frame, app: &App, area: Rect) { fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
@@ -188,7 +187,7 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let scope = Scope::new(&app.scope) let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Vertical) .orientation(Orientation::Vertical)
.color(Color::Green); .color(Color::Green);
frame.render_widget(scope, inner); frame.render_widget(scope, inner);

View File

@@ -33,8 +33,8 @@ fn render_banks(frame: &mut Frame, app: &App, area: Rect) {
let banks_with_playback: Vec<usize> = app let banks_with_playback: Vec<usize> = app
.slot_data .slot_data
.iter() .iter()
.filter(|(active, _, _)| *active) .filter(|s| s.active)
.map(|(_, bank, _)| *bank) .map(|s| s.bank)
.collect(); .collect();
let bank_names: Vec<Option<&str>> = app let bank_names: Vec<Option<&str>> = app
@@ -44,7 +44,7 @@ fn render_banks(frame: &mut Frame, app: &App, area: Rect) {
.map(|b| b.name.as_deref()) .map(|b| b.name.as_deref())
.collect(); .collect();
render_grid(frame, inner, app.patterns_cursor, app.edit_bank, &banks_with_playback, &bank_names); render_grid(frame, inner, app.patterns_cursor, app.editor_ctx.bank, &banks_with_playback, &bank_names);
} }
fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) { fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) {
@@ -77,12 +77,12 @@ fn render_patterns(frame: &mut Frame, app: &App, area: Rect, bank: usize) {
let playing_patterns: Vec<usize> = app let playing_patterns: Vec<usize> = app
.slot_data .slot_data
.iter() .iter()
.filter(|(active, b, _)| *active && *b == bank) .filter(|s| s.active && s.bank == bank)
.map(|(_, _, pattern)| *pattern) .map(|s| s.pattern)
.collect(); .collect();
let edit_pattern = if app.edit_bank == bank { let edit_pattern = if app.editor_ctx.bank == bank {
app.edit_pattern app.editor_ctx.pattern
} else { } else {
usize::MAX usize::MAX
}; };

View File

@@ -34,11 +34,6 @@ impl<'a> Scope<'a> {
self.color = c; self.color = c;
self self
} }
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
} }
impl Widget for Scope<'_> { impl Widget for Scope<'_> {

View File

@@ -32,7 +32,9 @@ use orbit::{EffectParams, Orbit};
use sample::{FileSource, SampleEntry, SampleInfo, SamplePool, WebSampleSource}; use sample::{FileSource, SampleEntry, SampleInfo, SamplePool, WebSampleSource};
use schedule::Schedule; use schedule::Schedule;
#[cfg(feature = "native")] #[cfg(feature = "native")]
use telemetry::EngineMetrics; use std::sync::Arc;
#[cfg(feature = "native")]
pub use telemetry::EngineMetrics;
use types::{DelayType, Source, BLOCK_SIZE, CHANNELS, MAX_ORBITS, MAX_VOICES}; use types::{DelayType, Source, BLOCK_SIZE, CHANNELS, MAX_ORBITS, MAX_VOICES};
use voice::{Voice, VoiceParams}; use voice::{Voice, VoiceParams};
@@ -55,7 +57,7 @@ pub struct Engine {
pub effect_params: EffectParams, pub effect_params: EffectParams,
// Telemetry (native only) // Telemetry (native only)
#[cfg(feature = "native")] #[cfg(feature = "native")]
pub metrics: EngineMetrics, pub metrics: Arc<EngineMetrics>,
} }
impl Engine { impl Engine {
@@ -96,7 +98,48 @@ impl Engine {
comb_damp: 0.1, comb_damp: 0.1,
}, },
#[cfg(feature = "native")] #[cfg(feature = "native")]
metrics: EngineMetrics::default(), metrics: Arc::new(EngineMetrics::default()),
}
}
#[cfg(feature = "native")]
pub fn new_with_metrics(
sample_rate: f32,
output_channels: usize,
metrics: Arc<EngineMetrics>,
) -> Self {
let mut orbits = Vec::with_capacity(MAX_ORBITS);
for _ in 0..MAX_ORBITS {
orbits.push(Orbit::new(sample_rate));
}
Self {
sr: sample_rate,
isr: 1.0 / sample_rate,
voices: vec![Voice::default(); MAX_VOICES],
active_voices: 0,
orbits,
schedule: Schedule::new(),
time: 0.0,
tick: 0,
output_channels,
output: vec![0.0; BLOCK_SIZE * output_channels],
sample_pool: SamplePool::new(),
samples: Vec::with_capacity(256),
sample_index: Vec::new(),
effect_params: EffectParams {
delay_time: 0.333,
delay_feedback: 0.6,
delay_type: DelayType::Standard,
verb_decay: 0.75,
verb_damp: 0.95,
verb_predelay: 0.1,
verb_diff: 0.7,
comb_freq: 220.0,
comb_feedback: 0.9,
comb_damp: 0.1,
},
metrics,
} }
} }