diff --git a/Cargo.toml b/Cargo.toml index 3258470..078d016 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,8 @@ clap = { version = "4.0.32", features = ["derive"] } derive_more = "0.99.17" thiserror = "1.0.48" rustfft = "6.1.0" -cpal = "0.15.3" +# cross platform audio library backend +cpal = { version = "0.15.3", optional = true } # for TUI backend ratatui = { version = "0.26", features = ["all-widgets"], optional = true } crossterm = { version = "0.27", optional = true } @@ -25,8 +26,10 @@ libpulse-binding = { version = "2.0", optional = true } libpulse-simple-binding = { version = "2.25", optional = true } [features] -default = ["tui"] +default = ["tui", "file", "cpal", "pulseaudio"] +file = [] tui = ["dep:ratatui", "dep:crossterm"] +cpal = ["dep:cpal"] pulseaudio = ["dep:libpulse-binding", "dep:libpulse-simple-binding"] [profile.release] # make small binaries! will take quite longer, from https://github.com/johnthagen/min-sized-rust diff --git a/src/app.rs b/src/app.rs index 42749e1..7e66595 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,40 +26,38 @@ pub struct App { } // 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 { +impl App { + pub fn new(ui: &crate::cfg::UiOptions, source: &crate::cfg::SourceOptions) -> Self { let graph = GraphConfig { axis_color: Color::DarkGray, labels_color: Color::Cyan, palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta], - scale: args.scale as f64, - width: args.buffer, // TODO also make bit depth customizable - samples: args.buffer, - sampling_rate: args.sample_rate, - references: !args.no_reference, - show_ui: !args.no_ui, - scatter: args.scatter, + scale: ui.scale as f64, + width: source.buffer, // TODO also make bit depth customizable + samples: source.buffer, + sampling_rate: source.sample_rate, + references: !ui.no_reference, + show_ui: !ui.no_ui, + scatter: ui.scatter, pause: false, - marker_type: if args.no_braille { + marker_type: if ui.no_braille { Marker::Dot } else { Marker::Braille }, }; - let oscilloscope = Oscilloscope::from_args(args); - let vectorscope = Vectorscope::from_args(args); - let spectroscope = Spectroscope::from_args(args); + let oscilloscope = Oscilloscope::from_args(source); + let vectorscope = Vectorscope::from_args(source); + let spectroscope = Spectroscope::from_args(source); App { graph, oscilloscope, vectorscope, spectroscope, mode: CurrentDisplayMode::Oscilloscope, - channels: args.channels as u8, + channels: source.channels as u8, } } -} -impl App { pub fn run(&mut self, mut source: Box>, terminal: &mut Terminal) -> Result<(), io::Error> { let mut fps = 0; let mut framerate = 0; @@ -147,8 +145,8 @@ impl App { _ => 1.0, }; match key.code { - KeyCode::Up => update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..1.5), // inverted to act as zoom - KeyCode::Down => update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..1.5), // inverted to act as zoom + KeyCode::Up => update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0), // inverted to act as zoom + KeyCode::Down => update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0), // inverted to act as zoom KeyCode::Right => update_value_i(&mut self.graph.samples, true, 25, magnitude, 0..self.graph.width*2), KeyCode::Left => update_value_i(&mut self.graph.samples, false, 25, magnitude, 0..self.graph.width*2), KeyCode::Char('q') => quit = true, diff --git a/src/cfg.rs b/src/cfg.rs new file mode 100644 index 0000000..3ef02ec --- /dev/null +++ b/src/cfg.rs @@ -0,0 +1,119 @@ +use clap::{Parser, Subcommand}; + +use crate::music::Note; + +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, help_template = HELP_TEMPLATE)] +pub struct ScopeArgs { + #[clap(subcommand)] + pub source: ScopeSource, + + #[command(flatten)] + pub opts: SourceOptions, + + #[command(flatten)] + pub ui: UiOptions, +} + +#[derive(Debug, Clone, Parser)] +pub struct UiOptions { + /// floating point vertical scale, from 0 to 1 + #[arg(short, long, value_name = "x", default_value_t = 1.0)] + pub scale: f32, + + /// use vintage looking scatter mode instead of line mode + #[arg(long, default_value_t = false)] + pub scatter: bool, + + /// don't draw reference line + #[arg(long, default_value_t = false)] + pub no_reference: bool, + + /// hide UI and only draw waveforms + #[arg(long, default_value_t = false)] + pub no_ui: bool, + + /// don't use braille dots for drawing lines + #[arg(long, default_value_t = false)] + pub no_braille: bool, +} + +#[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, + + /// limit data flow to match requested sample rate (UNIMPLEMENTED) + #[arg(short, long, default_value_t = false)] + limit_rate: bool, + }, + + /// use new experimental CPAL backend + Audio { + /// source device to attach to + device: Option, + + /// timeout (in seconds) waiting for audio stream + #[arg(long, default_value_t = 60)] + timeout: u64, + } +} + +#[derive(Debug, Clone, Parser)] +pub struct SourceOptions { + /// number of channels to open + #[arg(long, value_name = "N", default_value_t = 2)] + pub channels: usize, + + /// size of audio buffer, and width of scope + #[arg(short, long, value_name = "SIZE", default_value_t = 2048)] + pub buffer: u32, + + /// sample rate to use + #[arg(long, value_name = "HZ", default_value_t = 48000)] + pub sample_rate: u32, + + /// tune buffer size to be in tune with given note (overrides buffer option) + #[arg(long, value_name = "NOTE")] + pub tune: Option, +} + +// TODO its convenient to keep this here but it's not really the best place... +impl SourceOptions { + pub fn tune(&mut self) { + if let Some(txt) = &self.tune { // TODO make it less jank + if let Ok(note) = txt.parse::() { + self.buffer = note.tune_buffer_size(self.sample_rate); + while self.buffer % (self.channels as u32 * 2) != 0 { // TODO customizable bit depth + self.buffer += 1; // TODO jank but otherwise it doesn't align + } + } else { + eprintln!("[!] Unrecognized note '{}', ignoring option", txt); + } + } + } +} diff --git a/src/display/mod.rs b/src/display/mod.rs index 18fa72a..27d9482 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -36,7 +36,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 from_args(args: &crate::cfg::SourceOptions) -> Self where Self : Sized; fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec; fn mode_str(&self) -> &'static str; diff --git a/src/display/oscilloscope.rs b/src/display/oscilloscope.rs index f0eff28..624009e 100644 --- a/src/display/oscilloscope.rs +++ b/src/display/oscilloscope.rs @@ -15,7 +15,7 @@ pub struct Oscilloscope { } impl DisplayMode for Oscilloscope { - fn from_args(_args: &crate::ScopeArgs) -> Self { + fn from_args(_opts: &crate::cfg::SourceOptions) -> Self { Oscilloscope::default() } diff --git a/src/display/spectroscope.rs b/src/display/spectroscope.rs index da99133..570df65 100644 --- a/src/display/spectroscope.rs +++ b/src/display/spectroscope.rs @@ -38,10 +38,10 @@ pub fn hann_window(samples: &[f64]) -> Vec { } impl DisplayMode for Spectroscope { - fn from_args(args: &crate::ScopeArgs) -> Self { + fn from_args(opts: &crate::cfg::SourceOptions) -> Self { Spectroscope { - sampling_rate: args.sample_rate, - buffer_size: args.buffer / (2 * args.channels as u32), + sampling_rate: opts.sample_rate, + buffer_size: opts.buffer, average: 1, buf: Vec::new(), window: false, log_y: true, diff --git a/src/display/vectorscope.rs b/src/display/vectorscope.rs index 1d2d9c1..6d72a9a 100644 --- a/src/display/vectorscope.rs +++ b/src/display/vectorscope.rs @@ -8,7 +8,7 @@ use super::{DisplayMode, GraphConfig, DataSet, Dimension}; pub struct Vectorscope {} impl DisplayMode for Vectorscope { - fn from_args(_args: &crate::ScopeArgs) -> Self { + fn from_args(_opts: &crate::cfg::SourceOptions) -> Self { Vectorscope::default() } @@ -26,8 +26,8 @@ impl DisplayMode for Vectorscope { 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]), + Dimension::X => ("left -", [-cfg.scale, cfg.scale]), + Dimension::Y => ("| right", [-cfg.scale, cfg.scale]), }; let mut a = Axis::default(); if cfg.show_ui { // TODO don't make it necessary to check show_ui inside here @@ -38,8 +38,8 @@ impl DisplayMode for Vectorscope { fn references(&self, cfg: &GraphConfig) -> Vec { vec![ - DataSet::new(None, vec![(-(cfg.scale as f64), 0.0), (cfg.scale as f64, 0.0)], cfg.marker_type, GraphType::Line, cfg.axis_color), - DataSet::new(None, vec![(0.0, -(cfg.scale as f64)), (0.0, cfg.scale as f64)], cfg.marker_type, GraphType::Line, cfg.axis_color), + DataSet::new(None, vec![(-cfg.scale, 0.0), (cfg.scale, 0.0)], cfg.marker_type, GraphType::Line, cfg.axis_color), + DataSet::new(None, vec![(0.0, -cfg.scale), (0.0, cfg.scale)], cfg.marker_type, GraphType::Line, cfg.axis_color), ] } diff --git a/src/input/cpal.rs b/src/input/cpal.rs index eb70cba..87da94b 100644 --- a/src/input/cpal.rs +++ b/src/input/cpal.rs @@ -25,7 +25,7 @@ pub enum AudioDeviceErrors { } impl DefaultAudioDeviceWithCPAL { - pub fn new(device: Option<&str>, channels: u32, sample_rate: u32, buffer: u32, timeout_secs: u64) -> Result>, AudioDeviceErrors> { + pub fn new(device: Option<&str>, opts: &crate::cfg::SourceOptions, timeout_secs: u64) -> Result>, AudioDeviceErrors> { let host = cpal::default_host(); let device = match device { Some(name) => host @@ -37,14 +37,15 @@ impl DefaultAudioDeviceWithCPAL { .ok_or(AudioDeviceErrors::NotFound)?, }; let cfg = cpal::StreamConfig { - channels: channels as u16, - buffer_size: cpal::BufferSize::Fixed(buffer * channels * 2), - sample_rate: cpal::SampleRate(sample_rate), + channels: opts.channels as u16, + buffer_size: cpal::BufferSize::Fixed(opts.buffer * opts.channels as u32 * 2), + sample_rate: cpal::SampleRate(opts.sample_rate), }; let (tx, rx) = mpsc::channel(); + let channels = opts.channels; let stream = device.build_input_stream( &cfg, - move |data:&[f32], _info| tx.send(stream_to_matrix(data.iter().cloned(), channels as usize, 1.)).unwrap_or(()), + move |data:&[f32], _info| tx.send(stream_to_matrix(data.iter().cloned(), channels, 1.)).unwrap_or(()), |e| eprintln!("error in input stream: {e}"), Some(std::time::Duration::from_secs(timeout_secs)), )?; diff --git a/src/input/file.rs b/src/input/file.rs index 3aa3980..a9f000a 100644 --- a/src/input/file.rs +++ b/src/input/file.rs @@ -15,12 +15,14 @@ pub struct FileSource { impl FileSource { #[allow(clippy::new_ret_no_self)] - pub fn new(path: &str, channels: usize, sample_rate: usize, buffer: usize, limit_rate: bool) -> Result>, std::io::Error> { + pub fn new(path: &str, opts: &crate::cfg::SourceOptions, limit_rate: bool) -> Result>, std::io::Error> { Ok(Box::new( FileSource { - channels, sample_rate, limit_rate, + channels: opts.channels, + sample_rate: opts.sample_rate as usize, + limit_rate, file: File::open(path)?, - buffer: vec![0u8; buffer * channels], + buffer: vec![0u8; opts.buffer as usize * opts.channels], } )) } diff --git a/src/input/pulse.rs b/src/input/pulse.rs index dfe8e33..f7a7ca8 100644 --- a/src/input/pulse.rs +++ b/src/input/pulse.rs @@ -11,19 +11,18 @@ pub struct PulseAudioSimpleDataSource { 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> { + pub fn new(device: Option<&str>, opts: &crate::cfg::SourceOptions, server_buffer: u32) -> Result>, PAErr> { let spec = Spec { format: Format::S16NE, // TODO allow more formats? - channels, rate, + channels: opts.channels as u8, + rate: opts.sample_rate, }; if !spec.is_valid() { return Err(PAErr(0)); // TODO what error number should we throw? } let attrs = BufferAttr { - maxlength: server_buffer * buffer * channels as u32 * 2, - fragsize: buffer, + maxlength: server_buffer * opts.buffer * opts.channels as u32 * 2, + fragsize: opts.buffer, ..Default::default() }; let simple = Simple::new( @@ -38,8 +37,8 @@ impl PulseAudioSimpleDataSource { )?; Ok(Box::new(Self { simple, - buffer: vec![0; buffer as usize * channels as usize * 2], - channels: channels as usize + buffer: vec![0; opts.buffer as usize * opts.channels * 2], + channels: opts.channels })) } } diff --git a/src/main.rs b/src/main.rs index 50cad92..36bf5a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,160 +1,40 @@ mod app; +mod cfg; mod music; mod input; mod display; use app::App; -use ratatui::{ - backend::CrosstermBackend, - Terminal, -}; -use crossterm::{ - execute, terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen - }, -}; +use cfg::{ScopeArgs, ScopeSource}; +use clap::Parser; +use ratatui::{backend::CrosstermBackend, Terminal}; +use crossterm::{execute, terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen +}}; -use clap::{Parser, Subcommand}; - -use crate::music::Note; - -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, help_template = HELP_TEMPLATE)] -pub struct ScopeArgs { - #[clap(subcommand)] - source: ScopeSource, - - /// number of channels to open - #[arg(long, value_name = "N", default_value_t = 2)] - channels: usize, - - /// 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 = 2048)] - buffer: u32, - - /// sample rate to use - #[arg(long, value_name = "HZ", default_value_t = 48000)] - sample_rate: u32, - - /// floating point vertical scale, from 0 to 1 - #[arg(short, long, value_name = "x", default_value_t = 1.0)] - scale: f32, - - /// use vintage looking scatter mode instead of line mode - #[arg(long, default_value_t = false)] - scatter: bool, - - /// don't draw reference line - #[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, -} - -#[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, - - /// limit data flow to match requested sample rate (UNIMPLEMENTED) - #[arg(short, long, default_value_t = false)] - limit_rate: bool, - }, - - /// use new experimental CPAL backend - Audio { - /// source device to attach to - device: Option, - - /// timeout (in seconds) waiting for audio stream - #[arg(long, default_value_t = 60)] - timeout: u64, - } -} fn main() -> Result<(), Box> { let mut args = ScopeArgs::parse(); + args.opts.tune(); - if let Some(txt) = &args.tune { // TODO make it less jank - if let Ok(note) = txt.parse::() { - args.buffer = note.tune_buffer_size(args.sample_rate); - 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 { - eprintln!("[!] Unrecognized note '{}', ignoring option", txt); - } - } - - let source = match &args.source { - + let source = match args.source { #[cfg(feature = "pulseaudio")] ScopeSource::Pulse { device, server_buffer } => { - input::pulse::PulseAudioSimpleDataSource::new( - device.as_deref(), - args.channels as u8, - args.sample_rate, - args.buffer, - *server_buffer, - )? + input::pulse::PulseAudioSimpleDataSource::new(device.as_deref(), &args.opts, server_buffer)? }, + #[cfg(feature = "file")] ScopeSource::File { path, limit_rate } => { - input::file::FileSource::new( - path, - args.channels, - args.sample_rate as usize, - args.buffer as usize, - *limit_rate - )? + input::file::FileSource::new(&path, &args.opts, limit_rate)? }, + #[cfg(feature = "cpal")] ScopeSource::Audio { device, timeout } => { - input::cpal::DefaultAudioDeviceWithCPAL::new( - device.as_deref(), - args.channels as u32, - args.sample_rate, - args.buffer, - *timeout, - )? + input::cpal::DefaultAudioDeviceWithCPAL::new(device.as_deref(), &args.opts, timeout)? } }; - let mut app = App::from(&args); + let mut app = App::new(&args.ui, &args.opts); // setup terminal enable_raw_mode()?;