internal process dsp, skipping ladspa pipewire attempt, and reworked waveforms
This commit is contained in:
@@ -24,6 +24,12 @@ parking_lot = "0.12"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# In-process DSP
|
||||||
|
ssstretch = "0.1" # Signalsmith Stretch — independent pitch + tempo
|
||||||
|
biquad = "0.5" # IIR biquad filters for cutoff and band split
|
||||||
|
atomic_float = "1" # AtomicF32 for lock-free param state
|
||||||
|
timestretch = "0.4" # detect_bpm / BeatGrid for waveform overlay
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Terminal DJ suite — Rust + Ratatui + PipeWire graph DSP.
|
|||||||
│ PipeWire Graph │
|
│ PipeWire Graph │
|
||||||
│ │
|
│ │
|
||||||
│ djdeck.deck.a ──► input.djdeck.chain.a │
|
│ djdeck.deck.a ──► input.djdeck.chain.a │
|
||||||
│ [rubberband LADSPA] │
|
│ [pitch → lpL/lpR → reverb] │
|
||||||
│ output.djdeck.chain.a ──► default sink │
|
│ output.djdeck.chain.a ──► default sink │
|
||||||
│ │
|
│ │
|
||||||
│ (same for z, s, x) │
|
│ (same for z, s, x) │
|
||||||
@@ -32,29 +32,55 @@ Terminal DJ suite — Rust + Ratatui + PipeWire graph DSP.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each deck stream outputs raw decoded PCM. The PipeWire graph handles all DSP:
|
Each deck stream outputs raw decoded PCM. The PipeWire graph handles all DSP:
|
||||||
- **Pitch shifting** and **time stretching** — rubberband LADSPA via `libpipewire-module-filter-chain`
|
- **Pitch shifting** — rubberband `Semitones` port (requires `ladspa-rubberband`)
|
||||||
|
- **Cutoff filter** — `lowpass_iir` + `highpass_iir` (swh-plugins), `Cutoff Frequency` port:
|
||||||
|
- Negative value → lowpass (kill highs), positive → highpass (kill lows), 0 → flat
|
||||||
|
- **Reverb** — `plate` `Dry/wet mix` port (swh-plugins): 0=dry → 1=wet
|
||||||
- **Volume (fader)** — `wpctl set-volume` on the stream node
|
- **Volume (fader)** — `wpctl set-volume` on the stream node
|
||||||
- **Pitch/Tempo params** — `pw-cli set-param <node-id> Props '{ params = [...] }'`
|
- **Tempo** — varispeed in audio engine; rubberband compensates pitch automatically
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
### System
|
### System
|
||||||
|
|
||||||
|
**Required:**
|
||||||
```bash
|
```bash
|
||||||
# Arch
|
# Arch
|
||||||
pacman -S pipewire wireplumber pipewire-audio ladspa rubberband
|
pacman -S pipewire wireplumber pipewire-audio ladspa rubberband
|
||||||
|
|
||||||
# Ubuntu / Debian
|
# Ubuntu / Debian
|
||||||
apt install pipewire wireplumber pipewire-audio ladspa-sdk rubberband-ladspa
|
apt install pipewire wireplumber ladspa-sdk rubberband-ladspa
|
||||||
|
|
||||||
# Fedora
|
# Fedora
|
||||||
dnf install pipewire wireplumber ladspa rubberband-ladspa
|
dnf install pipewire wireplumber ladspa rubberband-ladspa
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Required** (cutoff + reverb):
|
||||||
|
```bash
|
||||||
|
# Arch
|
||||||
|
pacman -S swh-plugins
|
||||||
|
|
||||||
|
# Ubuntu / Debian
|
||||||
|
apt install swh-plugins
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
dnf install swh-plugins
|
||||||
|
```
|
||||||
|
|
||||||
Verify the rubberband LADSPA plugin is present:
|
Verify the rubberband LADSPA plugin is present:
|
||||||
```bash
|
```bash
|
||||||
analyseplugin ladspa-rubberband.so
|
python3 -c "
|
||||||
# should list labels including: rubberband_pitchshifter_stereo
|
import ctypes; lib = ctypes.CDLL('/usr/lib/ladspa/ladspa-rubberband.so')
|
||||||
|
lib.ladspa_descriptor.restype = ctypes.c_void_p
|
||||||
|
class D(ctypes.Structure):
|
||||||
|
_fields_ = [('uid',ctypes.c_ulong),('label',ctypes.c_char_p),('props',ctypes.c_int),('name',ctypes.c_char_p)]
|
||||||
|
i=0
|
||||||
|
while True:
|
||||||
|
d=lib.ladspa_descriptor(i)
|
||||||
|
if not d: break
|
||||||
|
print(ctypes.cast(d,ctypes.POINTER(D)).contents.label.decode()); i+=1
|
||||||
|
"
|
||||||
|
# should include: rubberband-pitchshifter-stereo
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rust
|
### Rust
|
||||||
@@ -77,10 +103,11 @@ cargo build --release
|
|||||||
Logs → `/tmp/djdeck.log`. Set `RUST_LOG=debug` for verbose output.
|
Logs → `/tmp/djdeck.log`. Set `RUST_LOG=debug` for verbose output.
|
||||||
|
|
||||||
On startup djdeck will:
|
On startup djdeck will:
|
||||||
1. Load `libpipewire-module-filter-chain` for each deck (rubberband LADSPA)
|
1. Start 4 PipeWire playback streams, each targeting its filter-chain sink
|
||||||
2. Start 4 PipeWire playback streams, each targeting its filter-chain sink
|
2. Load `libpipewire-module-filter-chain` for each deck via `pw-cli load-module` (pitch → lpL/lpR → reverb)
|
||||||
3. Wait 800ms for PipeWire to settle, then resolve node IDs via `pw-dump`
|
3. Wait 800ms for PipeWire to settle, then resolve node IDs via `pw-dump`
|
||||||
4. Start the TUI and OSC server
|
4. Explicitly link each chain's output to the default sink via `pw-link -L`
|
||||||
|
5. Start the TUI and OSC server
|
||||||
|
|
||||||
Node IDs for each deck are shown in the deck panel title: `[pw:stream/filter]`.
|
Node IDs for each deck are shown in the deck panel title: `[pw:stream/filter]`.
|
||||||
|
|
||||||
@@ -96,8 +123,10 @@ Node IDs for each deck are shown in the deck panel title: `[pw:stream/filter]`.
|
|||||||
| `c` | Set cue point |
|
| `c` | Set cue point |
|
||||||
| `v` | Jump to cue point |
|
| `v` | Jump to cue point |
|
||||||
| `↑ / ↓` | Fader ±5% (via `wpctl set-volume`) |
|
| `↑ / ↓` | Fader ±5% (via `wpctl set-volume`) |
|
||||||
| `[ / ]` | Pitch ±0.5 semitones (via `pw-cli`, rubberband) |
|
| `[ / ]` | Pitch ±0.5 semitones (rubberband `Semitones` port) |
|
||||||
| `, / .` | Tempo ±1% (via `pw-cli`, rubberband) |
|
| `, / .` | Tempo ±1% (varispeed; rubberband compensates pitch) |
|
||||||
|
| `- / =` | Cutoff ±500 (negative=LP kills highs, positive=HP kills lows, 0=flat) |
|
||||||
|
| `o / p` | Reverb wet ±5% (requires swh-plugins) |
|
||||||
| `q` | Quit (unloads filter-chain modules) |
|
| `q` | Quit (unloads filter-chain modules) |
|
||||||
|
|
||||||
**File selector:** type to filter, `↑/↓` navigate, `Enter` load, `Esc` cancel.
|
**File selector:** type to filter, `↑/↓` navigate, `Enter` load, `Esc` cancel.
|
||||||
|
|||||||
+173
-255
@@ -1,17 +1,14 @@
|
|||||||
/// App — top-level state and event loop.
|
/// App — top-level state and event loop.
|
||||||
///
|
///
|
||||||
/// Startup sequence:
|
/// Startup:
|
||||||
/// 1. Load filter-chain modules for all 4 decks via pw-cli
|
/// 1. Start the audio engine (streams autoconnect to the default sink).
|
||||||
/// 2. Start PipeWire audio streams (they auto-connect to filter-chain sinks)
|
/// 2. Start the OSC server.
|
||||||
/// 3. Resolve PipeWire node IDs (pw-dump) with a short settle delay
|
/// 3. Run the TUI event loop.
|
||||||
/// 4. Start OSC server
|
|
||||||
/// 5. Run TUI + event loop
|
|
||||||
///
|
///
|
||||||
/// Runtime param changes:
|
/// All effect changes are lock-free atomic writes into the per-deck
|
||||||
/// Fader → wpctl set-volume <stream-node-id> <value>
|
/// `DeckParams` shared with the realtime DSP callback — no subprocesses,
|
||||||
/// Pitch → pw-cli set-param <filter-node-id> Props { params = ["pitch:Pitch scale" "<v>"] }
|
/// no PipeWire object IDs, no `node.target` routing.
|
||||||
/// Tempo → pw-cli set-param <filter-node-id> Props { params = ["pitch:Time ratio" "<v>"] }
|
use std::{io, sync::atomic::Ordering, time::Duration};
|
||||||
use std::{io, time::Duration};
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
@@ -24,59 +21,30 @@ use ratatui::{backend::CrosstermBackend, Terminal};
|
|||||||
use crate::{
|
use crate::{
|
||||||
audio::{AudioCmd, AudioEngine, AudioStatus},
|
audio::{AudioCmd, AudioEngine, AudioStatus},
|
||||||
deck::{Deck, DeckId, PlayState},
|
deck::{Deck, DeckId, PlayState},
|
||||||
effect_chain::EffectChain,
|
dsp::DeckParams,
|
||||||
file_selector::FileSelector,
|
file_selector::FileSelector,
|
||||||
osc::{self, OscAppCmd},
|
osc::{self, OscAppCmd},
|
||||||
tui,
|
tui,
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub decks: Vec<Deck>,
|
pub decks: Vec<Deck>,
|
||||||
pub focused_deck: usize,
|
pub focused_deck: usize,
|
||||||
pub file_selector: Option<FileSelector>,
|
pub file_selector: Option<FileSelector>,
|
||||||
|
|
||||||
audio: AudioEngine,
|
audio: Option<AudioEngine>,
|
||||||
chains: Vec<EffectChain>, // one per deck, in order
|
/// Per-deck atomic params, shared with the audio engine's realtime callback.
|
||||||
osc_rx: crossbeam_channel::Receiver<OscAppCmd>,
|
/// Populated when the audio engine starts.
|
||||||
|
params: Vec<Arc<DeckParams>>,
|
||||||
|
osc_rx: crossbeam_channel::Receiver<OscAppCmd>,
|
||||||
audio_dir: PathBuf,
|
audio_dir: PathBuf,
|
||||||
|
dirty: bool,
|
||||||
/// Track if we need to redraw the UI
|
|
||||||
dirty: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(audio_dir: Option<PathBuf>) -> Result<Self> {
|
pub fn new(audio_dir: Option<PathBuf>) -> 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);
|
let (osc_tx, osc_rx) = crossbeam_channel::bounded(128);
|
||||||
osc::start_osc_server(osc_tx)?;
|
osc::start_osc_server(osc_tx)?;
|
||||||
|
|
||||||
@@ -84,14 +52,16 @@ impl App {
|
|||||||
.map(|i| Deck::new(DeckId::from_index(i).unwrap()))
|
.map(|i| Deck::new(DeckId::from_index(i).unwrap()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let audio_dir = audio_dir.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
let audio_dir = audio_dir.unwrap_or_else(|| {
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
decks,
|
decks,
|
||||||
focused_deck: 0,
|
focused_deck: 0,
|
||||||
file_selector: None,
|
file_selector: None,
|
||||||
audio,
|
audio: None,
|
||||||
chains,
|
params: Vec::new(),
|
||||||
osc_rx,
|
osc_rx,
|
||||||
audio_dir,
|
audio_dir,
|
||||||
dirty: true,
|
dirty: true,
|
||||||
@@ -102,12 +72,23 @@ impl App {
|
|||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
// Give PipeWire a moment to settle before resolving node IDs
|
let engine = AudioEngine::new()?;
|
||||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
self.params = engine.params.clone();
|
||||||
self.resolve_all_node_ids();
|
self.audio = Some(engine);
|
||||||
|
|
||||||
|
// Seed the realtime params with each deck's initial mix state so the
|
||||||
|
// first audio block reflects the on-screen defaults exactly.
|
||||||
|
for (i, p) in self.params.iter().enumerate() {
|
||||||
|
let d = &self.decks[i];
|
||||||
|
p.fader.store(d.fader, Ordering::Relaxed);
|
||||||
|
p.pitch.store(d.pitch, Ordering::Relaxed);
|
||||||
|
p.tempo.store(d.tempo, Ordering::Relaxed);
|
||||||
|
p.cutoff.store(d.cutoff, Ordering::Relaxed);
|
||||||
|
p.reverb_wet.store(d.reverb, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
let result = self.event_loop(&mut terminal).await;
|
let result = self.event_loop(&mut terminal).await;
|
||||||
|
|
||||||
@@ -117,67 +98,38 @@ impl App {
|
|||||||
result
|
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(
|
async fn event_loop(
|
||||||
&mut self,
|
&mut self,
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Track if any deck is playing to determine frame rate
|
let mut last_frame = std::time::Instant::now();
|
||||||
let mut playing = false;
|
|
||||||
let mut last_frame_time = std::time::Instant::now();
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check if any deck is playing
|
let playing = self.decks.iter().any(|d| d.play_state == PlayState::Playing);
|
||||||
playing = self.decks.iter().any(|d| d.play_state == PlayState::Playing);
|
let interval = if playing {
|
||||||
|
Duration::from_millis(16)
|
||||||
// Use adaptive frame rate: 60fps when playing, 30fps when idle
|
|
||||||
let frame_interval = if playing {
|
|
||||||
Duration::from_millis(16) // ~60fps
|
|
||||||
} else {
|
} else {
|
||||||
Duration::from_millis(33) // ~30fps
|
Duration::from_millis(33)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only redraw if dirty or if we're at the right time
|
if self.dirty || last_frame.elapsed() >= interval {
|
||||||
if self.dirty || last_frame_time.elapsed() >= frame_interval {
|
|
||||||
terminal.draw(|f| tui::draw(f, self))?;
|
terminal.draw(|f| tui::draw(f, self))?;
|
||||||
self.dirty = false;
|
self.dirty = false;
|
||||||
last_frame_time = std::time::Instant::now();
|
last_frame = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain audio status (limit to 100 messages per frame to prevent spinning)
|
let mut n = 0;
|
||||||
let mut status_count = 0;
|
while let Ok(status) = self.audio.as_ref().unwrap().status_rx.try_recv() {
|
||||||
while let Ok(status) = self.audio.status_rx.try_recv() {
|
|
||||||
self.handle_audio_status(status);
|
self.handle_audio_status(status);
|
||||||
status_count += 1;
|
n += 1;
|
||||||
if status_count >= 100 {
|
if n >= 200 { break; }
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain OSC commands
|
|
||||||
while let Ok(cmd) = self.osc_rx.try_recv() {
|
while let Ok(cmd) = self.osc_rx.try_recv() {
|
||||||
if self.handle_osc_cmd(cmd)? { return Ok(()); }
|
if self.handle_osc_cmd(cmd)? { return Ok(()); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard (use adaptive poll interval)
|
if event::poll(interval)? {
|
||||||
if event::poll(frame_interval)? {
|
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
if self.handle_key(key)? { return Ok(()); }
|
if self.handle_key(key)? { return Ok(()); }
|
||||||
}
|
}
|
||||||
@@ -189,39 +141,62 @@ impl App {
|
|||||||
|
|
||||||
fn handle_audio_status(&mut self, status: AudioStatus) {
|
fn handle_audio_status(&mut self, status: AudioStatus) {
|
||||||
match status {
|
match status {
|
||||||
AudioStatus::Loaded { deck, duration_samples, sample_rate, name, stream_node_id } => {
|
AudioStatus::Metadata { deck, name, artist, album, duration_samples, sample_rate } => {
|
||||||
let d = &mut self.decks[deck as usize];
|
let d = &mut self.decks[deck as usize];
|
||||||
d.duration = duration_samples;
|
|
||||||
d.sample_rate = sample_rate;
|
|
||||||
d.track_name = Some(name.clone());
|
d.track_name = Some(name.clone());
|
||||||
d.position = 0;
|
d.artist = artist;
|
||||||
d.play_state = PlayState::Stopped;
|
d.album = album;
|
||||||
// stream_node_id from audio engine is 0 (placeholder); resolve properly
|
if duration_samples > 0 {
|
||||||
if stream_node_id > 0 {
|
d.duration = duration_samples;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
d.sample_rate = sample_rate;
|
||||||
|
d.play_state = PlayState::Stopped;
|
||||||
|
d.position = 0;
|
||||||
|
d.waveform_bands.clear();
|
||||||
|
d.bpm = None;
|
||||||
osc::send_osc(
|
osc::send_osc(
|
||||||
&format!("/deck/{}/loaded", deck as usize),
|
&format!("/deck/{}/loaded", deck as usize),
|
||||||
vec![rosc::OscType::String(name)],
|
vec![rosc::OscType::String(name)],
|
||||||
);
|
);
|
||||||
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AudioStatus::WaveformChunk { deck, bands, .. } => {
|
||||||
|
self.decks[deck as usize].waveform_bands.extend_from_slice(&bands);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioStatus::Bpm { deck, bpm } => {
|
||||||
|
self.decks[deck as usize].bpm = Some(bpm);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioStatus::PcmChunk { deck, load_gen, frames, sample_rate } => {
|
||||||
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(
|
||||||
|
crate::audio::AudioCmd::AppendPcm { deck, load_gen, frames }
|
||||||
|
);
|
||||||
|
self.decks[deck as usize].sample_rate = sample_rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioStatus::Ready { deck, load_gen, duration_samples, sample_rate } => {
|
||||||
|
let d = &mut self.decks[deck as usize];
|
||||||
|
d.duration = duration_samples;
|
||||||
|
d.sample_rate = sample_rate;
|
||||||
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(
|
||||||
|
crate::audio::AudioCmd::MarkDecodingDone { deck, load_gen }
|
||||||
|
);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
AudioStatus::Position { deck, position } => {
|
AudioStatus::Position { deck, position } => {
|
||||||
self.decks[deck as usize].position = position;
|
self.decks[deck as usize].position = position;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
osc::send_osc(
|
osc::send_osc(
|
||||||
&format!("/deck/{}/position", deck as usize),
|
&format!("/deck/{}/position", deck as usize),
|
||||||
vec![rosc::OscType::Float(
|
vec![rosc::OscType::Float(self.decks[deck as usize].progress())],
|
||||||
self.decks[deck as usize].progress()
|
|
||||||
)],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioStatus::Vu { deck, l, r } => {
|
AudioStatus::Vu { deck, l, r } => {
|
||||||
let d = &mut self.decks[deck as usize];
|
let d = &mut self.decks[deck as usize];
|
||||||
d.vu.0 = (d.vu.0 * 0.65).max(l);
|
d.vu.0 = (d.vu.0 * 0.65).max(l);
|
||||||
@@ -232,10 +207,12 @@ impl App {
|
|||||||
vec![rosc::OscType::Float(l), rosc::OscType::Float(r)],
|
vec![rosc::OscType::Float(l), rosc::OscType::Float(r)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioStatus::TrackEnded(deck) => {
|
AudioStatus::TrackEnded(deck) => {
|
||||||
self.decks[deck as usize].play_state = PlayState::Stopped;
|
self.decks[deck as usize].play_state = PlayState::Stopped;
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioStatus::Error { deck, msg } => {
|
AudioStatus::Error { deck, msg } => {
|
||||||
tracing::error!("Deck {:?}: {}", deck, msg);
|
tracing::error!("Deck {:?}: {}", deck, msg);
|
||||||
self.decks[deck as usize].track_name = Some(format!("ERR: {}", msg));
|
self.decks[deck as usize].track_name = Some(format!("ERR: {}", msg));
|
||||||
@@ -244,17 +221,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OSC commands ─────────────────────────────────────────────────────────
|
// ─── OSC ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn handle_osc_cmd(&mut self, cmd: OscAppCmd) -> Result<bool> {
|
fn handle_osc_cmd(&mut self, cmd: OscAppCmd) -> Result<bool> {
|
||||||
match cmd {
|
match cmd {
|
||||||
OscAppCmd::Audio(audio_cmd) => {
|
OscAppCmd::Audio(audio_cmd) => {
|
||||||
self.sync_play_state_from_audio_cmd(&audio_cmd);
|
self.sync_play_state_from_audio_cmd(&audio_cmd);
|
||||||
let _ = self.audio.cmd_tx.send(audio_cmd);
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(audio_cmd);
|
||||||
}
|
}
|
||||||
OscAppCmd::SetFader { deck, value } => self.apply_fader(deck, value),
|
OscAppCmd::SetFader { deck, value } => self.apply_fader(deck, value),
|
||||||
OscAppCmd::SetPitch { deck, semitones } => self.apply_pitch(deck, semitones),
|
OscAppCmd::SetPitch { deck, semitones } => self.apply_pitch(deck, semitones),
|
||||||
OscAppCmd::SetTempo { deck, multiplier } => self.apply_tempo(deck, multiplier),
|
OscAppCmd::SetTempo { deck, multiplier } => self.apply_tempo(deck, multiplier),
|
||||||
|
OscAppCmd::SetCutoff { deck, hz } => self.apply_cutoff(deck, hz),
|
||||||
|
OscAppCmd::SetReverb { deck, wet } => self.apply_reverb(deck, wet),
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
@@ -276,102 +255,41 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match (key.modifiers, key.code) {
|
match (key.modifiers, key.code) {
|
||||||
(_, KeyCode::Char('q')) => return Ok(true),
|
(_, KeyCode::Char('q'))
|
||||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true),
|
| (KeyModifiers::CONTROL, KeyCode::Char('c')) => return Ok(true),
|
||||||
|
|
||||||
// Deck focus
|
(KeyModifiers::NONE, KeyCode::Char('a')) => { self.focused_deck = 0; self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Char('a')) => {
|
(KeyModifiers::NONE, KeyCode::Char('s')) => { self.focused_deck = 2; self.dirty = true; }
|
||||||
self.focused_deck = 0;
|
(KeyModifiers::NONE, KeyCode::Char('z')) => { self.focused_deck = 1; self.dirty = true; }
|
||||||
self.dirty = true;
|
(KeyModifiers::NONE, KeyCode::Char('x')) => { self.focused_deck = 3; self.dirty = true; }
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Char('z')) => {
|
|
||||||
self.focused_deck = 1;
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Char('s')) => {
|
|
||||||
self.focused_deck = 2;
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Char('x')) => {
|
|
||||||
self.focused_deck = 3;
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Open file selector
|
(KeyModifiers::SHIFT, KeyCode::Char('A')) => { self.open_selector(0); self.dirty = true; }
|
||||||
(KeyModifiers::SHIFT, KeyCode::Char('A')) => {
|
(KeyModifiers::SHIFT, KeyCode::Char('S')) => { self.open_selector(2); self.dirty = true; }
|
||||||
self.open_selector(0);
|
(KeyModifiers::SHIFT, KeyCode::Char('Z')) => { self.open_selector(1); self.dirty = true; }
|
||||||
self.dirty = true;
|
(KeyModifiers::SHIFT, KeyCode::Char('X')) => { self.open_selector(3); self.dirty = true; }
|
||||||
},
|
|
||||||
(KeyModifiers::SHIFT, KeyCode::Char('Z')) => {
|
|
||||||
self.open_selector(1);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::SHIFT, KeyCode::Char('S')) => {
|
|
||||||
self.open_selector(2);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::SHIFT, KeyCode::Char('X')) => {
|
|
||||||
self.open_selector(3);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Transport
|
(KeyModifiers::NONE, KeyCode::Char(' ')) => { self.toggle_play(); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Char(' ')) => {
|
(KeyModifiers::NONE, KeyCode::Enter) => { self.stop_focused(); self.dirty = true; }
|
||||||
self.toggle_play();
|
(KeyModifiers::NONE, KeyCode::Left) => { self.seek_relative(-0.01); self.dirty = true; }
|
||||||
self.dirty = true;
|
(KeyModifiers::NONE, KeyCode::Right) => { self.seek_relative(0.01); self.dirty = true; }
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Enter) => {
|
|
||||||
self.stop_focused();
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Left) => {
|
|
||||||
self.seek_relative(-0.01);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Right) => {
|
|
||||||
self.seek_relative(0.01);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Cue
|
(KeyModifiers::NONE, KeyCode::Char('c')) => { self.set_cue(); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Char('c')) => {
|
(KeyModifiers::NONE, KeyCode::Char('v')) => { self.goto_cue(); self.dirty = true; }
|
||||||
self.set_cue();
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Char('v')) => {
|
|
||||||
self.goto_cue();
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fader → wpctl
|
(KeyModifiers::NONE, KeyCode::Up) => { self.nudge_fader(0.05); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Up) => {
|
(KeyModifiers::NONE, KeyCode::Down) => { self.nudge_fader(-0.05); self.dirty = true; }
|
||||||
self.nudge_fader(0.05);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Down) => {
|
|
||||||
self.nudge_fader(-0.05);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pitch → pw-cli filter-chain Props
|
(KeyModifiers::NONE, KeyCode::Char(']')) => { self.nudge_pitch(0.5); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Char(']')) => {
|
(KeyModifiers::NONE, KeyCode::Char('[')) => { self.nudge_pitch(-0.5); self.dirty = true; }
|
||||||
self.nudge_pitch(0.5);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
(KeyModifiers::NONE, KeyCode::Char('[')) => {
|
|
||||||
self.nudge_pitch(-0.5);
|
|
||||||
self.dirty = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tempo → pw-cli filter-chain Props
|
(KeyModifiers::NONE, KeyCode::Char('.')) => { self.nudge_tempo(0.01); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Char('.')) => {
|
(KeyModifiers::NONE, KeyCode::Char(',')) => { self.nudge_tempo(-0.01); self.dirty = true; }
|
||||||
self.nudge_tempo(0.01);
|
|
||||||
self.dirty = true;
|
(KeyModifiers::NONE, KeyCode::Char('=')) => { self.nudge_cutoff(500.0); self.dirty = true; }
|
||||||
},
|
(KeyModifiers::NONE, KeyCode::Char('-')) => { self.nudge_cutoff(-500.0); self.dirty = true; }
|
||||||
(KeyModifiers::NONE, KeyCode::Char(',')) => {
|
|
||||||
self.nudge_tempo(-0.01);
|
(KeyModifiers::NONE, KeyCode::Char('p')) => { self.nudge_reverb(0.05); self.dirty = true; }
|
||||||
self.dirty = true;
|
(KeyModifiers::NONE, KeyCode::Char('o')) => { self.nudge_reverb(-0.05); self.dirty = true; }
|
||||||
},
|
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -390,22 +308,21 @@ impl App {
|
|||||||
let deck = sel.deck;
|
let deck = sel.deck;
|
||||||
self.decks[deck as usize].track = Some(path.clone());
|
self.decks[deck as usize].track = Some(path.clone());
|
||||||
self.focused_deck = deck as usize;
|
self.focused_deck = deck as usize;
|
||||||
let _ = self.audio.cmd_tx.send(AudioCmd::Load { deck, path });
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Load { deck, path });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.file_selector = None;
|
self.file_selector = None;
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => { self.file_selector.as_mut().unwrap().push_char(c); }
|
KeyCode::Char(c) => { self.file_selector.as_mut().unwrap().push_char(c); }
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
self.dirty = true;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn focused_id(&self) -> DeckId {
|
fn focused_id(&self) -> DeckId { DeckId::from_index(self.focused_deck).unwrap() }
|
||||||
DeckId::from_index(self.focused_deck).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_selector(&mut self, idx: usize) {
|
fn open_selector(&mut self, idx: usize) {
|
||||||
if let Some(id) = DeckId::from_index(idx) {
|
if let Some(id) = DeckId::from_index(idx) {
|
||||||
@@ -420,11 +337,11 @@ impl App {
|
|||||||
match d.play_state {
|
match d.play_state {
|
||||||
PlayState::Playing => {
|
PlayState::Playing => {
|
||||||
d.play_state = PlayState::Paused;
|
d.play_state = PlayState::Paused;
|
||||||
let _ = self.audio.cmd_tx.send(AudioCmd::Pause(deck));
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Pause(deck));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
d.play_state = PlayState::Playing;
|
d.play_state = PlayState::Playing;
|
||||||
let _ = self.audio.cmd_tx.send(AudioCmd::Play(deck));
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Play(deck));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,20 +349,20 @@ impl App {
|
|||||||
fn stop_focused(&mut self) {
|
fn stop_focused(&mut self) {
|
||||||
let deck = self.focused_id();
|
let deck = self.focused_id();
|
||||||
self.decks[self.focused_deck].play_state = PlayState::Stopped;
|
self.decks[self.focused_deck].play_state = PlayState::Stopped;
|
||||||
let _ = self.audio.cmd_tx.send(AudioCmd::Stop(deck));
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Stop(deck));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seek_relative(&mut self, delta: f32) {
|
fn seek_relative(&mut self, delta: f32) {
|
||||||
let deck = self.focused_id();
|
let deck = self.focused_id();
|
||||||
let pos = (self.decks[self.focused_deck].progress() + delta).clamp(0.0, 1.0);
|
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 });
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::Seek { deck, position: pos });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_cue(&mut self) {
|
fn set_cue(&mut self) {
|
||||||
let deck = self.focused_id();
|
let deck = self.focused_id();
|
||||||
let pos = self.decks[self.focused_deck].position;
|
let pos = self.decks[self.focused_deck].position;
|
||||||
self.decks[self.focused_deck].cue_point = Some(pos);
|
self.decks[self.focused_deck].cue_point = Some(pos);
|
||||||
let _ = self.audio.cmd_tx.send(AudioCmd::SetCue(deck));
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::SetCue(deck));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn goto_cue(&mut self) {
|
fn goto_cue(&mut self) {
|
||||||
@@ -453,27 +370,25 @@ impl App {
|
|||||||
if let Some(p) = self.decks[self.focused_deck].cue_point {
|
if let Some(p) = self.decks[self.focused_deck].cue_point {
|
||||||
self.decks[self.focused_deck].position = p;
|
self.decks[self.focused_deck].position = p;
|
||||||
}
|
}
|
||||||
let _ = self.audio.cmd_tx.send(AudioCmd::GotoCue(deck));
|
let _ = self.audio.as_ref().unwrap().cmd_tx.send(AudioCmd::GotoCue(deck));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Effect appliers: lock-free writes into shared DeckParams ────────────
|
||||||
|
|
||||||
fn nudge_fader(&mut self, delta: f32) {
|
fn nudge_fader(&mut self, delta: f32) {
|
||||||
let deck = self.focused_id();
|
let deck = self.focused_id();
|
||||||
let new_val = (self.decks[self.focused_deck].fader + delta).clamp(0.0, 1.0);
|
let new_val = (self.decks[self.focused_deck].fader + delta).clamp(0.0, 1.5);
|
||||||
self.apply_fader(deck, new_val);
|
self.apply_fader(deck, new_val);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_fader(&mut self, deck: DeckId, value: f32) {
|
fn apply_fader(&mut self, deck: DeckId, value: f32) {
|
||||||
let value = value.clamp(0.0, 1.0);
|
let value = value.clamp(0.0, 1.5);
|
||||||
self.decks[deck as usize].fader = value;
|
self.decks[deck as usize].fader = value;
|
||||||
if let Some(node_id) = self.decks[deck as usize].stream_node_id {
|
self.params[deck as usize].fader.store(value, Ordering::Relaxed);
|
||||||
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) {
|
fn nudge_pitch(&mut self, delta: f32) {
|
||||||
let deck = self.focused_id();
|
let deck = self.focused_id();
|
||||||
let new_val = (self.decks[self.focused_deck].pitch + delta).clamp(-12.0, 12.0);
|
let new_val = (self.decks[self.focused_deck].pitch + delta).clamp(-12.0, 12.0);
|
||||||
self.apply_pitch(deck, new_val);
|
self.apply_pitch(deck, new_val);
|
||||||
}
|
}
|
||||||
@@ -481,13 +396,11 @@ impl App {
|
|||||||
fn apply_pitch(&mut self, deck: DeckId, semitones: f32) {
|
fn apply_pitch(&mut self, deck: DeckId, semitones: f32) {
|
||||||
let semitones = semitones.clamp(-12.0, 12.0);
|
let semitones = semitones.clamp(-12.0, 12.0);
|
||||||
self.decks[deck as usize].pitch = semitones;
|
self.decks[deck as usize].pitch = semitones;
|
||||||
if let Err(e) = self.chains[deck as usize].set_pitch(semitones) {
|
self.params[deck as usize].pitch.store(semitones, Ordering::Relaxed);
|
||||||
tracing::warn!("set_pitch deck {}: {e}", deck.label());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nudge_tempo(&mut self, delta: f32) {
|
fn nudge_tempo(&mut self, delta: f32) {
|
||||||
let deck = self.focused_id();
|
let deck = self.focused_id();
|
||||||
let new_val = (self.decks[self.focused_deck].tempo + delta).clamp(0.5, 2.0);
|
let new_val = (self.decks[self.focused_deck].tempo + delta).clamp(0.5, 2.0);
|
||||||
self.apply_tempo(deck, new_val);
|
self.apply_tempo(deck, new_val);
|
||||||
}
|
}
|
||||||
@@ -495,25 +408,30 @@ impl App {
|
|||||||
fn apply_tempo(&mut self, deck: DeckId, multiplier: f32) {
|
fn apply_tempo(&mut self, deck: DeckId, multiplier: f32) {
|
||||||
let multiplier = multiplier.clamp(0.5, 2.0);
|
let multiplier = multiplier.clamp(0.5, 2.0);
|
||||||
self.decks[deck as usize].tempo = multiplier;
|
self.decks[deck as usize].tempo = multiplier;
|
||||||
if let Err(e) = self.chains[deck as usize].set_tempo(multiplier) {
|
self.params[deck as usize].tempo.store(multiplier, Ordering::Relaxed);
|
||||||
tracing::warn!("set_tempo deck {}: {e}", deck.label());
|
}
|
||||||
}
|
|
||||||
|
fn nudge_cutoff(&mut self, delta: f32) {
|
||||||
|
let deck = self.focused_id();
|
||||||
|
let new_val = (self.decks[self.focused_deck].cutoff + delta).clamp(-20_000.0, 20_000.0);
|
||||||
|
self.apply_cutoff(deck, new_val);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_cutoff(&mut self, deck: DeckId, value: f32) {
|
||||||
|
let value = value.clamp(-20_000.0, 20_000.0);
|
||||||
|
self.decks[deck as usize].cutoff = value;
|
||||||
|
self.params[deck as usize].cutoff.store(value, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nudge_reverb(&mut self, delta: f32) {
|
||||||
|
let deck = self.focused_id();
|
||||||
|
let new_val = (self.decks[self.focused_deck].reverb + delta).clamp(0.0, 1.0);
|
||||||
|
self.apply_reverb(deck, new_val);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_reverb(&mut self, deck: DeckId, wet: f32) {
|
||||||
|
let wet = wet.clamp(0.0, 1.0);
|
||||||
|
self.decks[deck as usize].reverb = wet;
|
||||||
|
self.params[deck as usize].reverb_wet.store(wet, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 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
|
|
||||||
}
|
|
||||||
|
|||||||
+443
-171
@@ -3,11 +3,12 @@
|
|||||||
/// Responsibilities:
|
/// Responsibilities:
|
||||||
/// - Decode audio files (symphonia) into interleaved stereo f32 PCM
|
/// - Decode audio files (symphonia) into interleaved stereo f32 PCM
|
||||||
/// - Drive one PipeWire playback stream per deck
|
/// - Drive one PipeWire playback stream per deck
|
||||||
/// - Each stream targets the deck's filter-chain sink node
|
/// - Run the per-deck DSP graph (`crate::dsp::DeckDsp`) inside the realtime
|
||||||
/// (djdeck.chain.<x> input side) so all DSP happens in the graph
|
/// process callback: pitch + tempo + cutoff + reverb + fader, all in-process
|
||||||
/// - Report position, VU, and load status back to the app via channel
|
/// - Stream connects with AUTOCONNECT to the default sink (no filter chains,
|
||||||
///
|
/// no `node.target`)
|
||||||
/// No DSP happens here. Pitch/tempo/EQ all live in the PipeWire graph.
|
/// - Effect parameters flow lock-free via `Arc<DeckParams>` atomics shared
|
||||||
|
/// with the app
|
||||||
use std::{
|
use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
@@ -21,13 +22,16 @@ use symphonia::core::{
|
|||||||
codecs::DecoderOptions,
|
codecs::DecoderOptions,
|
||||||
formats::FormatOptions,
|
formats::FormatOptions,
|
||||||
io::MediaSourceStream,
|
io::MediaSourceStream,
|
||||||
meta::MetadataOptions,
|
meta::{MetadataOptions, StandardTagKey},
|
||||||
probe::Hint,
|
probe::Hint,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{deck::DeckId, effect_chain::EffectChain};
|
use crate::{
|
||||||
|
deck::DeckId,
|
||||||
|
dsp::{DeckDsp, DeckParams},
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Commands ───────────────────────────────────────────────────────────────
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AudioCmd {
|
pub enum AudioCmd {
|
||||||
@@ -38,124 +42,170 @@ pub enum AudioCmd {
|
|||||||
Seek { deck: DeckId, position: f32 },
|
Seek { deck: DeckId, position: f32 },
|
||||||
SetCue(DeckId),
|
SetCue(DeckId),
|
||||||
GotoCue(DeckId),
|
GotoCue(DeckId),
|
||||||
// Volume is handled by wpctl; we just record it in state.
|
/// Append a decoded PCM chunk (interleaved stereo f32) to a deck's buffer.
|
||||||
// Fader changes are applied directly from app.rs via EffectChain::set_volume.
|
/// `load_gen` must match the deck's current generation to guard against
|
||||||
|
/// stale decodes from a previous Load.
|
||||||
|
AppendPcm { deck: DeckId, load_gen: u64, frames: Vec<f32> },
|
||||||
|
/// Signal that decoding is complete for this generation.
|
||||||
|
MarkDecodingDone { deck: DeckId, load_gen: u64 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Status updates ─────────────────────────────────────────────────────────
|
// ─── Status updates ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AudioStatus {
|
pub enum AudioStatus {
|
||||||
Loaded {
|
/// Sent immediately from file headers — before full decode.
|
||||||
deck: DeckId,
|
Metadata {
|
||||||
|
deck: DeckId,
|
||||||
|
name: String,
|
||||||
|
artist: Option<String>,
|
||||||
|
album: Option<String>,
|
||||||
|
/// Estimated from codec_params; exact after full decode.
|
||||||
duration_samples: u64,
|
duration_samples: u64,
|
||||||
|
sample_rate: u32,
|
||||||
|
},
|
||||||
|
/// Waveform peak data sent in chunks as the file decodes (~4096-sample chunks).
|
||||||
|
/// Each entry is `[bass_peak, mid_peak, high_peak]` — peak amplitudes after
|
||||||
|
/// splitting the mono mix through a Linkwitz-Riley-ish crossover (LP 250 Hz,
|
||||||
|
/// BP 250–4000 Hz, HP 4000 Hz). The UI blends these into per-column color.
|
||||||
|
WaveformChunk {
|
||||||
|
deck: DeckId,
|
||||||
|
bands: Vec<[f32; 3]>,
|
||||||
|
/// Sample-frame offset of the first peak in this chunk.
|
||||||
|
frame_offset: u64,
|
||||||
|
},
|
||||||
|
/// BPM estimate from `timestretch::detect_bpm` over the first ~60 s of
|
||||||
|
/// decoded audio. Emitted once per Load, after `Ready`.
|
||||||
|
Bpm { deck: DeckId, bpm: f32 },
|
||||||
|
/// Decoded PCM chunk — routed back through the app into AudioCmd::AppendPcm.
|
||||||
|
PcmChunk {
|
||||||
|
deck: DeckId,
|
||||||
|
load_gen: u64,
|
||||||
|
frames: Vec<f32>,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
name: String,
|
|
||||||
/// PipeWire object ID of the stream node (for wpctl volume)
|
|
||||||
stream_node_id: u32,
|
|
||||||
},
|
},
|
||||||
Position {
|
/// Full PCM is in memory; deck is ready to play.
|
||||||
deck: DeckId,
|
Ready {
|
||||||
position: u64,
|
deck: DeckId,
|
||||||
},
|
load_gen: u64,
|
||||||
Vu {
|
duration_samples: u64,
|
||||||
deck: DeckId,
|
sample_rate: u32,
|
||||||
l: f32,
|
|
||||||
r: f32,
|
|
||||||
},
|
},
|
||||||
|
Position { deck: DeckId, position: u64 },
|
||||||
|
Vu { deck: DeckId, l: f32, r: f32 },
|
||||||
TrackEnded(DeckId),
|
TrackEnded(DeckId),
|
||||||
Error {
|
Error { deck: DeckId, msg: String },
|
||||||
deck: DeckId,
|
|
||||||
msg: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Per-deck internal state ──────────────────────────────────────────────────
|
// ─── Per-deck internal state ──────────────────────────────────────────────────
|
||||||
|
|
||||||
struct DeckState {
|
struct DeckState {
|
||||||
id: DeckId,
|
id: DeckId,
|
||||||
playing: bool,
|
playing: bool,
|
||||||
pcm: Vec<f32>, // interleaved stereo f32
|
/// Play was requested before PCM arrived; start as soon as first chunk lands.
|
||||||
|
pending_play: bool,
|
||||||
|
/// True once the decode thread has sent every frame (safe to stop at EOF).
|
||||||
|
decoding_done: bool,
|
||||||
|
/// Incremented on each Load; guards AppendPcm from a stale decode thread.
|
||||||
|
load_gen: u64,
|
||||||
|
pcm: Vec<f32>,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
read_pos: usize, // byte index into pcm (always even)
|
/// Frame index into `pcm` (no fractional resampling — the DSP stretcher
|
||||||
cue_pos: usize,
|
/// handles tempo). Advanced by the number of frames consumed each block.
|
||||||
|
read_pos: usize,
|
||||||
/// Track last VU update time to rate-limit updates
|
cue_pos: usize,
|
||||||
|
dsp: DeckDsp,
|
||||||
|
params: Arc<DeckParams>,
|
||||||
last_vu_update: std::time::Instant,
|
last_vu_update: std::time::Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeckState {
|
impl DeckState {
|
||||||
fn new(id: DeckId) -> Self {
|
fn new(id: DeckId, sample_rate: u32, params: Arc<DeckParams>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
playing: false,
|
playing: false,
|
||||||
pcm: Vec::new(),
|
pending_play: false,
|
||||||
sample_rate: 44100,
|
decoding_done: true,
|
||||||
read_pos: 0,
|
load_gen: 0,
|
||||||
cue_pos: 0,
|
pcm: Vec::new(),
|
||||||
|
sample_rate,
|
||||||
|
read_pos: 0,
|
||||||
|
cue_pos: 0,
|
||||||
|
dsp: DeckDsp::new(sample_rate),
|
||||||
|
params,
|
||||||
last_vu_update: std::time::Instant::now(),
|
last_vu_update: std::time::Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn total_frames(&self) -> usize { self.pcm.len() / 2 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public handle ─────────────────────────────────────────────────────
|
// ─── Public handle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct AudioEngine {
|
pub struct AudioEngine {
|
||||||
pub cmd_tx: Sender<AudioCmd>,
|
pub cmd_tx: Sender<AudioCmd>,
|
||||||
pub status_rx: Receiver<AudioStatus>,
|
pub status_rx: Receiver<AudioStatus>,
|
||||||
|
/// One `Arc<DeckParams>` per deck — the app writes effect parameters into
|
||||||
|
/// these atomics directly. The realtime callback snapshots them per block.
|
||||||
|
pub params: Vec<Arc<DeckParams>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioEngine {
|
impl AudioEngine {
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let (cmd_tx, cmd_rx) = bounded::<AudioCmd>(64);
|
let (cmd_tx, cmd_rx) = bounded::<AudioCmd>(64);
|
||||||
let (status_tx, status_rx) = bounded::<AudioStatus>(512);
|
let (status_tx, status_rx) = bounded::<AudioStatus>(1024);
|
||||||
|
|
||||||
|
let params: Vec<Arc<DeckParams>> = (0..4).map(|_| DeckParams::new()).collect();
|
||||||
|
let params_for_engine = params.clone();
|
||||||
|
|
||||||
thread::Builder::new()
|
thread::Builder::new()
|
||||||
.name("audio-engine".into())
|
.name("audio-engine".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) = engine_thread(cmd_rx, status_tx) {
|
if let Err(e) = engine_thread(cmd_rx, status_tx, params_for_engine) {
|
||||||
tracing::error!("Audio engine crashed: {e}");
|
tracing::error!("Audio engine crashed: {e}");
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Self { cmd_tx, status_rx })
|
Ok(Self { cmd_tx, status_rx, params })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Engine thread ──────────────────────────────────────────────────────
|
// ─── Engine thread ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn engine_thread(cmd_rx: Receiver<AudioCmd>, status_tx: Sender<AudioStatus>) -> Result<()> {
|
const STREAM_SR: u32 = 44_100;
|
||||||
|
|
||||||
|
fn engine_thread(
|
||||||
|
cmd_rx: Receiver<AudioCmd>,
|
||||||
|
status_tx: Sender<AudioStatus>,
|
||||||
|
params: Vec<Arc<DeckParams>>,
|
||||||
|
) -> Result<()> {
|
||||||
use pipewire::{context::ContextRc, main_loop::MainLoopRc, stream::StreamRc};
|
use pipewire::{context::ContextRc, main_loop::MainLoopRc, stream::StreamRc};
|
||||||
|
|
||||||
let main_loop = MainLoopRc::new(None)?;
|
let main_loop = MainLoopRc::new(None)?;
|
||||||
let context = ContextRc::new(&main_loop, None)?;
|
let context = ContextRc::new(&main_loop, None)?;
|
||||||
let core = context.connect_rc(None)?;
|
let core = context.connect_rc(None)?;
|
||||||
|
|
||||||
// Shared per-deck state (written by command thread, read by PW callback)
|
|
||||||
let deck_states: Vec<Arc<Mutex<DeckState>>> = (0..4)
|
let deck_states: Vec<Arc<Mutex<DeckState>>> = (0..4)
|
||||||
.map(|i| Arc::new(Mutex::new(DeckState::new(DeckId::from_index(i).unwrap()))))
|
.map(|i| {
|
||||||
|
let id = DeckId::from_index(i).unwrap();
|
||||||
|
Arc::new(Mutex::new(DeckState::new(id, STREAM_SR, params[i].clone())))
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Build one PipeWire stream per deck
|
let mut _streams: Vec<StreamRc> = Vec::new();
|
||||||
let mut _streams: Vec<StreamRc> = Vec::new();
|
let mut _listeners = Vec::new();
|
||||||
let mut _listeners = Vec::new();
|
|
||||||
|
|
||||||
for (i, ds) in deck_states.iter().enumerate() {
|
for (i, ds) in deck_states.iter().enumerate() {
|
||||||
let deck_id = DeckId::from_index(i).unwrap();
|
let deck_id = DeckId::from_index(i).unwrap();
|
||||||
let ds_cb = Arc::clone(ds);
|
let ds_cb = Arc::clone(ds);
|
||||||
let status_tx_cb = status_tx.clone();
|
let status_tx_cb = status_tx.clone();
|
||||||
|
|
||||||
// Target: the input (sink) side of this deck's filter-chain node
|
|
||||||
let target_name = format!("input.{}", EffectChain::node_name_for(deck_id));
|
|
||||||
|
|
||||||
let props = pipewire::properties::properties! {
|
let props = pipewire::properties::properties! {
|
||||||
*pipewire::keys::MEDIA_TYPE => "Audio",
|
*pipewire::keys::MEDIA_TYPE => "Audio",
|
||||||
*pipewire::keys::MEDIA_CATEGORY => "Playback",
|
*pipewire::keys::MEDIA_CATEGORY => "Playback",
|
||||||
*pipewire::keys::MEDIA_ROLE => "Music",
|
*pipewire::keys::MEDIA_ROLE => "Music",
|
||||||
*pipewire::keys::APP_NAME => "djdeck",
|
*pipewire::keys::APP_NAME => "djdeck",
|
||||||
*pipewire::keys::NODE_NAME => format!("djdeck.deck.{}", deck_id.node_suffix()),
|
*pipewire::keys::NODE_NAME => format!("djdeck.deck.{}", deck_id.node_suffix()),
|
||||||
// Route to our filter-chain sink
|
|
||||||
"node.target" => target_name,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let stream = StreamRc::new(
|
let stream = StreamRc::new(
|
||||||
@@ -172,41 +222,47 @@ fn engine_thread(cmd_rx: Receiver<AudioCmd>, status_tx: Sender<AudioStatus>) ->
|
|||||||
|
|
||||||
if let Some(mut buf) = stream.dequeue_buffer() {
|
if let Some(mut buf) = stream.dequeue_buffer() {
|
||||||
let datas = buf.datas_mut();
|
let datas = buf.datas_mut();
|
||||||
if datas.is_empty() {
|
if datas.is_empty() { return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = &mut datas[0];
|
let data = &mut datas[0];
|
||||||
|
|
||||||
let out_bytes = data.data().unwrap();
|
let out_bytes = data.data().unwrap();
|
||||||
let n_frames = out_bytes.len() / 8; // f32 stereo
|
let n_frames = out_bytes.len() / 8;
|
||||||
|
|
||||||
let out = unsafe {
|
let out = unsafe {
|
||||||
std::slice::from_raw_parts_mut(out_bytes.as_ptr() as *mut f32, n_frames * 2)
|
std::slice::from_raw_parts_mut(
|
||||||
|
out_bytes.as_ptr() as *mut f32,
|
||||||
|
n_frames * 2,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut vu_l = 0.0f32;
|
|
||||||
let mut vu_r = 0.0f32;
|
|
||||||
|
|
||||||
if ds.playing && !ds.pcm.is_empty() {
|
if ds.playing && !ds.pcm.is_empty() {
|
||||||
for frame in 0..n_frames {
|
let total = ds.total_frames();
|
||||||
if ds.read_pos + 1 >= ds.pcm.len() {
|
if ds.read_pos >= total {
|
||||||
|
if ds.decoding_done {
|
||||||
ds.playing = false;
|
ds.playing = false;
|
||||||
out[frame * 2] = 0.0;
|
|
||||||
out[frame * 2 + 1] = 0.0;
|
|
||||||
let _ = status_tx_cb.try_send(AudioStatus::TrackEnded(ds.id));
|
let _ = status_tx_cb.try_send(AudioStatus::TrackEnded(ds.id));
|
||||||
continue;
|
}
|
||||||
|
out.fill(0.0);
|
||||||
|
} else {
|
||||||
|
// Split disjoint field borrows so the DSP can read
|
||||||
|
// `pcm` while mutating its internal state.
|
||||||
|
let start = ds.read_pos;
|
||||||
|
let DeckState { dsp, pcm, params, .. } = &mut **ds;
|
||||||
|
let consumed = dsp.process(params, pcm, start, out);
|
||||||
|
ds.read_pos = (start + consumed).min(total);
|
||||||
|
|
||||||
|
// VU from the post-DSP output.
|
||||||
|
let mut vu_l = 0.0f32;
|
||||||
|
let mut vu_r = 0.0f32;
|
||||||
|
for i in 0..n_frames {
|
||||||
|
vu_l = vu_l.max(out[i * 2].abs());
|
||||||
|
vu_r = vu_r.max(out[i * 2 + 1].abs());
|
||||||
}
|
}
|
||||||
|
|
||||||
let l = ds.pcm[ds.read_pos];
|
let now = std::time::Instant::now();
|
||||||
let r = ds.pcm[ds.read_pos + 1];
|
if now.duration_since(ds.last_vu_update) >= std::time::Duration::from_millis(16) {
|
||||||
ds.read_pos += 2;
|
let _ = status_tx_cb.try_send(AudioStatus::Vu { deck: ds.id, l: vu_l, r: vu_r });
|
||||||
|
ds.last_vu_update = now;
|
||||||
out[frame * 2] = l;
|
}
|
||||||
out[frame * 2 + 1] = r;
|
|
||||||
|
|
||||||
vu_l = vu_l.max(l.abs());
|
|
||||||
vu_r = vu_r.max(r.abs());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
out.fill(0.0);
|
out.fill(0.0);
|
||||||
@@ -216,29 +272,17 @@ fn engine_thread(cmd_rx: Receiver<AudioCmd>, status_tx: Sender<AudioStatus>) ->
|
|||||||
let chunk = data.chunk_mut();
|
let chunk = data.chunk_mut();
|
||||||
*chunk.offset_mut() = 0;
|
*chunk.offset_mut() = 0;
|
||||||
*chunk.stride_mut() = 8;
|
*chunk.stride_mut() = 8;
|
||||||
*chunk.size_mut() = (n_frames * 8) as u32;
|
*chunk.size_mut() = (n_frames * 8) as u32;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pos = (ds.read_pos / 2) as u64;
|
|
||||||
let _ = status_tx_cb.try_send(AudioStatus::Position {
|
let _ = status_tx_cb.try_send(AudioStatus::Position {
|
||||||
deck: ds.id,
|
deck: ds.id,
|
||||||
position: pos,
|
position: ds.read_pos as u64,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rate-limit VU updates to ~60Hz (16ms) to reduce CPU usage
|
|
||||||
// while still providing smooth updates during playback
|
|
||||||
let now = std::time::Instant::now();
|
|
||||||
if now.duration_since(ds.last_vu_update) >= std::time::Duration::from_millis(16) {
|
|
||||||
let _ = status_tx_cb.try_send(AudioStatus::Vu {
|
|
||||||
deck: ds.id,
|
|
||||||
l: vu_l,
|
|
||||||
r: vu_r,
|
|
||||||
});
|
|
||||||
ds.last_vu_update = now;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.register()?; // Connect the stream with F32LE stereo format
|
.register()?;
|
||||||
|
|
||||||
let pod_bytes = make_f32_stereo_pod()?;
|
let pod_bytes = make_f32_stereo_pod()?;
|
||||||
let mut params = [pipewire::spa::pod::Pod::from_bytes(&pod_bytes).unwrap()];
|
let mut params = [pipewire::spa::pod::Pod::from_bytes(&pod_bytes).unwrap()];
|
||||||
stream.connect(
|
stream.connect(
|
||||||
@@ -254,7 +298,7 @@ fn engine_thread(cmd_rx: Receiver<AudioCmd>, status_tx: Sender<AudioStatus>) ->
|
|||||||
_streams.push(stream);
|
_streams.push(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command processing thread — decodes files and mutates deck states
|
// Command processing thread
|
||||||
let ds_cmd = deck_states.clone();
|
let ds_cmd = deck_states.clone();
|
||||||
let st_cmd = status_tx.clone();
|
let st_cmd = status_tx.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -267,63 +311,52 @@ fn engine_thread(cmd_rx: Receiver<AudioCmd>, status_tx: Sender<AudioStatus>) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Command handler ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn handle_cmd(cmd: AudioCmd, states: &[Arc<Mutex<DeckState>>], status_tx: &Sender<AudioStatus>) {
|
fn handle_cmd(cmd: AudioCmd, states: &[Arc<Mutex<DeckState>>], status_tx: &Sender<AudioStatus>) {
|
||||||
match cmd {
|
match cmd {
|
||||||
AudioCmd::Load { deck, path } => {
|
AudioCmd::Load { deck, path } => {
|
||||||
let name = path
|
let load_gen = {
|
||||||
.file_name()
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
.and_then(|n| n.to_str())
|
ds.pcm.clear();
|
||||||
.unwrap_or("unknown")
|
ds.read_pos = 0;
|
||||||
.to_string();
|
ds.cue_pos = 0;
|
||||||
|
ds.playing = false;
|
||||||
match decode_file(&path) {
|
ds.pending_play = false;
|
||||||
Ok((pcm, sr)) => {
|
ds.decoding_done = false;
|
||||||
let total = (pcm.len() / 2) as u64;
|
ds.load_gen = ds.load_gen.wrapping_add(1);
|
||||||
{
|
ds.load_gen
|
||||||
let mut ds = states[deck as usize].lock().unwrap();
|
};
|
||||||
ds.pcm = pcm;
|
let st = status_tx.clone();
|
||||||
ds.sample_rate = sr;
|
thread::spawn(move || {
|
||||||
ds.read_pos = 0;
|
if let Err(e) = decode_file_streaming(&path, deck, load_gen, &st) {
|
||||||
ds.cue_pos = 0;
|
let _ = st.send(AudioStatus::Error { deck, msg: e.to_string() });
|
||||||
ds.playing = false;
|
|
||||||
}
|
|
||||||
// stream_node_id: we don't have it from here — the app queries
|
|
||||||
// pw-dump after connecting. Emit 0 as placeholder; app fills it in.
|
|
||||||
let _ = status_tx.send(AudioStatus::Loaded {
|
|
||||||
deck,
|
|
||||||
duration_samples: total,
|
|
||||||
sample_rate: sr,
|
|
||||||
name,
|
|
||||||
stream_node_id: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
});
|
||||||
let _ = status_tx.send(AudioStatus::Error {
|
|
||||||
deck,
|
|
||||||
msg: e.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AudioCmd::Play(deck) => {
|
AudioCmd::Play(deck) => {
|
||||||
let mut ds = states[deck as usize].lock().unwrap();
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
if !ds.pcm.is_empty() {
|
if !ds.pcm.is_empty() {
|
||||||
ds.playing = true;
|
ds.playing = true;
|
||||||
|
} else {
|
||||||
|
ds.pending_play = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AudioCmd::Pause(deck) => {
|
AudioCmd::Pause(deck) => {
|
||||||
let mut ds = states[deck as usize].lock().unwrap();
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
ds.playing = false;
|
ds.playing = false;
|
||||||
|
ds.pending_play = false;
|
||||||
}
|
}
|
||||||
AudioCmd::Stop(deck) => {
|
AudioCmd::Stop(deck) => {
|
||||||
let mut ds = states[deck as usize].lock().unwrap();
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
ds.playing = false;
|
ds.playing = false;
|
||||||
ds.read_pos = 0;
|
ds.pending_play = false;
|
||||||
|
ds.read_pos = 0;
|
||||||
}
|
}
|
||||||
AudioCmd::Seek { deck, position } => {
|
AudioCmd::Seek { deck, position } => {
|
||||||
let mut ds = states[deck as usize].lock().unwrap();
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
let total = ds.pcm.len();
|
let total = ds.total_frames();
|
||||||
ds.read_pos = ((total as f32 * position.clamp(0.0, 1.0)) as usize / 2) * 2;
|
ds.read_pos = ((total as f32 * position.clamp(0.0, 1.0)) as usize).min(total);
|
||||||
}
|
}
|
||||||
AudioCmd::SetCue(deck) => {
|
AudioCmd::SetCue(deck) => {
|
||||||
let mut ds = states[deck as usize].lock().unwrap();
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
@@ -333,15 +366,46 @@ fn handle_cmd(cmd: AudioCmd, states: &[Arc<Mutex<DeckState>>], status_tx: &Sende
|
|||||||
let mut ds = states[deck as usize].lock().unwrap();
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
ds.read_pos = ds.cue_pos;
|
ds.read_pos = ds.cue_pos;
|
||||||
}
|
}
|
||||||
|
AudioCmd::AppendPcm { deck, load_gen, frames } => {
|
||||||
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
|
if ds.load_gen != load_gen { return; } // stale decode
|
||||||
|
let was_empty = ds.pcm.is_empty();
|
||||||
|
ds.pcm.extend_from_slice(&frames);
|
||||||
|
if was_empty && ds.pending_play {
|
||||||
|
ds.playing = true;
|
||||||
|
ds.pending_play = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCmd::MarkDecodingDone { deck, load_gen } => {
|
||||||
|
let mut ds = states[deck as usize].lock().unwrap();
|
||||||
|
if ds.load_gen == load_gen {
|
||||||
|
ds.decoding_done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Symphonia decode ─────────────────────────────────────────────────────────────
|
// ─── Symphonia streaming decode ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Number of PCM stereo frames per waveform peak bucket (~0.09 s at 44.1 kHz).
|
||||||
|
const PEAK_BUCKET: usize = 4096;
|
||||||
|
|
||||||
|
/// Open file, emit Metadata immediately, then decode to PCM while streaming
|
||||||
|
/// both waveform peaks and raw PCM chunks every 64 peak-buckets (~6 s).
|
||||||
|
fn decode_file_streaming(
|
||||||
|
path: &PathBuf,
|
||||||
|
deck: DeckId,
|
||||||
|
load_gen: u64,
|
||||||
|
status_tx: &Sender<AudioStatus>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
/// Decode any supported audio file into interleaved stereo f32 PCM.
|
|
||||||
fn decode_file(path: &PathBuf) -> Result<(Vec<f32>, u32)> {
|
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
@@ -356,6 +420,7 @@ fn decode_file(path: &PathBuf) -> Result<(Vec<f32>, u32)> {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut format = probed.format;
|
let mut format = probed.format;
|
||||||
|
|
||||||
let track = format
|
let track = format
|
||||||
.tracks()
|
.tracks()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -363,33 +428,218 @@ fn decode_file(path: &PathBuf) -> Result<(Vec<f32>, u32)> {
|
|||||||
.ok_or_else(|| anyhow!("No audio track found"))?
|
.ok_or_else(|| anyhow!("No audio track found"))?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
let sr = track.codec_params.sample_rate.unwrap_or(44100);
|
let sr = track.codec_params.sample_rate.unwrap_or(44100);
|
||||||
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
|
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
|
||||||
let mut decoder =
|
|
||||||
symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?;
|
let est_duration = track.codec_params.n_frames.unwrap_or(0);
|
||||||
|
|
||||||
|
let (artist, album) = extract_tags(&mut format);
|
||||||
|
|
||||||
|
let _ = status_tx.send(AudioStatus::Metadata {
|
||||||
|
deck,
|
||||||
|
name,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
duration_samples: est_duration,
|
||||||
|
sample_rate: sr,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut decoder = symphonia::default::get_codecs()
|
||||||
|
.make(&track.codec_params, &DecoderOptions::default())?;
|
||||||
|
|
||||||
let track_id = track.id;
|
let track_id = track.id;
|
||||||
let mut pcm: Vec<f32> = Vec::new();
|
|
||||||
|
let mut pcm_batch: Vec<f32> = Vec::with_capacity(64 * PEAK_BUCKET * 2);
|
||||||
|
let mut total_frames: u64 = 0;
|
||||||
|
// Mono accumulator used for BPM detection. Capped at 60 s.
|
||||||
|
const BPM_MAX_SAMPLES_SECS: u64 = 60;
|
||||||
|
let bpm_cap = (sr as u64 * BPM_MAX_SAMPLES_SECS) as usize;
|
||||||
|
let mut mono_for_bpm: Vec<f32> = Vec::with_capacity(bpm_cap.min(2_700_000));
|
||||||
|
let mut wf = WaveformAccum::new(sr);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let packet = match format.next_packet() {
|
let packet = match format.next_packet() {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(symphonia::core::errors::Error::IoError(_)) => break,
|
Err(symphonia::core::errors::Error::IoError(_)) => break,
|
||||||
Err(symphonia::core::errors::Error::ResetRequired) => break,
|
Err(symphonia::core::errors::Error::ResetRequired) => break,
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
if packet.track_id() != track_id {
|
if packet.track_id() != track_id { continue; }
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoded = decoder.decode(&packet)?;
|
let decoded = decoder.decode(&packet)?;
|
||||||
push_samples(&mut pcm, decoded, channels);
|
push_samples_and_peaks(
|
||||||
|
&mut pcm_batch,
|
||||||
|
&mut total_frames,
|
||||||
|
decoded,
|
||||||
|
channels,
|
||||||
|
&mut wf,
|
||||||
|
&mut mono_for_bpm,
|
||||||
|
bpm_cap,
|
||||||
|
deck,
|
||||||
|
load_gen,
|
||||||
|
sr,
|
||||||
|
status_tx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((pcm, sr))
|
wf.flush(&mut |_| {});
|
||||||
|
if !wf.bands_batch.is_empty() {
|
||||||
|
let _ = status_tx.send(AudioStatus::WaveformChunk {
|
||||||
|
deck,
|
||||||
|
bands: std::mem::take(&mut wf.bands_batch),
|
||||||
|
frame_offset: wf.bucket_start_frame,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !pcm_batch.is_empty() {
|
||||||
|
let _ = status_tx.send(AudioStatus::PcmChunk {
|
||||||
|
deck, load_gen,
|
||||||
|
frames: pcm_batch,
|
||||||
|
sample_rate: sr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = status_tx.send(AudioStatus::Ready {
|
||||||
|
deck,
|
||||||
|
load_gen,
|
||||||
|
duration_samples: total_frames,
|
||||||
|
sample_rate: sr,
|
||||||
|
});
|
||||||
|
|
||||||
|
// BPM detection on the mono accumulator. Runs synchronously in the decode
|
||||||
|
// thread, so it doesn't compete with the realtime callback. ~hundreds of
|
||||||
|
// ms for a 60 s clip; the Ready status above means the track is already
|
||||||
|
// playable before this fires.
|
||||||
|
if !mono_for_bpm.is_empty() {
|
||||||
|
let bpm = timestretch::detect_bpm(&mono_for_bpm, sr);
|
||||||
|
if bpm.is_finite() && bpm > 0.0 {
|
||||||
|
let _ = status_tx.send(AudioStatus::Bpm { deck, bpm: bpm as f32 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_samples(out: &mut Vec<f32>, buf: AudioBufferRef, channels: usize) {
|
// ─── Per-decode waveform accumulator ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Splits the mono mix into bass / mid / high via biquads, then takes the peak
|
||||||
|
// per band over PEAK_BUCKET frames. Each `bands_batch` entry is one bucket
|
||||||
|
// `[bass, mid, high]`. Sent off every 64 buckets (~6 s) as a `WaveformChunk`.
|
||||||
|
|
||||||
|
struct WaveformAccum {
|
||||||
|
bass_lp: biquad::DirectForm1<f32>,
|
||||||
|
mid_bp: biquad::DirectForm1<f32>,
|
||||||
|
high_hp: biquad::DirectForm1<f32>,
|
||||||
|
bucket_bass: f32,
|
||||||
|
bucket_mid: f32,
|
||||||
|
bucket_high: f32,
|
||||||
|
bucket_count: usize,
|
||||||
|
bands_batch: Vec<[f32; 3]>,
|
||||||
|
frame_offset: u64,
|
||||||
|
bucket_start_frame: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WaveformAccum {
|
||||||
|
fn new(sample_rate: u32) -> Self {
|
||||||
|
use biquad::{Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type};
|
||||||
|
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
|
||||||
|
let lp_co = Coefficients::<f32>::from_params(
|
||||||
|
Type::LowPass, fs, Hertz::<f32>::from_hz(250.0).unwrap(), Q_BUTTERWORTH_F32
|
||||||
|
).unwrap();
|
||||||
|
let bp_co = Coefficients::<f32>::from_params(
|
||||||
|
Type::BandPass, fs, Hertz::<f32>::from_hz(1500.0).unwrap(), 0.7
|
||||||
|
).unwrap();
|
||||||
|
let hp_co = Coefficients::<f32>::from_params(
|
||||||
|
Type::HighPass, fs, Hertz::<f32>::from_hz(4000.0).unwrap(), Q_BUTTERWORTH_F32
|
||||||
|
).unwrap();
|
||||||
|
Self {
|
||||||
|
bass_lp: DirectForm1::<f32>::new(lp_co),
|
||||||
|
mid_bp: DirectForm1::<f32>::new(bp_co),
|
||||||
|
high_hp: DirectForm1::<f32>::new(hp_co),
|
||||||
|
bucket_bass: 0.0,
|
||||||
|
bucket_mid: 0.0,
|
||||||
|
bucket_high: 0.0,
|
||||||
|
bucket_count: 0,
|
||||||
|
bands_batch: Vec::with_capacity(64),
|
||||||
|
frame_offset: 0,
|
||||||
|
bucket_start_frame: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn push_sample(&mut self, mono: f32) {
|
||||||
|
use biquad::Biquad as _;
|
||||||
|
let b = self.bass_lp.run(mono).abs();
|
||||||
|
let m = self.mid_bp .run(mono).abs();
|
||||||
|
let h = self.high_hp.run(mono).abs();
|
||||||
|
if b > self.bucket_bass { self.bucket_bass = b; }
|
||||||
|
if m > self.bucket_mid { self.bucket_mid = m; }
|
||||||
|
if h > self.bucket_high { self.bucket_high = h; }
|
||||||
|
self.bucket_count += 1;
|
||||||
|
self.frame_offset += 1;
|
||||||
|
|
||||||
|
if self.bucket_count >= PEAK_BUCKET {
|
||||||
|
// Compress: sqrt expands quiet detail.
|
||||||
|
self.bands_batch.push([
|
||||||
|
self.bucket_bass.sqrt(),
|
||||||
|
self.bucket_mid.sqrt(),
|
||||||
|
self.bucket_high.sqrt(),
|
||||||
|
]);
|
||||||
|
self.bucket_bass = 0.0;
|
||||||
|
self.bucket_mid = 0.0;
|
||||||
|
self.bucket_high = 0.0;
|
||||||
|
self.bucket_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush any in-progress bucket if non-empty.
|
||||||
|
fn flush(&mut self, _emit: &mut dyn FnMut(&[[f32; 3]])) {
|
||||||
|
if self.bucket_count > 0 {
|
||||||
|
self.bands_batch.push([
|
||||||
|
self.bucket_bass.sqrt(),
|
||||||
|
self.bucket_mid.sqrt(),
|
||||||
|
self.bucket_high.sqrt(),
|
||||||
|
]);
|
||||||
|
self.bucket_bass = 0.0;
|
||||||
|
self.bucket_mid = 0.0;
|
||||||
|
self.bucket_high = 0.0;
|
||||||
|
self.bucket_count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_tags(format: &mut Box<dyn symphonia::core::formats::FormatReader>) -> (Option<String>, Option<String>) {
|
||||||
|
let mut artist = None;
|
||||||
|
let mut album = None;
|
||||||
|
|
||||||
|
if let Some(meta) = format.metadata().current() {
|
||||||
|
for tag in meta.tags() {
|
||||||
|
match tag.std_key {
|
||||||
|
Some(StandardTagKey::Artist) => artist = Some(tag.value.to_string()),
|
||||||
|
Some(StandardTagKey::AlbumArtist) if artist.is_none()
|
||||||
|
=> artist = Some(tag.value.to_string()),
|
||||||
|
Some(StandardTagKey::Album) => album = Some(tag.value.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(artist, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn push_samples_and_peaks(
|
||||||
|
pcm_batch: &mut Vec<f32>,
|
||||||
|
total_frames: &mut u64,
|
||||||
|
buf: AudioBufferRef,
|
||||||
|
channels: usize,
|
||||||
|
wf: &mut WaveformAccum,
|
||||||
|
mono_for_bpm: &mut Vec<f32>,
|
||||||
|
bpm_cap: usize,
|
||||||
|
deck: DeckId,
|
||||||
|
load_gen: u64,
|
||||||
|
sample_rate: u32,
|
||||||
|
status_tx: &Sender<AudioStatus>,
|
||||||
|
) {
|
||||||
macro_rules! push {
|
macro_rules! push {
|
||||||
($buf:expr, $scale:expr) => {{
|
($buf:expr, $scale:expr) => {{
|
||||||
let frames = $buf.frames();
|
let frames = $buf.frames();
|
||||||
@@ -397,11 +647,35 @@ fn push_samples(out: &mut Vec<f32>, buf: AudioBufferRef, channels: usize) {
|
|||||||
let l = *$buf.chan(0).get(f).unwrap_or(&Default::default());
|
let l = *$buf.chan(0).get(f).unwrap_or(&Default::default());
|
||||||
let r = if channels >= 2 {
|
let r = if channels >= 2 {
|
||||||
*$buf.chan(1).get(f).unwrap_or(&Default::default())
|
*$buf.chan(1).get(f).unwrap_or(&Default::default())
|
||||||
} else {
|
} else { l };
|
||||||
l
|
|
||||||
};
|
let lf = l as f32 * $scale;
|
||||||
out.push(l as f32 * $scale);
|
let rf = r as f32 * $scale;
|
||||||
out.push(r as f32 * $scale);
|
pcm_batch.push(lf);
|
||||||
|
pcm_batch.push(rf);
|
||||||
|
*total_frames += 1;
|
||||||
|
|
||||||
|
let mono = (lf + rf) * 0.5;
|
||||||
|
wf.push_sample(mono);
|
||||||
|
|
||||||
|
if mono_for_bpm.len() < bpm_cap {
|
||||||
|
mono_for_bpm.push(mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
if wf.bands_batch.len() >= 64 {
|
||||||
|
let _ = status_tx.send(AudioStatus::WaveformChunk {
|
||||||
|
deck,
|
||||||
|
bands: std::mem::take(&mut wf.bands_batch),
|
||||||
|
frame_offset: wf.bucket_start_frame,
|
||||||
|
});
|
||||||
|
let _ = status_tx.send(AudioStatus::PcmChunk {
|
||||||
|
deck,
|
||||||
|
load_gen,
|
||||||
|
frames: std::mem::take(pcm_batch),
|
||||||
|
sample_rate,
|
||||||
|
});
|
||||||
|
wf.bucket_start_frame = wf.frame_offset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
@@ -410,13 +684,13 @@ fn push_samples(out: &mut Vec<f32>, buf: AudioBufferRef, channels: usize) {
|
|||||||
AudioBufferRef::F32(b) => push!(b, 1.0_f32),
|
AudioBufferRef::F32(b) => push!(b, 1.0_f32),
|
||||||
AudioBufferRef::F64(b) => push!(b, 1.0_f32),
|
AudioBufferRef::F64(b) => push!(b, 1.0_f32),
|
||||||
AudioBufferRef::S16(b) => push!(b, 1.0 / 32768.0_f32),
|
AudioBufferRef::S16(b) => push!(b, 1.0 / 32768.0_f32),
|
||||||
AudioBufferRef::S32(b) => push!(b, 1.0 / 2147483648.0_f32),
|
AudioBufferRef::S32(b) => push!(b, 1.0 / 2_147_483_648.0_f32),
|
||||||
AudioBufferRef::U8(b) => push!(b, 1.0 / 128.0_f32),
|
AudioBufferRef::U8(b) => push!(b, 1.0 / 128.0_f32),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PipeWire format pod helper ───────────────────────────────────────────────
|
// ─── PipeWire format pod ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn make_f32_stereo_pod() -> Result<Vec<u8>> {
|
fn make_f32_stereo_pod() -> Result<Vec<u8>> {
|
||||||
use pipewire::spa::param::ParamType;
|
use pipewire::spa::param::ParamType;
|
||||||
@@ -431,15 +705,13 @@ fn make_f32_stereo_pod() -> Result<Vec<u8>> {
|
|||||||
&Value::Object(object!(
|
&Value::Object(object!(
|
||||||
SpaTypes::ObjectParamFormat,
|
SpaTypes::ObjectParamFormat,
|
||||||
ParamType::EnumFormat,
|
ParamType::EnumFormat,
|
||||||
property!(FormatProperties::MediaType, Id, MediaType::Audio),
|
property!(FormatProperties::MediaType, Id, MediaType::Audio),
|
||||||
property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||||
property!(FormatProperties::AudioFormat, Id, AudioFormat::F32LE),
|
property!(FormatProperties::AudioFormat, Id, AudioFormat::F32LE),
|
||||||
property!(FormatProperties::AudioRate, Int, 44100i32),
|
property!(FormatProperties::AudioRate, Int, STREAM_SR as i32),
|
||||||
property!(FormatProperties::AudioChannels, Int, 2i32),
|
property!(FormatProperties::AudioChannels, Int, 2i32),
|
||||||
)),
|
)),
|
||||||
)?
|
)?.0.into_inner();
|
||||||
.0
|
|
||||||
.into_inner();
|
|
||||||
|
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-32
@@ -1,5 +1,13 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
// ─── Defaults ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const PITCH_DEFAULT: f32 = 0.0;
|
||||||
|
pub const TEMPO_DEFAULT: f32 = 1.0;
|
||||||
|
pub const CUTOFF_DEFAULT: f32 = 0.0;
|
||||||
|
pub const REVERB_DEFAULT: f32 = 0.0;
|
||||||
|
pub const FADER_DEFAULT: f32 = 0.8;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum DeckId {
|
pub enum DeckId {
|
||||||
A = 0,
|
A = 0,
|
||||||
@@ -63,53 +71,57 @@ pub struct Deck {
|
|||||||
pub id: DeckId,
|
pub id: DeckId,
|
||||||
|
|
||||||
// Track
|
// Track
|
||||||
pub track: Option<PathBuf>,
|
pub track: Option<PathBuf>,
|
||||||
pub track_name: Option<String>,
|
pub track_name: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
|
||||||
// Transport
|
// Transport
|
||||||
pub play_state: PlayState,
|
pub play_state: PlayState,
|
||||||
pub position: u64, // samples
|
pub position: u64,
|
||||||
pub duration: u64, // samples
|
pub duration: u64,
|
||||||
pub sample_rate: u32,
|
pub sample_rate: u32,
|
||||||
pub cue_point: Option<u64>,
|
pub cue_point: Option<u64>,
|
||||||
|
|
||||||
// Per-deck mix controls
|
// Mix / effect controls (mirror of the realtime DSP state in audio.rs)
|
||||||
/// 0.0 – 1.0, sent to wpctl on the deck's PipeWire stream node
|
pub fader: f32, // 0.0 .. 1.5
|
||||||
pub fader: f32,
|
pub pitch: f32, // semitones, -12 .. +12
|
||||||
|
pub tempo: f32, // 0.5 .. 2.0
|
||||||
|
pub cutoff: f32, // < 0 lowpass Hz, > 0 highpass Hz, 0 flat
|
||||||
|
pub reverb: f32, // 0.0 .. 1.0
|
||||||
|
|
||||||
// Effect chain parameters (sent to pw-cli / filter-chain node)
|
// VU (updated by audio engine at ~60 Hz)
|
||||||
/// 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),
|
pub vu: (f32, f32),
|
||||||
|
|
||||||
// PipeWire object IDs (populated after graph setup)
|
// Per-bucket band peaks for the waveform: [bass, mid, high], one entry per
|
||||||
/// Object ID of the deck's playback stream node
|
// ~0.1 s of audio (PEAK_BUCKET frames). Sent in WaveformChunks during decode.
|
||||||
pub stream_node_id: Option<u32>,
|
pub waveform_bands: Vec<[f32; 3]>,
|
||||||
/// Object ID of the filter-chain node for this deck
|
|
||||||
pub filter_node_id: Option<u32>,
|
/// BPM estimate from the decode-time analyzer; `None` until detected.
|
||||||
|
pub bpm: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deck {
|
impl Deck {
|
||||||
pub fn new(id: DeckId) -> Self {
|
pub fn new(id: DeckId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
track: None,
|
track: None,
|
||||||
track_name: None,
|
track_name: None,
|
||||||
play_state: PlayState::Stopped,
|
artist: None,
|
||||||
position: 0,
|
album: None,
|
||||||
duration: 0,
|
play_state: PlayState::Stopped,
|
||||||
|
position: 0,
|
||||||
|
duration: 0,
|
||||||
sample_rate: 44100,
|
sample_rate: 44100,
|
||||||
cue_point: None,
|
cue_point: None,
|
||||||
fader: 0.8,
|
fader: FADER_DEFAULT,
|
||||||
pitch: 0.0,
|
pitch: PITCH_DEFAULT,
|
||||||
tempo: 1.0,
|
tempo: TEMPO_DEFAULT,
|
||||||
vu: (0.0, 0.0),
|
cutoff: CUTOFF_DEFAULT,
|
||||||
stream_node_id: None,
|
reverb: REVERB_DEFAULT,
|
||||||
filter_node_id: None,
|
vu: (0.0, 0.0),
|
||||||
|
waveform_bands: Vec::new(),
|
||||||
|
bpm: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +129,10 @@ impl Deck {
|
|||||||
self.track.is_some()
|
self.track.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn peaks_empty(&self) -> bool {
|
||||||
|
self.waveform_bands.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn progress(&self) -> f32 {
|
pub fn progress(&self) -> f32 {
|
||||||
if self.duration == 0 { 0.0 } else {
|
if self.duration == 0 { 0.0 } else {
|
||||||
(self.position as f32 / self.duration as f32).clamp(0.0, 1.0)
|
(self.position as f32 / self.duration as f32).clamp(0.0, 1.0)
|
||||||
|
|||||||
+489
@@ -0,0 +1,489 @@
|
|||||||
|
//! In-process DSP chain per deck.
|
||||||
|
//!
|
||||||
|
//! Order:
|
||||||
|
//! raw PCM at native sample rate
|
||||||
|
//! → Signalsmith Stretch (tempo + pitch, independent, realtime)
|
||||||
|
//! → biquad lowpass cascade L/R (cutoff < 0)
|
||||||
|
//! → biquad highpass cascade L/R (cutoff > 0)
|
||||||
|
//! → Schroeder stereo reverb (dry ↔ wet)
|
||||||
|
//! → fader (linear gain)
|
||||||
|
//! → interleaved stereo to PipeWire stream buffer
|
||||||
|
//!
|
||||||
|
//! Params (`DeckParams`) live behind lock-free `AtomicF32`s. The app thread
|
||||||
|
//! writes them; the realtime callback snapshots them at the top of every
|
||||||
|
//! block. No `Mutex`, no `pw-cli`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use atomic_float::AtomicF32;
|
||||||
|
use biquad::{
|
||||||
|
Biquad as _, Coefficients, DirectForm1, Hertz, Q_BUTTERWORTH_F32, Type,
|
||||||
|
};
|
||||||
|
use ssstretch::Stretch;
|
||||||
|
|
||||||
|
// ─── Limits ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const PITCH_MIN: f32 = -12.0;
|
||||||
|
pub const PITCH_MAX: f32 = 12.0;
|
||||||
|
pub const TEMPO_MIN: f32 = 0.5;
|
||||||
|
pub const TEMPO_MAX: f32 = 2.0;
|
||||||
|
pub const CUTOFF_RANGE: f32 = 20_000.0; // ±20 kHz
|
||||||
|
pub const FADER_MAX: f32 = 1.5;
|
||||||
|
|
||||||
|
const LP_OPEN_HZ: f32 = 20_000.0;
|
||||||
|
const HP_OPEN_HZ: f32 = 20.0;
|
||||||
|
|
||||||
|
// ─── Lock-free parameter state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub struct DeckParams {
|
||||||
|
pub tempo: AtomicF32, // 0.5..2.0 (output_frames = input_frames / tempo)
|
||||||
|
pub pitch: AtomicF32, // semitones, -12..+12
|
||||||
|
/// < 0 → lowpass at |value| Hz; > 0 → highpass at value Hz; 0 → bypass.
|
||||||
|
pub cutoff: AtomicF32,
|
||||||
|
pub reverb_wet: AtomicF32, // 0..1
|
||||||
|
pub fader: AtomicF32, // 0..1.5
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeckParams {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
tempo: AtomicF32::new(1.0),
|
||||||
|
pitch: AtomicF32::new(0.0),
|
||||||
|
cutoff: AtomicF32::new(0.0),
|
||||||
|
reverb_wet: AtomicF32::new(0.0),
|
||||||
|
fader: AtomicF32::new(0.8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DSP graph ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Cached {
|
||||||
|
tempo: f32,
|
||||||
|
pitch: f32,
|
||||||
|
cutoff: f32,
|
||||||
|
reverb_wet: f32,
|
||||||
|
fader: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Stretch` wraps a `cxx::UniquePtr` to a C++ object which is `!Send` by
|
||||||
|
/// default. The Signalsmith Stretch instance is safe to move between threads
|
||||||
|
/// as long as access is externally synchronised, which `DeckState`'s
|
||||||
|
/// `Mutex` guarantees.
|
||||||
|
struct SendStretch(Stretch);
|
||||||
|
unsafe impl Send for SendStretch {}
|
||||||
|
|
||||||
|
impl std::ops::Deref for SendStretch {
|
||||||
|
type Target = Stretch;
|
||||||
|
fn deref(&self) -> &Stretch { &self.0 }
|
||||||
|
}
|
||||||
|
impl std::ops::DerefMut for SendStretch {
|
||||||
|
fn deref_mut(&mut self) -> &mut Stretch { &mut self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeckDsp {
|
||||||
|
sample_rate: u32,
|
||||||
|
stretcher: SendStretch,
|
||||||
|
lp_l: DirectForm1<f32>,
|
||||||
|
lp_r: DirectForm1<f32>,
|
||||||
|
hp_l: DirectForm1<f32>,
|
||||||
|
hp_r: DirectForm1<f32>,
|
||||||
|
reverb: SchroederStereo,
|
||||||
|
cached: Cached,
|
||||||
|
// Scratch — sized lazily, kept across calls.
|
||||||
|
in_l: Vec<f32>,
|
||||||
|
in_r: Vec<f32>,
|
||||||
|
out_l: Vec<f32>,
|
||||||
|
out_r: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeckDsp {
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
let mut stretcher = Stretch::new();
|
||||||
|
stretcher.preset_default(2, sample_rate as f32);
|
||||||
|
stretcher.set_transpose_semitones(0.0, None);
|
||||||
|
|
||||||
|
let lp = make_lp(sample_rate, LP_OPEN_HZ);
|
||||||
|
let hp = make_hp(sample_rate, HP_OPEN_HZ);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
sample_rate,
|
||||||
|
stretcher: SendStretch(stretcher),
|
||||||
|
lp_l: DirectForm1::<f32>::new(lp),
|
||||||
|
lp_r: DirectForm1::<f32>::new(lp),
|
||||||
|
hp_l: DirectForm1::<f32>::new(hp),
|
||||||
|
hp_r: DirectForm1::<f32>::new(hp),
|
||||||
|
reverb: SchroederStereo::new(sample_rate),
|
||||||
|
cached: Cached {
|
||||||
|
tempo: 1.0, pitch: 0.0, cutoff: 0.0,
|
||||||
|
reverb_wet: 0.0, fader: 0.8,
|
||||||
|
},
|
||||||
|
in_l: Vec::new(),
|
||||||
|
in_r: Vec::new(),
|
||||||
|
out_l: Vec::new(),
|
||||||
|
out_r: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process one block. Reads up to `pcm` starting at `start_frame`,
|
||||||
|
/// writes `out_frames = out.len() / 2` interleaved stereo frames to `out`,
|
||||||
|
/// returns the number of *input* frames consumed (so the caller can
|
||||||
|
/// advance its read cursor).
|
||||||
|
///
|
||||||
|
/// If the source runs out of audio mid-block, the tail is filled with
|
||||||
|
/// silence and a partial input count is returned.
|
||||||
|
pub fn process(
|
||||||
|
&mut self,
|
||||||
|
params: &DeckParams,
|
||||||
|
pcm: &[f32], // interleaved stereo, native SR
|
||||||
|
start_frame: usize, // frame index into pcm
|
||||||
|
out: &mut [f32], // interleaved stereo, len = out_frames * 2
|
||||||
|
) -> usize /* input frames consumed */ {
|
||||||
|
let out_frames = out.len() / 2;
|
||||||
|
if out_frames == 0 { return 0; }
|
||||||
|
|
||||||
|
// 1. Snapshot params and react to changes.
|
||||||
|
let p = Cached {
|
||||||
|
tempo: params.tempo.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
.clamp(TEMPO_MIN, TEMPO_MAX),
|
||||||
|
pitch: params.pitch.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
.clamp(PITCH_MIN, PITCH_MAX),
|
||||||
|
cutoff: params.cutoff.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
.clamp(-CUTOFF_RANGE, CUTOFF_RANGE),
|
||||||
|
reverb_wet: params.reverb_wet.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
.clamp(0.0, 1.0),
|
||||||
|
fader: params.fader.load(std::sync::atomic::Ordering::Relaxed)
|
||||||
|
.clamp(0.0, FADER_MAX),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (p.pitch - self.cached.pitch).abs() > f32::EPSILON {
|
||||||
|
self.stretcher.set_transpose_semitones(p.pitch, None);
|
||||||
|
}
|
||||||
|
if (p.cutoff - self.cached.cutoff).abs() > 0.5 {
|
||||||
|
let (lp_hz, hp_hz) = cutoff_to_freqs(p.cutoff);
|
||||||
|
self.lp_l.update_coefficients(make_lp(self.sample_rate, lp_hz));
|
||||||
|
self.lp_r.update_coefficients(make_lp(self.sample_rate, lp_hz));
|
||||||
|
self.hp_l.update_coefficients(make_hp(self.sample_rate, hp_hz));
|
||||||
|
self.hp_r.update_coefficients(make_hp(self.sample_rate, hp_hz));
|
||||||
|
}
|
||||||
|
self.cached = p;
|
||||||
|
|
||||||
|
// 2. Pull enough input for `out_frames` output, given tempo.
|
||||||
|
// Signalsmith Stretch maps `input_samples → output_samples`
|
||||||
|
// where time-stretch ratio = input_samples / output_samples.
|
||||||
|
// tempo = playback speed multiplier, so input = out_frames * tempo.
|
||||||
|
let want_in = ((out_frames as f32 * p.tempo).round() as usize).max(1);
|
||||||
|
let total = pcm.len() / 2;
|
||||||
|
let available = total.saturating_sub(start_frame);
|
||||||
|
let take_in = want_in.min(available);
|
||||||
|
|
||||||
|
self.in_l.resize(want_in, 0.0);
|
||||||
|
self.in_r.resize(want_in, 0.0);
|
||||||
|
for i in 0..take_in {
|
||||||
|
let f = start_frame + i;
|
||||||
|
self.in_l[i] = pcm[f * 2];
|
||||||
|
self.in_r[i] = pcm[f * 2 + 1];
|
||||||
|
}
|
||||||
|
// Zero-fill any short tail so the stretcher still receives `want_in`.
|
||||||
|
for i in take_in..want_in {
|
||||||
|
self.in_l[i] = 0.0;
|
||||||
|
self.in_r[i] = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.out_l.resize(out_frames, 0.0);
|
||||||
|
self.out_r.resize(out_frames, 0.0);
|
||||||
|
|
||||||
|
// 3. Stretch.
|
||||||
|
let in_ptrs: [*const f32; 2] = [self.in_l.as_ptr(), self.in_r.as_ptr()];
|
||||||
|
let mut out_ptrs: [*mut f32; 2] = [self.out_l.as_mut_ptr(), self.out_r.as_mut_ptr()];
|
||||||
|
unsafe {
|
||||||
|
self.stretcher.process(
|
||||||
|
&in_ptrs, want_in as i32,
|
||||||
|
&mut out_ptrs, out_frames as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Biquads, reverb, fader → interleave into `out`.
|
||||||
|
for i in 0..out_frames {
|
||||||
|
let mut l = self.out_l[i];
|
||||||
|
let mut r = self.out_r[i];
|
||||||
|
|
||||||
|
l = self.lp_l.run(l);
|
||||||
|
r = self.lp_r.run(r);
|
||||||
|
l = self.hp_l.run(l);
|
||||||
|
r = self.hp_r.run(r);
|
||||||
|
|
||||||
|
let (rl, rr) = self.reverb.process(l, r);
|
||||||
|
l = l * (1.0 - p.reverb_wet) + rl * p.reverb_wet;
|
||||||
|
r = r * (1.0 - p.reverb_wet) + rr * p.reverb_wet;
|
||||||
|
|
||||||
|
l *= p.fader;
|
||||||
|
r *= p.fader;
|
||||||
|
|
||||||
|
out[i * 2] = l;
|
||||||
|
out[i * 2 + 1] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
take_in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cutoff → LP/HP frequency mapping ──────────────────────────────────────
|
||||||
|
|
||||||
|
fn cutoff_to_freqs(value: f32) -> (f32, f32) {
|
||||||
|
if value < 0.0 {
|
||||||
|
((-value).clamp(HP_OPEN_HZ, LP_OPEN_HZ), HP_OPEN_HZ)
|
||||||
|
} else if value > 0.0 {
|
||||||
|
(LP_OPEN_HZ, value.clamp(HP_OPEN_HZ, LP_OPEN_HZ))
|
||||||
|
} else {
|
||||||
|
(LP_OPEN_HZ, HP_OPEN_HZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_lp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
|
||||||
|
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
|
||||||
|
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
|
||||||
|
Coefficients::<f32>::from_params(Type::LowPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_hp(sample_rate: u32, cutoff_hz: f32) -> Coefficients<f32> {
|
||||||
|
let fs = Hertz::<f32>::from_hz(sample_rate as f32).unwrap();
|
||||||
|
let f0 = Hertz::<f32>::from_hz(cutoff_hz.clamp(HP_OPEN_HZ, LP_OPEN_HZ)).unwrap();
|
||||||
|
Coefficients::<f32>::from_params(Type::HighPass, fs, f0, Q_BUTTERWORTH_F32).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stereo Schroeder reverb ───────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Four parallel comb filters per channel + two series allpass filters per
|
||||||
|
// channel, with light L↔R cross-feedback for stereo width. Magic numbers are
|
||||||
|
// the classic Freeverb constants, scaled to actual sample rate.
|
||||||
|
|
||||||
|
const COMB_DELAYS_44K: [usize; 4] = [1116, 1188, 1277, 1356];
|
||||||
|
const ALLP_DELAYS_44K: [usize; 2] = [225, 556];
|
||||||
|
const COMB_FEEDBACK: f32 = 0.84;
|
||||||
|
const COMB_DAMPING: f32 = 0.2;
|
||||||
|
const ALLP_FEEDBACK: f32 = 0.5;
|
||||||
|
const STEREO_SPREAD: usize = 23;
|
||||||
|
|
||||||
|
struct Comb {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
last: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Comb {
|
||||||
|
fn new(delay: usize) -> Self { Self { buf: vec![0.0; delay], idx: 0, last: 0.0 } }
|
||||||
|
fn run(&mut self, x: f32) -> f32 {
|
||||||
|
let y = self.buf[self.idx];
|
||||||
|
// One-pole lowpass in feedback path = damping.
|
||||||
|
self.last = y * (1.0 - COMB_DAMPING) + self.last * COMB_DAMPING;
|
||||||
|
self.buf[self.idx] = x + self.last * COMB_FEEDBACK;
|
||||||
|
self.idx = (self.idx + 1) % self.buf.len();
|
||||||
|
y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AllPass {
|
||||||
|
buf: Vec<f32>,
|
||||||
|
idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AllPass {
|
||||||
|
fn new(delay: usize) -> Self { Self { buf: vec![0.0; delay], idx: 0 } }
|
||||||
|
fn run(&mut self, x: f32) -> f32 {
|
||||||
|
let y = self.buf[self.idx];
|
||||||
|
self.buf[self.idx] = x + y * ALLP_FEEDBACK;
|
||||||
|
self.idx = (self.idx + 1) % self.buf.len();
|
||||||
|
y - x // standard Schroeder allpass output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SchroederStereo {
|
||||||
|
comb_l: [Comb; 4],
|
||||||
|
comb_r: [Comb; 4],
|
||||||
|
allp_l: [AllPass; 2],
|
||||||
|
allp_r: [AllPass; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SchroederStereo {
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
let scale = sample_rate as f32 / 44_100.0;
|
||||||
|
let s = |d: usize| ((d as f32 * scale) as usize).max(1);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
comb_l: [
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[0])),
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[1])),
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[2])),
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[3])),
|
||||||
|
],
|
||||||
|
comb_r: [
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[0] + STEREO_SPREAD)),
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[1] + STEREO_SPREAD)),
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[2] + STEREO_SPREAD)),
|
||||||
|
Comb::new(s(COMB_DELAYS_44K[3] + STEREO_SPREAD)),
|
||||||
|
],
|
||||||
|
allp_l: [
|
||||||
|
AllPass::new(s(ALLP_DELAYS_44K[0])),
|
||||||
|
AllPass::new(s(ALLP_DELAYS_44K[1])),
|
||||||
|
],
|
||||||
|
allp_r: [
|
||||||
|
AllPass::new(s(ALLP_DELAYS_44K[0] + STEREO_SPREAD)),
|
||||||
|
AllPass::new(s(ALLP_DELAYS_44K[1] + STEREO_SPREAD)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process(&mut self, in_l: f32, in_r: f32) -> (f32, f32) {
|
||||||
|
// Cross-feed a tiny amount of each channel into the other's combs
|
||||||
|
// for an enveloping stereo image.
|
||||||
|
let xl = in_l + 0.15 * in_r;
|
||||||
|
let xr = in_r + 0.15 * in_l;
|
||||||
|
|
||||||
|
let mut l = 0.0;
|
||||||
|
let mut r = 0.0;
|
||||||
|
for c in &mut self.comb_l { l += c.run(xl); }
|
||||||
|
for c in &mut self.comb_r { r += c.run(xr); }
|
||||||
|
l *= 0.25;
|
||||||
|
r *= 0.25;
|
||||||
|
|
||||||
|
for a in &mut self.allp_l { l = a.run(l); }
|
||||||
|
for a in &mut self.allp_r { r = a.run(r); }
|
||||||
|
|
||||||
|
(l, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
const SR: u32 = 44_100;
|
||||||
|
|
||||||
|
fn sine(freq: f32, n_frames: usize) -> Vec<f32> {
|
||||||
|
let mut pcm = vec![0.0f32; n_frames * 2];
|
||||||
|
for i in 0..n_frames {
|
||||||
|
let s = (2.0 * PI * freq * i as f32 / SR as f32).sin() * 0.5;
|
||||||
|
pcm[i * 2] = s;
|
||||||
|
pcm[i * 2 + 1] = s;
|
||||||
|
}
|
||||||
|
pcm
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms(samples: &[f32]) -> f32 {
|
||||||
|
let n = samples.len() as f32;
|
||||||
|
(samples.iter().map(|x| x * x).sum::<f32>() / n).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowpass at 1 kHz attenuates a 5 kHz sine more than -10 dB.
|
||||||
|
#[test]
|
||||||
|
fn lowpass_cuts_highs() {
|
||||||
|
let params = DeckParams::new();
|
||||||
|
params.cutoff.store(-1000.0, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut dsp = DeckDsp::new(SR);
|
||||||
|
|
||||||
|
let pcm = sine(5000.0, SR as usize); // 1 s of 5 kHz
|
||||||
|
let mut out = vec![0.0f32; SR as usize * 2];
|
||||||
|
// Run a few blocks to settle the biquad and the stretcher buffer.
|
||||||
|
let block = 1024;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for chunk_start in (0..SR as usize).step_by(block) {
|
||||||
|
let end = (chunk_start + block).min(SR as usize);
|
||||||
|
let block_frames = end - chunk_start;
|
||||||
|
let mut block_out = vec![0.0f32; block_frames * 2];
|
||||||
|
let consumed = dsp.process(¶ms, &pcm, cursor, &mut block_out);
|
||||||
|
cursor += consumed;
|
||||||
|
out[chunk_start * 2..end * 2].copy_from_slice(&block_out);
|
||||||
|
}
|
||||||
|
// Skip first 0.2 s for biquad/stretcher transient.
|
||||||
|
let settled = &out[(SR as usize / 5) * 2..];
|
||||||
|
let out_rms = rms(settled);
|
||||||
|
let in_rms = rms(&pcm[(SR as usize / 5) * 2..]);
|
||||||
|
let ratio_db = 20.0 * (out_rms / in_rms).log10();
|
||||||
|
assert!(ratio_db < -10.0, "5 kHz through 1 kHz LP only attenuated {:.1} dB", ratio_db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highpass at 1 kHz attenuates a 100 Hz sine more than -10 dB.
|
||||||
|
#[test]
|
||||||
|
fn highpass_cuts_lows() {
|
||||||
|
let params = DeckParams::new();
|
||||||
|
params.cutoff.store(1000.0, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut dsp = DeckDsp::new(SR);
|
||||||
|
|
||||||
|
let pcm = sine(100.0, SR as usize);
|
||||||
|
let mut out = vec![0.0f32; SR as usize * 2];
|
||||||
|
let block = 1024;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for chunk_start in (0..SR as usize).step_by(block) {
|
||||||
|
let end = (chunk_start + block).min(SR as usize);
|
||||||
|
let block_frames = end - chunk_start;
|
||||||
|
let mut block_out = vec![0.0f32; block_frames * 2];
|
||||||
|
let consumed = dsp.process(¶ms, &pcm, cursor, &mut block_out);
|
||||||
|
cursor += consumed;
|
||||||
|
out[chunk_start * 2..end * 2].copy_from_slice(&block_out);
|
||||||
|
}
|
||||||
|
let settled = &out[(SR as usize / 5) * 2..];
|
||||||
|
let out_rms = rms(settled);
|
||||||
|
let in_rms = rms(&pcm[(SR as usize / 5) * 2..]);
|
||||||
|
let ratio_db = 20.0 * (out_rms / in_rms).log10();
|
||||||
|
assert!(ratio_db < -10.0, "100 Hz through 1 kHz HP only attenuated {:.1} dB", ratio_db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At tempo = 2.0, each output block consumes ~2× the input frames it would
|
||||||
|
/// at tempo = 1.0.
|
||||||
|
#[test]
|
||||||
|
fn tempo_doubles_input_consumption() {
|
||||||
|
let pcm = sine(440.0, SR as usize * 2);
|
||||||
|
let mut out = vec![0.0f32; 4096 * 2];
|
||||||
|
|
||||||
|
let p_normal = DeckParams::new();
|
||||||
|
let mut dsp_normal = DeckDsp::new(SR);
|
||||||
|
let in_normal = dsp_normal.process(&p_normal, &pcm, 0, &mut out);
|
||||||
|
|
||||||
|
let p_fast = DeckParams::new();
|
||||||
|
p_fast.tempo.store(2.0, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut dsp_fast = DeckDsp::new(SR);
|
||||||
|
let in_fast = dsp_fast.process(&p_fast, &pcm, 0, &mut out);
|
||||||
|
|
||||||
|
let ratio = in_fast as f32 / in_normal as f32;
|
||||||
|
assert!((ratio - 2.0).abs() < 0.05,
|
||||||
|
"tempo=2.0 consumed {} frames vs tempo=1.0 {} frames (ratio {:.3}, want 2.0)",
|
||||||
|
in_fast, in_normal, ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverb produces non-zero tail after the dry signal stops.
|
||||||
|
#[test]
|
||||||
|
fn reverb_has_tail() {
|
||||||
|
let params = DeckParams::new();
|
||||||
|
params.reverb_wet.store(1.0, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut dsp = DeckDsp::new(SR);
|
||||||
|
|
||||||
|
// 0.1 s of audio followed by 0.4 s of silent input — but we'll
|
||||||
|
// process the silent part and look for non-zero output.
|
||||||
|
let mut pcm = sine(440.0, SR as usize / 10);
|
||||||
|
pcm.extend(vec![0.0f32; (SR as usize / 10 * 4) * 2]);
|
||||||
|
|
||||||
|
let mut out = vec![0.0f32; pcm.len()];
|
||||||
|
let block = 1024;
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let total_frames = pcm.len() / 2;
|
||||||
|
for chunk_start in (0..total_frames).step_by(block) {
|
||||||
|
let end = (chunk_start + block).min(total_frames);
|
||||||
|
let block_frames = end - chunk_start;
|
||||||
|
let mut block_out = vec![0.0f32; block_frames * 2];
|
||||||
|
let consumed = dsp.process(¶ms, &pcm, cursor, &mut block_out);
|
||||||
|
cursor += consumed;
|
||||||
|
out[chunk_start * 2..end * 2].copy_from_slice(&block_out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last 0.1 s should still have energy from the reverb tail.
|
||||||
|
let tail_start = (total_frames - SR as usize / 10) * 2;
|
||||||
|
let tail_rms = rms(&out[tail_start..]);
|
||||||
|
assert!(tail_rms > 1e-4, "reverb tail RMS too small: {}", tail_rms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
/// 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::info!(
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -81,7 +81,7 @@ impl FileSelector {
|
|||||||
let dir = p.parent().and_then(|d| d.to_str()).unwrap_or("");
|
let dir = p.parent().and_then(|d| d.to_str()).unwrap_or("");
|
||||||
ListItem::new(Line::from(vec![
|
ListItem::new(Line::from(vec![
|
||||||
Span::styled(name, Style::default().fg(Color::White)),
|
Span::styled(name, Style::default().fg(Color::White)),
|
||||||
Span::styled(format!(" {}", dir), Style::default().fg(Color::DarkGray)),
|
Span::styled(format!(" {}", dir), Style::default().fg(Color::Gray)),
|
||||||
]))
|
]))
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,7 @@ mod app;
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod deck;
|
mod deck;
|
||||||
mod effect_chain;
|
mod dsp;
|
||||||
mod file_selector;
|
mod file_selector;
|
||||||
mod osc;
|
mod osc;
|
||||||
mod tui;
|
mod tui;
|
||||||
@@ -19,9 +19,11 @@ async fn main() -> Result<()> {
|
|||||||
tracing_subscriber::EnvFilter::from_default_env()
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
.add_directive(tracing::Level::INFO.into()),
|
.add_directive(tracing::Level::INFO.into()),
|
||||||
)
|
)
|
||||||
.with_writer(log_file)
|
.with_writer(std::sync::Mutex::new(log_file))
|
||||||
|
.with_ansi(false)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
eprintln!("djdeck: logs → /tmp/djdeck.log");
|
||||||
tracing::info!("djdeck starting");
|
tracing::info!("djdeck starting");
|
||||||
|
|
||||||
// Parse command line argument for audio directory
|
// Parse command line argument for audio directory
|
||||||
|
|||||||
+12
-6
@@ -9,6 +9,8 @@
|
|||||||
/// fader f (0.0–1.0) → wpctl set-volume on stream node
|
/// fader f (0.0–1.0) → wpctl set-volume on stream node
|
||||||
/// pitch f (semitones) → pw-cli Props on filter node
|
/// pitch f (semitones) → pw-cli Props on filter node
|
||||||
/// tempo f (multiplier)→ pw-cli Props on filter node
|
/// tempo f (multiplier)→ pw-cli Props on filter node
|
||||||
|
/// cutoff f (Hz, 100–20000) → pw-cli Props lpL/lpR on filter node
|
||||||
|
/// reverb f (0.0–1.0) → pw-cli Props reverb:Wet level
|
||||||
/// cue/set, cue/goto
|
/// cue/set, cue/goto
|
||||||
///
|
///
|
||||||
/// Outgoing:
|
/// Outgoing:
|
||||||
@@ -33,9 +35,11 @@ pub const SEND_PORT: u16 = 9001;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum OscAppCmd {
|
pub enum OscAppCmd {
|
||||||
Audio(AudioCmd),
|
Audio(AudioCmd),
|
||||||
SetPitch { deck: DeckId, semitones: f32 },
|
SetPitch { deck: DeckId, semitones: f32 },
|
||||||
SetTempo { deck: DeckId, multiplier: f32 },
|
SetTempo { deck: DeckId, multiplier: f32 },
|
||||||
SetFader { deck: DeckId, value: f32 },
|
SetFader { deck: DeckId, value: f32 },
|
||||||
|
SetCutoff { deck: DeckId, hz: f32 },
|
||||||
|
SetReverb { deck: DeckId, wet: f32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_osc_server(cmd_tx: Sender<OscAppCmd>) -> Result<()> {
|
pub fn start_osc_server(cmd_tx: Sender<OscAppCmd>) -> Result<()> {
|
||||||
@@ -79,9 +83,11 @@ fn handle_msg(msg: OscMessage, tx: &Sender<OscAppCmd>) {
|
|||||||
("pause", None) => Some(OscAppCmd::Audio(AudioCmd::Pause(deck))),
|
("pause", None) => Some(OscAppCmd::Audio(AudioCmd::Pause(deck))),
|
||||||
("stop", None) => Some(OscAppCmd::Audio(AudioCmd::Stop(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 })),
|
("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 }),
|
("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 }),
|
("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 }),
|
("tempo", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetTempo { deck, multiplier: v }),
|
||||||
|
("cutoff", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetCutoff { deck, hz: v }),
|
||||||
|
("reverb", None) => f32_arg(&msg.args, 0).map(|v| OscAppCmd::SetReverb { deck, wet: v }),
|
||||||
("cue", Some("set")) => Some(OscAppCmd::Audio(AudioCmd::SetCue(deck))),
|
("cue", Some("set")) => Some(OscAppCmd::Audio(AudioCmd::SetCue(deck))),
|
||||||
("cue", Some("goto")) => Some(OscAppCmd::Audio(AudioCmd::GotoCue(deck))),
|
("cue", Some("goto")) => Some(OscAppCmd::Audio(AudioCmd::GotoCue(deck))),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
+390
-93
@@ -20,9 +20,9 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1), // header
|
||||||
Constraint::Min(1),
|
Constraint::Min(1), // 2×2 deck grid
|
||||||
Constraint::Length(4),
|
Constraint::Length(3), // help
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
@@ -31,31 +31,33 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) {
|
|||||||
Span::styled(" ♫ djdeck ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
Span::styled(" ♫ djdeck ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT),
|
format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::Gray),
|
||||||
),
|
),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
"PipeWire graph: each deck → rubberband filter-chain → sink",
|
"in-proc DSP: signalsmith-stretch(pitch+tempo) → biquad(cutoff) → schroeder(reverb)",
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::Gray),
|
||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
f.render_widget(header, chunks[0]);
|
f.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
// 2×2 deck grid
|
// 2×2 deck grid. Keyboard layout: A S / Z X
|
||||||
|
// Index mapping: top-left=A(0), top-right=S(2), bottom-left=Z(1), bottom-right=X(3)
|
||||||
|
let render_order = [[0usize, 2usize], [1usize, 3usize]];
|
||||||
|
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
.split(chunks[1]);
|
.split(chunks[1]);
|
||||||
|
|
||||||
for row in 0..2usize {
|
for (row_i, row_idxs) in render_order.iter().enumerate() {
|
||||||
let cols = Layout::default()
|
let cols = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
.split(rows[row]);
|
.split(rows[row_i]);
|
||||||
|
|
||||||
for col in 0..2usize {
|
for (col_i, &deck_idx) in row_idxs.iter().enumerate() {
|
||||||
let idx = row * 2 + col;
|
if deck_idx < app.decks.len() {
|
||||||
if idx < app.decks.len() {
|
draw_deck(f, &app.decks[deck_idx], cols[col_i], app.focused_deck == deck_idx);
|
||||||
draw_deck(f, &app.decks[idx], cols[col], app.focused_deck == idx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,65 +83,90 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
|
|||||||
Style::default().fg(Color::DarkGray)
|
Style::default().fg(Color::DarkGray)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Truncate track name to fit
|
// Title: deck label + play state + truncated track name + node IDs
|
||||||
let max_name = area.width.saturating_sub(18) as usize;
|
let max_name = area.width.saturating_sub(22) as usize;
|
||||||
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
|
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
|
||||||
let track_display = if track_display.len() > max_name {
|
let track_display = if track_display.len() > max_name {
|
||||||
format!("{}…", &track_display[..max_name.saturating_sub(1)])
|
format!("{}…", &track_display[..max_name.saturating_sub(1)])
|
||||||
} else {
|
} else {
|
||||||
track_display.to_string()
|
track_display.to_string()
|
||||||
};
|
};
|
||||||
|
let bpm = deck.bpm.map(|b| format!(" {:.1} BPM ", b)).unwrap_or_default();
|
||||||
// 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!(
|
let title = format!(
|
||||||
" Deck {} {} {} {} ",
|
" Deck {} {} {}{}",
|
||||||
deck.id.label(),
|
deck.id.label(), deck.play_state.icon(), track_display, bpm
|
||||||
deck.play_state.icon(),
|
|
||||||
track_display,
|
|
||||||
graph_status,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let outer = Block::default()
|
let outer = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(title)
|
.title(title)
|
||||||
.border_style(border_style)
|
.border_style(border_style);
|
||||||
.style(Style::default());
|
|
||||||
f.render_widget(outer, area);
|
f.render_widget(outer, area);
|
||||||
|
|
||||||
let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 });
|
let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 });
|
||||||
|
if inner.height < 4 { return; }
|
||||||
|
|
||||||
|
// Layout inside the deck:
|
||||||
|
//
|
||||||
|
// [V][L][R] timecode + progress bar
|
||||||
|
// [V][L][R] info / effects
|
||||||
|
// [V][L][R] waveform (grows to fill space, min 4 rows)
|
||||||
|
// [V][L][R] timeline (1 row, at the very bottom)
|
||||||
|
//
|
||||||
|
// V = volume fader bar (3 chars: bar + space)
|
||||||
|
// L = left VU bar (1 char)
|
||||||
|
// R = right VU bar (1 char)
|
||||||
|
// Total left strip: 3 chars wide
|
||||||
|
|
||||||
|
let vu_cols = 3u16; // V·LR
|
||||||
|
let (left_area, right_area) = if inner.width > vu_cols + 4 {
|
||||||
|
let cols = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Length(vu_cols), Constraint::Min(1)])
|
||||||
|
.split(inner);
|
||||||
|
(Some(cols[0]), cols[1])
|
||||||
|
} else {
|
||||||
|
(None, inner)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Right side: timecode(1) + progress(1) + info(2+) + waveform(4+) + timeline(1)
|
||||||
|
let fixed = 3u16; // timecode + progress + timeline
|
||||||
|
let wave_h = ((right_area.height / 2).max(4))
|
||||||
|
.min(right_area.height.saturating_sub(fixed + 2));
|
||||||
|
let info_h = right_area.height.saturating_sub(fixed + wave_h).max(2);
|
||||||
|
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(1), // timecode
|
Constraint::Length(1), // timecode
|
||||||
Constraint::Length(1), // progress
|
Constraint::Length(1), // progress
|
||||||
Constraint::Length(1), // gap
|
Constraint::Length(info_h), // info + effects
|
||||||
Constraint::Length(1), // fader
|
Constraint::Length(wave_h), // waveform
|
||||||
Constraint::Length(1), // pitch + tempo
|
Constraint::Length(1), // timeline label
|
||||||
Constraint::Length(1), // gap
|
|
||||||
Constraint::Length(1), // VU
|
|
||||||
])
|
])
|
||||||
.split(inner);
|
.split(right_area);
|
||||||
|
|
||||||
// Timecode
|
// ── Left strip: V · L · R (full height) ─────────────────────────────────
|
||||||
|
if let Some(left) = left_area {
|
||||||
|
draw_vu_strip(f, deck, left, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Timecode ─────────────────────────────────────────────────────────────
|
||||||
let (em, es) = deck.elapsed();
|
let (em, es) = deck.elapsed();
|
||||||
let (rm, rs) = deck.remaining();
|
let (rm, rs) = deck.remaining();
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
Span::styled(format!("{:02}:{:02}", em, es), Style::default().fg(color).add_modifier(Modifier::BOLD)),
|
Span::styled(
|
||||||
|
format!("{:02}:{:02}", em, es),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
|
||||||
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::DarkGray)),
|
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::White)),
|
||||||
])),
|
])),
|
||||||
rows[0],
|
rows[0],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Progress
|
// ── Progress bar ─────────────────────────────────────────────────────────
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Gauge::default()
|
Gauge::default()
|
||||||
.gauge_style(Style::default().fg(color))
|
.gauge_style(Style::default().fg(color))
|
||||||
@@ -148,83 +175,353 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
|
|||||||
rows[1],
|
rows[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fader
|
// ── Info + effects ────────────────────────────────────────────────────────
|
||||||
|
draw_info_effects(f, deck, rows[2]);
|
||||||
|
|
||||||
|
// ── Waveform ──────────────────────────────────────────────────────────────
|
||||||
|
draw_waveform(f, deck, rows[3], color);
|
||||||
|
|
||||||
|
// ── Timeline label (bottom row) ───────────────────────────────────────────
|
||||||
let fader_pct = (deck.fader * 100.0) as u16;
|
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 }))
|
|
||||||
.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(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
Span::styled("PITCH ", Style::default().fg(Color::DarkGray)),
|
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{:+.1}st", deck.pitch),
|
format!("VOL {:3}%", fader_pct),
|
||||||
Style::default().fg(if deck.pitch.abs() > 0.05 { Color::Yellow } else { Color::White }),
|
Style::default().fg(if fader_pct > 10 { Color::White } else { Color::Gray }),
|
||||||
),
|
|
||||||
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],
|
rows[4],
|
||||||
);
|
);
|
||||||
|
|
||||||
// VU
|
|
||||||
draw_vu(f, deck.vu, rows[6], color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_vu(f: &mut Frame, vu: (f32, f32), area: Rect, color: Color) {
|
// ── Left VU/fader strip ───────────────────────────────────────────────────────
|
||||||
let half = area.width / 2;
|
//
|
||||||
let la = Rect { x: area.x, y: area.y, width: half.saturating_sub(1), height: 1 };
|
// Three 1-char-wide columns spanning the full deck inner height:
|
||||||
let ra = Rect { x: area.x + half, y: area.y, width: half, height: 1 };
|
// col 0: V — volume/fader bar (white/gray)
|
||||||
|
// col 1: L — left channel VU (deck colour → yellow → red)
|
||||||
|
// col 2: R — right channel VU
|
||||||
|
// Bottom row of each column shows the label character.
|
||||||
|
|
||||||
let vc = |p: u16| if p > 90 { Color::Red } else if p > 70 { Color::Yellow } else { color };
|
fn draw_vu_strip(f: &mut Frame, deck: &Deck, area: Rect, color: Color) {
|
||||||
|
if area.width < 3 || area.height < 1 { return; }
|
||||||
|
let h = area.height as f32;
|
||||||
|
|
||||||
let lp = (vu.0 * 100.0) as u16;
|
let bars: [(f32, Color, &str); 3] = [
|
||||||
let rp = (vu.1 * 100.0) as u16;
|
(deck.fader, Color::White, "V"),
|
||||||
|
(deck.vu.0, color, "L"),
|
||||||
|
(deck.vu.1, color, "R"),
|
||||||
|
];
|
||||||
|
|
||||||
f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(lp))).percent(lp.min(100)).label("L"), la);
|
for (col, (level, base_col, label)) in bars.iter().enumerate() {
|
||||||
f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp))).percent(rp.min(100)).label("R"), ra);
|
let x = area.x + col as u16;
|
||||||
|
let filled_rows = (*level * (h - 1.0)) as usize; // -1 for label row
|
||||||
|
let bar_color = if *level > 0.9 {
|
||||||
|
Color::Red
|
||||||
|
} else if *level > 0.7 {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
*base_col
|
||||||
|
};
|
||||||
|
|
||||||
|
for row in 0..area.height {
|
||||||
|
let y = area.y + area.height - 1 - row;
|
||||||
|
let span = if row == 0 {
|
||||||
|
// Bottom row — label
|
||||||
|
Span::styled(label.to_string(), Style::default().fg(Color::Gray))
|
||||||
|
} else {
|
||||||
|
let ch = if (row as usize) <= filled_rows { '█' } else { ' ' };
|
||||||
|
Span::styled(ch.to_string(), Style::default().fg(bar_color))
|
||||||
|
};
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![span])),
|
||||||
|
Rect { x, y, width: 1, height: 1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Info + effects grid ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn draw_info_effects(f: &mut Frame, deck: &Deck, area: Rect) {
|
||||||
|
if area.height < 1 { return; }
|
||||||
|
|
||||||
|
// Artist/album (truncated) on first line, then effects in 2-column grid
|
||||||
|
let artist_str = deck.artist.as_deref().unwrap_or("");
|
||||||
|
let album_str = deck.album.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
let max_w = area.width.saturating_sub(1) as usize;
|
||||||
|
let meta_line = if !artist_str.is_empty() || !album_str.is_empty() {
|
||||||
|
let full = if !album_str.is_empty() {
|
||||||
|
format!("{} — {}", artist_str, album_str)
|
||||||
|
} else {
|
||||||
|
artist_str.to_string()
|
||||||
|
};
|
||||||
|
if full.len() > max_w {
|
||||||
|
format!("{}…", &full[..max_w.saturating_sub(1)])
|
||||||
|
} else {
|
||||||
|
full
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effects: pitch, tempo, cutoff, reverb — render in a 2×2 grid of text
|
||||||
|
let pitch_str = format!("PTCH {:+.1}st", deck.pitch);
|
||||||
|
let tempo_str = format!("TMPO {:.2}×", deck.tempo);
|
||||||
|
let cutoff_str = if deck.cutoff < -50.0 {
|
||||||
|
format!("CTOF LP {:.0}Hz", -deck.cutoff)
|
||||||
|
} else if deck.cutoff > 50.0 {
|
||||||
|
format!("CTOF HP {:.0}Hz", deck.cutoff)
|
||||||
|
} else {
|
||||||
|
"CTOF flat".to_string()
|
||||||
|
};
|
||||||
|
let reverb_str = format!("REVB {:.0}%", deck.reverb * 100.0);
|
||||||
|
|
||||||
|
let pitch_active = deck.pitch.abs() > 0.05;
|
||||||
|
let tempo_active = (deck.tempo - 1.0).abs() > 0.005;
|
||||||
|
let cutoff_active = deck.cutoff.abs() > 50.0;
|
||||||
|
let reverb_active = deck.reverb > 0.01;
|
||||||
|
|
||||||
|
let eff = |s: &str, active: bool| -> Span {
|
||||||
|
Span::styled(
|
||||||
|
s.to_string(),
|
||||||
|
Style::default().fg(if active { Color::Yellow } else { Color::Gray }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
if !meta_line.is_empty() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(meta_line, Style::default().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effects grid: two per row
|
||||||
|
let col_w = (area.width as usize / 2).max(12);
|
||||||
|
let e0 = format!("{:<width$}", pitch_str, width = col_w);
|
||||||
|
let e1 = format!("{:<width$}", tempo_str, width = col_w);
|
||||||
|
let e2 = format!("{:<width$}", cutoff_str, width = col_w);
|
||||||
|
let e3 = reverb_str.clone();
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![eff(&e0, pitch_active), eff(&e1, tempo_active)]));
|
||||||
|
lines.push(Line::from(vec![eff(&e2, cutoff_active), eff(&e3, reverb_active)]));
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(lines),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Waveform timeline ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shows a 30-second window centred on the current playback position.
|
||||||
|
// Each terminal column represents (30s / width) seconds of audio.
|
||||||
|
// Amplitude encoded using braille dot-matrix: each char = 2 cols × 4 rows,
|
||||||
|
// giving 8 amplitude levels per 2 horizontal samples.
|
||||||
|
//
|
||||||
|
// We use 2 terminal rows → each row covers 4 braille dot rows → amplitude
|
||||||
|
// is encoded across both rows for 8 distinct levels per column.
|
||||||
|
|
||||||
|
const BRAILLE_BASE: u32 = 0x2800;
|
||||||
|
|
||||||
|
/// Map an amplitude 0..1 into braille dots for two terminal rows (top, bottom).
|
||||||
|
/// The braille char encodes a 2-wide × 4-tall grid; we use the left column only
|
||||||
|
/// (dots 1,2,3,7 = bottom row; dots 4,5,6,8 = hmm… let's use block rows).
|
||||||
|
/// Simpler: use the 8 vertical block elements for a single row.
|
||||||
|
fn amplitude_to_braille_char(amp: f32) -> char {
|
||||||
|
// Fill both columns symmetrically from bottom to top.
|
||||||
|
// Braille dot layout (standard numbering):
|
||||||
|
// 1 4 (top)
|
||||||
|
// 2 5
|
||||||
|
// 3 6
|
||||||
|
// 7 8 (bottom)
|
||||||
|
// Bit positions: dot1=0, dot2=1, dot3=2, dot4=3, dot5=4, dot6=5, dot7=6, dot8=7
|
||||||
|
//
|
||||||
|
// Fill order (bottom-first, both halves together):
|
||||||
|
// L-bot both-bot L-2nd both-2nd L-3rd both-3rd L-top both-top
|
||||||
|
// 7(6) 8(7) 3(2) 6(5) 2(1) 5(4) 1(0) 4(3)
|
||||||
|
//
|
||||||
|
// This makes partial cells look wide (not thin left sticks), so waveform
|
||||||
|
// peaks are visually clean at any height.
|
||||||
|
let level = (amp.clamp(0.0, 1.0) * 8.0) as u32;
|
||||||
|
const BITS: [u32; 9] = [
|
||||||
|
0x00, // 0 — empty
|
||||||
|
0x40, // 1 — ⡀ dot7
|
||||||
|
0xC0, // 2 — ⣀ dot7+dot8
|
||||||
|
0xC4, // 3 — ⣄ + dot3
|
||||||
|
0xE4, // 4 — ⣤ + dot6
|
||||||
|
0xE6, // 5 — ⣦ + dot2
|
||||||
|
0xF6, // 6 — ⣶ + dot5
|
||||||
|
0xF7, // 7 — ⣷ + dot1
|
||||||
|
0xFF, // 8 — ⣿ + dot4 (full)
|
||||||
|
];
|
||||||
|
let code = BRAILLE_BASE + BITS[level.min(8) as usize];
|
||||||
|
char::from_u32(code).unwrap_or('░')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stereo-band waveform.
|
||||||
|
///
|
||||||
|
/// Each terminal column always represents an integer number of peak buckets,
|
||||||
|
/// and the viewport's left edge is *snapped* to a bucket boundary. That
|
||||||
|
/// guarantees a given audio sample always lands in the same column as long as
|
||||||
|
/// the window width is unchanged, so the bars hold still while the playhead
|
||||||
|
/// slides across them — no per-frame re-scaling, no dithering.
|
||||||
|
///
|
||||||
|
/// The bar height in each column is the max of the three band peaks (overall
|
||||||
|
/// loudness). The column's color is `(R = bass, G = mid, B = high)`, scaled to
|
||||||
|
/// the band ratios — bass-heavy kicks come out red, mids green, hats blue, a
|
||||||
|
/// full mix near-white.
|
||||||
|
fn draw_waveform(f: &mut Frame, deck: &Deck, area: Rect, focus_color: Color) {
|
||||||
|
if area.width < 4 || area.height < 1 { return; }
|
||||||
|
|
||||||
|
let n_rows = area.height as usize;
|
||||||
|
let width = area.width as usize;
|
||||||
|
|
||||||
|
let sr = deck.sample_rate as f64;
|
||||||
|
let pos_secs = deck.position as f64 / sr;
|
||||||
|
let win_secs = 30.0_f64;
|
||||||
|
|
||||||
|
// PEAK_BUCKET must match audio.rs's constant.
|
||||||
|
const PEAK_BUCKET: usize = 4096;
|
||||||
|
let secs_per_peak = PEAK_BUCKET as f64 / sr;
|
||||||
|
let bands = &deck.waveform_bands;
|
||||||
|
let total = bands.len();
|
||||||
|
|
||||||
|
// Buckets per column. Snap to integer so the bucket→column mapping is
|
||||||
|
// stable as `pos_secs` advances by less than one column's worth of audio.
|
||||||
|
let buckets_per_col = ((win_secs / secs_per_peak) / width as f64)
|
||||||
|
.round()
|
||||||
|
.max(1.0) as usize;
|
||||||
|
let secs_per_col = buckets_per_col as f64 * secs_per_peak;
|
||||||
|
|
||||||
|
// Snap viewport left edge to a column-aligned bucket boundary.
|
||||||
|
let half_win = secs_per_col * width as f64 / 2.0;
|
||||||
|
let target_left_sec = (pos_secs - half_win).max(0.0);
|
||||||
|
let left_bucket = ((target_left_sec / secs_per_peak) as usize
|
||||||
|
/ buckets_per_col)
|
||||||
|
* buckets_per_col;
|
||||||
|
let cursor_col = ((pos_secs - left_bucket as f64 * secs_per_peak) / secs_per_col)
|
||||||
|
.clamp(0.0, width as f64) as usize;
|
||||||
|
|
||||||
|
// Per-column band amplitudes (max-of-bucket within column).
|
||||||
|
let col_bands: Vec<[f32; 3]> = (0..width).map(|col| {
|
||||||
|
let bs = left_bucket + col * buckets_per_col;
|
||||||
|
let be = (bs + buckets_per_col).min(total);
|
||||||
|
if bs >= total { return [0.0, 0.0, 0.0]; }
|
||||||
|
let mut acc = [0.0f32; 3];
|
||||||
|
for b in &bands[bs..be] {
|
||||||
|
if b[0] > acc[0] { acc[0] = b[0]; }
|
||||||
|
if b[1] > acc[1] { acc[1] = b[1]; }
|
||||||
|
if b[2] > acc[2] { acc[2] = b[2]; }
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Build per-column (height, color) once, reused across rows.
|
||||||
|
let col_render: Vec<(f32, Color)> = col_bands.iter().map(|b| {
|
||||||
|
let height = b[0].max(b[1]).max(b[2]); // total loudness
|
||||||
|
let color = blend_band_color(b[0], b[1], b[2], focus_color);
|
||||||
|
(height, color)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Render row by row, top-down. Each row covers a fixed amplitude slice.
|
||||||
|
for row in 0..n_rows {
|
||||||
|
let band_top = 1.0 - row as f32 / n_rows as f32;
|
||||||
|
let band_bottom = 1.0 - (row + 1) as f32 / n_rows as f32;
|
||||||
|
let band_size = band_top - band_bottom;
|
||||||
|
|
||||||
|
let mut spans: Vec<Span> = Vec::with_capacity(width);
|
||||||
|
for col in 0..width {
|
||||||
|
let (amp, color) = col_render[col];
|
||||||
|
|
||||||
|
// Past columns get the band color directly; future columns get a
|
||||||
|
// dimmer version so the playhead is visually obvious.
|
||||||
|
let style_color = if col < cursor_col {
|
||||||
|
color
|
||||||
|
} else if col == cursor_col {
|
||||||
|
Color::White
|
||||||
|
} else {
|
||||||
|
dim_color(color)
|
||||||
|
};
|
||||||
|
|
||||||
|
let ch = if col == cursor_col && row == n_rows - 1 {
|
||||||
|
'┃'
|
||||||
|
} else {
|
||||||
|
let local_amp = if amp >= band_top {
|
||||||
|
1.0
|
||||||
|
} else if amp <= band_bottom {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(amp - band_bottom) / band_size
|
||||||
|
};
|
||||||
|
amplitude_to_braille_char(local_amp)
|
||||||
|
};
|
||||||
|
|
||||||
|
spans.push(Span::styled(ch.to_string(), Style::default().fg(style_color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(spans)),
|
||||||
|
Rect { y: area.y + row as u16, height: 1, ..area },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map per-band peaks (bass, mid, high) ∈ [0,1] to an RGB color via additive
|
||||||
|
/// mixing: bass=red, mid=green, high=blue. Silence falls back to the deck's
|
||||||
|
/// focus color so empty regions still look like a deck strip, not garbage.
|
||||||
|
fn blend_band_color(bass: f32, mid: f32, high: f32, fallback: Color) -> Color {
|
||||||
|
let total = bass + mid + high;
|
||||||
|
if total < 0.01 { return fallback; }
|
||||||
|
// Normalize each band to 0..1 of the strongest band, then map to byte.
|
||||||
|
let m = bass.max(mid).max(high).max(1e-6);
|
||||||
|
let r = (bass / m * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let g = (mid / m * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
let b = (high / m * 255.0).clamp(0.0, 255.0) as u8;
|
||||||
|
Color::Rgb(r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Darken an RGB color so the not-yet-played future portion of the waveform
|
||||||
|
/// is visually distinct from the past portion.
|
||||||
|
fn dim_color(c: Color) -> Color {
|
||||||
|
match c {
|
||||||
|
Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
|
||||||
|
_ => Color::DarkGray,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Help bar ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn draw_help(f: &mut Frame, area: Rect) {
|
fn draw_help(f: &mut Frame, area: Rect) {
|
||||||
let help = Paragraph::new(vec![
|
let help = Paragraph::new(vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(" Focus: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Focus: ", Style::default().fg(Color::Gray)),
|
||||||
k("a"), k("z"), k("s"), k("x"),
|
k("a"), k("s"), k("z"), k("x"),
|
||||||
Span::styled(" Load: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Load: ", Style::default().fg(Color::Gray)),
|
||||||
k("Shift+A/Z/S/X"),
|
k("Shift+A/S/Z/X"),
|
||||||
Span::styled(" Play/Pause: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Play/Pause: ", Style::default().fg(Color::Gray)),
|
||||||
k("Space"),
|
k("Space"),
|
||||||
Span::styled(" Stop: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Stop: ", Style::default().fg(Color::Gray)),
|
||||||
k("Enter"),
|
k("Enter"),
|
||||||
|
Span::styled(" Seek: ", Style::default().fg(Color::Gray)),
|
||||||
|
k("←/→"),
|
||||||
|
Span::styled(" Quit: ", Style::default().fg(Color::Gray)),
|
||||||
|
k("q"),
|
||||||
]),
|
]),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled(" Fader: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Fader: ", Style::default().fg(Color::Gray)), k("↑/↓"),
|
||||||
k("↑/↓"),
|
Span::styled(" Pitch: ", Style::default().fg(Color::Gray)), k("[/]"),
|
||||||
Span::styled(" Pitch: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Tempo: ", Style::default().fg(Color::Gray)), k(",/."),
|
||||||
k("[/]"),
|
Span::styled(" Cutoff: ",Style::default().fg(Color::Gray)), k("-/="),
|
||||||
Span::styled(" Tempo: ", Style::default().fg(Color::DarkGray)),
|
Span::styled(" Reverb: ",Style::default().fg(Color::Gray)), k("o/p"),
|
||||||
k(",/."),
|
Span::styled(" Cue: ", Style::default().fg(Color::Gray)),
|
||||||
Span::styled(" Cue: ", Style::default().fg(Color::DarkGray)),
|
k("c"), Span::raw("set "), k("v"), Span::raw("goto"),
|
||||||
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()
|
.block(Block::default()
|
||||||
.borders(Borders::TOP)
|
.borders(Borders::TOP)
|
||||||
.border_style(Style::default().fg(Color::DarkGray)))
|
.border_style(Style::default().fg(Color::DarkGray)));
|
||||||
.style(Style::default());
|
|
||||||
|
|
||||||
f.render_widget(help, area);
|
f.render_widget(help, area);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user