1
0
Fork 0
mirror of https://github.com/alemidev/scope-tui.git synced 2024-11-14 18:59:19 +01:00

chore: refactored a little

This commit is contained in:
əlemi 2024-03-18 06:12:34 +01:00
parent 155a5df67d
commit 9d3e73f640
Signed by: alemi
GPG key ID: A4895B84D311642C
11 changed files with 183 additions and 181 deletions

View file

@ -16,7 +16,8 @@ clap = { version = "4.0.32", features = ["derive"] }
derive_more = "0.99.17" derive_more = "0.99.17"
thiserror = "1.0.48" thiserror = "1.0.48"
rustfft = "6.1.0" rustfft = "6.1.0"
cpal = "0.15.3" # cross platform audio library backend
cpal = { version = "0.15.3", optional = true }
# for TUI backend # for TUI backend
ratatui = { version = "0.26", features = ["all-widgets"], optional = true } ratatui = { version = "0.26", features = ["all-widgets"], optional = true }
crossterm = { version = "0.27", 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 } libpulse-simple-binding = { version = "2.25", optional = true }
[features] [features]
default = ["tui"] default = ["tui", "file", "cpal", "pulseaudio"]
file = []
tui = ["dep:ratatui", "dep:crossterm"] tui = ["dep:ratatui", "dep:crossterm"]
cpal = ["dep:cpal"]
pulseaudio = ["dep:libpulse-binding", "dep:libpulse-simple-binding"] 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 [profile.release] # make small binaries! will take quite longer, from https://github.com/johnthagen/min-sized-rust

View file

@ -26,40 +26,38 @@ pub struct App {
} }
// TODO another way to build this that doesn't require getting cli args directly!!! // TODO another way to build this that doesn't require getting cli args directly!!!
impl From::<&crate::ScopeArgs> for App { impl App {
fn from(args: &crate::ScopeArgs) -> Self { pub fn new(ui: &crate::cfg::UiOptions, source: &crate::cfg::SourceOptions) -> Self {
let graph = GraphConfig { let graph = GraphConfig {
axis_color: Color::DarkGray, axis_color: Color::DarkGray,
labels_color: Color::Cyan, labels_color: Color::Cyan,
palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta], palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta],
scale: args.scale as f64, scale: ui.scale as f64,
width: args.buffer, // TODO also make bit depth customizable width: source.buffer, // TODO also make bit depth customizable
samples: args.buffer, samples: source.buffer,
sampling_rate: args.sample_rate, sampling_rate: source.sample_rate,
references: !args.no_reference, references: !ui.no_reference,
show_ui: !args.no_ui, show_ui: !ui.no_ui,
scatter: args.scatter, scatter: ui.scatter,
pause: false, pause: false,
marker_type: if args.no_braille { marker_type: if ui.no_braille {
Marker::Dot Marker::Dot
} else { } else {
Marker::Braille Marker::Braille
}, },
}; };
let oscilloscope = Oscilloscope::from_args(args); let oscilloscope = Oscilloscope::from_args(source);
let vectorscope = Vectorscope::from_args(args); let vectorscope = Vectorscope::from_args(source);
let spectroscope = Spectroscope::from_args(args); let spectroscope = Spectroscope::from_args(source);
App { App {
graph, oscilloscope, vectorscope, spectroscope, graph, oscilloscope, vectorscope, spectroscope,
mode: CurrentDisplayMode::Oscilloscope, mode: CurrentDisplayMode::Oscilloscope,
channels: args.channels as u8, channels: source.channels as u8,
} }
} }
}
impl App {
pub fn run<T : Backend>(&mut self, mut source: Box<dyn DataSource<f64>>, terminal: &mut Terminal<T>) -> Result<(), io::Error> { pub fn run<T : Backend>(&mut self, mut source: Box<dyn DataSource<f64>>, terminal: &mut Terminal<T>) -> Result<(), io::Error> {
let mut fps = 0; let mut fps = 0;
let mut framerate = 0; let mut framerate = 0;
@ -147,8 +145,8 @@ impl App {
_ => 1.0, _ => 1.0,
}; };
match key.code { 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::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..1.5), // 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::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::Left => update_value_i(&mut self.graph.samples, false, 25, magnitude, 0..self.graph.width*2),
KeyCode::Char('q') => quit = true, KeyCode::Char('q') => quit = true,

119
src/cfg.rs Normal file
View file

@ -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<String>,
/// 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<String>,
/// 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<String>,
}
// 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::<Note>() {
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);
}
}
}
}

View file

