535 lines
21 KiB
Rust
535 lines
21 KiB
Rust
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),
|
||
)
|
||
}
|