This commit is contained in:
2026-01-19 18:27:07 +01:00
parent ac9e64dcb7
commit 4391995eae
11 changed files with 35390 additions and 39 deletions

1
Cargo.lock generated
View File

@@ -2089,6 +2089,7 @@ name = "seq"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"clap",
"cpal", "cpal",
"crossterm", "crossterm",
"doux", "doux",

View File

@@ -13,6 +13,7 @@ rusty_link = "0.4"
ratatui = "0.29" ratatui = "0.29"
crossterm = "0.28" crossterm = "0.28"
cpal = "0.15" cpal = "0.15"
clap = { version = "4", features = ["derive"] }
rhai = { version = "1.24", features = ["sync"] } rhai = { version = "1.24", features = ["sync"] }
rand = "0.8" rand = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@@ -1,3 +1,4 @@
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;
@@ -20,6 +21,12 @@ pub enum Focus {
Editor, Editor,
} }
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum PatternField {
Length,
Speed,
}
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub enum Modal { pub enum Modal {
None, None,
@@ -28,6 +35,8 @@ pub enum Modal {
LoadFrom(String), LoadFrom(String),
RenameBank { bank: usize, name: String }, RenameBank { bank: usize, name: String },
RenamePattern { bank: usize, pattern: usize, name: String }, RenamePattern { bank: usize, pattern: usize, name: String },
SetPattern { field: PatternField, input: String },
AddSamplePath(String),
} }
#[derive(Clone, Copy, PartialEq, Eq, Default)] #[derive(Clone, Copy, PartialEq, Eq, Default)]
@@ -37,6 +46,41 @@ pub enum PatternsViewLevel {
Patterns { bank: usize }, Patterns { bank: usize },
} }
#[derive(Clone)]
pub struct AudioConfig {
pub output_device: Option<String>,
pub input_device: Option<String>,
pub channels: u16,
pub buffer_size: u32,
pub sample_rate: f32,
pub sample_paths: Vec<PathBuf>,
pub sample_count: usize,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
output_device: None,
input_device: None,
channels: 2,
buffer_size: 512,
sample_rate: 44100.0,
sample_paths: Vec::new(),
sample_count: 0,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum AudioFocus {
#[default]
OutputDevice,
InputDevice,
Channels,
BufferSize,
SamplePaths,
}
pub struct App { pub struct App {
pub tempo: f64, pub tempo: f64,
pub beat: f64, pub beat: f64,
@@ -80,6 +124,12 @@ pub struct App {
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_focus: AudioFocus,
pub available_output_devices: Vec<AudioDeviceInfo>,
pub available_input_devices: Vec<AudioDeviceInfo>,
pub restart_pending: bool,
} }
impl App { impl App {
@@ -125,6 +175,12 @@ impl App {
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_focus: AudioFocus::default(),
available_output_devices: doux::audio::list_output_devices(),
available_input_devices: doux::audio::list_input_devices(),
restart_pending: false,
} }
} }
@@ -491,4 +547,151 @@ impl App {
self.compile_current_step(); self.compile_current_step();
} }
} }
pub fn open_pattern_modal(&mut self, field: PatternField) {
let current = match field {
PatternField::Length => self.current_edit_pattern().length.to_string(),
PatternField::Speed => self.current_edit_pattern().speed.label().to_string(),
};
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

@@ -8,6 +8,12 @@ use crate::link::LinkState;
use crate::model::Project; use crate::model::Project;
use crate::script::{Rng, ScriptEngine, StepContext, Variables}; use crate::script::{Rng, ScriptEngine, StepContext, Variables};
pub struct AudioStreamConfig {
pub output_device: Option<String>,
pub channels: u16,
pub buffer_size: u32,
}
pub const MAX_SLOTS: usize = 8; pub const MAX_SLOTS: usize = 8;
#[derive(Clone, Copy, Default)] #[derive(Clone, Copy, Default)]
@@ -39,6 +45,7 @@ impl AudioState {
} }
pub fn build_stream( pub fn build_stream(
config: &AudioStreamConfig,
engine: Arc<Mutex<Engine>>, engine: Arc<Mutex<Engine>>,
link: Arc<LinkState>, link: Arc<LinkState>,
playing: Arc<AtomicBool>, playing: Arc<AtomicBool>,
@@ -49,16 +56,28 @@ pub fn build_stream(
slot_changes: Arc<Mutex<Vec<SlotChange>>>, slot_changes: Arc<Mutex<Vec<SlotChange>>>,
variables: Variables, variables: Variables,
rng: Rng, rng: Rng,
) -> (Stream, f32) { ) -> Result<(Stream, f32), String> {
let host = cpal::default_host(); 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 device = match &config.output_device {
let sample_rate = config.sample_rate().0 as f32; Some(name) => doux::audio::find_output_device(name)
.ok_or_else(|| format!("Device not found: {name}"))?,
None => host.default_output_device().ok_or("No default output device")?,
};
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
let sample_rate = default_config.sample_rate().0 as f32;
let buffer_size = if config.buffer_size > 0 {
cpal::BufferSize::Fixed(config.buffer_size)
} else {
cpal::BufferSize::Default
};
let stream_config = cpal::StreamConfig { let stream_config = cpal::StreamConfig {
channels: 2, channels: config.channels,
sample_rate: config.sample_rate(), sample_rate: default_config.sample_rate(),
buffer_size: cpal::BufferSize::Default, buffer_size,
}; };
let quantum = 4.0; let quantum = 4.0;
@@ -66,11 +85,12 @@ pub fn build_stream(
let script_engine = ScriptEngine::new(); let script_engine = ScriptEngine::new();
let sr = sample_rate; let sr = sample_rate;
let channels = config.channels as usize;
let stream = device let stream = device
.build_output_stream( .build_output_stream(
&stream_config, &stream_config,
move |data: &mut [f32], _| { move |data: &mut [f32], _| {
let buffer_samples = data.len() / 2; 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); let is_playing = playing.load(Ordering::Relaxed);
@@ -168,8 +188,8 @@ pub fn build_stream(
|err| eprintln!("stream error: {err}"), |err| eprintln!("stream error: {err}"),
None, None,
) )
.expect("failed to build stream"); .map_err(|e| format!("Failed to build stream: {e}"))?;
stream.play().expect("failed to play stream"); stream.play().map_err(|e| format!("Failed to play stream: {e}"))?;
(stream, sample_rate) Ok((stream, sample_rate))
} }

View File

@@ -15,6 +15,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use clap::Parser;
use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -24,16 +25,42 @@ use doux::Engine;
use ratatui::prelude::CrosstermBackend; use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use app::{App, Focus, Modal}; use app::{App, AudioFocus, Focus, Modal, PatternField};
use audio::{SlotChange, MAX_SLOTS}; use audio::{AudioStreamConfig, SlotChange, MAX_SLOTS};
use link::LinkState; use link::LinkState;
use model::Project; use model::Project;
use page::Page; use page::Page;
#[derive(Parser)]
#[command(name = "seq", about = "A step sequencer with Ableton Link support")]
struct Args {
/// Directory containing audio samples to load (can be specified multiple times)
#[arg(short, long)]
samples: Vec<PathBuf>,
/// Output audio device (name or index)
#[arg(short, long)]
output: Option<String>,
/// Input audio device (name or index)
#[arg(short, long)]
input: Option<String>,
/// Number of output channels
#[arg(short, long, default_value = "2")]
channels: u16,
/// Audio buffer size in samples
#[arg(short, long, default_value = "512")]
buffer: u32,
}
const TEMPO: f64 = 120.0; const TEMPO: f64 = 120.0;
const QUANTUM: f64 = 4.0; const QUANTUM: f64 = 4.0;
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
let args = Args::parse();
let link = Arc::new(LinkState::new(TEMPO, QUANTUM)); let link = Arc::new(LinkState::new(TEMPO, QUANTUM));
link.enable(); link.enable();
@@ -48,10 +75,32 @@ fn main() -> io::Result<()> {
let mut app = App::new(TEMPO, QUANTUM); let mut app = App::new(TEMPO, QUANTUM);
// Apply CLI args to audio config
app.audio_config.output_device = args.output;
app.audio_config.input_device = args.input;
app.audio_config.channels = args.channels;
app.audio_config.buffer_size = args.buffer;
app.audio_config.sample_paths = args.samples;
let engine = Arc::new(Mutex::new(Engine::new(44100.0))); let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
let project = Arc::new(Mutex::new(Project::default())); let project = Arc::new(Mutex::new(Project::default()));
let (_stream, sample_rate) = audio::build_stream( // Load sample directories
for path in &app.audio_config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
let count = index.len();
engine.lock().unwrap().sample_index.extend(index);
app.audio_config.sample_count += count;
}
let stream_config = AudioStreamConfig {
output_device: app.audio_config.output_device.clone(),
channels: app.audio_config.channels,
buffer_size: app.audio_config.buffer_size,
};
let (mut stream, sample_rate) = audio::build_stream(
&stream_config,
Arc::clone(&engine), Arc::clone(&engine),
Arc::clone(&link), Arc::clone(&link),
Arc::clone(&playing), Arc::clone(&playing),
@@ -62,7 +111,10 @@ fn main() -> io::Result<()> {
Arc::clone(&slot_changes), Arc::clone(&slot_changes),
Arc::clone(&app.variables), Arc::clone(&app.variables),
Arc::clone(&app.rng), Arc::clone(&app.rng),
); )
.expect("Failed to start audio");
app.audio_config.sample_rate = sample_rate;
{ {
let mut eng = engine.lock().unwrap(); let mut eng = engine.lock().unwrap();
@@ -77,6 +129,69 @@ fn main() -> io::Result<()> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
loop { loop {
if app.restart_pending {
app.restart_pending = false;
drop(stream);
let new_config = AudioStreamConfig {
output_device: app.audio_config.output_device.clone(),
channels: app.audio_config.channels,
buffer_size: app.audio_config.buffer_size,
};
{
let mut eng = engine.lock().unwrap();
*eng = Engine::new_with_channels(eng.sr, new_config.channels as usize);
}
match audio::build_stream(
&new_config,
Arc::clone(&engine),
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&project),
slot_steps.clone(),
Arc::clone(&event_count),
Arc::clone(&slot_data),
Arc::clone(&slot_changes),
Arc::clone(&app.variables),
Arc::clone(&app.rng),
) {
Ok((new_stream, sr)) => {
stream = new_stream;
app.audio_config.sample_rate = sr;
{
let mut eng = engine.lock().unwrap();
eng.sr = sr;
eng.isr = 1.0 / sr;
}
app.status_message = Some("Audio restarted".to_string());
}
Err(e) => {
app.status_message = Some(format!("Restart failed: {e}"));
let (fallback_stream, _) = audio::build_stream(
&AudioStreamConfig {
output_device: None,
channels: 2,
buffer_size: 512,
},
Arc::clone(&engine),
Arc::clone(&link),
Arc::clone(&playing),
Arc::clone(&project),
slot_steps.clone(),
Arc::clone(&event_count),
Arc::clone(&slot_data),
Arc::clone(&slot_changes),
Arc::clone(&app.variables),
Arc::clone(&app.rng),
)
.expect("Failed to restart with defaults");
stream = fallback_stream;
}
}
}
app.update_from_link(&link); app.update_from_link(&link);
app.playing = playing.load(Ordering::Relaxed); app.playing = playing.load(Ordering::Relaxed);
app.event_count = event_count.load(Ordering::Relaxed); app.event_count = event_count.load(Ordering::Relaxed);
@@ -206,6 +321,71 @@ fn main() -> io::Result<()> {
} }
_ => {} _ => {}
}, },
Modal::SetPattern { field, input } => match key.code {
KeyCode::Enter => {
let field = *field;
let (bank, pattern) = (app.edit_bank, app.edit_pattern);
match field {
PatternField::Length => {
if let Ok(len) = input.parse::<usize>() {
app.project.pattern_at_mut(bank, pattern).set_length(len);
let new_len = app.project.pattern_at(bank, pattern).length;
if app.current_step >= new_len {
app.current_step = new_len - 1;
}
app.status_message = Some(format!("Length set to {new_len}"));
} else {
app.status_message = Some("Invalid length".to_string());
}
}
PatternField::Speed => {
if let Some(speed) = model::PatternSpeed::from_label(input) {
app.project.pattern_at_mut(bank, pattern).speed = speed;
app.status_message = Some(format!("Speed set to {}", speed.label()));
} else {
app.status_message = Some("Invalid speed (try 1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x)".to_string());
}
}
}
app.modal = Modal::None;
}
KeyCode::Esc => {
app.modal = Modal::None;
}
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) => {
input.push(c);
}
_ => {}
},
Modal::AddSamplePath(path) => match key.code {
KeyCode::Enter => {
let sample_path = PathBuf::from(path.as_str());
if sample_path.is_dir() {
let index = doux::loader::scan_samples_dir(&sample_path);
let count = index.len();
engine.lock().unwrap().sample_index.extend(index);
app.audio_config.sample_count += count;
app.add_sample_path(sample_path);
app.status_message = Some(format!("Added {count} samples"));
} else {
app.status_message = Some("Path is not a directory".to_string());
}
app.modal = Modal::None;
}
KeyCode::Esc => {
app.modal = Modal::None;
}
KeyCode::Backspace => {
path.pop();
}
KeyCode::Char(c) => {
path.push(c);
}
_ => {}
},
Modal::None => { Modal::None => {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -263,6 +443,8 @@ fn main() -> io::Result<()> {
} }
KeyCode::Char('[') => app.speed_decrease(), KeyCode::Char('[') => app.speed_decrease(),
KeyCode::Char(']') => app.speed_increase(), KeyCode::Char(']') => app.speed_increase(),
KeyCode::Char('L') => app.open_pattern_modal(PatternField::Length),
KeyCode::Char('S') => app.open_pattern_modal(PatternField::Speed),
KeyCode::Char('c') if ctrl => app.copy_step(), KeyCode::Char('c') if ctrl => app.copy_step(),
KeyCode::Char('v') if ctrl => app.paste_step(), KeyCode::Char('v') if ctrl => app.paste_step(),
_ => {} _ => {}
@@ -354,6 +536,47 @@ fn main() -> io::Result<()> {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.modal = Modal::ConfirmQuit { selected: false }; app.modal = Modal::ConfirmQuit { selected: false };
} }
KeyCode::Up | KeyCode::Char('k') => {
app.prev_audio_focus();
}
KeyCode::Down | KeyCode::Char('j') => {
app.next_audio_focus();
}
KeyCode::Left => match app.audio_focus {
AudioFocus::OutputDevice => app.prev_output_device(),
AudioFocus::InputDevice => app.prev_input_device(),
AudioFocus::Channels => app.adjust_channels(-1),
AudioFocus::BufferSize => app.adjust_buffer_size(-64),
AudioFocus::SamplePaths => app.remove_last_sample_path(),
},
KeyCode::Right => match app.audio_focus {
AudioFocus::OutputDevice => app.next_output_device(),
AudioFocus::InputDevice => app.next_input_device(),
AudioFocus::Channels => app.adjust_channels(1),
AudioFocus::BufferSize => app.adjust_buffer_size(64),
AudioFocus::SamplePaths => {}
},
KeyCode::Char('R') => {
app.trigger_restart();
// Reload samples on restart
let mut eng = engine.lock().unwrap();
eng.sample_index.clear();
app.audio_config.sample_count = 0;
for path in &app.audio_config.sample_paths {
let index = doux::loader::scan_samples_dir(path);
app.audio_config.sample_count += index.len();
eng.sample_index.extend(index);
}
}
KeyCode::Char('A') => {
app.modal = Modal::AddSamplePath(String::new());
}
KeyCode::Char('D') => {
app.refresh_audio_devices();
let out_count = app.available_output_devices.len();
let in_count = app.available_input_devices.len();
app.status_message = Some(format!("Found {out_count} output, {in_count} input devices"));
}
KeyCode::Char('h') => { KeyCode::Char('h') => {
engine.lock().unwrap().hush(); engine.lock().unwrap().hush();
} }

View File

@@ -60,6 +60,19 @@ impl PatternSpeed {
Self::Octo => Self::Quad, Self::Octo => Self::Quad,
} }
} }
pub fn from_label(s: &str) -> Option<Self> {
match s.trim() {
"1/8x" | "1/8" | "0.125x" => Some(Self::Eighth),
"1/4x" | "1/4" | "0.25x" => Some(Self::Quarter),
"1/2x" | "1/2" | "0.5x" => Some(Self::Half),
"1x" | "1" => Some(Self::Normal),
"2x" | "2" => Some(Self::Double),
"4x" | "4" => Some(Self::Quad),
"8x" | "8" => Some(Self::Octo),
_ => None,
}
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]

View File

@@ -4,7 +4,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::{App, Modal}; use crate::app::{App, Modal, PatternField};
use crate::page::Page; use crate::page::Page;
use crate::views::{audio_view, doc_view, main_view, patterns_view}; use crate::views::{audio_view, doc_view, main_view, patterns_view};
@@ -276,5 +276,68 @@ fn render_modal(frame: &mut Frame, app: &App) {
); );
frame.render_widget(modal, area); frame.render_widget(modal, area);
} }
Modal::SetPattern { field, input } => {
let (title, hint) = match field {
PatternField::Length => ("Set Length (2-32)", "Enter number"),
PatternField::Speed => ("Set Speed", "1/8x, 1/4x, 1/2x, 1x, 2x, 4x, 8x"),
};
let width = 45.min(term.width.saturating_sub(4));
let height = 6.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::Yellow));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(input, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
])),
rows[0],
);
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::new().fg(Color::DarkGray))),
rows[1],
);
}
Modal::AddSamplePath(path) => {
let width = 60.min(term.width.saturating_sub(4));
let height = 6.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("Add Sample Path")
.border_style(Style::new().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("> "),
Span::styled(path, Style::new().fg(Color::Cyan)),
Span::styled("", Style::new().fg(Color::White)),
])),
rows[0],
);
frame.render_widget(
Paragraph::new(Span::styled(
"Enter directory path containing samples",
Style::new().fg(Color::DarkGray),
)),
rows[1],
);
}
} }
} }

