Files
djdeck/src/tui.rs
T

535 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph},
Frame,
};
use crate::{app::App, deck::{Deck, DeckId}};
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
draw_base(f, app, area);
if let Some(sel) = &mut app.file_selector {
sel.render(f, area);
}
}
fn draw_base(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(1), // 2×2 deck grid
Constraint::Length(3), // help
])
.split(area);
// Header
let header = Paragraph::new(Line::from(vec![
Span::styled(" ♫ djdeck ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(
format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT),
Style::default().fg(Color::Gray),
),
Span::styled(
"in-proc DSP: signalsmith-stretch(pitch+tempo) → biquad(cutoff) → schroeder(reverb)",
Style::default().fg(Color::Gray),
),
]));
f.render_widget(header, chunks[0]);
// 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()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
for (row_i, row_idxs) in render_order.iter().enumerate() {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[row_i]);
for (col_i, &deck_idx) in row_idxs.iter().enumerate() {
if deck_idx < app.decks.len() {
draw_deck(f, &app.decks[deck_idx], cols[col_i], app.focused_deck == deck_idx);
}
}
}
draw_help(f, chunks[2]);
}
fn deck_color(id: DeckId) -> Color {
match id {
DeckId::A => Color::Cyan,
DeckId::Z => Color::Magenta,
DeckId::S => Color::Green,
DeckId::X => Color::Yellow,
}
}
fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
let color = deck_color(deck.id);
let border_style = if focused {
Style::default().fg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
// Title: deck label + play state + truncated track name + node IDs
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 = if track_display.len() > max_name {
format!("{}…", &track_display[..max_name.saturating_sub(1)])
} else {
track_display.to_string()
};
let bpm = deck.bpm.map(|b| format!(" {:.1} BPM ", b)).unwrap_or_default();
let title = format!(
" Deck {} {} {}{}",
deck.id.label(), deck.play_state.icon(), track_display, bpm
);
let outer = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
f.render_widget(outer, area);
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()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // timecode
Constraint::Length(1), // progress
Constraint::Length(info_h), // info + effects
Constraint::Length(wave_h), // waveform
Constraint::Length(1), // timeline label
])
.split(right_area);
// ── 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 (rm, rs) = deck.remaining();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("{:02}:{:02}", em, es),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::White)),
])),
rows[0],
);
// ── Progress bar ─────────────────────────────────────────────────────────
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(color))
.percent((deck.progress() * 100.0) as u16)
.label(""),
rows[1],
);
// ── 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;
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("VOL {:3}%", fader_pct),
Style::default().fg(if fader_pct > 10 { Color::White } else { Color::Gray }),
),
])),
rows[4],
);
}
// ── Left VU/fader strip ───────────────────────────────────────────────────────
//
// Three 1-char-wide columns spanning the full deck inner height:
// 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.
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 bars: [(f32, Color, &str); 3] = [
(deck.fader, Color::White, "V"),
(deck.vu.0, color, "L"),
(deck.vu.1, color, "R"),
];
for (col, (level, base_col, label)) in bars.iter().enumerate() {
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) {
let help = Paragraph::new(vec![
Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(Color::Gray)),
k("a"), k("s"), k("z"), k("x"),
Span::styled(" Load: ", Style::default().fg(Color::Gray)),
k("Shift+A/S/Z/X"),
Span::styled(" Play/Pause: ", Style::default().fg(Color::Gray)),
k("Space"),
Span::styled(" Stop: ", Style::default().fg(Color::Gray)),
k("Enter"),
Span::styled(" Seek: ", Style::default().fg(Color::Gray)),
k("←/→"),
Span::styled(" Quit: ", Style::default().fg(Color::Gray)),
k("q"),
]),
Line::from(vec![
Span::styled(" Fader: ", Style::default().fg(Color::Gray)), k("↑/↓"),
Span::styled(" Pitch: ", Style::default().fg(Color::Gray)), k("[/]"),
Span::styled(" Tempo: ", Style::default().fg(Color::Gray)), k(",/."),
Span::styled(" Cutoff: ",Style::default().fg(Color::Gray)), k("-/="),
Span::styled(" Reverb: ",Style::default().fg(Color::Gray)), k("o/p"),
Span::styled(" Cue: ", Style::default().fg(Color::Gray)),
k("c"), Span::raw("set "), k("v"), Span::raw("goto"),
]),
])
.block(Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(help, area);
}
fn k(s: &str) -> Span<'_> {
Span::styled(
format!("[{}] ", s),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)
}