internal process dsp, skipping ladspa pipewire attempt, and reworked waveforms
This commit is contained in:
+390
-93
@@ -20,9 +20,9 @@ fn draw_base(f: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(1), // 2×2 deck grid
|
||||
Constraint::Length(3), // help
|
||||
])
|
||||
.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(
|
||||
format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
Span::styled(
|
||||
"PipeWire graph: each deck → rubberband filter-chain → sink",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
"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
|
||||
// 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 in 0..2usize {
|
||||
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]);
|
||||
.split(rows[row_i]);
|
||||
|
||||
for col in 0..2usize {
|
||||
let idx = row * 2 + col;
|
||||
if idx < app.decks.len() {
|
||||
draw_deck(f, &app.decks[idx], cols[col], app.focused_deck == idx);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,65 +83,90 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
// Truncate track name to fit
|
||||
let max_name = area.width.saturating_sub(18) as usize;
|
||||
// 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()
|
||||
};
|
||||
|
||||
// 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 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,
|
||||
graph_status,
|
||||
" Deck {} {} {}{}",
|
||||
deck.id.label(), deck.play_state.icon(), track_display, bpm
|
||||
);
|
||||
|
||||
let outer = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.border_style(border_style)
|
||||
.style(Style::default());
|
||||
.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(1), // gap
|
||||
Constraint::Length(1), // fader
|
||||
Constraint::Length(1), // pitch + tempo
|
||||
Constraint::Length(1), // gap
|
||||
Constraint::Length(1), // VU
|
||||
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(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 (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(
|
||||
format!("{:02}:{:02}", em, es),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::White)),
|
||||
])),
|
||||
rows[0],
|
||||
);
|
||||
|
||||
// Progress
|
||||
// ── Progress bar ─────────────────────────────────────────────────────────
|
||||
f.render_widget(
|
||||
Gauge::default()
|
||||
.gauge_style(Style::default().fg(color))
|
||||
@@ -148,83 +175,353 @@ fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
|
||||
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;
|
||||
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(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled("PITCH ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{:+.1}st", deck.pitch),
|
||||
Style::default().fg(if deck.pitch.abs() > 0.05 { Color::Yellow } else { Color::White }),
|
||||
),
|
||||
Span::styled(" TEMPO ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{:.2}×", deck.tempo),
|
||||
Style::default().fg(if (deck.tempo - 1.0).abs() > 0.01 { Color::Yellow } else { Color::White }),
|
||||
format!("VOL {:3}%", fader_pct),
|
||||
Style::default().fg(if fader_pct > 10 { Color::White } else { Color::Gray }),
|
||||
),
|
||||
])),
|
||||
rows[4],
|
||||
);
|
||||
|
||||
// VU
|
||||
draw_vu(f, deck.vu, rows[6], color);
|
||||
}
|
||||
|
||||
fn draw_vu(f: &mut Frame, vu: (f32, f32), area: Rect, color: Color) {
|
||||
let half = area.width / 2;
|
||||
let la = Rect { x: area.x, y: area.y, width: half.saturating_sub(1), height: 1 };
|
||||
let ra = Rect { x: area.x + half, y: area.y, width: half, height: 1 };
|
||||
// ── 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.
|
||||
|
||||
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 rp = (vu.1 * 100.0) as u16;
|
||||
let bars: [(f32, Color, &str); 3] = [
|
||||
(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);
|
||||
f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp))).percent(rp.min(100)).label("R"), ra);
|
||||
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::DarkGray)),
|
||||
k("a"), k("z"), k("s"), k("x"),
|
||||
Span::styled(" Load: ", Style::default().fg(Color::DarkGray)),
|
||||
k("Shift+A/Z/S/X"),
|
||||
Span::styled(" Play/Pause: ", Style::default().fg(Color::DarkGray)),
|
||||
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::DarkGray)),
|
||||
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::DarkGray)),
|
||||
k("↑/↓"),
|
||||
Span::styled(" Pitch: ", Style::default().fg(Color::DarkGray)),
|
||||
k("[/]"),
|
||||
Span::styled(" Tempo: ", Style::default().fg(Color::DarkGray)),
|
||||
k(",/."),
|
||||
Span::styled(" Cue: ", Style::default().fg(Color::DarkGray)),
|
||||
k("c"), Span::raw(" set "), k("v"), Span::raw(" goto"),
|
||||
Span::styled(" Seek: ", Style::default().fg(Color::DarkGray)),
|
||||
k("←/→"),
|
||||
Span::styled(" Quit: ", Style::default().fg(Color::DarkGray)),
|
||||
k("q"),
|
||||
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)))
|
||||
.style(Style::default());
|
||||
.border_style(Style::default().fg(Color::DarkGray)));
|
||||
|
||||
f.render_widget(help, area);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user