@ -36,7 +36,7 @@ impl GraphConfig {
#[allow(clippy::ptr_arg)] // TODO temporarily! it's a shitty solution #[allow(clippy::ptr_arg)] // TODO temporarily! it's a shitty solution
pub trait DisplayMode { pub trait DisplayMode {
// MUST define // 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 axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this
fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>; fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>;
fn mode_str(&self) -> &'static str; fn mode_str(&self) -> &'static str;

View file

@ -15,7 +15,7 @@ pub struct Oscilloscope {
} }
impl DisplayMode for Oscilloscope { impl DisplayMode for Oscilloscope {
fn from_args(_args: &crate::ScopeArgs) -> Self { fn from_args(_opts: &crate::cfg::SourceOptions) -> Self {
Oscilloscope::default() Oscilloscope::default()
} }

View file

@ -38,10 +38,10 @@ pub fn hann_window(samples: &[f64]) -> Vec<f64> {
} }
impl DisplayMode for Spectroscope { impl DisplayMode for Spectroscope {
fn from_args(args: &crate::ScopeArgs) -> Self { fn from_args(opts: &crate::cfg::SourceOptions) -> Self {
Spectroscope { Spectroscope {
sampling_rate: args.sample_rate, sampling_rate: opts.sample_rate,
buffer_size: args.buffer / (2 * args.channels as u32), buffer_size: opts.buffer,
average: 1, buf: Vec::new(), average: 1, buf: Vec::new(),
window: false, window: false,
log_y: true, log_y: true,

View file

@ -8,7 +8,7 @@ use super::{DisplayMode, GraphConfig, DataSet, Dimension};
pub struct Vectorscope {} pub struct Vectorscope {}
impl DisplayMode for Vectorscope { impl DisplayMode for Vectorscope {
fn from_args(_args: &crate::ScopeArgs) -> Self { fn from_args(_opts: &crate::cfg::SourceOptions) -> Self {
Vectorscope::default() Vectorscope::default()
} }
@ -26,8 +26,8 @@ impl DisplayMode for Vectorscope {
fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis {
let (name, bounds) = match dimension { let (name, bounds) = match dimension {
Dimension::X => ("left -", [-(cfg.scale as f64), cfg.scale as f64]), Dimension::X => ("left -", [-cfg.scale, cfg.scale]),
Dimension::Y => ("| right", [-(cfg.scale as f64), cfg.scale as f64]), Dimension::Y => ("| right", [-cfg.scale, cfg.scale]),
}; };
let mut a = Axis::default(); let mut a = Axis::default();
if cfg.show_ui { // TODO don't make it necessary to check show_ui inside here 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<DataSet> { fn references(&self, cfg: &GraphConfig) -> Vec<DataSet> {
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![(-cfg.scale, 0.0), (cfg.scale, 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![(0.0, -cfg.scale), (0.0, cfg.scale)], cfg.marker_type, GraphType::Line, cfg.axis_color),
] ]
} }

View file

@ -25,7 +25,7 @@ pub enum AudioDeviceErrors {
} }
impl DefaultAudioDeviceWithCPAL { impl DefaultAudioDeviceWithCPAL {
pub fn new(device: Option<&str>, channels: u32, sample_rate: u32, buffer: u32, timeout_secs: u64) -> Result<Box<impl super::DataSource<f64>>, AudioDeviceErrors> { pub fn new(device: Option<&str>, opts: &crate::cfg::SourceOptions, timeout_secs: u64) -> Result<Box<impl super::DataSource<f64>>, AudioDeviceErrors> {
let host = cpal::default_host(); let host = cpal::default_host();
let device = match device { let device = match device {
Some(name) => host Some(name) => host
@ -37,14 +37,15 @@ impl DefaultAudioDeviceWithCPAL {
.ok_or(AudioDeviceErrors::NotFound)?, .ok_or(AudioDeviceErrors::NotFound)?,
}; };
let cfg = cpal::StreamConfig { let cfg = cpal::StreamConfig {
channels: channels as u16, channels: opts.channels as u16,
buffer_size: cpal::BufferSize::Fixed(buffer * channels * 2), buffer_size: cpal::BufferSize::Fixed(opts.buffer * opts.channels as u32 * 2),
sample_rate: cpal::SampleRate(sample_rate), sample_rate: cpal::SampleRate(opts.sample_rate),
}; };
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let channels = opts.channels;
let stream = device.build_input_stream( let stream = device.build_input_stream(
&cfg, &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}"), |e| eprintln!("error in input stream: {e}"),
Some(std::time::Duration::from_secs(timeout_secs)), Some(std::time::Duration::from_secs(timeout_secs)),
)?; )?;

View file

@ -15,12 +15,14 @@ pub struct FileSource {
impl FileSource { impl FileSource {
#[allow(clippy::new_ret_no_self)] #[allow(clippy::new_ret_no_self)]
pub fn new(path: &str, channels: usize, sample_rate: usize, buffer: usize, limit_rate: bool) -> Result<Box<dyn super::DataSource<f64>>, std::io::Error> { pub fn new(path: &str, opts: &crate::cfg::SourceOptions, limit_rate: bool) -> Result<Box<dyn super::DataSource<f64>>, std::io::Error> {
Ok(Box::new( Ok(Box::new(
FileSource { FileSource {
channels, sample_rate, limit_rate, channels: opts.channels,
sample_rate: opts.sample_rate as usize,
limit_rate,
file: File::open(path)?, file: File::open(path)?,
buffer: vec![0u8; buffer * channels], buffer: vec![0u8; opts.buffer as usize * opts.channels],
} }
)) ))
} }

View file

@ -11,19 +11,18 @@ pub struct PulseAudioSimpleDataSource {
impl PulseAudioSimpleDataSource { impl PulseAudioSimpleDataSource {
#[allow(clippy::new_ret_no_self)] #[allow(clippy::new_ret_no_self)]
pub fn new( pub fn new(device: Option<&str>, opts: &crate::cfg::SourceOptions, server_buffer: u32) -> Result<Box<dyn super::DataSource<f64>>, PAErr> {
device: Option<&str>, channels: u8, rate: u32, buffer: u32, server_buffer: u32
) -> Result<Box<dyn super::DataSource<f64>>, PAErr> {
let spec = Spec { let spec = Spec {
format: Format::S16NE, // TODO allow more formats? format: Format::S16NE, // TODO allow more formats?
channels, rate, channels: opts.channels as u8,
rate: opts.sample_rate,
}; };
if !spec.is_valid() { if !spec.is_valid() {
return Err(PAErr(0)); // TODO what error number should we throw? return Err(PAErr(0)); // TODO what error number should we throw?
} }
let attrs = BufferAttr { let attrs = BufferAttr {
maxlength: server_buffer * buffer * channels as u32 * 2, maxlength: server_buffer * opts.buffer * opts.channels as u32 * 2,
fragsize: buffer, fragsize: opts.buffer,
..Default::default() ..Default::default()
}; };
let simple = Simple::new( let simple = Simple::new(
@ -38,8 +37,8 @@ impl PulseAudioSimpleDataSource {
)?; )?;
Ok(Box::new(Self { Ok(Box::new(Self {
simple, simple,
buffer: vec![0; buffer as usize * channels as usize * 2], buffer: vec![0; opts.buffer as usize * opts.channels * 2],
channels: channels as usize channels: opts.channels
})) }))
} }
} }

View file

@ -1,160 +1,40 @@
mod app; mod app;
mod cfg;
mod music; mod music;
mod input; mod input;
mod display; mod display;
use app::App; use app::App;
use ratatui::{ use cfg::{ScopeArgs, ScopeSource};
backend::CrosstermBackend, use clap::Parser;
Terminal, use ratatui::{backend::CrosstermBackend, Terminal};
}; use crossterm::{execute, terminal::{
use crossterm::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen
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<String>,
/// 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<String>,
/// 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<String>,
/// timeout (in seconds) waiting for audio stream
#[arg(long, default_value_t = 60)]
timeout: u64,
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = ScopeArgs::parse(); let mut args = ScopeArgs::parse();
args.opts.tune();
if let Some(txt) = &args.tune { // TODO make it less jank let source = match args.source {
if let Ok(note) = txt.parse::<Note>() {
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 {
#[cfg(feature = "pulseaudio")] #[cfg(feature = "pulseaudio")]
ScopeSource::Pulse { device, server_buffer } => { ScopeSource::Pulse { device, server_buffer } => {
input::pulse::PulseAudioSimpleDataSource::new( input::pulse::PulseAudioSimpleDataSource::new(device.as_deref(), &args.opts, server_buffer)?
device.as_deref(),
args.channels as u8,
args.sample_rate,
args.buffer,
*server_buffer,
)?
}, },
#[cfg(feature = "file")]
ScopeSource::File { path, limit_rate } => { ScopeSource::File { path, limit_rate } => {
input::file::FileSource::new( input::file::FileSource::new(&path, &args.opts, limit_rate)?
path,
args.channels,
args.sample_rate as usize,
args.buffer as usize,
*limit_rate
)?
}, },
#[cfg(feature = "cpal")]
ScopeSource::Audio { device, timeout } => { ScopeSource::Audio { device, timeout } => {
input::cpal::DefaultAudioDeviceWithCPAL::new( input::cpal::DefaultAudioDeviceWithCPAL::new(device.as_deref(), &args.opts, timeout)?
device.as_deref(),
args.channels as u32,
args.sample_rate,
args.buffer,
*timeout,
)?
} }
}; };
let mut app = App::from(&args); let mut app = App::new(&args.ui, &args.opts);
// setup terminal // setup terminal
enable_raw_mode()?; enable_raw_mode()?;