1
0
Fork 0
mirror of https://github.com/alemidev/scope-tui.git synced 2025-01-08 02:33:55 +01:00

feat: better UI and keybinds, more CLI opts

Added CLI option to specify channel number, but I'm not sure it's really
working. Refactored AppConfig a little. Added threshold indication on
status bar. References and UI are separate settings and can be toggled
individually. At least 1 sample is always shown when discarding due to
triggering. Range and threshold are configured with arrow keys. Added
more colors to palette. Added ability to toggle braille mode. Fix: fully
write "fps" instead of cutting "s" out.
This commit is contained in:
əlemi 2023-01-05 00:51:05 +01:00
parent df273dd011
commit d72e471dbd
No known key found for this signature in database
GPG key ID: BBCBFE5D7244634E
4 changed files with 192 additions and 127 deletions

View file

@ -26,6 +26,12 @@ pub struct App {
impl App {
pub fn update_values(&mut self) {
if self.cfg.scale > 32768 {
self.cfg.scale = 32768;
}
if self.cfg.scale < 0 {
self.cfg.scale = 0;
}
if self.cfg.vectorscope {
self.names.x = "left -".into();
self.names.y = "| right".into();
@ -44,72 +50,41 @@ impl App {
}
}
pub fn bounds(&self, axis: &Dimension) -> [f64;2] {
match axis {
Dimension::X => self.bounds.x,
Dimension::Y => self.bounds.y,
pub fn marker_type(&self) -> symbols::Marker {
if self.cfg.braille {
symbols::Marker::Braille
} else {
symbols::Marker::Dot
}
}
pub fn name(&self, axis: &Dimension) -> &str {
match axis {
Dimension::X => self.names.x.as_str(),
Dimension::Y => self.names.y.as_str(),
pub fn graph_type(&self) -> GraphType {
if self.cfg.scatter {
GraphType::Scatter
} else {
GraphType::Line
}
}
pub fn scatter(&self) -> bool {
match self.cfg.graph_type {
GraphType::Scatter => true,
_ => false,
}
}
// pub fn references(&self) -> Vec<Dataset> {
// vec![
// Dataset::default()
// .name("")
// .marker(self.cfg.marker_type)
// .graph_type(GraphType::Line)
// .style(Style::default().fg(self.cfg.axis_color))
// .data(&self.references.x),
// Dataset::default()
// .name("")
// .marker(self.cfg.marker_type)
// .graph_type(GraphType::Line)
// .style(Style::default().fg(self.cfg.axis_color))
// .data(&self.references.y),
// ]
// }
pub fn update_scale(&mut self, increment: i32) {
if increment > 0 || increment.abs() < self.cfg.scale as i32 {
self.cfg.scale = ((self.cfg.scale as i32) + increment) as u32;
self.update_values();
}
}
pub fn set_scatter(&mut self, scatter: bool) {
self.cfg.graph_type = if scatter { GraphType::Scatter } else { GraphType::Line };
pub fn palette(&self, index: usize) -> Color {
*self.cfg.palette.get(index % self.cfg.palette.len()).unwrap_or(&Color::White)
}
}
impl From::<&crate::Args> for App {
fn from(args: &crate::Args) -> Self {
let marker_type = if args.no_braille { symbols::Marker::Dot } else { symbols::Marker::Braille };
let graph_type = if args.scatter { GraphType::Scatter } else { GraphType::Line };
let cfg = AppConfig {
title: "TUI Oscilloscope -- <me@alemi.dev>".into(),
axis_color: Color::DarkGray,
palette: vec![Color::Red, Color::Yellow],
palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta],
scale: args.range,
width: args.buffer / 4, // TODO It's 4 because 2 channels and 2 bytes per sample!
width: args.buffer / (2 * args.channels as u32), // TODO also make bit depth customizable
triggering: args.triggering,
threshold: args.threshold,
vectorscope: args.vectorscope,
references: !args.no_reference,
marker_type, graph_type,
show_ui: !args.no_ui,
braille: !args.no_braille,
scatter: args.scatter,
};
let mut app = App {
@ -136,7 +111,7 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
// setup audio capture
let spec = Spec {
format: Format::S16NE,
channels: 2,
channels: args.channels,
rate: args.sample_rate,
};
assert!(spec.is_valid());
@ -151,7 +126,7 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
"ScopeTUI", // Our applications name
Direction::Record, // We want a record stream
dev, // Use requested device, or default
"Music", // Description of our stream
"data", // Description of our stream
&spec, // Our sample format
None, // Use default channel map
Some(&BufferAttr {
@ -182,66 +157,79 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
}
if !pause {
channels = fmt.oscilloscope(&mut buffer, 2);
}
channels = fmt.oscilloscope(&mut buffer, args.channels);
if app.cfg.triggering {
// TODO allow to customize channel to use for triggering and threshold
if let Some(ch) = channels.get(0) {
let mut discard = 0;
for i in 0..ch.len() { // seek to first sample rising through threshold
if i + 1 < ch.len() && ch[i] <= app.cfg.threshold && ch[i+1] > app.cfg.threshold { // triggered
break;
} else {
discard += 1;
if app.cfg.triggering {
// TODO allow to customize channel to use for triggering
if let Some(ch) = channels.get(0) {
let mut discard = 0;
for i in 0..ch.len()-1 { // seek to first sample rising through threshold
if ch[i] <= app.cfg.threshold && ch[i+1] > app.cfg.threshold { // triggered
break;
} else {
discard += 1;
}
}
for ch in channels.iter_mut() {
let limit = if ch.len() < discard { ch.len() } else { discard };
*ch = ch[limit..].to_vec();
}
}
for ch in channels.iter_mut() {
*ch = ch[discard..].to_vec();
}
}
}
let samples = channels.iter().map(|x| x.len()).max().unwrap_or(0);
let mut measures;
let mut measures : Vec<(String, Vec<(f64, f64)>)>;
// This third buffer is kinda weird because of lifetimes on Datasets, TODO
// would be nice to make it more straight forward instead of this deep tuple magic
if app.cfg.vectorscope {
measures = vec![];
for chunk in channels.chunks(2) {
for (i, chunk) in channels.chunks(2).enumerate() {
let mut tmp = vec![];
for i in 0..chunk[0].len() {
tmp.push((chunk[0][i] as f64, chunk[1][i] as f64));
match chunk.len() {
2 => {
for i in 0..std::cmp::min(chunk[0].len(), chunk[0].len()) {
tmp.push((chunk[0][i] as f64, chunk[1][i] as f64));
}
},
1 => {
for i in 0..chunk[0].len() {
tmp.push((chunk[0][i] as f64, i as f64));
}
},
_ => continue,
}
// split it in two so the math downwards still works the same
let pivot = tmp.len() / 2;
measures.push(tmp[pivot..].to_vec()); // put more recent first
measures.push(tmp[..pivot].to_vec());
measures.push((channel_name(i * 2, true), tmp[pivot..].to_vec())); // put more recent first
measures.push((channel_name((i * 2) + 1, true), tmp[..pivot].to_vec()));
}
} else {
measures = vec![vec![]; channels.len()];
for i in 0..channels[0].len() {
for j in 0..channels.len() {
measures[j].push((i as f64, channels[j][i]));
measures = vec![];
for (i, channel) in channels.iter().enumerate() {
let mut tmp = vec![];
for i in 0..channel.len() {
tmp.push((i as f64, channel[i]));
}
measures.push((channel_name(i, false), tmp));
}
}
let mut datasets = vec![];
let trigger_pt;
if app.cfg.references {
trigger_pt = [(0.0, app.cfg.threshold)];
datasets.push(data_set("", &app.references.x, app.cfg.marker_type, GraphType::Line, app.cfg.axis_color));
datasets.push(data_set("", &app.references.y, app.cfg.marker_type, GraphType::Line, app.cfg.axis_color));
datasets.push(data_set("T", &trigger_pt, app.cfg.marker_type, GraphType::Scatter, Color::Cyan));
datasets.push(data_set("", &app.references.x, app.marker_type(), GraphType::Line, app.cfg.axis_color));
datasets.push(data_set("", &app.references.y, app.marker_type(), GraphType::Line, app.cfg.axis_color));
}
let ds_names = if app.cfg.vectorscope { vec!["1", "2"] } else { vec!["R", "L"] };
let palette : Vec<Color> = app.cfg.palette.iter().rev().map(|x| x.clone()).collect();
let trigger_pt = [(0.0, app.cfg.threshold)];
datasets.push(data_set("T", &trigger_pt, app.marker_type(), GraphType::Scatter, Color::Cyan));
for (i, ds) in measures.iter().rev().enumerate() {
datasets.push(data_set(ds_names[i], ds, app.cfg.marker_type, app.cfg.graph_type, palette[i % palette.len()]));
let m_len = measures.len() - 1;
for (i, (name, ds)) in measures.iter().rev().enumerate() {
datasets.push(data_set(&name, ds, app.marker_type(), app.graph_type(), app.palette(m_len - i)));
}
fps += 1;
@ -254,23 +242,8 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
terminal.draw(|f| {
let mut size = f.size();
if app.cfg.references {
let heading = Table::new(
vec![
Row::new(
vec![
Cell::from(format!("TUI {}", if app.cfg.vectorscope { "Vectorscope" } else { "Oscilloscope" })).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Cell::from(format!("{}{} mode", if app.cfg.triggering { "triggered " } else { "" }, if app.scatter() { "scatter" } else { "line" })),
Cell::from(format!("range +-{}", app.cfg.scale)),
Cell::from(format!("{}smpl", samples as u32)),
Cell::from(format!("{:.1}kHz", args.sample_rate as f32 / 1000.0)),
Cell::from(format!("{}fps", framerate)),
]
)
]
)
.style(Style::default().fg(Color::Cyan))
.widths(&[Constraint::Percentage(40), Constraint::Percentage(20), Constraint::Percentage(20), Constraint::Percentage(7), Constraint::Percentage(7), Constraint::Percentage(6)]);
if app.cfg.show_ui {
let heading = header(&args, &app, samples as u32, framerate);
f.render_widget(heading, Rect { x: size.x, y: size.y, width: size.width, height:1 });
size.height -= 1;
size.y += 1;
@ -283,9 +256,31 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
if let Some(Event::Key(key)) = poll_event()? {
match key.modifiers {
KeyModifiers::CONTROL => {
KeyModifiers::SHIFT => {
match key.code {
KeyCode::Up => app.cfg.threshold += 1000.0,
KeyCode::Down => app.cfg.threshold -= 1000.0,
KeyCode::Right => app.cfg.scale += 1000,
KeyCode::Left => app.cfg.scale -= 1000,
_ => {},
}
},
KeyModifiers::CONTROL => {
match key.code { // mimic other programs shortcuts to quit, for user friendlyness
KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('w') => break,
KeyCode::Up => app.cfg.threshold += 10.0,
KeyCode::Down => app.cfg.threshold -= 10.0,
KeyCode::Right => app.cfg.scale += 10,
KeyCode::Left => app.cfg.scale -= 10,
_ => {},
}
},
KeyModifiers::ALT => {
match key.code {
KeyCode::Up => app.cfg.threshold += 1.0,
KeyCode::Down => app.cfg.threshold -= 1.0,
KeyCode::Right => app.cfg.scale += 1,
KeyCode::Left => app.cfg.scale -= 1,
_ => {},
}
},
@ -293,18 +288,25 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
match key.code {
KeyCode::Char('q') => break,
KeyCode::Char(' ') => pause = !pause,
KeyCode::Char('=') => app.update_scale(-1000),
KeyCode::Char('-') => app.update_scale(1000),
KeyCode::Char('+') => app.update_scale(-100),
KeyCode::Char('_') => app.update_scale(100),
KeyCode::Char('v') => app.cfg.vectorscope = !app.cfg.vectorscope,
KeyCode::Char('s') => app.set_scatter(!app.scatter()), // TODO no funcs
KeyCode::Char('h') => app.cfg.references = !app.cfg.references,
KeyCode::Char('t') => app.cfg.triggering = !app.cfg.triggering,
KeyCode::Up => app.cfg.threshold += 100.0,
KeyCode::Down => app.cfg.threshold -= 100.0,
KeyCode::PageUp => app.cfg.threshold += 1000.0,
KeyCode::PageDown => app.cfg.threshold -= 1000.0,
KeyCode::Char('s') => app.cfg.scatter = !app.cfg.scatter,
KeyCode::Char('b') => app.cfg.braille = !app.cfg.braille,
KeyCode::Char('h') => app.cfg.show_ui = !app.cfg.show_ui,
KeyCode::Char('r') => app.cfg.references = !app.cfg.references,
KeyCode::Char('t') => app.cfg.triggering = !app.cfg.triggering,
KeyCode::Up => app.cfg.threshold += 100.0,
KeyCode::Down => app.cfg.threshold -= 100.0,
KeyCode::Right => app.cfg.scale += 100,
KeyCode::Left => app.cfg.scale -= 100,
KeyCode::Esc => { // reset settings
app.cfg.references = !args.no_reference;
app.cfg.braille = !args.no_braille;
app.cfg.threshold = args.threshold;
app.cfg.width = args.buffer / (args.channels as u32 * 2); // TODO ...
app.cfg.scale = args.range;
app.cfg.vectorscope = args.vectorscope;
app.cfg.triggering = args.triggering;
},
_ => {},
}
}
@ -316,9 +318,38 @@ pub fn run_app<T : Backend>(args: Args, terminal: &mut Terminal<T>) -> Result<()
Ok(())
}
// TODO these functions probably shouldn't be here
fn header(args: &Args, app: &App, samples: u32, framerate: u32) -> Table<'static> {
Table::new(
vec![
Row::new(
vec![
Cell::from(format!("TUI {}", if app.cfg.vectorscope { "Vectorscope" } else { "Oscilloscope" })).style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Cell::from(format!("{} plot", if app.cfg.scatter { "scatter" } else { "line" })),
Cell::from(format!("{}", if app.cfg.triggering { "triggered" } else { "live" } )),
Cell::from(format!("threshold {:.0}", app.cfg.threshold)),
Cell::from(format!("range +-{}", app.cfg.scale)),
Cell::from(format!("{}smpl", samples as u32)),
Cell::from(format!("{:.1}kHz", args.sample_rate as f32 / 1000.0)),
Cell::from(format!("{}fps", framerate)),
]
)
]
)
.style(Style::default().fg(Color::Cyan))
.widths(&[
Constraint::Percentage(32),
Constraint::Percentage(12),
Constraint::Percentage(12),
Constraint::Percentage(12),
Constraint::Percentage(12),
Constraint::Percentage(6),
Constraint::Percentage(6),
Constraint::Percentage(6)
])
}
fn poll_event() -> Result<Option<Event>, std::io::Error> {
if event::poll(Duration::from_millis(0))? {
Ok(Some(event::read()?))
@ -343,10 +374,22 @@ fn data_set<'a>(
}
fn axis(app: &App, dim: Dimension) -> Axis {
let (name, bounds) = match dim {
Dimension::X => (&app.names.x, &app.bounds.x),
Dimension::Y => (&app.names.y, &app.bounds.y),
};
let mut a = Axis::default();
if app.cfg.references {
a = a.title(Span::styled(app.name(&dim), Style::default().fg(Color::Cyan)));
if app.cfg.show_ui {
a = a.title(Span::styled(name, Style::default().fg(Color::Cyan)));
}
a.style(Style::default().fg(app.cfg.axis_color)).bounds(*bounds)
}
fn channel_name(index: usize, vectorscope: bool) -> String {
if vectorscope { return format!("{}", index); }
match index {
0 => "L".into(),
1 => "R".into(),
_ => format!("{}", index),
}
a.style(Style::default().fg(app.cfg.axis_color))
.bounds(app.bounds(&dim))
}

View file

@ -1,4 +1,4 @@
use tui::{style::Color, widgets::GraphType, symbols};
use tui::style::Color;
// use crate::parser::SampleParser;
@ -39,18 +39,18 @@ impl Default for ChartReferences {
}
pub struct AppConfig {
pub title: String,
pub axis_color: Color,
pub palette: Vec<Color>,
pub scale: u32,
pub scale: i32,
pub width: u32,
pub vectorscope: bool,
pub references: bool,
pub show_ui: bool,
pub triggering: bool,
pub threshold: f64,
pub marker_type: symbols::Marker,
pub graph_type: GraphType,
pub scatter: bool,
pub braille: bool,
}

View file

@ -17,9 +17,18 @@ use clap::Parser;
use crate::music::Note;
use crate::app::run_app;
const HELP_TEMPLATE : &str = "{before-help}\
{name} {version} -- by {author}
{about}
{usage-heading} {usage}
{all-args}{after-help}
";
/// A simple oscilloscope/vectorscope for your terminal
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[command(author, version, about, long_about = None, help_template = HELP_TEMPLATE)]
pub struct Args {
/// Audio device to attach to
device: Option<String>,
@ -30,7 +39,7 @@ pub struct Args {
/// Max value, positive and negative, on amplitude scale
#[arg(short, long, value_name = "SIZE", default_value_t = 20000)]
range: u32, // TODO counterintuitive, improve this
range: i32, // TODO counterintuitive, improve this
/// Use vintage looking scatter mode instead of line mode
#[arg(long, default_value_t = false)]
@ -44,6 +53,10 @@ pub struct Args {
#[arg(long, value_name = "NOTE")]
tune: Option<String>,
/// Number of channels to open
#[arg(long, value_name = "N", default_value_t = 2)]
channels: u8,
/// Sample rate to use
#[arg(long, value_name = "HZ", default_value_t = 44100)]
sample_rate: u32,
@ -64,6 +77,10 @@ pub struct Args {
#[arg(long, default_value_t = false)]
no_reference: bool,
/// Hide UI and only draw waveforms
#[arg(long, default_value_t = false)]
no_ui: bool,
/// Don't use braille dots for drawing lines
#[arg(long, default_value_t = false)]
no_braille: bool,
@ -75,7 +92,7 @@ fn main() -> Result<(), std::io::Error> {
if let Some(txt) = &args.tune { // TODO make it less jank
if let Ok(note) = txt.parse::<Note>() {
args.buffer = note.tune_buffer_size(args.sample_rate);
while args.buffer % 4 != 0 {
while args.buffer % (args.channels as u32 * 2) != 0 { // TODO customizable bit depth
args.buffer += 1; // TODO jank but otherwise it doesn't align
}
} else {

View file

@ -8,14 +8,15 @@
// }
pub trait SampleParser {
fn oscilloscope(&self, data: &mut [u8], channels: u32) -> Vec<Vec<f64>>;
fn oscilloscope(&self, data: &mut [u8], channels: u8) -> Vec<Vec<f64>>;
fn sample_size(&self) -> usize;
}
pub struct Signed16PCM {}
/// TODO these are kinda inefficient, can they be faster?
impl SampleParser for Signed16PCM {
fn oscilloscope(&self, data: &mut [u8], channels: u32) -> Vec<Vec<f64>> {
fn oscilloscope(&self, data: &mut [u8], channels: u8) -> Vec<Vec<f64>> {
let mut out = vec![vec![]; channels as usize];
let mut channel = 0;
for chunk in data.chunks(2) {
@ -25,4 +26,8 @@ impl SampleParser for Signed16PCM {
}
out
}
fn sample_size(&self) -> usize {
return 2; // 16 bit, thus 2 bytes
}
}