initial code scetch
This commit is contained in:
422
src/app.rs
Normal file
422
src/app.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
/// App — top-level state and event loop.
|
||||
///
|
||||
/// Startup sequence:
|
||||
/// 1. Load filter-chain modules for all 4 decks via pw-cli
|
||||
/// 2. Start PipeWire audio streams (they auto-connect to filter-chain sinks)
|
||||
/// 3. Resolve PipeWire node IDs (pw-dump) with a short settle delay
|
||||
/// 4. Start OSC server
|
||||
/// 5. Run TUI + event loop
|
||||
///
|
||||
/// Runtime param changes:
|
||||
/// Fader → wpctl set-volume <stream-node-id> <value>
|
||||
/// Pitch → pw-cli set-param <filter-node-id> Props { params = ["pitch:Pitch scale" "<v>"] }
|
||||
/// Tempo → pw-cli set-param <filter-node-id> Props { params = ["pitch:Time ratio" "<v>"] }
|
||||
use std::{io, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
|
||||
use crate::{
|
||||
audio::{AudioCmd, AudioEngine, AudioStatus},
|
||||
deck::{Deck, DeckId, PlayState},
|
||||
effect_chain::EffectChain,
|
||||
file_selector::FileSelector,
|
||||
osc::{self, OscAppCmd},
|
||||
tui,
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
pub decks: Vec<Deck>,
|
||||
pub focused_deck: usize,
|
||||
pub file_selector: Option<FileSelector>,
|
||||
|
||||
audio: AudioEngine,
|
||||
chains: Vec<EffectChain>, // one per deck, in order
|
||||
osc_rx: crossbeam_channel::Receiver<OscAppCmd>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Result<Self> {
|
||||
// 1. Load filter-chain modules for all 4 decks
|
||||
tracing::info!("Loading PipeWire filter-chain modules…");
|
||||
let mut chains = Vec::new();
|
||||
for i in 0..4 {
|
||||
let deck_id = DeckId::from_index(i).unwrap();
|
||||
match EffectChain::load(deck_id) {
|
||||
Ok(chain) => {
|
||||
tracing::info!("Filter-chain loaded for deck {}", deck_id.label());
|
||||
chains.push(chain);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Filter-chain load failed for deck {} (rubberband LADSPA not installed?): {e}",
|
||||
deck_id.label()
|
||||
);
|
||||
// Push a stub with no module_id so the rest of the app still works
|
||||
chains.push(EffectChain {
|
||||
deck: deck_id,
|
||||
module_id: None,
|
||||
node_id: None,
|
||||
node_name: EffectChain::node_name_for(deck_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Start audio engine (PipeWire streams)
|
||||
let audio = AudioEngine::new()?;
|
||||
|
||||
// 3. OSC channel
|
||||
let (osc_tx, osc_rx) = crossbeam_channel::bounded(128);
|
||||
osc::start_osc_server(osc_tx)?;
|
||||
|
||||
let decks = (0..4)
|
||||
.map(|i| Deck::new(DeckId::from_index(i).unwrap()))
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
decks,
|
||||
focused_deck: 0,
|
||||
file_selector: None,
|
||||
audio,
|
||||
chains,
|
||||
osc_rx,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Give PipeWire a moment to settle before resolving node IDs
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
self.resolve_all_node_ids();
|
||||
|
||||
let result = self.event_loop(&mut terminal).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Query pw-dump and fill in filter_node_id for each deck.
|
||||
fn resolve_all_node_ids(&mut self) {
|
||||
for (i, chain) in self.chains.iter_mut().enumerate() {
|
||||
if chain.module_id.is_none() { continue; }
|
||||
match chain.resolve_node_id() {
|
||||
Ok(()) => {
|
||||
self.decks[i].filter_node_id = chain.node_id;
|
||||
tracing::info!(
|
||||
"Deck {} filter node id = {:?}",
|
||||
chain.deck.label(),
|
||||
chain.node_id
|
||||
);
|
||||
}
|
||||
Err(e) => tracing::warn!("Could not resolve filter node for deck {}: {e}", chain.deck.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn event_loop(
|
||||
&mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| tui::draw(f, self))?;
|
||||
|
||||
// Drain audio status
|
||||
while let Ok(status) = self.audio.status_rx.try_recv() {
|
||||
self.handle_audio_status(status);
|
||||
}
|
||||
|
||||
// Drain OSC commands
|
||||
while let Ok(cmd) = self.osc_rx.try_recv() {
|
||||
if self.handle_osc_cmd(cmd)? { return Ok(()); }
|
||||
}
|
||||
|
||||
// Keyboard (16ms poll → ~60fps)
|
||||
if event::poll(Duration::from_millis(16))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if self.handle_key(key)? { return Ok(()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audio status ─────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_audio_status(&mut self, status: AudioStatus) {
|
||||
match status {
|
||||
AudioStatus::Loaded { deck, duration_samples, sample_rate, name, stream_node_id } => {
|
||||
let d = &mut self.decks[deck as usize];
|
||||
d.duration = duration_samples;
|
||||
d.sample_rate = sample_rate;
|
||||
d.track_name = Some(name.clone());
|
||||
d.position = 0;
|
||||
d.play_state = PlayState::Stopped;
|
||||
// stream_node_id from audio engine is 0 (placeholder); resolve properly
|
||||
if stream_node_id > 0 {
|
||||
d.stream_node_id = Some(stream_node_id);
|
||||
} else {
|
||||
// Try to find our stream node in pw-dump by node name
|
||||
if let Some(id) = find_pw_node_id(
|
||||
&format!("djdeck.deck.{}", deck.node_suffix())
|
||||
) {
|
||||
d.stream_node_id = Some(id);
|
||||
}
|
||||
}
|
||||
osc::send_osc(
|
||||
&format!("/deck/{}/loaded", deck as usize),
|
||||
vec![rosc::OscType::String(name)],
|
||||
);
|
||||
}
|
||||
AudioStatus::Position { deck, position } => {
|
||||
self.decks[deck as usize].position = position;
|
||||
osc::send_osc(
|
||||
&format!("/deck/{}/position", deck as usize),
|
||||
vec![rosc::OscType::Float(
|
||||
self.decks[deck as usize].progress()
|
||||
)],
|
||||
);
|
||||
}
|
||||
AudioStatus::Vu { deck, l, r } => {
|
||||
let d = &mut self.decks[deck as usize];
|
||||
d.vu.0 = (d.vu.0 * 0.65).max(l);
|
||||
d.vu.1 = (d.vu.1 * 0.65).max(r);
|
||||
osc::send_osc(
|
||||
&format!("/deck/{}/vu", deck as usize),
|
||||
vec![rosc::OscType::Float(l), rosc::OscType::Float(r)],
|
||||
);
|
||||
}
|
||||
AudioStatus::TrackEnded(deck) => {
|
||||
self.decks[deck as usize].play_state = PlayState::Stopped;
|
||||
}
|
||||
AudioStatus::Error { deck, msg } => {
|
||||
tracing::error!("Deck {:?}: {}", deck, msg);
|
||||
self.decks[deck as usize].track_name = Some(format!("ERR: {}", msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OSC commands ─────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_osc_cmd(&mut self, cmd: OscAppCmd) -> Result<bool> {
|
||||
match cmd {
|
||||
OscAppCmd::Audio(audio_cmd) => {
|
||||
self.sync_play_state_from_audio_cmd(&audio_cmd);
|
||||
let _ = self.audio.cmd_tx.send(audio_cmd);
|
||||
}
|
||||
OscAppCmd::SetFader { deck, value } => self.apply_fader(deck, value),
|
||||
OscAppCmd::SetPitch { deck, semitones } => self.apply_pitch(deck, semitones),
|
||||
OscAppCmd::SetTempo { deck, multiplier } => self.apply_tempo(deck, multiplier),
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn sync_play_state_from_audio_cmd(&mut self, cmd: &AudioCmd) {
|
||||
match cmd {
|
||||
AudioCmd::Play(d) => self.decks[*d as usize].play_state = PlayState::Playing,
|
||||
AudioCmd::Pause(d) => self.decks[*d as usize].play_state = PlayState::Paused,
|
||||
AudioCmd::Stop(d) => self.decks[*d as usize].play_state = PlayState::Stopped,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keyboard ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
|
||||
if self.file_selector.is_some() {
|
||||
return self.handle_key_selector(key);
|
||||
}
|
||||
|
||||
match (key.modifiers, key.code) {
|
||||
(_, KeyCode::Char('q')) => return Ok(true),
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true),
|
||||
|
||||
// Deck focus
|
||||
(KeyModifiers::NONE, KeyCode::Char('a')) => self.focused_deck = 0,
|
||||
(KeyModifiers::NONE, KeyCode::Char('z')) => self.focused_deck = 1,
|
||||
(KeyModifiers::NONE, KeyCode::Char('s')) => self.focused_deck = 2,
|
||||
(KeyModifiers::NONE, KeyCode::Char('x')) => self.focused_deck = 3,
|
||||
|
||||
// Open file selector
|
||||
(KeyModifiers::SHIFT, KeyCode::Char('A')) => self.open_selector(0),
|
||||
(KeyModifiers::SHIFT, KeyCode::Char('Z')) => self.open_selector(1),
|
||||
(KeyModifiers::SHIFT, KeyCode::Char('S')) => self.open_selector(2),
|
||||
(KeyModifiers::SHIFT, KeyCode::Char('X')) => self.open_selector(3),
|
||||
|
||||
// Transport
|
||||
(KeyModifiers::NONE, KeyCode::Char(' ')) => self.toggle_play(),
|
||||
(KeyModifiers::NONE, KeyCode::Enter) => self.stop_focused(),
|
||||
(KeyModifiers::NONE, KeyCode::Left) => self.seek_relative(-0.01),
|
||||
(KeyModifiers::NONE, KeyCode::Right) => self.seek_relative(0.01),
|
||||
|
||||
// Cue
|
||||
(KeyModifiers::NONE, KeyCode::Char('c')) => self.set_cue(),
|
||||
(KeyModifiers::NONE, KeyCode::Char('v')) => self.goto_cue(),
|
||||
|
||||
// Fader → wpctl
|
||||
(KeyModifiers::NONE, KeyCode::Up) => self.nudge_fader(0.05),
|
||||
(KeyModifiers::NONE, KeyCode::Down) => self.nudge_fader(-0.05),
|
||||
|
||||
// Pitch → pw-cli filter-chain Props
|
||||
(KeyModifiers::NONE, KeyCode::Char(']')) => self.nudge_pitch(0.5),
|
||||
(KeyModifiers::NONE, KeyCode::Char('[')) => self.nudge_pitch(-0.5),
|
||||
|
||||
// Tempo → pw-cli filter-chain Props
|
||||
(KeyModifiers::NONE, KeyCode::Char('.')) => self.nudge_tempo(0.01),
|
||||
(KeyModifiers::NONE, KeyCode::Char(',')) => self.nudge_tempo(-0.01),
|
||||
|
||||
_ => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn handle_key_selector(&mut self, key: KeyEvent) -> Result<bool> {
|
||||
match key.code {
|
||||
KeyCode::Esc => { self.file_selector = None; }
|
||||
KeyCode::Up => { self.file_selector.as_mut().unwrap().move_up(); }
|
||||
KeyCode::Down => { self.file_selector.as_mut().unwrap().move_down(); }
|
||||
KeyCode::Backspace => { self.file_selector.as_mut().unwrap().pop_char(); }
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = &self.file_selector {
|
||||
if let Some(path) = sel.confirm() {
|
||||
let deck = sel.deck;
|
||||
self.decks[deck as usize].track = Some(path.clone());
|
||||
self.focused_deck = deck as usize;
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::Load { deck, path });
|
||||
}
|
||||
}
|
||||
self.file_selector = None;
|
||||
}
|
||||
KeyCode::Char(c) => { self.file_selector.as_mut().unwrap().push_char(c); }
|
||||
_ => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn focused_id(&self) -> DeckId {
|
||||
DeckId::from_index(self.focused_deck).unwrap()
|
||||
}
|
||||
|
||||
fn open_selector(&mut self, idx: usize) {
|
||||
if let Some(id) = DeckId::from_index(idx) {
|
||||
self.file_selector = Some(FileSelector::new(id));
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_play(&mut self) {
|
||||
let deck = self.focused_id();
|
||||
let d = &mut self.decks[self.focused_deck];
|
||||
if !d.is_loaded() { return; }
|
||||
match d.play_state {
|
||||
PlayState::Playing => {
|
||||
d.play_state = PlayState::Paused;
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::Pause(deck));
|
||||
}
|
||||
_ => {
|
||||
d.play_state = PlayState::Playing;
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::Play(deck));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_focused(&mut self) {
|
||||
let deck = self.focused_id();
|
||||
self.decks[self.focused_deck].play_state = PlayState::Stopped;
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::Stop(deck));
|
||||
}
|
||||
|
||||
fn seek_relative(&mut self, delta: f32) {
|
||||
let deck = self.focused_id();
|
||||
let pos = (self.decks[self.focused_deck].progress() + delta).clamp(0.0, 1.0);
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::Seek { deck, position: pos });
|
||||
}
|
||||
|
||||
fn set_cue(&mut self) {
|
||||
let deck = self.focused_id();
|
||||
let pos = self.decks[self.focused_deck].position;
|
||||
self.decks[self.focused_deck].cue_point = Some(pos);
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::SetCue(deck));
|
||||
}
|
||||
|
||||
fn goto_cue(&mut self) {
|
||||
let deck = self.focused_id();
|
||||
if let Some(p) = self.decks[self.focused_deck].cue_point {
|
||||
self.decks[self.focused_deck].position = p;
|
||||
}
|
||||
let _ = self.audio.cmd_tx.send(AudioCmd::GotoCue(deck));
|
||||
}
|
||||
|
||||
fn nudge_fader(&mut self, delta: f32) {
|
||||
let deck = self.focused_id();
|
||||
let new_val = (self.decks[self.focused_deck].fader + delta).clamp(0.0, 1.0);
|
||||
self.apply_fader(deck, new_val);
|
||||
}
|
||||
|
||||
fn apply_fader(&mut self, deck: DeckId, value: f32) {
|
||||
let value = value.clamp(0.0, 1.0);
|
||||
self.decks[deck as usize].fader = value;
|
||||
if let Some(node_id) = self.decks[deck as usize].stream_node_id {
|
||||
if let Err(e) = EffectChain::set_volume(node_id, value) {
|
||||
tracing::warn!("set_volume deck {}: {e}", deck.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge_pitch(&mut self, delta: f32) {
|
||||
let deck = self.focused_id();
|
||||
let new_val = (self.decks[self.focused_deck].pitch + delta).clamp(-12.0, 12.0);
|
||||
self.apply_pitch(deck, new_val);
|
||||
}
|
||||
|
||||
fn apply_pitch(&mut self, deck: DeckId, semitones: f32) {
|
||||
let semitones = semitones.clamp(-12.0, 12.0);
|
||||
self.decks[deck as usize].pitch = semitones;
|
||||
if let Err(e) = self.chains[deck as usize].set_pitch(semitones) {
|
||||
tracing::warn!("set_pitch deck {}: {e}", deck.label());
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge_tempo(&mut self, delta: f32) {
|
||||
let deck = self.focused_id();
|
||||
let new_val = (self.decks[self.focused_deck].tempo + delta).clamp(0.5, 2.0);
|
||||
self.apply_tempo(deck, new_val);
|
||||
}
|
||||
|
||||
fn apply_tempo(&mut self, deck: DeckId, multiplier: f32) {
|
||||
let multiplier = multiplier.clamp(0.5, 2.0);
|
||||
self.decks[deck as usize].tempo = multiplier;
|
||||
if let Err(e) = self.chains[deck as usize].set_tempo(multiplier) {
|
||||
tracing::warn!("set_tempo deck {}: {e}", deck.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── pw-dump node ID lookup ────────────────────────────────────────────────────
|
||||
|
||||
/// Find a PipeWire node's object ID by its node.name property, via pw-dump.
|
||||
fn find_pw_node_id(node_name: &str) -> Option<u32> {
|
||||
let out = std::process::Command::new("pw-dump").output().ok()?;
|
||||
let json: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
let arr = json.as_array()?;
|
||||
for obj in arr {
|
||||
if obj.get("type")?.as_str()? != "PipeWire:Interface:Node" { continue; }
|
||||
let props = obj.get("info")?.get("props")?;
|
||||
if props.get("node.name")?.as_str()? == node_name {
|
||||
return obj.get("id")?.as_u64().map(|i| i as u32);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
75
src/cache.rs
Normal file
75
src/cache.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use std::{fs, path::{Path, PathBuf}};
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const AUDIO_EXTS: &[&str] = &[
|
||||
"mp3", "flac", "ogg", "wav", "aac", "m4a", "opus", "wma",
|
||||
];
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CacheFile {
|
||||
root: String,
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
fn cache_path() -> PathBuf {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("djdeck")
|
||||
.join("file_index.json")
|
||||
}
|
||||
|
||||
pub fn get_audio_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let cp = cache_path();
|
||||
let root_str = root.to_string_lossy().into_owned();
|
||||
|
||||
// Try cache
|
||||
if let Ok(data) = fs::read_to_string(&cp) {
|
||||
if let Ok(cache) = serde_json::from_str::<CacheFile>(&data) {
|
||||
if cache.root == root_str {
|
||||
return Ok(cache.files.into_iter().map(PathBuf::from).collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk
|
||||
let mut files: Vec<PathBuf> = walkdir::WalkDir::new(root)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| AUDIO_EXTS.contains(&x.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|e| e.into_path())
|
||||
.collect();
|
||||
|
||||
files.sort();
|
||||
|
||||
// Write cache
|
||||
if let Some(p) = cp.parent() { let _ = fs::create_dir_all(p); }
|
||||
let cache = CacheFile {
|
||||
root: root_str,
|
||||
files: files.iter().map(|p| p.to_string_lossy().into_owned()).collect(),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&cache) {
|
||||
let _ = fs::write(&cp, json);
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn filter_files<'a>(files: &'a [PathBuf], query: &str) -> Vec<&'a PathBuf> {
|
||||
let q = query.to_lowercase();
|
||||
if q.is_empty() {
|
||||
return files.iter().collect();
|
||||
}
|
||||
files
|
||||
.iter()
|
||||
.filter(|p| p.to_string_lossy().to_lowercase().contains(&q))
|
||||
.collect()
|
||||
}
|
||||
135
src/deck.rs
Normal file
135
src/deck.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DeckId {
|
||||
A = 0,
|
||||
Z = 1,
|
||||
S = 2,
|
||||
X = 3,
|
||||
}
|
||||
|
||||
impl DeckId {
|
||||
pub fn from_index(i: usize) -> Option<Self> {
|
||||
match i {
|
||||
0 => Some(Self::A),
|
||||
1 => Some(Self::Z),
|
||||
2 => Some(Self::S),
|
||||
3 => Some(Self::X),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::A => "A",
|
||||
Self::Z => "Z",
|
||||
Self::S => "S",
|
||||
Self::X => "X",
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase node name suffix used in PipeWire node names
|
||||
pub fn node_suffix(self) -> &'static str {
|
||||
match self {
|
||||
Self::A => "a",
|
||||
Self::Z => "z",
|
||||
Self::S => "s",
|
||||
Self::X => "x",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PlayState {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
Cued,
|
||||
}
|
||||
|
||||
impl PlayState {
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Playing => "▶",
|
||||
Self::Paused => "⏸",
|
||||
Self::Stopped => "⏹",
|
||||
Self::Cued => "⏮",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Deck {
|
||||
pub id: DeckId,
|
||||
|
||||
// Track
|
||||
pub track: Option<PathBuf>,
|
||||
pub track_name: Option<String>,
|
||||
|
||||
// Transport
|
||||
pub play_state: PlayState,
|
||||
pub position: u64, // samples
|
||||
pub duration: u64, // samples
|
||||
pub sample_rate: u32,
|
||||
pub cue_point: Option<u64>,
|
||||
|
||||
// Per-deck mix controls
|
||||
/// 0.0 – 1.0, sent to wpctl on the deck's PipeWire stream node
|
||||
pub fader: f32,
|
||||
|
||||
// Effect chain parameters (sent to pw-cli / filter-chain node)
|
||||
/// Semitones -12.0 .. +12.0
|
||||
pub pitch: f32,
|
||||
/// Tempo multiplier 0.5 .. 2.0
|
||||
pub tempo: f32,
|
||||
|
||||
// VU (updated by audio engine)
|
||||
pub vu: (f32, f32),
|
||||
|
||||
// PipeWire object IDs (populated after graph setup)
|
||||
/// Object ID of the deck's playback stream node
|
||||
pub stream_node_id: Option<u32>,
|
||||
/// Object ID of the filter-chain node for this deck
|
||||
pub filter_node_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
pub fn new(id: DeckId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
track: None,
|
||||
track_name: None,
|
||||
play_state: PlayState::Stopped,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
sample_rate: 44100,
|
||||
cue_point: None,
|
||||
fader: 0.8,
|
||||
pitch: 0.0,
|
||||
tempo: 1.0,
|
||||
vu: (0.0, 0.0),
|
||||
stream_node_id: None,
|
||||
filter_node_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_loaded(&self) -> bool {
|
||||
self.track.is_some()
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> f32 {
|
||||
if self.duration == 0 { 0.0 } else {
|
||||
(self.position as f32 / self.duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn elapsed(&self) -> (u64, u64) {
|
||||
let s = self.position / self.sample_rate as u64;
|
||||
(s / 60, s % 60)
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> (u64, u64) {
|
||||
let s = self.duration.saturating_sub(self.position) / self.sample_rate as u64;
|
||||
(s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
267
src/effect_chain.rs
Normal file
267
src/effect_chain.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
/// Effect chain management
|
||||
///
|
||||
/// Each deck gets a PipeWire filter-chain node loaded via:
|
||||
/// pw-cli load-module libpipewire-module-filter-chain '{ ... }'
|
||||
///
|
||||
/// The chain hosts the rubberband LADSPA pitch+tempo plugin.
|
||||
/// Runtime parameter changes go through:
|
||||
/// pw-cli set-param <node-id> Props '{ params = ["pitch:Pitch scale" "1.0"] }'
|
||||
///
|
||||
/// Fader (volume) is applied on the deck's own playback stream via:
|
||||
/// wpctl set-volume <stream-node-id> <value>
|
||||
///
|
||||
/// Node IDs are discovered after load by parsing pw-dump JSON output.
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::deck::DeckId;
|
||||
|
||||
/// Known rubberband LADSPA plugin labels and their control port names.
|
||||
/// Run `analyseplugin ladspa-rubberband.so` to see the full list on your system.
|
||||
/// We use the stereo pitch shifter variant.
|
||||
const RUBBERBAND_PLUGIN: &str = "ladspa-rubberband";
|
||||
const RUBBERBAND_LABEL: &str = "rubberband_pitchshifter_stereo";
|
||||
const PITCH_PORT: &str = "Pitch scale"; // control port name
|
||||
const TEMPO_PORT: &str = "Time ratio"; // control port name (some builds call it "Tempo ratio")
|
||||
|
||||
pub struct EffectChain {
|
||||
pub deck: DeckId,
|
||||
/// Module ID returned by pw-cli load-module (used for unload)
|
||||
pub module_id: Option<u32>,
|
||||
/// PipeWire object ID of the filter-chain node (used for set-param)
|
||||
pub node_id: Option<u32>,
|
||||
/// Human-readable node name used to find the node ID in pw-dump
|
||||
pub node_name: String,
|
||||
}
|
||||
|
||||
impl EffectChain {
|
||||
/// Build the node name for a deck — must be unique and stable.
|
||||
pub fn node_name_for(deck: DeckId) -> String {
|
||||
format!("djdeck.chain.{}", deck.node_suffix())
|
||||
}
|
||||
|
||||
/// Load the filter-chain module for this deck.
|
||||
/// Returns an EffectChain with module_id set; node_id is resolved separately.
|
||||
pub fn load(deck: DeckId) -> Result<Self> {
|
||||
let node_name = Self::node_name_for(deck);
|
||||
|
||||
// Build the SPA JSON argument for pw-cli load-module
|
||||
// We expose the filter-chain as a sink that the deck stream auto-connects to,
|
||||
// and whose output goes to the default audio sink.
|
||||
let args = format!(
|
||||
r#"{{
|
||||
node.name = "{node_name}"
|
||||
node.description = "djdeck Deck {label} chain"
|
||||
media.name = "{node_name}"
|
||||
filter.graph = {{
|
||||
nodes = [
|
||||
{{
|
||||
type = ladspa
|
||||
name = pitch
|
||||
plugin = {plugin}
|
||||
label = {label_plugin}
|
||||
control = {{
|
||||
"{pitch_port}" = 1.0
|
||||
"{tempo_port}" = 1.0
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
audio.channels = 2
|
||||
audio.position = [ FL FR ]
|
||||
capture.props = {{
|
||||
node.name = "input.{node_name}"
|
||||
media.class = Audio/Sink
|
||||
node.passive = true
|
||||
}}
|
||||
playback.props = {{
|
||||
node.name = "output.{node_name}"
|
||||
media.class = Audio/Source
|
||||
}}
|
||||
}}"#,
|
||||
node_name = node_name,
|
||||
label = deck.label(),
|
||||
plugin = RUBBERBAND_PLUGIN,
|
||||
label_plugin = RUBBERBAND_LABEL,
|
||||
pitch_port = PITCH_PORT,
|
||||
tempo_port = TEMPO_PORT,
|
||||
);
|
||||
|
||||
tracing::info!("Loading filter-chain for deck {}: {}", deck.label(), node_name);
|
||||
|
||||
let output = Command::new("pw-cli")
|
||||
.args(["load-module", "libpipewire-module-filter-chain", &args])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!("pw-cli load-module failed: {}", err));
|
||||
}
|
||||
|
||||
// pw-cli prints something like "Module: id:42, ..."
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let module_id = parse_id_from_pw_cli_output(&stdout, "Module");
|
||||
|
||||
tracing::info!(
|
||||
"Deck {} filter-chain loaded, module_id={:?}",
|
||||
deck.label(),
|
||||
module_id
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
deck,
|
||||
module_id,
|
||||
node_id: None,
|
||||
node_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the node_id by scanning pw-dump JSON for our node name.
|
||||
/// Call this once after load, ideally with a short delay to let PipeWire settle.
|
||||
pub fn resolve_node_id(&mut self) -> Result<()> {
|
||||
let output = Command::new("pw-dump").output()?;
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("pw-dump failed"));
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
|
||||
let objects = json.as_array().ok_or_else(|| anyhow!("pw-dump not array"))?;
|
||||
|
||||
for obj in objects {
|
||||
let type_str = obj.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
if type_str != "PipeWire:Interface:Node" {
|
||||
continue;
|
||||
}
|
||||
let props = obj.get("info")
|
||||
.and_then(|i| i.get("props"))
|
||||
.unwrap_or(&serde_json::Value::Null);
|
||||
|
||||
let name = props.get("node.name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
// The filter-chain creates two sub-nodes (input. and output. prefixed).
|
||||
// We want the output (playback) side for routing, but either gives us params.
|
||||
// Match the capture/sink side: "input.<node_name>"
|
||||
if name == format!("input.{}", self.node_name) {
|
||||
let id = obj.get("id").and_then(|i| i.as_u64()).map(|i| i as u32);
|
||||
self.node_id = id;
|
||||
tracing::info!(
|
||||
"Resolved filter node id {:?} for deck {}",
|
||||
id,
|
||||
self.deck.label()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"Could not find filter-chain node '{}' in pw-dump",
|
||||
self.node_name
|
||||
))
|
||||
}
|
||||
|
||||
/// Set pitch in semitones. Converted to a scale factor (2^(st/12)).
|
||||
pub fn set_pitch(&self, semitones: f32) -> Result<()> {
|
||||
let scale = 2.0_f32.powf(semitones / 12.0);
|
||||
self.set_param("pitch", PITCH_PORT, scale)
|
||||
}
|
||||
|
||||
/// Set tempo multiplier (1.0 = normal, 1.1 = 10% faster).
|
||||
pub fn set_tempo(&self, multiplier: f32) -> Result<()> {
|
||||
// rubberband time ratio is 1/tempo_multiplier
|
||||
let ratio = 1.0 / multiplier.clamp(0.5, 2.0);
|
||||
self.set_param("pitch", TEMPO_PORT, ratio)
|
||||
}
|
||||
|
||||
/// Low-level: set a LADSPA control port value on the filter-chain node.
|
||||
///
|
||||
/// Uses:
|
||||
/// pw-cli set-param <node-id> Props '{ params = ["<plugin_node_name>:<port>" "<value>"] }'
|
||||
fn set_param(&self, plugin_node: &str, port: &str, value: f32) -> Result<()> {
|
||||
let node_id = self
|
||||
.node_id
|
||||
.ok_or_else(|| anyhow!("filter node_id not resolved yet"))?;
|
||||
|
||||
let param = format!(
|
||||
"{{ params = [\"{plugin_node}:{port}\" \"{value:.6}\"] }}",
|
||||
plugin_node = plugin_node,
|
||||
port = port,
|
||||
value = value,
|
||||
);
|
||||
|
||||
tracing::debug!(
|
||||
"set-param deck {} node {} Props {}",
|
||||
self.deck.label(),
|
||||
node_id,
|
||||
param
|
||||
);
|
||||
|
||||
let status = Command::new("pw-cli")
|
||||
.args(["set-param", &node_id.to_string(), "Props", ¶m])
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"pw-cli set-param failed for deck {}",
|
||||
self.deck.label()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the volume (fader) on the deck's PipeWire stream node via wpctl.
|
||||
///
|
||||
/// wpctl set-volume <node-id> <value>
|
||||
pub fn set_volume(stream_node_id: u32, value: f32) -> Result<()> {
|
||||
let status = Command::new("wpctl")
|
||||
.args([
|
||||
"set-volume",
|
||||
&stream_node_id.to_string(),
|
||||
&format!("{:.4}", value.clamp(0.0, 1.5)),
|
||||
])
|
||||
.status()?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"wpctl set-volume failed for node {}",
|
||||
stream_node_id
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unload this module from PipeWire.
|
||||
pub fn unload(&self) -> Result<()> {
|
||||
if let Some(id) = self.module_id {
|
||||
let status = Command::new("pw-cli")
|
||||
.args(["unload-module", &id.to_string()])
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("pw-cli unload-module {} failed", id));
|
||||
}
|
||||
tracing::info!("Unloaded filter-chain module {} for deck {}", id, self.deck.label());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EffectChain {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = self.unload() {
|
||||
tracing::warn!("EffectChain drop unload error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a numeric ID from pw-cli output lines like:
|
||||
/// "Module: id:42, type:PipeWire:Interface:Module/3"
|
||||
fn parse_id_from_pw_cli_output(text: &str, prefix: &str) -> Option<u32> {
|
||||
for line in text.lines() {
|
||||
if line.trim_start().starts_with(prefix) {
|
||||
// look for "id:N"
|
||||
if let Some(pos) = line.find("id:") {
|
||||
let rest = &line[pos + 3..];
|
||||
let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
|
||||
return rest[..end].parse().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
115
src/file_selector.rs
Normal file
115
src/file_selector.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::{cache, deck::DeckId};
|
||||
|
||||
pub struct FileSelector {
|
||||
pub deck: DeckId,
|
||||
pub query: String,
|
||||
all_files: Vec<PathBuf>,
|
||||
pub filtered: Vec<PathBuf>,
|
||||
pub list_state: ListState,
|
||||
}
|
||||
|
||||
impl FileSelector {
|
||||
pub fn new(deck: DeckId) -> Self {
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let all_files = cache::get_audio_files(&cwd).unwrap_or_default();
|
||||
let filtered = all_files.clone();
|
||||
let mut ls = ListState::default();
|
||||
if !filtered.is_empty() { ls.select(Some(0)); }
|
||||
Self { deck, query: String::new(), all_files, filtered, list_state: ls }
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.filtered = cache::filter_files(&self.all_files, &self.query)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
self.list_state.select(if self.filtered.is_empty() { None } else { Some(0) });
|
||||
}
|
||||
|
||||
pub fn push_char(&mut self, c: char) { self.query.push(c); self.refresh(); }
|
||||
pub fn pop_char(&mut self) { self.query.pop(); self.refresh(); }
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
if self.filtered.is_empty() { return; }
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some(if i == 0 { self.filtered.len() - 1 } else { i - 1 }));
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
if self.filtered.is_empty() { return; }
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some((i + 1) % self.filtered.len()));
|
||||
}
|
||||
|
||||
pub fn confirm(&self) -> Option<PathBuf> {
|
||||
self.filtered.get(self.list_state.selected()?).cloned()
|
||||
}
|
||||
|
||||
pub fn render(&mut self, f: &mut Frame, area: Rect) {
|
||||
let popup = centered_rect(70, 70, area);
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(1)])
|
||||
.split(popup);
|
||||
|
||||
f.render_widget(Clear, popup);
|
||||
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Load track → Deck {} ", self.deck.label()))
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(outer, popup);
|
||||
|
||||
let search = Paragraph::new(format!(" {}▌", self.query))
|
||||
.block(Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Search ")
|
||||
.border_style(Style::default().fg(Color::Yellow)));
|
||||
f.render_widget(search, chunks[0]);
|
||||
|
||||
let items: Vec<ListItem> = self.filtered.iter().map(|p| {
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("?");
|
||||
let dir = p.parent().and_then(|d| d.to_str()).unwrap_or("");
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(name, Style::default().fg(Color::White)),
|
||||
Span::styled(format!(" {}", dir), Style::default().fg(Color::DarkGray)),
|
||||
]))
|
||||
}).collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM))
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[1], &mut self.list_state);
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(px: u16, py: u16, r: Rect) -> Rect {
|
||||
let vert = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - py) / 2),
|
||||
Constraint::Percentage(py),
|
||||
Constraint::Percentage((100 - py) / 2),
|
||||
])
|
||||
.split(r);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - px) / 2),
|
||||
Constraint::Percentage(px),
|
||||
Constraint::Percentage((100 - px) / 2),
|
||||
])
|
||||
.split(vert[1])[1]
|
||||
}
|
||||
28
src/main.rs
Normal file
28
src/main.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
mod app;
|
||||
mod audio;
|
||||
mod cache;
|
||||
mod deck;
|
||||
mod effect_chain;
|
||||
mod file_selector;
|
||||
mod osc;
|
||||
mod tui;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Log to file so we don't pollute the TUI
|
||||
let log_file = std::fs::File::create("/tmp/djdeck.log")?;
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive(tracing::Level::INFO.into()),
|
||||
)
|
||||
.with_writer(log_file)
|
||||
.init();
|
||||
|
||||
tracing::info!("djdeck starting");
|
||||
|
||||
let mut app = app::App::new()?;
|
||||
app.run().await
|
||||
}
|
||||
108
src/osc.rs
Normal file
108
src/osc.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
/// OSC server and sender
|
||||
///
|
||||
/// Listen port : 9000
|
||||
/// Send port : 9001
|
||||
///
|
||||
/// Incoming address map (/deck/<n>/...):
|
||||
/// play, pause, stop
|
||||
/// seek f (0.0–1.0)
|
||||
/// fader f (0.0–1.0) → wpctl set-volume on stream node
|
||||
/// pitch f (semitones) → pw-cli Props on filter node
|
||||
/// tempo f (multiplier)→ pw-cli Props on filter node
|
||||
/// cue/set, cue/goto
|
||||
///
|
||||
/// Outgoing:
|
||||
/// /deck/<n>/position f
|
||||
/// /deck/<n>/vu f f
|
||||
/// /deck/<n>/loaded s
|
||||
use std::{net::UdpSocket, thread};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::Sender;
|
||||
use rosc::{encoder, OscMessage, OscPacket, OscType};
|
||||
|
||||
use crate::audio::AudioCmd;
|
||||
use crate::deck::DeckId;
|
||||
|
||||
pub const LISTEN_PORT: u16 = 9000;
|
||||
pub const SEND_PORT: u16 = 9001;
|
||||
|
||||
/// Messages that require effect chain interaction (pitch/tempo/fader)
|
||||
/// are sent back through AppCmd so the app can call the appropriate
|
||||
/// EffectChain / wpctl method.
|
||||
#[derive(Debug)]
|
||||
pub enum OscAppCmd {
|
||||
Audio(AudioCmd),
|
||||
SetPitch { deck: DeckId, semitones: f32 },
|
||||
SetTempo { deck: DeckId, multiplier: f32 },
|
||||
SetFader { deck: DeckId, value: f32 },
|
||||
}
|
||||
|
||||
pub fn start_osc_server(cmd_tx: Sender<OscAppCmd>) -> Result<()> {
|
||||
let socket = UdpSocket::bind(format!("0.0.0.0:{}", LISTEN_PORT))?;
|
||||
tracing::info!("OSC listening on :{}", LISTEN_PORT);
|
||||
|
||||
thread::Builder::new()
|
||||
.name("osc-server".into())
|
||||
.spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((size, _)) => {
|
||||
if let Ok((_, pkt)) = rosc::decoder::decode_udp(&buf[..size]) {
|
||||
handle_packet(pkt, &cmd_tx);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("OSC recv: {e}"),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_packet(pkt: OscPacket, tx: &Sender<OscAppCmd>) {
|
||||
match pkt {
|
||||
OscPacket::Message(msg) => handle_msg(msg, tx),
|
||||
OscPacket::Bundle(b) => b.content.into_iter().for_each(|p| handle_packet(p, tx)),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_msg(msg: OscMessage, tx: &Sender<OscAppCmd>) {
|
||||
let parts: Vec<&str> = msg.addr.trim_start_matches('/').split('/').collect();
|
||||
if parts.len() < 3 || parts[0] != "deck" { return; }
|
||||
let n: usize = parts[1].parse().unwrap_or(99);
|
||||
let deck = match DeckId::from_index(n) { Some(d) => d, None => return };
|
||||
|
||||
let cmd = match (parts[2], parts.get(3).copied()) {
|
||||
("play", None) => Some(OscAppCmd::Audio(AudioCmd::Play(deck))),
|
||||
("pause", None) => Some(OscAppCmd::Audio(AudioCmd::Pause(deck))),
|
||||
("stop", None) => Some(OscAppCmd::Audio(AudioCmd::Stop(deck))),
|
||||
("seek", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::Audio(AudioCmd::Seek { deck, position: v })),
|
||||
("fader", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetFader { deck, value: v }),
|
||||
("pitch", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetPitch { deck, semitones: v }),
|
||||
("tempo", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetTempo { deck, multiplier: v }),
|
||||
("cue", Some("set")) => Some(OscAppCmd::Audio(AudioCmd::SetCue(deck))),
|
||||
("cue", Some("goto")) => Some(OscAppCmd::Audio(AudioCmd::GotoCue(deck))),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(c) = cmd { let _ = tx.send(c); }
|
||||
}
|
||||
|
||||
fn f32_arg(args: &[OscType], i: usize) -> Option<f32> {
|
||||
match args.get(i)? {
|
||||
OscType::Float(f) => Some(*f),
|
||||
OscType::Double(d) => Some(*d as f32),
|
||||
OscType::Int(i) => Some(*i as f32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_osc(addr: &str, args: Vec<OscType>) {
|
||||
let Ok(sock) = UdpSocket::bind("0.0.0.0:0") else { return };
|
||||
let msg = OscPacket::Message(OscMessage { addr: addr.to_string(), args });
|
||||
if let Ok(buf) = encoder::encode(&msg) {
|
||||
let _ = sock.send_to(&buf, format!("127.0.0.1:{}", SEND_PORT));
|
||||
}
|
||||
}
|
||||
238
src/tui.rs
Normal file
238
src/tui.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Gauge, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::{app::App, deck::{Deck, DeckId}};
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
let area = f.area();
|
||||
draw_base(f, app, area);
|
||||
if let Some(sel) = &mut app.file_selector {
|
||||
sel.render(f, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_base(f: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(4),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" ♫ djdeck ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(
|
||||
format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::styled(
|
||||
"PipeWire graph: each deck → rubberband filter-chain → sink",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]));
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// 2×2 deck grid
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(chunks[1]);
|
||||
|
||||
for row in 0..2usize {
|
||||
let cols = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(rows[row]);
|
||||
|
||||
for col in 0..2usize {
|
||||
let idx = row * 2 + col;
|
||||
if idx < app.decks.len() {
|
||||
draw_deck(f, &app.decks[idx], cols[col], app.focused_deck == idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw_help(f, chunks[2]);
|
||||
}
|
||||
|
||||
fn deck_color(id: DeckId) -> Color {
|
||||
match id {
|
||||
DeckId::A => Color::Cyan,
|
||||
DeckId::Z => Color::Magenta,
|
||||
DeckId::S => Color::Green,
|
||||
DeckId::X => Color::Yellow,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
|
||||
let color = deck_color(deck.id);
|
||||
|
||||
let border_style = if focused {
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
// Truncate track name to fit
|
||||
let max_name = area.width.saturating_sub(18) as usize;
|
||||
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
|
||||
let track_display = if track_display.len() > max_name {
|
||||
format!("{}…", &track_display[..max_name.saturating_sub(1)])
|
||||
} else {
|
||||
track_display.to_string()
|
||||
};
|
||||
|
||||
// Node ID status
|
||||
let graph_status = match (deck.stream_node_id, deck.filter_node_id) {
|
||||
(Some(s), Some(f)) => format!(" [pw:{}/{}]", s, f),
|
||||
(Some(s), None) => format!(" [pw:{}/--]", s),
|
||||
_ => " [pw:--]".to_string(),
|
||||
};
|
||||
|
||||
let title = format!(
|
||||
" Deck {} {} {} {} ",
|
||||
deck.id.label(),
|
||||
deck.play_state.icon(),
|
||||
track_display,
|
||||
graph_status,
|
||||
);
|
||||
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(Color::Black));
|
||||
f.render_widget(outer, area);
|
||||
|
||||
let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 });
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // timecode
|
||||
Constraint::Length(1), // progress
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(1), // fader
|
||||
Constraint::Length(1), // pitch + tempo
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(1), // VU
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Timecode
|
||||
let (em, es) = deck.elapsed();
|
||||
let (rm, rs) = deck.remaining();
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!("{:02}:{:02}", em, es), Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::DarkGray)),
|
||||
])),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
// Progress
|
||||
f.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default().fg(color).bg(Color::Black))
|
||||
.percent((deck.progress() * 100.0) as u16)
|
||||
.label(""),
|
||||
rows[1],
|
||||
);
|
||||
|
||||
// Fader
|
||||
let fader_pct = (deck.fader * 100.0) as u16;
|
||||
f.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default()
|
||||
.fg(if fader_pct > 10 { Color::White } else { Color::DarkGray })
|
||||
.bg(Color::Black))
|
||||
.percent(fader_pct.min(100))
|
||||
.label(format!("VOL {:3}%", fader_pct)),
|
||||
rows[3],
|
||||
);
|
||||
|
||||
// Pitch + Tempo (both live in rubberband filter-chain node)
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("PITCH ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{:+.1}st", deck.pitch),
|
||||
Style::default().fg(if deck.pitch.abs() > 0.05 { Color::Yellow } else { Color::White }),
|
||||
),
|
||||
Span::styled(" TEMPO ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{:.2}×", deck.tempo),
|
||||
Style::default().fg(if (deck.tempo - 1.0).abs() > 0.01 { Color::Yellow } else { Color::White }),
|
||||
),
|
||||
])),
|
||||
rows[4],
|
||||
);
|
||||
|
||||
// VU
|
||||
draw_vu(f, deck.vu, rows[6], color);
|
||||
}
|
||||
|
||||
fn draw_vu(f: &mut Frame, vu: (f32, f32), area: Rect, color: Color) {
|
||||
let half = area.width / 2;
|
||||
let la = Rect { x: area.x, y: area.y, width: half.saturating_sub(1), height: 1 };
|
||||
let ra = Rect { x: area.x + half, y: area.y, width: half, height: 1 };
|
||||
|
||||
let vc = |p: u16| if p > 90 { Color::Red } else if p > 70 { Color::Yellow } else { color };
|
||||
|
||||
let lp = (vu.0 * 100.0) as u16;
|
||||
let rp = (vu.1 * 100.0) as u16;
|
||||
|
||||
f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(lp)).bg(Color::Black)).percent(lp.min(100)).label("L"), la);
|
||||
f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp)).bg(Color::Black)).percent(rp.min(100)).label("R"), ra);
|
||||
}
|
||||
|
||||
fn draw_help(f: &mut Frame, area: Rect) {
|
||||
let help = Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Focus: ", Style::default().fg(Color::DarkGray)),
|
||||
k("a"), k("z"), k("s"), k("x"),
|
||||
Span::styled(" Load: ", Style::default().fg(Color::DarkGray)),
|
||||
k("Shift+A/Z/S/X"),
|
||||
Span::styled(" Play/Pause: ", Style::default().fg(Color::DarkGray)),
|
||||
k("Space"),
|
||||
Span::styled(" Stop: ", Style::default().fg(Color::DarkGray)),
|
||||
k("Enter"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Fader: ", Style::default().fg(Color::DarkGray)),
|
||||
k("↑/↓"),
|
||||
Span::styled(" Pitch: ", Style::default().fg(Color::DarkGray)),
|
||||
k("[/]"),
|
||||
Span::styled(" Tempo: ", Style::default().fg(Color::DarkGray)),
|
||||
k(",/."),
|
||||
Span::styled(" Cue: ", Style::default().fg(Color::DarkGray)),
|
||||
k("c"), Span::raw(" set "), k("v"), Span::raw(" goto"),
|
||||
Span::styled(" Seek: ", Style::default().fg(Color::DarkGray)),
|
||||
k("←/→"),
|
||||
Span::styled(" Quit: ", Style::default().fg(Color::DarkGray)),
|
||||
k("q"),
|
||||
]),
|
||||
])
|
||||
.block(Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::default().fg(Color::DarkGray)))
|
||||
.style(Style::default().bg(Color::Black));
|
||||
|
||||
f.render_widget(help, area);
|
||||
}
|
||||
|
||||
fn k(s: &str) -> Span<'_> {
|
||||
Span::styled(
|
||||
format!("[{}] ", s),
|
||||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user