wip
This commit is contained in:
@@ -13,6 +13,7 @@ rusty_link = "0.4"
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
cpal = "0.15"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rhai = { version = "1.24", features = ["sync"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
203
seq/src/app.rs
203
seq/src/app.rs
@@ -1,3 +1,4 @@
|
||||
use doux::audio::AudioDeviceInfo;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
use std::collections::HashMap;
|
||||
@@ -20,6 +21,12 @@ pub enum Focus {
|
||||
Editor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PatternField {
|
||||
Length,
|
||||
Speed,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum Modal {
|
||||
None,
|
||||
@@ -28,6 +35,8 @@ pub enum Modal {
|
||||
LoadFrom(String),
|
||||
RenameBank { bank: usize, name: String },
|
||||
RenamePattern { bank: usize, pattern: usize, name: String },
|
||||
SetPattern { field: PatternField, input: String },
|
||||
AddSamplePath(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -37,6 +46,41 @@ pub enum PatternsViewLevel {
|
||||
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 tempo: f64,
|
||||
pub beat: f64,
|
||||
@@ -80,6 +124,12 @@ pub struct App {
|
||||
pub clipboard: Option<arboard::Clipboard>,
|
||||
pub doc_topic: 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 {
|
||||
@@ -125,6 +175,12 @@ impl App {
|
||||
clipboard: arboard::Clipboard::new().ok(),
|
||||
doc_topic: 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();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ use crate::link::LinkState;
|
||||
use crate::model::Project;
|
||||
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;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
@@ -39,6 +45,7 @@ impl AudioState {
|
||||
}
|
||||
|
||||
pub fn build_stream(
|
||||
config: &AudioStreamConfig,
|
||||
engine: Arc<Mutex<Engine>>,
|
||||
link: Arc<LinkState>,
|
||||
playing: Arc<AtomicBool>,
|
||||
@@ -49,16 +56,28 @@ pub fn build_stream(
|
||||
slot_changes: Arc<Mutex<Vec<SlotChange>>>,
|
||||
variables: Variables,
|
||||
rng: Rng,
|
||||
) -> (Stream, f32) {
|
||||
) -> Result<(Stream, f32), String> {
|
||||
let host = cpal::default_host();
|
||||
let device = host.default_output_device().expect("no output device");
|
||||
let config = device.default_output_config().expect("no default config");
|
||||
let sample_rate = config.sample_rate().0 as f32;
|
||||
|
||||
let device = match &config.output_device {
|
||||
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 {
|
||||
channels: 2,
|
||||
sample_rate: config.sample_rate(),
|
||||
buffer_size: cpal::BufferSize::Default,
|
||||
channels: config.channels,
|
||||
sample_rate: default_config.sample_rate(),
|
||||
buffer_size,
|
||||
};
|
||||
|
||||
let quantum = 4.0;
|
||||
@@ -66,11 +85,12 @@ pub fn build_stream(
|
||||
let script_engine = ScriptEngine::new();
|
||||
|
||||
let sr = sample_rate;
|
||||
let channels = config.channels as usize;
|
||||
let stream = device
|
||||
.build_output_stream(
|
||||
&stream_config,
|
||||
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 is_playing = playing.load(Ordering::Relaxed);
|
||||
@@ -168,8 +188,8 @@ pub fn build_stream(
|
||||
|err| eprintln!("stream error: {err}"),
|
||||
None,
|
||||
)
|
||||
.expect("failed to build stream");
|
||||
.map_err(|e| format!("Failed to build stream: {e}"))?;
|
||||
|
||||
stream.play().expect("failed to play stream");
|
||||
(stream, sample_rate)
|
||||
stream.play().map_err(|e| format!("Failed to play stream: {e}"))?;
|
||||
Ok((stream, sample_rate))
|
||||
}
|
||||
|
||||
231
seq/src/main.rs
231
seq/src/main.rs
@@ -15,6 +15,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
@@ -24,16 +25,42 @@ use doux::Engine;
|
||||
use ratatui::prelude::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use app::{App, Focus, Modal};
|
||||
use audio::{SlotChange, MAX_SLOTS};
|
||||
use app::{App, AudioFocus, Focus, Modal, PatternField};
|
||||
use audio::{AudioStreamConfig, SlotChange, MAX_SLOTS};
|
||||
use link::LinkState;
|
||||
use model::Project;
|
||||
use page::Page;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "seq", about = "A step sequencer with Ableton Link support")]
|
||||
struct Args {
|
||||
/// Directory containing audio samples to load (can be specified multiple times)
|
||||
#[arg(short, long)]
|
||||
samples: Vec<PathBuf>,
|
||||
|
||||
/// Output audio device (name or index)
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
|
||||
/// Input audio device (name or index)
|
||||
#[arg(short, long)]
|
||||
input: Option<String>,
|
||||
|
||||
/// Number of output channels
|
||||
#[arg(short, long, default_value = "2")]
|
||||
channels: u16,
|
||||
|
||||
/// Audio buffer size in samples
|
||||
#[arg(short, long, default_value = "512")]
|
||||
buffer: u32,
|
||||
}
|
||||
|
||||
const TEMPO: f64 = 120.0;
|
||||
const QUANTUM: f64 = 4.0;
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let link = Arc::new(LinkState::new(TEMPO, QUANTUM));
|
||||
link.enable();
|
||||
|
||||
@@ -48,10 +75,32 @@ fn main() -> io::Result<()> {
|
||||
|
||||
let mut app = App::new(TEMPO, QUANTUM);
|
||||
|
||||
// Apply CLI args to audio config
|
||||
app.audio_config.output_device = args.output;
|
||||
app.audio_config.input_device = args.input;
|
||||
app.audio_config.channels = args.channels;
|
||||
app.audio_config.buffer_size = args.buffer;
|
||||
app.audio_config.sample_paths = args.samples;
|
||||
|
||||
let engine = Arc::new(Mutex::new(Engine::new(44100.0)));
|
||||
let project = Arc::new(Mutex::new(Project::default()));
|
||||
|
||||
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(&link),
|
||||
Arc::clone(&playing),
|
||||
@@ -62,7 +111,10 @@ fn main() -> io::Result<()> {
|
||||
Arc::clone(&slot_changes),
|
||||
Arc::clone(&app.variables),
|
||||
Arc::clone(&app.rng),
|
||||
);
|
||||
)
|
||||
.expect("Failed to start audio");
|
||||
|
||||
app.audio_config.sample_rate = sample_rate;
|
||||
|
||||
{
|
||||
let mut eng = engine.lock().unwrap();
|
||||
@@ -77,6 +129,69 @@ fn main() -> io::Result<()> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
loop {
|
||||
if app.restart_pending {
|
||||
app.restart_pending = false;
|
||||
drop(stream);
|
||||
|
||||
let new_config = AudioStreamConfig {
|
||||
output_device: app.audio_config.output_device.clone(),
|
||||
channels: app.audio_config.channels,
|
||||
buffer_size: app.audio_config.buffer_size,
|
||||
};
|
||||
|
||||
{
|
||||
let mut eng = engine.lock().unwrap();
|
||||
*eng = Engine::new_with_channels(eng.sr, new_config.channels as usize);
|
||||
}
|
||||
|
||||
match audio::build_stream(
|
||||
&new_config,
|
||||
Arc::clone(&engine),
|
||||
Arc::clone(&link),
|
||||
Arc::clone(&playing),
|
||||
Arc::clone(&project),
|
||||
slot_steps.clone(),
|
||||
Arc::clone(&event_count),
|
||||
Arc::clone(&slot_data),
|
||||
Arc::clone(&slot_changes),
|
||||
Arc::clone(&app.variables),
|
||||
Arc::clone(&app.rng),
|
||||
) {
|
||||
Ok((new_stream, sr)) => {
|
||||
stream = new_stream;
|
||||
app.audio_config.sample_rate = sr;
|
||||
{
|
||||
let mut eng = engine.lock().unwrap();
|
||||
eng.sr = sr;
|
||||
eng.isr = 1.0 / sr;
|
||||
}
|
||||
app.status_message = Some("Audio restarted".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
app.status_message = Some(format!("Restart failed: {e}"));
|
||||
let (fallback_stream, _) = audio::build_stream(
|
||||
&AudioStreamConfig {
|
||||
output_device: None,
|
||||
channels: 2,
|
||||
buffer_size: 512,
|
||||
},
|
||||
Arc::clone(&engine),
|
||||
Arc::clone(&link),
|
||||
Arc::clone(&playing),
|
||||
Arc::clone(&project),
|
||||
slot_steps.clone(),
|
||||
Arc::clone(&event_count),
|
||||
Arc::clone(&slot_data),
|
||||
Arc::clone(&slot_changes),
|
||||
Arc::clone(&app.variables),
|
||||
Arc::clone(&app.rng),
|
||||
)
|
||||
.expect("Failed to restart with defaults");
|
||||
stream = fallback_stream;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.update_from_link(&link);
|
||||
app.playing = playing.load(Ordering::Relaxed);
|
||||
app.event_count = event_count.load(Ordering::Relaxed);
|
||||
@@ -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 => {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
|
||||
@@ -263,6 +443,8 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
KeyCode::Char('[') => app.speed_decrease(),
|
||||
KeyCode::Char(']') => app.speed_increase(),
|
||||
KeyCode::Char('L') => app.open_pattern_modal(PatternField::Length),
|
||||
KeyCode::Char('S') => app.open_pattern_modal(PatternField::Speed),
|
||||
KeyCode::Char('c') if ctrl => app.copy_step(),
|
||||
KeyCode::Char('v') if ctrl => app.paste_step(),
|
||||
_ => {}
|
||||
@@ -354,6 +536,47 @@ fn main() -> io::Result<()> {
|
||||
KeyCode::Char('q') => {
|
||||
app.modal = Modal::ConfirmQuit { selected: false };
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
app.prev_audio_focus();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
app.next_audio_focus();
|
||||
}
|
||||
KeyCode::Left => match app.audio_focus {
|
||||
AudioFocus::OutputDevice => app.prev_output_device(),
|
||||
AudioFocus::InputDevice => app.prev_input_device(),
|
||||
AudioFocus::Channels => app.adjust_channels(-1),
|
||||
AudioFocus::BufferSize => app.adjust_buffer_size(-64),
|
||||
AudioFocus::SamplePaths => app.remove_last_sample_path(),
|
||||
},
|
||||
KeyCode::Right => match app.audio_focus {
|
||||
AudioFocus::OutputDevice => app.next_output_device(),
|
||||
AudioFocus::InputDevice => app.next_input_device(),
|
||||
AudioFocus::Channels => app.adjust_channels(1),
|
||||
AudioFocus::BufferSize => app.adjust_buffer_size(64),
|
||||
AudioFocus::SamplePaths => {}
|
||||
},
|
||||
KeyCode::Char('R') => {
|
||||
app.trigger_restart();
|
||||
// Reload samples on restart
|
||||
let mut eng = engine.lock().unwrap();
|
||||
eng.sample_index.clear();
|
||||
app.audio_config.sample_count = 0;
|
||||
for path in &app.audio_config.sample_paths {
|
||||
let index = doux::loader::scan_samples_dir(path);
|
||||
app.audio_config.sample_count += index.len();
|
||||
eng.sample_index.extend(index);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('A') => {
|
||||
app.modal = Modal::AddSamplePath(String::new());
|
||||
}
|
||||
KeyCode::Char('D') => {
|
||||
app.refresh_audio_devices();
|
||||
let out_count = app.available_output_devices.len();
|
||||
let in_count = app.available_input_devices.len();
|
||||
app.status_message = Some(format!("Found {out_count} output, {in_count} input devices"));
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
engine.lock().unwrap().hush();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,19 @@ impl PatternSpeed {
|
||||
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)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::{App, Modal};
|
||||
use crate::app::{App, Modal, PatternField};
|
||||
use crate::page::Page;
|
||||
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);
|
||||
}
|
||||
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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,141 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Gauge, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::{App, AudioFocus};
|
||||
|
||||
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) {
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
.areas(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_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 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)
|
||||
.map(|i| {
|
||||
if i % 2 == 0 {
|
||||
Constraint::Fill(1)
|
||||
Constraint::Length(row_height)
|
||||
} else {
|
||||
Constraint::Length(1)
|
||||
}
|
||||
|
||||
@@ -127,17 +127,41 @@ fn render_grid(
|
||||
(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 label = if label.is_empty() {
|
||||
format!("{:02}", idx + 1)
|
||||
} else {
|
||||
label.to_string()
|
||||
};
|
||||
let tile = Paragraph::new(label)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
|
||||
let name = names.get(idx).and_then(|n| *n).unwrap_or("");
|
||||
let number = format!("{:02}", idx + 1);
|
||||
let cell = cols[col];
|
||||
|
||||
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 label = if name.is_empty() {
|
||||
format!("{}{:02}", prefix, idx + 1)
|
||||
} else {
|
||||
format!("{}{}", prefix, name)
|
||||
};
|
||||
let tile = Paragraph::new(label)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
|
||||
let number = format!("{}{:02}", prefix, idx + 1);
|
||||
let cell = cols[col];
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user