View File

@@ -4,10 +4,141 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Gauge, Paragraph}; use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::{App, AudioFocus};
pub fn render(frame: &mut Frame, app: &App, area: Rect) { pub fn render(frame: &mut Frame, app: &App, area: Rect) {
render_stats(frame, app, area); let [config_area, stats_area] =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area);
render_config(frame, app, config_area);
render_stats(frame, app, stats_area);
}
fn truncate_name(name: &str, max_len: usize) -> String {
if name.len() > max_len {
format!("{}...", &name[..max_len.saturating_sub(3)])
} else {
name.to_string()
}
}
fn render_config(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title("Configuration")
.border_style(Style::new().fg(Color::Magenta));
let inner = block.inner(area);
frame.render_widget(block, area);
let [output_area, input_area, channels_area, buffer_area, rate_area, samples_area, _, hints_area] =
Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(inner);
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
let dim = Style::new().fg(Color::DarkGray);
let output_name = truncate_name(app.current_output_device_name(), 25);
let output_style = if app.audio_focus == AudioFocus::OutputDevice {
highlight
} else {
normal
};
let output_line = Line::from(vec![
Span::styled("Output ", dim),
Span::styled("< ", output_style),
Span::styled(output_name, output_style),
Span::styled(" >", output_style),
]);
frame.render_widget(Paragraph::new(output_line), output_area);
let input_name = truncate_name(app.current_input_device_name(), 25);
let input_style = if app.audio_focus == AudioFocus::InputDevice {
highlight
} else {
normal
};
let input_line = Line::from(vec![
Span::styled("Input ", dim),
Span::styled("< ", input_style),
Span::styled(input_name, input_style),
Span::styled(" >", input_style),
]);
frame.render_widget(Paragraph::new(input_line), input_area);
let channels_style = if app.audio_focus == AudioFocus::Channels {
highlight
} else {
normal
};
let channels_line = Line::from(vec![
Span::styled("Channels ", dim),
Span::styled("< ", channels_style),
Span::styled(format!("{:2}", app.audio_config.channels), channels_style),
Span::styled(" >", channels_style),
]);
frame.render_widget(Paragraph::new(channels_line), channels_area);
let buffer_style = if app.audio_focus == AudioFocus::BufferSize {
highlight
} else {
normal
};
let buffer_line = Line::from(vec![
Span::styled("Buffer ", dim),
Span::styled("< ", buffer_style),
Span::styled(format!("{:4}", app.audio_config.buffer_size), buffer_style),
Span::styled(" >", buffer_style),
]);
frame.render_widget(Paragraph::new(buffer_line), buffer_area);
let rate_line = Line::from(vec![
Span::styled("Rate ", dim),
Span::styled(format!("{:.0} Hz", app.audio_config.sample_rate), normal),
]);
frame.render_widget(Paragraph::new(rate_line), rate_area);
let samples_style = if app.audio_focus == AudioFocus::SamplePaths {
highlight
} else {
normal
};
let mut sample_lines = vec![Line::from(vec![
Span::styled("Samples ", dim),
Span::styled(
format!("{} paths, {} indexed", app.audio_config.sample_paths.len(), app.audio_config.sample_count),
samples_style,
),
])];
for (i, path) in app.audio_config.sample_paths.iter().take(2).enumerate() {
let path_str = path.to_string_lossy();
let display = truncate_name(&path_str, 35);
sample_lines.push(Line::from(vec![
Span::styled(" ", dim),
Span::styled(format!("{}: {}", i + 1, display), Style::new().fg(Color::DarkGray)),
]));
}
frame.render_widget(Paragraph::new(sample_lines), samples_area);
let hints_line = Line::from(vec![
Span::styled("[R] Restart ", Style::new().fg(Color::Cyan)),
Span::styled("[A] Add path ", Style::new().fg(Color::DarkGray)),
Span::styled("[D] Refresh", Style::new().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(hints_line), hints_area);
} }
fn render_stats(frame: &mut Frame, app: &App, area: Rect) { fn render_stats(frame: &mut Frame, app: &App, area: Rect) {

View File

@@ -14,7 +14,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
.areas(area); .areas(area);
let [seq_area, editor_area] = let [seq_area, editor_area] =
Layout::vertical([Constraint::Length(9), Constraint::Fill(1)]).areas(main_area); Layout::vertical([Constraint::Fill(3), Constraint::Fill(2)]).areas(main_area);
render_sequencer(frame, app, seq_area); render_sequencer(frame, app, seq_area);
render_editor(frame, app, editor_area); render_editor(frame, app, editor_area);
@@ -60,10 +60,13 @@ fn render_sequencer(frame: &mut Frame, app: &App, area: Rect) {
}; };
let steps_per_row = length.div_ceil(num_rows); let steps_per_row = length.div_ceil(num_rows);
let spacing = num_rows.saturating_sub(1) as u16;
let row_height = inner.height.saturating_sub(spacing) / num_rows as u16;
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1) let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
.map(|i| { .map(|i| {
if i % 2 == 0 { if i % 2 == 0 {
Constraint::Fill(1) Constraint::Length(row_height)
} else { } else {
Constraint::Length(1) Constraint::Length(1)
} }

View File

@@ -127,17 +127,41 @@ fn render_grid(
(false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)), (false, false, false) => (Color::Rgb(45, 48, 55), Color::Rgb(120, 125, 135)),
}; };
let label = names.get(idx).and_then(|n| *n).unwrap_or_else(|| ""); let name = names.get(idx).and_then(|n| *n).unwrap_or("");
let label = if label.is_empty() { let number = format!("{:02}", idx + 1);
format!("{:02}", idx + 1) let cell = cols[col];
} else {
label.to_string()
};
let tile = Paragraph::new(label)
.alignment(Alignment::Center)
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
frame.render_widget(tile, cols[col]); // Fill background
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
let center_y = cell.y + cell.height / 2;
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
if name.is_empty() {
// Number centered
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} else {
// Number centered at top
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
top_area,
);
// Name centered in middle
frame.render_widget(
Paragraph::new(name)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
}
} }
} }
} }
@@ -179,16 +203,40 @@ fn render_pattern_grid(
}; };
let name = names.get(idx).and_then(|n| *n).unwrap_or(""); let name = names.get(idx).and_then(|n| *n).unwrap_or("");
let label = if name.is_empty() { let number = format!("{}{:02}", prefix, idx + 1);
format!("{}{:02}", prefix, idx + 1) let cell = cols[col];
} else {
format!("{}{}", prefix, name)
};
let tile = Paragraph::new(label)
.alignment(Alignment::Center)
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
frame.render_widget(tile, cols[col]); // Fill background
frame.render_widget(Block::default().style(Style::new().bg(bg)), cell);
let top_area = Rect::new(cell.x, cell.y, cell.width, 1);
let center_y = cell.y + cell.height / 2;
let center_area = Rect::new(cell.x, center_y, cell.width, 1);
if name.is_empty() {
// Number centered
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
} else {
// Number centered at top
frame.render_widget(
Paragraph::new(number)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::DIM)),
top_area,
);
// Name centered in middle
frame.render_widget(
Paragraph::new(name)
.alignment(Alignment::Center)
.style(Style::new().fg(fg).add_modifier(Modifier::BOLD)),
center_area,
);
}
} }
} }
} }

34645
test2.seq Normal file

File diff suppressed because it is too large Load Diff