From 37af6d9fc4893e1b3a378e83d1ef2cefb67a895c Mon Sep 17 00:00:00 2001 From: alemi Date: Sun, 17 Sep 2023 07:10:08 +0200 Subject: [PATCH] feat: restructured project, initial broken fft --- Cargo.toml | 3 +- src/app.rs | 566 +++++++++++------------------------- src/config.rs | 61 ---- src/display/mod.rs | 81 ++++++ src/display/oscilloscope.rs | 149 ++++++++++ src/display/spectroscope.rs | 53 ++++ src/display/vectorscope.rs | 76 +++++ src/main.rs | 21 +- src/source.rs | 2 +- 9 files changed, 555 insertions(+), 457 deletions(-) delete mode 100644 src/config.rs create mode 100644 src/display/mod.rs create mode 100644 src/display/oscilloscope.rs create mode 100644 src/display/spectroscope.rs create mode 100644 src/display/vectorscope.rs diff --git a/Cargo.toml b/Cargo.toml index 6b7b03c..0c57abe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scope-tui" -version = "0.2.1" +version = "0.2.2" edition = "2021" authors = [ "alemi " ] description = "A simple oscilloscope/vectorscope for the terminal, inspired by cava" @@ -19,3 +19,4 @@ libpulse-simple-binding = "2.25" clap = { version = "4.0.32", features = ["derive"] } derive_more = "0.99.17" thiserror = "1.0.48" +easyfft = "0.4.0" diff --git a/src/app.rs b/src/app.rs index 8e9d891..90bae99 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,444 +1,232 @@ -use std::{io::{self, ErrorKind}, time::{Duration, Instant}}; +use std::{io, time::{Duration, Instant}, ops::Range}; use tui::{ - style::Color, widgets::{GraphType, Table, Row, Cell}, symbols, + style::Color, widgets::{Table, Row, Cell}, symbols::Marker, backend::Backend, - widgets::{Chart, Axis, Dataset}, - Terminal, text::Span, style::{Style, Modifier}, layout::{Rect, Constraint} + widgets::Chart, + Terminal, style::{Style, Modifier}, layout::{Rect, Constraint} }; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; -use libpulse_simple_binding::Simple; -use libpulse_binding::{stream::Direction, def::BufferAttr}; -use libpulse_binding::sample::{Spec, Format}; - -use crate::{Args, source::{PulseAudioSimple, DataSource}}; -use crate::config::{ChartNames, ChartBounds, ChartReferences, AppConfig, Dimension}; +use crate::{source::DataSource, display::{GraphConfig, oscilloscope::Oscilloscope, DisplayMode, Dimension, vectorscope::Vectorscope, spectroscope::Spectroscope}}; use crate::parser::{SampleParser, Signed16PCM}; -pub fn run_app(args: Args, terminal: &mut Terminal) -> Result<(), io::Error> { - // prepare globals - let mut app = App::from(&args); - let fmt = Signed16PCM{}; // TODO some way to choose this? - let mut source = PulseAudioSimple::new( - args.device.as_deref(), - args.channels, - args.sample_rate, - args.buffer, - args.server_buffer - ).unwrap(); - - let mut fps = 0; - let mut framerate = 0; - let mut last_poll = Instant::now(); - let mut channels = vec![]; - - loop { - let data = source.recv().unwrap(); - - if !app.cfg.pause { - channels = fmt.oscilloscope(data, args.channels); - } - - let mut trigger_offset = 0; - - if app.cfg.triggering { - // TODO allow to customize channel to use for triggering - if let Some(ch) = channels.get(0) { - for i in 0..ch.len() { // seek to first sample rising through threshold - if triggered(ch, i, app.cfg.threshold, app.cfg.depth, app.cfg.falling_edge) { // triggered - break; - } else { - trigger_offset += 1; - } - } - // for ch in channels.iter_mut() { - // let limit = if ch.len() < discard { ch.len() } else { discard }; - // *ch = ch[limit..].to_vec(); - // } - } - } - - let mut measures : Vec<(String, Vec<(f64, f64)>)> = vec![]; - let mut peaks : Vec> = vec![]; - - // 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 { - for (i, chunk) in channels.chunks(2).enumerate() { - let mut tmp = vec![]; - 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((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 { - for (i, channel) in channels.iter().enumerate() { - let mut tmp = vec![]; - let mut peak_up = 0.0; - let mut peak_down = 0.0; - for i in 0..channel.len() { - if i >= trigger_offset { - tmp.push(((i - trigger_offset) as f64, channel[i])); - } - if channel[i] > peak_up { - peak_up = channel[i]; - } - if channel[i] < peak_down { - peak_down = channel[i]; - } - } - measures.push((channel_name(i, false), tmp)); - peaks.push(vec![(0.0, peak_down), (0.0, peak_up)]); - } - } - - let samples = measures.iter().map(|(_,x)| x.len()).max().unwrap_or(0); - - let mut datasets = vec![]; - - if app.cfg.references { - 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 trigger_pt; - if app.cfg.triggering { - trigger_pt = [(0.0, app.cfg.threshold)]; - datasets.push(data_set("T", &trigger_pt, app.marker_type(), GraphType::Scatter, Color::Cyan)); - } - - let m_len = measures.len() - 1; - - if !app.cfg.vectorscope && app.cfg.peaks { - for (i, pt) in peaks.iter().rev().enumerate() { - datasets.push(data_set("", pt, app.marker_type(), GraphType::Scatter, app.palette(m_len - i))); - } - } - - 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; - - if last_poll.elapsed().as_secs() >= 1 { - framerate = fps; - fps = 0; - last_poll = Instant::now(); - } - - terminal.draw(|f| { - let mut size = f.size(); - if app.cfg.show_ui { - let heading = header(&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; - } - let chart = Chart::new(datasets) - .x_axis(axis(&app, Dimension::X)) // TODO allow to have axis sometimes? - .y_axis(axis(&app, Dimension::Y)); - f.render_widget(chart, size) - })?; - - if process_events(&mut app, &args)? { - break; - } - } - - Ok(()) +pub enum CurrentDisplayMode { + Oscilloscope, + Vectorscope, + Spectroscope, } pub struct App { - pub cfg: AppConfig, - pub references: ChartReferences, - pub bounds: ChartBounds, - pub names: ChartNames, + pause: bool, + channels: u8, + graph: GraphConfig, + oscilloscope: Oscilloscope, + vectorscope: Vectorscope, + spectroscope: Spectroscope, + mode: CurrentDisplayMode, } impl App { - pub fn update_values(&mut self) { - if self.cfg.scale > 32770 { // sample max value is 32768 (32 bits), but we leave 2 pixels for - self.cfg.scale = 32770; // padding (and to not "disaling" range when reaching limit) - } - if self.cfg.scale < 0 { - self.cfg.scale = 0; - } - if self.cfg.depth < 1 { - self.cfg.depth = 1; - } - if self.cfg.vectorscope { - self.names.x = "left -".into(); - self.names.y = "| right".into(); - self.bounds.x = [-(self.cfg.scale as f64), self.cfg.scale as f64]; - self.bounds.y = [-(self.cfg.scale as f64), self.cfg.scale as f64]; - self.references.x = vec![(-(self.cfg.scale as f64), 0.0), (self.cfg.scale as f64, 0.0)]; - self.references.y = vec![(0.0, -(self.cfg.scale as f64)), (0.0, self.cfg.scale as f64)]; - } else { - self.names.x = "time -".into(); - self.names.y = "| amplitude".into(); - self.bounds.x = [0.0, self.cfg.width as f64]; - self.bounds.y = [-(self.cfg.scale as f64), self.cfg.scale as f64]; - self.references.x = vec![(0.0, 0.0), (self.cfg.width as f64, 0.0)]; - let half_width = self.cfg.width as f64 / 2.0; - self.references.y = vec![(half_width, -(self.cfg.scale as f64)), (half_width, self.cfg.scale as f64)]; + pub fn run(&mut self, mut source: impl DataSource, terminal: &mut Terminal) -> Result<(), io::Error> { + // prepare globals + let fmt = Signed16PCM{}; // TODO some way to choose this? + + let mut fps = 0; + let mut framerate = 0; + let mut last_poll = Instant::now(); + let mut channels = vec![]; + + loop { + let data = source.recv().unwrap(); + + if !self.pause { + channels = fmt.oscilloscope(data, self.channels); + } + + fps += 1; + + if last_poll.elapsed().as_secs() >= 1 { + framerate = fps; + fps = 0; + last_poll = Instant::now(); + } + + { + let display = match self.mode { + CurrentDisplayMode::Oscilloscope => &self.oscilloscope as &dyn DisplayMode, + CurrentDisplayMode::Vectorscope => &self.vectorscope as &dyn DisplayMode, + CurrentDisplayMode::Spectroscope => &self.spectroscope as &dyn DisplayMode, + }; + + let mut datasets = Vec::new(); + if self.graph.references { + datasets.append(&mut display.references(&self.graph)); + } + datasets.append(&mut display.process(&self.graph, &channels)); + terminal.draw(|f| { + let mut size = f.size(); + if self.graph.show_ui { + f.render_widget( + make_header(&self.graph, &display.header(&self.graph), framerate, self.pause), + Rect { x: size.x, y: size.y, width: size.width, height:1 } // a 1px line at the top + ); + size.height -= 1; + size.y += 1; + } + let chart = Chart::new(datasets.iter().map(|x| x.into()).collect()) + .x_axis(display.axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes? + .y_axis(display.axis(&self.graph, Dimension::Y)); + f.render_widget(chart, size) + }).unwrap(); + } + + while event::poll(Duration::from_millis(0))? { // process all enqueued events + let event = event::read()?; + + if self.process_events(event.clone())? { return Ok(()); } + self.current_display().handle(event); + } } } - pub fn marker_type(&self) -> symbols::Marker { - if self.cfg.braille { - symbols::Marker::Braille - } else { - symbols::Marker::Dot + fn current_display(&mut self) -> &mut dyn DisplayMode { + match self.mode { + CurrentDisplayMode::Oscilloscope => &mut self.oscilloscope as &mut dyn DisplayMode, + CurrentDisplayMode::Vectorscope => &mut self.vectorscope as &mut dyn DisplayMode, + CurrentDisplayMode::Spectroscope => &mut self.spectroscope as &mut dyn DisplayMode, } } - pub fn graph_type(&self) -> GraphType { - if self.cfg.scatter { - GraphType::Scatter - } else { - GraphType::Line - } + fn process_events(&mut self, event: Event) -> Result { + let mut quit = false; + if let Event::Key(key) = event { + if let KeyModifiers::CONTROL = key.modifiers { + match key.code { // mimic other programs shortcuts to quit, for user friendlyness + KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('w') => quit = true, + _ => {}, + } + } + let magnitude = match key.modifiers { + KeyModifiers::SHIFT => 10.0, + KeyModifiers::CONTROL => 5.0, + KeyModifiers::ALT => 0.2, + _ => 1.0, + }; + match key.code { + KeyCode::Up => update_value_i(&mut self.graph.scale, true, 250, magnitude, 0..32768), // inverted to act as zoom + KeyCode::Down => update_value_i(&mut self.graph.scale, false, 250, magnitude, 0..32768), // inverted to act as zoom + KeyCode::Right => update_value_i(&mut self.graph.samples, true, 25, magnitude, 0..self.graph.width), + KeyCode::Left => update_value_i(&mut self.graph.samples, false, 25, magnitude, 0..self.graph.width), + KeyCode::Char('q') => quit = true, + KeyCode::Char(' ') => self.pause = !self.pause, + KeyCode::Char('s') => self.graph.scatter = !self.graph.scatter, + KeyCode::Char('h') => self.graph.show_ui = !self.graph.show_ui, + KeyCode::Char('r') => self.graph.references = !self.graph.references, + KeyCode::Tab => { // switch modes + match self.mode { + CurrentDisplayMode::Oscilloscope => self.mode = CurrentDisplayMode::Vectorscope, + CurrentDisplayMode::Vectorscope => self.mode = CurrentDisplayMode::Spectroscope, + CurrentDisplayMode::Spectroscope => self.mode = CurrentDisplayMode::Oscilloscope, + } + }, + _ => {}, + } + }; + + Ok(quit) } +} - pub fn palette(&self, index: usize) -> Color { - *self.cfg.palette.get(index % self.cfg.palette.len()).unwrap_or(&Color::White) +pub fn update_value_f(val: &mut f64, base: f64, magnitude: f64, range: Range) { + let delta = base * magnitude; + if *val + delta > range.end { + *val = range.end + } else if *val + delta < range.start { + *val = range.start + } else { + *val += delta; + } +} + +pub fn update_value_i(val: &mut u32, inc: bool, base: u32, magnitude: f64, range: Range) { + let delta = (base as f64 * magnitude) as u32; + if inc { + if range.end - delta < *val { + *val = range.end + } else { + *val += delta + } + } else if range.start + delta > *val { + *val = range.start + } else { + *val -= delta } } impl From::<&crate::Args> for App { fn from(args: &crate::Args) -> Self { - let cfg = AppConfig { + let graph = GraphConfig { axis_color: Color::DarkGray, + labels_color: Color::Cyan, palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta], scale: args.range, width: args.buffer / (2 * args.channels as u32), // TODO also make bit depth customizable + samples: args.buffer / (2 * args.channels as u32), + references: !args.no_reference, + show_ui: !args.no_ui, + scatter: args.scatter, + marker_type: if args.no_braille { + Marker::Dot + } else { + Marker::Braille + }, + }; + + let oscilloscope = Oscilloscope { triggering: args.triggering, depth: args.check_depth, threshold: args.threshold, - vectorscope: args.vectorscope, - references: !args.no_reference, - show_ui: !args.no_ui, - braille: !args.no_braille, - scatter: args.scatter, falling_edge: args.falling_edge, peaks: args.show_peaks, + }; + + let vectorscope = Vectorscope {}; + let spectroscope = Spectroscope {}; + + App { + graph, oscilloscope, vectorscope, spectroscope, + mode: CurrentDisplayMode::Oscilloscope, + channels: args.channels, pause: false, - }; - - let mut app = App { - cfg, - references: ChartReferences::default(), - bounds: ChartBounds::default(), - names: ChartNames::default(), - }; - - app.update_values(); - - app + } } } -// TODO these functions probably shouldn't be here -fn header(app: &App, samples: u32, framerate: u32) -> Table<'static> { +fn make_header<'a>(cfg: &GraphConfig, module_header: &'a str, fps: usize, pause: bool) -> Table<'a> { 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!("{}", - if app.cfg.triggering { - format!( - "{} {:.0}{} trigger", - if app.cfg.falling_edge { "v" } else { "^" }, - app.cfg.threshold, - if app.cfg.depth > 1 { format!(":{}", app.cfg.depth) } else { "".into() }, - ) - } else { - "live".into() - } - )), - Cell::from(format!("-{}+ range", app.cfg.scale)), - Cell::from(format!("{}/{} sample", app.cfg.width, samples as u32)), - Cell::from(format!("{}fps", framerate)), - Cell::from(format!("{}{}", if app.cfg.peaks { "|" } else { " " }, if app.cfg.scatter { "***" } else { "---" })), - Cell::from(format!("{}", if app.cfg.pause { "||" } else { "|>" } )), + Cell::from("tui **scope").style(Style::default().fg(*cfg.palette.get(0).expect("empty palette?")).add_modifier(Modifier::BOLD)), + Cell::from(module_header), + Cell::from(format!("-{}+", cfg.scale)), + Cell::from(format!("{}/{} spf", cfg.samples, cfg.width)), + Cell::from(format!("{}fps", fps)), + Cell::from(if cfg.scatter { "***" } else { "---" }), + Cell::from(if pause { "||" } else { "|>" }), ] ) ] ) - .style(Style::default().fg(Color::Cyan)) + .style(Style::default().fg(cfg.labels_color)) .widths(&[ Constraint::Percentage(35), - Constraint::Percentage(15), - Constraint::Percentage(15), - Constraint::Percentage(15), + Constraint::Percentage(25), + Constraint::Percentage(7), + Constraint::Percentage(13), Constraint::Percentage(6), Constraint::Percentage(6), Constraint::Percentage(6) ]) } - -fn process_events(app: &mut App, args: &Args) -> Result { - let mut quit = false; - - if event::poll(Duration::from_millis(0))? { // process all enqueued events - let event = event::read()?; - - match event { - Event::Key(key) => { - match key.modifiers { - KeyModifiers::SHIFT => { - match key.code { - KeyCode::Up => app.cfg.scale -= 1000, // inverted to act as zoom - KeyCode::Down => app.cfg.scale += 1000, // inverted to act as zoom - KeyCode::Right => app.cfg.width += 100, - KeyCode::Left => app.cfg.width -= 100, - KeyCode::PageUp => app.cfg.threshold += 1000.0, - KeyCode::PageDown => app.cfg.threshold -= 1000.0, - _ => {}, - } - }, - KeyModifiers::CONTROL => { - match key.code { // mimic other programs shortcuts to quit, for user friendlyness - KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('w') => quit = true, - KeyCode::Up => app.cfg.scale -= 50, // inverted to act as zoom - KeyCode::Down => app.cfg.scale += 50, // inverted to act as zoom - KeyCode::Right => app.cfg.width += 5, - KeyCode::Left => app.cfg.width -= 5, - KeyCode::PageUp => app.cfg.threshold += 50.0, - KeyCode::PageDown => app.cfg.threshold -= 50.0, - KeyCode::Char('r') => { // reset settings - app.cfg.references = !args.no_reference; - app.cfg.show_ui = !args.no_ui; - 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; - }, - _ => {}, - } - }, - _ => { - match key.code { - KeyCode::Char('q') => quit = true, - KeyCode::Char(' ') => app.cfg.pause = !app.cfg.pause, - KeyCode::Char('v') => app.cfg.vectorscope = !app.cfg.vectorscope, - 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('e') => app.cfg.falling_edge = !app.cfg.falling_edge, - KeyCode::Char('t') => app.cfg.triggering = !app.cfg.triggering, - KeyCode::Char('p') => app.cfg.peaks = !app.cfg.peaks, - KeyCode::Char('=') => app.cfg.depth += 1, - KeyCode::Char('-') => app.cfg.depth -= 1, - KeyCode::Char('+') => app.cfg.depth += 10, - KeyCode::Char('_') => app.cfg.depth -= 10, - KeyCode::Up => app.cfg.scale -= 250, // inverted to act as zoom - KeyCode::Down => app.cfg.scale += 250, // inverted to act as zoom - KeyCode::Right => app.cfg.width += 25, - KeyCode::Left => app.cfg.width -= 25, - KeyCode::PageUp => app.cfg.threshold += 250.0, - KeyCode::PageDown => app.cfg.threshold -= 250.0, - KeyCode::Tab => { // only reset "zoom" - app.cfg.width = args.buffer / (args.channels as u32 * 2); // TODO ... - app.cfg.scale = args.range; - }, - KeyCode::Esc => { // back to oscilloscope - app.cfg.references = !args.no_reference; - app.cfg.show_ui = !args.no_ui; - app.cfg.vectorscope = args.vectorscope; - }, - _ => {}, - } - } - } - app.update_values(); - }, - _ => {}, - }; - } - - Ok(quit) -} - -// TODO can this be made nicer? -fn triggered(data: &[f64], index: usize, threshold: f64, depth: u32, falling_edge:bool) -> bool { - if data.len() < index + (1+depth as usize) { return false; } - if falling_edge { - if data[index] >= threshold { - for i in 1..=depth as usize { - if data[index+i] >= threshold { - return false; - } - } - return true; - } else { - return false; - } - } else { - if data[index] <= threshold { - for i in 1..=depth as usize { - if data[index+i] <= threshold { - return false; - } - } - return true; - } else { - return false; - } - } -} - -fn data_set<'a>( - name: &'a str, - data: &'a [(f64, f64)], - marker_type: symbols::Marker, - graph_type: GraphType, - axis_color: Color -) -> Dataset<'a> { - Dataset::default() - .name(name) - .marker(marker_type) - .graph_type(graph_type) - .style(Style::default().fg(axis_color)) - .data(&data) -} - -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.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), - } -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 3aee952..0000000 --- a/src/config.rs +++ /dev/null @@ -1,61 +0,0 @@ -use tui::style::Color; - -// use crate::parser::SampleParser; - -pub enum Dimension { - X, Y -} - -#[derive(Default)] -pub struct ChartNames { - pub x: String, - pub y: String, -} - - -pub struct ChartBounds { - pub x: [f64;2], - pub y: [f64;2], -} - -impl Default for ChartBounds { - fn default() -> Self { - ChartBounds { x: [0.0, 0.0], y: [0.0, 0.0] } - } -} - -pub struct ChartReferences { - pub x: Vec<(f64, f64)>, - pub y: Vec<(f64, f64)>, -} - -impl Default for ChartReferences { - fn default() -> Self { - ChartReferences { - x: vec![(0.0, 0.0), (0.0, 1.0)], - y: vec![(0.5, 1.0), (0.5, -1.0)] - } - } -} - -pub struct AppConfig { - pub axis_color: Color, - pub palette: Vec, - - pub scale: i32, - pub width: u32, - pub vectorscope: bool, - pub references: bool, - pub show_ui: bool, - pub peaks: bool, - - pub triggering: bool, - pub threshold: f64, - pub depth: u32, - pub falling_edge: bool, - - pub scatter: bool, - pub braille: bool, - - pub pause: bool, -} diff --git a/src/display/mod.rs b/src/display/mod.rs new file mode 100644 index 0000000..efdb016 --- /dev/null +++ b/src/display/mod.rs @@ -0,0 +1,81 @@ +pub mod oscilloscope; +pub mod vectorscope; +pub mod spectroscope; + +use crossterm::event::Event; +use tui::{widgets::{Dataset, Axis, GraphType}, style::{Style, Color}, symbols::Marker}; + +pub enum Dimension { + X, Y +} + +pub struct GraphConfig { + pub samples: u32, + pub scale: u32, + pub width: u32, + pub scatter: bool, + pub references: bool, + pub show_ui: bool, + pub marker_type: Marker, + pub palette: Vec, + pub labels_color: Color, + pub axis_color: Color, +} + +impl GraphConfig { + pub fn palette(&self, index: usize) -> Color { + *self.palette.get(index % self.palette.len()).unwrap_or(&Color::White) + } +} + +#[allow(clippy::ptr_arg)] // TODO temporarily! it's a shitty solution +pub trait DisplayMode { + // MUST define + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this + fn process(&self, cfg: &GraphConfig, data: &Vec>) -> Vec; + + // SHOULD override + fn handle(&mut self, _event: Event) {} + fn channel_name(&self, index: usize) -> String { format!("{}", index) } + fn header(&self, _cfg: &GraphConfig) -> String { "".into() } + fn references(&self, cfg: &GraphConfig) -> Vec { + let half_width = cfg.samples as f64 / 2.0; + vec![ + DataSet::new("".into(), vec![(0.0, 0.0), (cfg.width as f64, 0.0)], cfg.marker_type, GraphType::Line, cfg.axis_color), + DataSet::new("".into(), vec![(half_width, -(cfg.scale as f64)), (half_width, cfg.scale as f64)], cfg.marker_type, GraphType::Line, cfg.axis_color), + + ] + } +} + +pub struct DataSet { + name: String, + data: Vec<(f64, f64)>, + marker_type: Marker, + graph_type: GraphType, + color: Color, +} + +impl<'a> From::<&'a DataSet> for Dataset<'a> { + fn from(ds: &'a DataSet) -> Dataset<'a> { + Dataset::default() + .name(ds.name.clone()) + .marker(ds.marker_type) + .graph_type(ds.graph_type) + .style(Style::default().fg(ds.color)) + .data(&ds.data) + } +} + +impl DataSet { + pub fn new( + name: String, + data: Vec<(f64, f64)>, + marker_type: Marker, + graph_type: GraphType, + color: Color + ) -> Self { + DataSet { name, data, marker_type, graph_type, color } + } +} + diff --git a/src/display/oscilloscope.rs b/src/display/oscilloscope.rs new file mode 100644 index 0000000..96cf93c --- /dev/null +++ b/src/display/oscilloscope.rs @@ -0,0 +1,149 @@ +use crossterm::event::{Event, KeyModifiers, KeyCode}; +use tui::{widgets::{Axis, GraphType}, style::Style, text::Span}; + +use crate::app::update_value_f; + +use super::{DisplayMode, GraphConfig, DataSet, Dimension}; + +pub struct Oscilloscope { + pub triggering: bool, + pub falling_edge: bool, + pub threshold: f64, + pub depth: u32, + pub peaks: bool, +} + +impl DisplayMode for Oscilloscope { + fn channel_name(&self, index: usize) -> String { + match index { + 0 => "L".into(), + 1 => "R".into(), + _ => format!("{}", index), + } + } + + fn header(&self, _: &GraphConfig) -> String { + if self.triggering { + format!( + "{} {:.0}{} trigger", + if self.falling_edge { "v" } else { "^" }, + self.threshold, + if self.depth > 1 { format!(":{}", self.depth) } else { "".into() }, + ) + } else { + "live".into() + } + } + + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { + let (name, bounds) = match dimension { + Dimension::X => ("time -", [0.0, cfg.samples as f64]), + Dimension::Y => ("| amplitude", [-(cfg.scale as f64), cfg.scale as f64]), + }; + let mut a = Axis::default(); + if cfg.show_ui { // TODO don't make it necessary to check show_ui inside here + a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); + } + a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) + } + + fn process(&self, cfg: &GraphConfig, data: &Vec>) -> Vec { + let mut out = Vec::new(); + + let mut trigger_offset = 0; + if self.triggering { + for i in 0..data[0].len() { + if triggered(&data[0], i, self.threshold, self.depth, self.falling_edge) { // triggered + break; + } else { + trigger_offset += 1; + } + } + } + + if self.triggering { + out.push(DataSet::new("T".into(), vec![(0.0, self.threshold)], cfg.marker_type, GraphType::Scatter, cfg.labels_color)); + } + + for (n, channel) in data.iter().enumerate().rev() { + let (mut min, mut max) = (0.0, 0.0); + let mut tmp = Vec::new(); + for (i, sample) in channel.iter().enumerate() { + if *sample < min { min = *sample }; + if *sample > max { max = *sample }; + if i >= trigger_offset { + tmp.push(((i - trigger_offset) as f64, *sample)); + } + } + + if self.peaks { + out.push(DataSet::new( + "".into(), + vec![(0.0, min), (0.0, max)], + cfg.marker_type, + GraphType::Scatter, + cfg.palette(n) + )) + } + + out.push(DataSet::new( + self.channel_name(n), + tmp, + cfg.marker_type, + if cfg.scatter { GraphType::Scatter } else { GraphType::Line }, + cfg.palette(n), + )); + } + + out + } + + fn handle(&mut self, event: Event) { + if let Event::Key(key) = event { + let magnitude = match key.modifiers { + KeyModifiers::SHIFT => 10.0, + KeyModifiers::CONTROL => 5.0, + KeyModifiers::ALT => 0.2, + _ => 1.0, + }; + match key.code { + KeyCode::PageUp => update_value_f(&mut self.threshold, 250.0, magnitude, 0.0..32768.0), + KeyCode::PageDown => update_value_f(&mut self.threshold, -250.0, magnitude, 0.0..32768.0), + KeyCode::Char('t') => self.triggering = !self.triggering, + KeyCode::Char('e') => self.falling_edge = !self.falling_edge, + KeyCode::Char('p') => self.peaks = !self.peaks, + KeyCode::Char('=') => self.depth += 1, + KeyCode::Char('-') => self.depth -= 1, + KeyCode::Char('+') => self.depth += 10, + KeyCode::Char('_') => self.depth -= 10, + _ => {} + } + } + } +} + +// TODO can this be made nicer? +fn triggered(data: &[f64], index: usize, threshold: f64, depth: u32, falling_edge:bool) -> bool { + if data.len() < index + (1+depth as usize) { return false; } + if falling_edge { + if data[index] >= threshold { + for i in 1..=depth as usize { + if data[index+i] >= threshold { + return false; + } + } + true + } else { + false + } + } else if data[index] <= threshold { + for i in 1..=depth as usize { + if data[index+i] <= threshold { + return false; + } + } + true + } else { + false + } +} diff --git a/src/display/spectroscope.rs b/src/display/spectroscope.rs new file mode 100644 index 0000000..de1cc1c --- /dev/null +++ b/src/display/spectroscope.rs @@ -0,0 +1,53 @@ +use tui::{widgets::{Axis, GraphType}, style::Style, text::Span}; + +use super::{DisplayMode, GraphConfig, DataSet, Dimension}; + +use easyfft::prelude::*; + +pub struct Spectroscope {} + +impl DisplayMode for Spectroscope { + fn channel_name(&self, index: usize) -> String { + format!("{}", index) + } + + fn header(&self, _: &GraphConfig) -> String { + "live".into() + } + + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { + let (name, bounds) = match dimension { + Dimension::X => ("frequency -", [-(cfg.scale as f64), cfg.scale as f64]), + Dimension::Y => ("| level", [-(cfg.scale as f64), cfg.scale as f64]), + }; + let mut a = Axis::default(); + if cfg.show_ui { // TODO don't make it necessary to check show_ui inside here + a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); + } + a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) + } + + fn references(&self, cfg: &GraphConfig) -> Vec { + vec![ + DataSet::new("".into(), vec![(0.0, 0.0), (20000.0, 0.0)], cfg.marker_type, GraphType::Line, cfg.axis_color), + DataSet::new("".into(), vec![(0.0, 0.0), (0.0, cfg.scale as f64)], cfg.marker_type, GraphType::Line, cfg.axis_color), + ] + } + + fn process(&self, cfg: &GraphConfig, data: &Vec>) -> Vec { + let mut out = Vec::new(); + + for (n, chunk) in data.iter().enumerate() { + let tmp = chunk.real_fft().iter().map(|x| (x.re, x.im)).collect(); + out.push(DataSet::new( + self.channel_name(n), + tmp, + cfg.marker_type, + if cfg.scatter { GraphType::Scatter } else { GraphType::Line }, + cfg.palette(n), + )); + } + + out + } +} diff --git a/src/display/vectorscope.rs b/src/display/vectorscope.rs new file mode 100644 index 0000000..20a85b5 --- /dev/null +++ b/src/display/vectorscope.rs @@ -0,0 +1,76 @@ +use tui::{widgets::{Axis, GraphType}, style::Style, text::Span}; + +use super::{DisplayMode, GraphConfig, DataSet, Dimension}; + +pub struct Vectorscope {} + +impl DisplayMode for Vectorscope { + fn channel_name(&self, index: usize) -> String { + format!("{}", index) + } + + fn header(&self, _: &GraphConfig) -> String { + "live".into() + } + + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { + let (name, bounds) = match dimension { + Dimension::X => ("left -", [-(cfg.scale as f64), cfg.scale as f64]), + Dimension::Y => ("| right", [-(cfg.scale as f64), cfg.scale as f64]), + }; + let mut a = Axis::default(); + if cfg.show_ui { // TODO don't make it necessary to check show_ui inside here + a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); + } + a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) + } + + fn references(&self, cfg: &GraphConfig) -> Vec { + vec![ + DataSet::new("".into(), vec![(-(cfg.scale as f64), 0.0), (cfg.scale as f64, 0.0)], cfg.marker_type, GraphType::Line, cfg.axis_color), + DataSet::new("".into(), vec![(0.0, -(cfg.scale as f64)), (0.0, cfg.scale as f64)], cfg.marker_type, GraphType::Line, cfg.axis_color), + ] + } + + fn process(&self, cfg: &GraphConfig, data: &Vec>) -> Vec { + let mut out = Vec::new(); + + for (n, chunk) in data.chunks(2).enumerate() { + let mut tmp = vec![]; + match chunk.len() { + 2 => { + for i in 0..std::cmp::min(chunk[0].len(), chunk[1].len()) { + if i > cfg.samples as usize { break } + tmp.push((chunk[0][i], chunk[1][i])); + } + }, + 1 => { + for i in 0..chunk[0].len() { + if i > cfg.samples as usize { break } + tmp.push((chunk[0][i], i as f64)); + } + }, + _ => continue, + } + // split it in two for easier coloring + // TODO configure splitting in multiple parts? + let pivot = tmp.len() / 2; + out.push(DataSet::new( + self.channel_name((n * 2) + 1), + tmp[pivot..].to_vec(), + cfg.marker_type, + if cfg.scatter { GraphType::Scatter } else { GraphType::Line }, + cfg.palette((n * 2) + 1), + )); + out.push(DataSet::new( + self.channel_name(n * 2), + tmp[..pivot].to_vec(), + cfg.marker_type, + if cfg.scatter { GraphType::Scatter } else { GraphType::Line }, + cfg.palette(n * 2), + )); + } + + out + } +} diff --git a/src/main.rs b/src/main.rs index 3973272..499489b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ mod parser; mod app; -mod config; mod music; mod source; +mod display; +use app::App; +use source::PulseAudioSimple; use tui::{ backend::CrosstermBackend, Terminal, @@ -17,7 +19,6 @@ use crossterm::{ use clap::Parser; use crate::music::Note; -use crate::app::run_app; const HELP_TEMPLATE : &str = "{before-help}\ {name} {version} -- by {author} @@ -41,7 +42,7 @@ pub struct Args { /// Max value, positive and negative, on amplitude scale #[arg(short, long, value_name = "SIZE", default_value_t = 20000)] - range: i32, // TODO counterintuitive, improve this + range: u32, // TODO counterintuitive, improve this /// Use vintage looking scatter mode instead of line mode #[arg(long, default_value_t = false)] @@ -52,7 +53,7 @@ pub struct Args { vectorscope: bool, /// Show peaks for each channel as dots - #[arg(long, default_value_t = false)] + #[arg(long, default_value_t = true)] show_peaks: bool, /// Tune buffer size to be in tune with given note (overrides buffer option) @@ -114,6 +115,16 @@ fn main() -> Result<(), std::io::Error> { } } + let source = PulseAudioSimple::new( + args.device.as_deref(), + args.channels, + args.sample_rate, + args.buffer, + args.server_buffer + ).unwrap(); + + let mut app = App::from(&args); + // setup terminal enable_raw_mode()?; let mut stdout = std::io::stdout(); @@ -122,7 +133,7 @@ fn main() -> Result<(), std::io::Error> { let mut terminal = Terminal::new(backend)?; terminal.hide_cursor()?; - let res = run_app(args, &mut terminal); + let res = app.run(source, &mut terminal); // restore terminal disable_raw_mode()?; diff --git a/src/source.rs b/src/source.rs index 190b6bd..b5d9be6 100644 --- a/src/source.rs +++ b/src/source.rs @@ -1,4 +1,4 @@ -use libpulse_binding::{stream::Direction, sample::{Spec, Format}, def::BufferAttr, error::{PAErr, Code}}; +use libpulse_binding::{sample::{Spec, Format}, def::BufferAttr, error::PAErr, stream::Direction}; use libpulse_simple_binding::Simple; pub trait DataSource {