From 9378621a263e01febc5832b22c867d1f8bae5787 Mon Sep 17 00:00:00 2001 From: alemi Date: Mon, 18 Sep 2023 04:36:52 +0200 Subject: [PATCH] feat: added crude file source, gated pulseaudio now requires feature pulseaudio to build libpulse and pulse source. file source is always included. the cli got reworked a little bit but I'm not sure I like it this way, we'll see --- Cargo.toml | 19 +++-- src/app.rs | 29 +++----- src/display/mod.rs | 2 + src/display/oscilloscope.rs | 4 ++ src/display/spectroscope.rs | 9 +++ src/display/vectorscope.rs | 4 ++ src/main.rs | 135 ++++++++++++++++++------------------ src/source.rs | 119 ++++++++++++++++++++----------- 8 files changed, 187 insertions(+), 134 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f359864..94cbd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,8 @@ name = "scope-tui" version = "0.3.0" edition = "2021" authors = [ "alemi " ] -description = "A simple oscilloscope/vectorscope for the terminal, inspired by cava" -keywords = ["tui", "terminal", "audio", "visualization", "scope", "dashboard"] +description = "A simple oscilloscope/vectorscope/spectroscope for your terminal" +keywords = ["tui", "terminal", "audio", "visualization", "scope", "dashboard", "oscilloscope", "spectroscope"] repository = "https://github.com/alemidev/scope-tui" readme = "README.md" # documentation = @@ -12,11 +12,18 @@ readme = "README.md" [dependencies] -ratatui = { version = "0.23.0", features = ["all-widgets"] } -crossterm = "0.25" -libpulse-binding = "2.0" -libpulse-simple-binding = "2.25" clap = { version = "4.0.32", features = ["derive"] } derive_more = "0.99.17" thiserror = "1.0.48" rustfft = "6.1.0" +# for TUI backend +ratatui = { version = "0.23.0", features = ["all-widgets"], optional = true } +crossterm = { version = "0.25", optional = true } +# for pulseaudio +libpulse-binding = { version = "2.0", optional = true } +libpulse-simple-binding = { version = "2.25", optional = true } + +[features] +default = ["tui"] +tui = ["dep:ratatui", "dep:crossterm"] +pulseaudio = ["dep:libpulse-binding", "dep:libpulse-simple-binding"] diff --git a/src/app.rs b/src/app.rs index c24b33f..d2e9706 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,8 +27,9 @@ pub struct App { mode: CurrentDisplayMode, } -impl From::<&crate::Args> for App { - fn from(args: &crate::Args) -> Self { +// TODO another way to build this that doesn't require getting cli args directly!!! +impl From::<&crate::ScopeArgs> for App { + fn from(args: &crate::ScopeArgs) -> Self { let graph = GraphConfig { axis_color: Color::DarkGray, labels_color: Color::Cyan, @@ -36,6 +37,7 @@ impl From::<&crate::Args> for App { 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), + sampling_rate: args.sample_rate, references: !args.no_reference, show_ui: !args.no_ui, scatter: args.scatter, @@ -46,21 +48,9 @@ impl From::<&crate::Args> for App { }, }; - let oscilloscope = Oscilloscope { - triggering: args.triggering, - depth: args.check_depth, - threshold: args.threshold, - falling_edge: args.falling_edge, - peaks: args.show_peaks, - }; - - let vectorscope = Vectorscope::default(); - let spectroscope = Spectroscope { - sampling_rate: args.sample_rate, - buffer_size: graph.width, - average: 1, - buf: Vec::new(), - }; + let oscilloscope = Oscilloscope::from_args(args); + let vectorscope = Vectorscope::from_args(args); + let spectroscope = Spectroscope::from_args(args); App { graph, oscilloscope, vectorscope, spectroscope, @@ -72,7 +62,7 @@ impl From::<&crate::Args> for App { } impl App { - pub fn run(&mut self, mut source: impl DataSource, terminal: &mut Terminal) -> Result<(), io::Error> { + pub fn run(&mut self, mut source: Box, terminal: &mut Terminal) -> Result<(), io::Error> { // prepare globals let fmt = Signed16PCM{}; // TODO some way to choose this? @@ -82,7 +72,8 @@ impl App { let mut channels = vec![]; loop { - let data = source.recv().unwrap(); + let data = source.recv() + .ok_or(io::Error::new(io::ErrorKind::BrokenPipe, "data source returned null"))?; if !self.pause { channels = fmt.oscilloscope(data, self.channels); diff --git a/src/display/mod.rs b/src/display/mod.rs index 867aa92..f60174c 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -12,6 +12,7 @@ pub enum Dimension { #[derive(Debug, Clone)] pub struct GraphConfig { pub samples: u32, + pub sampling_rate: u32, pub scale: u32, pub width: u32, pub scatter: bool, @@ -32,6 +33,7 @@ impl GraphConfig { #[allow(clippy::ptr_arg)] // TODO temporarily! it's a shitty solution pub trait DisplayMode { // MUST define + fn from_args(args: &crate::ScopeArgs) -> Self where Self : Sized; fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this fn process(&mut self, cfg: &GraphConfig, data: &Vec>) -> Vec; fn mode_str(&self) -> &'static str; diff --git a/src/display/oscilloscope.rs b/src/display/oscilloscope.rs index 472c83e..30597c4 100644 --- a/src/display/oscilloscope.rs +++ b/src/display/oscilloscope.rs @@ -15,6 +15,10 @@ pub struct Oscilloscope { } impl DisplayMode for Oscilloscope { + fn from_args(_args: &crate::ScopeArgs) -> Self { + Oscilloscope::default() + } + fn mode_str(&self) -> &'static str { "oscillo" } diff --git a/src/display/spectroscope.rs b/src/display/spectroscope.rs index 1828e07..e7cad36 100644 --- a/src/display/spectroscope.rs +++ b/src/display/spectroscope.rs @@ -23,6 +23,14 @@ fn complex_to_magnitude(c: Complex) -> f64 { } impl DisplayMode for Spectroscope { + fn from_args(args: &crate::ScopeArgs) -> Self { + Spectroscope { + sampling_rate: args.sample_rate, + buffer_size: args.buffer / (2 * args.channels as u32), + average: 1, buf: Vec::new(), + } + } + fn mode_str(&self) -> &'static str { "spectro" } @@ -61,6 +69,7 @@ impl DisplayMode for Spectroscope { } fn process(&mut self, cfg: &GraphConfig, data: &Vec>) -> Vec { + if self.average == 0 { self.average = 1 } // otherwise fft breaks for (i, chan) in data.iter().enumerate() { if self.buf.len() <= i { self.buf.push(VecDeque::new()); diff --git a/src/display/vectorscope.rs b/src/display/vectorscope.rs index d3608d3..d7d9c87 100644 --- a/src/display/vectorscope.rs +++ b/src/display/vectorscope.rs @@ -6,6 +6,10 @@ use super::{DisplayMode, GraphConfig, DataSet, Dimension}; pub struct Vectorscope {} impl DisplayMode for Vectorscope { + fn from_args(_args: &crate::ScopeArgs) -> Self { + Vectorscope::default() + } + fn mode_str(&self) -> &'static str { "vector" } diff --git a/src/main.rs b/src/main.rs index 1f52754..07e9690 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,6 @@ mod source; mod display; use app::App; -use source::PulseAudioSimple; use ratatui::{ backend::CrosstermBackend, Terminal, @@ -16,7 +15,7 @@ use crossterm::{ }, }; -use clap::Parser; +use clap::{Parser, Subcommand}; use crate::music::Note; @@ -29,80 +28,73 @@ const HELP_TEMPLATE : &str = "{before-help}\ {all-args}{after-help} "; -/// A simple oscilloscope/vectorscope for your terminal +/// a simple oscilloscope/vectorscope for your terminal #[derive(Parser, Debug)] #[command(author, version, about, long_about = None, help_template = HELP_TEMPLATE)] -pub struct Args { - /// Audio device to attach to - device: Option, +pub struct ScopeArgs { + #[clap(subcommand)] + source: ScopeSource, - /// Size of audio buffer, and width of scope - #[arg(short, long, value_name = "SIZE", default_value_t = 8192)] - buffer: u32, - - /// Max value, positive and negative, on amplitude scale - #[arg(short, long, value_name = "SIZE", default_value_t = 20000)] - range: u32, // TODO counterintuitive, improve this - - /// Use vintage looking scatter mode instead of line mode - #[arg(long, default_value_t = false)] - scatter: bool, - - /// Combine left and right channels into vectorscope view - #[arg(long, default_value_t = false)] - vectorscope: bool, - - /// Show peaks for each channel as dots - #[arg(long, default_value_t = true)] - show_peaks: bool, - - /// Tune buffer size to be in tune with given note (overrides buffer option) - #[arg(long, value_name = "NOTE")] - tune: Option, - - /// Number of channels to open + /// number of channels to open #[arg(long, value_name = "N", default_value_t = 2)] channels: u8, - /// Sample rate to use + /// tune buffer size to be in tune with given note (overrides buffer option) + #[arg(long, value_name = "NOTE")] + tune: Option, + + /// size of audio buffer, and width of scope + #[arg(short, long, value_name = "SIZE", default_value_t = 8192)] + buffer: u32, + + /// sample rate to use #[arg(long, value_name = "HZ", default_value_t = 44100)] sample_rate: u32, - /// Pulseaudio server buffer size, in block number - #[arg(long, value_name = "N", default_value_t = 32)] - server_buffer: u32, + /// max value, positive and negative, on amplitude scale + #[arg(short, long, value_name = "SIZE", default_value_t = 20000)] + range: u32, // TODO counterintuitive, improve this - /// Start drawing at first rising edge + /// use vintage looking scatter mode instead of line mode #[arg(long, default_value_t = false)] - triggering: bool, + scatter: bool, - /// Threshold value for triggering - #[arg(long, value_name = "VAL", default_value_t = 0.0)] - threshold: f64, - - /// Length of trigger check in samples - #[arg(long, value_name = "SMPL", default_value_t = 1)] - check_depth: u32, - - /// Trigger upon falling edge instead of rising - #[arg(long, default_value_t = false)] - falling_edge: bool, - - /// Don't draw reference line + /// don't draw reference line #[arg(long, default_value_t = false)] no_reference: bool, - /// Hide UI and only draw waveforms + /// hide UI and only draw waveforms #[arg(long, default_value_t = false)] no_ui: bool, - /// Don't use braille dots for drawing lines + /// don't use braille dots for drawing lines #[arg(long, default_value_t = false)] no_braille: bool, } -fn main() -> Result<(), std::io::Error> { - let mut args = Args::parse(); +#[derive(Debug, Clone, Subcommand)] +pub enum ScopeSource { + + #[cfg(feature = "pulseaudio")] + /// use PulseAudio Simple api to read data from an audio sink + Pulse { + /// source device to attach to + device: Option, + + /// PulseAudio server buffer size, in block number + #[arg(long, value_name = "N", default_value_t = 32)] + server_buffer: u32, + }, + + /// use a file from filesystem and read its content + File { + /// path on filesystem of file or pipe + path: String, + }, +} + +fn main() -> Result<(), Box> { + let mut args = ScopeArgs::parse(); if let Some(txt) = &args.tune { // TODO make it less jank if let Ok(note) = txt.parse::() { @@ -115,13 +107,24 @@ 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 source = match &args.source { + + #[cfg(feature = "pulseaudio")] + ScopeSource::Pulse { device, server_buffer } => { + source::pulseaudio::PulseAudioSimpleDataSource::new( + device.as_deref(), + args.channels, + args.sample_rate, + args.buffer, + *server_buffer, + )? + }, + + ScopeSource::File { path } => { + source::file::FileSource::new(path, args.buffer)? + }, + + }; let mut app = App::from(&args); @@ -143,11 +146,9 @@ fn main() -> Result<(), std::io::Error> { )?; terminal.show_cursor()?; - match res { - Ok(()) => Ok(()), - Err(e) => { - eprintln!("[!] Error executing app: {:?}", e); - Err(e) - } + if let Err(e) = res { + eprintln!("[!] Error executing app: {:?}", e); } + + Ok(()) } diff --git a/src/source.rs b/src/source.rs index b5d9be6..ff214a8 100644 --- a/src/source.rs +++ b/src/source.rs @@ -1,53 +1,88 @@ -use libpulse_binding::{sample::{Spec, Format}, def::BufferAttr, error::PAErr, stream::Direction}; -use libpulse_simple_binding::Simple; - pub trait DataSource { fn recv(&mut self) -> Option<&[u8]>; // TODO convert in Result and make generic error } -pub struct PulseAudioSimple { - simple: Simple, - buffer: Vec, -} +pub mod file { + use std::{fs::File, io::Read}; -impl PulseAudioSimple { - pub fn new( - device: Option<&str>, channels: u8, rate: u32, buffer: u32, server_buffer: u32 - ) -> Result { - let spec = Spec { - format: Format::S16NE, // TODO allow more formats? - channels, rate, - }; - if !spec.is_valid() { - return Err(PAErr(0)); // TODO what error number should we throw? - } - let attrs = BufferAttr { - maxlength: server_buffer * buffer, - fragsize: buffer, - ..Default::default() - }; - let simple = Simple::new( - None, // Use the default server - "scope-tui", // Our application’s name - Direction::Record, // We want a record stream - device, // Use requested device, or default - "data", // Description of our stream - &spec, // Our sample format - None, // Use default channel map - Some(&attrs), // Our hints on how to handle client/server buffers - )?; - Ok(Self { simple, buffer: vec![0; buffer as usize] }) + pub struct FileSource { + file: File, + buffer: Vec, } -} -impl DataSource for PulseAudioSimple { - fn recv(&mut self) -> Option<&[u8]> { - match self.simple.read(&mut self.buffer) { - Ok(()) => Some(&self.buffer), - Err(e) => { - eprintln!("[!] could not receive from pulseaudio: {}", e); - None + impl FileSource { + #[allow(clippy::new_ret_no_self)] + pub fn new(path: &str, buffer: u32) -> Result, std::io::Error> { + Ok(Box::new( + FileSource { + file: File::open(path)?, + buffer: vec![0u8; buffer as usize], + } + )) + } + } + + impl super::DataSource for FileSource { + fn recv(&mut self) -> Option<&[u8]> { + match self.file.read_exact(&mut self.buffer) { + Ok(()) => Some(self.buffer.as_slice()), + Err(_e) => None, // TODO log it } } } } + +#[cfg(feature = "pulseaudio")] +pub mod pulseaudio { + use libpulse_binding::{sample::{Spec, Format}, def::BufferAttr, error::PAErr, stream::Direction}; + use libpulse_simple_binding::Simple; + + pub struct PulseAudioSimpleDataSource { + simple: Simple, + buffer: Vec, + } + + impl PulseAudioSimpleDataSource { + #[allow(clippy::new_ret_no_self)] + pub fn new( + device: Option<&str>, channels: u8, rate: u32, buffer: u32, server_buffer: u32 + ) -> Result, PAErr> { + let spec = Spec { + format: Format::S16NE, // TODO allow more formats? + channels, rate, + }; + if !spec.is_valid() { + return Err(PAErr(0)); // TODO what error number should we throw? + } + let attrs = BufferAttr { + maxlength: server_buffer * buffer, + fragsize: buffer, + ..Default::default() + }; + let simple = Simple::new( + None, // Use the default server + "scope-tui", // Our application’s name + Direction::Record, // We want a record stream + device, // Use requested device, or default + "data", // Description of our stream + &spec, // Our sample format + None, // Use default channel map + Some(&attrs), // Our hints on how to handle client/server buffers + )?; + Ok(Box::new(Self { simple, buffer: vec![0; buffer as usize] })) + } + } + + impl super::DataSource for PulseAudioSimpleDataSource { + fn recv(&mut self) -> Option<&[u8]> { + match self.simple.read(&mut self.buffer) { + Ok(()) => Some(&self.buffer), + Err(e) => { + eprintln!("[!] could not receive from pulseaudio: {}", e); + None + } + } + } + } + +}