1
0
Fork 0
mirror of https://github.com/alemidev/scope-tui.git synced 2024-11-14 10:49:20 +01:00

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
This commit is contained in:
əlemi 2023-09-18 04:36:52 +02:00
parent e559798385
commit 9378621a26
Signed by: alemi
GPG key ID: A4895B84D311642C
8 changed files with 187 additions and 134 deletions

View file

@ -3,8 +3,8 @@ name = "scope-tui"
version = "0.3.0" version = "0.3.0"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "A simple oscilloscope/vectorscope for the terminal, inspired by cava" description = "A simple oscilloscope/vectorscope/spectroscope for your terminal"
keywords = ["tui", "terminal", "audio", "visualization", "scope", "dashboard"] keywords = ["tui", "terminal", "audio", "visualization", "scope", "dashboard", "oscilloscope", "spectroscope"]
repository = "https://github.com/alemidev/scope-tui" repository = "https://github.com/alemidev/scope-tui"
readme = "README.md" readme = "README.md"
# documentation = # documentation =
@ -12,11 +12,18 @@ readme = "README.md"
[dependencies] [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"] } 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"
# 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"]

View file

@ -27,8 +27,9 @@ pub struct App {
mode: CurrentDisplayMode, mode: CurrentDisplayMode,
} }
impl From::<&crate::Args> for App { // TODO another way to build this that doesn't require getting cli args directly!!!
fn from(args: &crate::Args) -> Self { impl From::<&crate::ScopeArgs> for App {
fn from(args: &crate::ScopeArgs) -> Self {
let graph = GraphConfig { let graph = GraphConfig {
axis_color: Color::DarkGray, axis_color: Color::DarkGray,
labels_color: Color::Cyan, labels_color: Color::Cyan,
@ -36,6 +37,7 @@ impl From::<&crate::Args> for App {
scale: args.range, scale: args.range,
width: args.buffer / (2 * args.channels as u32), // TODO also make bit depth customizable width: args.buffer / (2 * args.channels as u32), // TODO also make bit depth customizable
samples: args.buffer / (2 * args.channels as u32), samples: args.buffer / (2 * args.channels as u32),
sampling_rate: args.sample_rate,
references: !args.no_reference, references: !args.no_reference,
show_ui: !args.no_ui, show_ui: !args.no_ui,
scatter: args.scatter, scatter: args.scatter,
@ -46,21 +48,9 @@ impl From::<&crate::Args> for App {
}, },
}; };
let oscilloscope = Oscilloscope { let oscilloscope = Oscilloscope::from_args(args);
triggering: args.triggering, let vectorscope = Vectorscope::from_args(args);
depth: args.check_depth, let spectroscope = Spectroscope::from_args(args);
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(),
};
App { App {
graph, oscilloscope, vectorscope, spectroscope, graph, oscilloscope, vectorscope, spectroscope,
@ -72,7 +62,7 @@ impl From::<&crate::Args> for App {
} }
impl App { impl App {
pub fn run<T : Backend>(&mut self, mut source: impl DataSource, terminal: &mut Terminal<T>) -> Result<(), io::Error> { pub fn run<T : Backend>(&mut self, mut source: Box<dyn DataSource>, terminal: &mut Terminal<T>) -> Result<(), io::Error> {
// prepare globals // prepare globals
let fmt = Signed16PCM{}; // TODO some way to choose this? let fmt = Signed16PCM{}; // TODO some way to choose this?
@ -82,7 +72,8 @@ impl App {
let mut channels = vec![]; let mut channels = vec![];
loop { 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 { if !self.pause {
channels = fmt.oscilloscope(data, self.channels); channels = fmt.oscilloscope(data, self.channels);

View file

@ -12,6 +12,7 @@ pub enum Dimension {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GraphConfig { pub struct GraphConfig {
pub samples: u32, pub samples: u32,
pub sampling_rate: u32,
pub scale: u32, pub scale: u32,
pub width: u32, pub width: u32,
pub scatter: bool, pub scatter: bool,
@ -32,6 +33,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 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: &Vec<Vec<f64>>) -> Vec<DataSet>; fn process(&mut self, cfg: &GraphConfig, data: &Vec<Vec<f64>>) -> Vec<DataSet>;
fn mode_str(&self) -> &'static str; fn mode_str(&self) -> &'static str;

View file

@ -15,6 +15,10 @@ pub struct Oscilloscope {
} }
impl DisplayMode for Oscilloscope { impl DisplayMode for Oscilloscope {
fn from_args(_args: &crate::ScopeArgs) -> Self {
Oscilloscope::default()
}
fn mode_str(&self) -> &'static str { fn mode_str(&self) -> &'static str {
"oscillo" "oscillo"
} }

View file

@ -23,6 +23,14 @@ fn complex_to_magnitude(c: Complex<f64>) -> f64 {
} }
impl DisplayMode for Spectroscope { 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 { fn mode_str(&self) -> &'static str {
"spectro" "spectro"
} }
@ -61,6 +69,7 @@ impl DisplayMode for Spectroscope {
} }
fn process(&mut self, cfg: &GraphConfig, data: &Vec<Vec<f64>>) -> Vec<DataSet> { fn process(&mut self, cfg: &GraphConfig, data: &Vec<Vec<f64>>) -> Vec<DataSet> {
if self.average == 0 { self.average = 1 } // otherwise fft breaks
for (i, chan) in data.iter().enumerate() { for (i, chan) in data.iter().enumerate() {
if self.buf.len() <= i { if self.buf.len() <= i {
self.buf.push(VecDeque::new()); self.buf.push(VecDeque::new());

View file

@ -6,6 +6,10 @@ 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 {
Vectorscope::default()
}
fn mode_str(&self) -> &'static str { fn mode_str(&self) -> &'static str {
"vector" "vector"
} }

View file

@ -5,7 +5,6 @@ mod source;
mod display; mod display;
use app::App; use app::App;
use source::PulseAudioSimple;
use ratatui::{ use ratatui::{
backend::CrosstermBackend, backend::CrosstermBackend,
Terminal, Terminal,
@ -16,7 +15,7 @@ use crossterm::{
}, },
}; };
use clap::Parser; use clap::{Parser, Subcommand};
use crate::music::Note; use crate::music::Note;
@ -29,80 +28,73 @@ const HELP_TEMPLATE : &str = "{before-help}\
{all-args}{after-help} {all-args}{after-help}
"; ";
/// A simple oscilloscope/vectorscope for your terminal /// a simple oscilloscope/vectorscope for your terminal
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None, help_template = HELP_TEMPLATE)] #[command(author, version, about, long_about = None, help_template = HELP_TEMPLATE)]
pub struct Args { pub struct ScopeArgs {
/// Audio device to attach to #[clap(subcommand)]
device: Option<String>, source: ScopeSource,
/// Size of audio buffer, and width of scope /// number of channels to open
#[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<String>,
/// Number of channels to open
#[arg(long, value_name = "N", default_value_t = 2)] #[arg(long, value_name = "N", default_value_t = 2)]
channels: u8, 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<String>,
/// 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)] #[arg(long, value_name = "HZ", default_value_t = 44100)]
sample_rate: u32, sample_rate: u32,
/// Pulseaudio server buffer size, in block number /// max value, positive and negative, on amplitude scale
#[arg(long, value_name = "N", default_value_t = 32)] #[arg(short, long, value_name = "SIZE", default_value_t = 20000)]
server_buffer: u32, 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)] #[arg(long, default_value_t = false)]
triggering: bool, scatter: bool,
/// Threshold value for triggering /// don't draw reference line
#[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
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
no_reference: bool, no_reference: bool,
/// Hide UI and only draw waveforms /// hide UI and only draw waveforms
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
no_ui: bool, 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)] #[arg(long, default_value_t = false)]
no_braille: bool, no_braille: bool,
} }
fn main() -> Result<(), std::io::Error> { #[derive(Debug, Clone, Subcommand)]
let mut args = Args::parse(); 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,
},
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = ScopeArgs::parse();
if let Some(txt) = &args.tune { // TODO make it less jank if let Some(txt) = &args.tune { // TODO make it less jank
if let Ok(note) = txt.parse::<Note>() { if let Ok(note) = txt.parse::<Note>() {
@ -115,13 +107,24 @@ fn main() -> Result<(), std::io::Error> {
} }
} }
let source = PulseAudioSimple::new( let source = match &args.source {
args.device.as_deref(),
args.channels, #[cfg(feature = "pulseaudio")]
args.sample_rate, ScopeSource::Pulse { device, server_buffer } => {
args.buffer, source::pulseaudio::PulseAudioSimpleDataSource::new(
args.server_buffer device.as_deref(),
).unwrap(); 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); let mut app = App::from(&args);
@ -143,11 +146,9 @@ fn main() -> Result<(), std::io::Error> {
)?; )?;
terminal.show_cursor()?; terminal.show_cursor()?;
match res { if let Err(e) = res {
Ok(()) => Ok(()), eprintln!("[!] Error executing app: {:?}", e);
Err(e) => {
eprintln!("[!] Error executing app: {:?}", e);
Err(e)
}
} }
Ok(())
} }

View file

@ -1,53 +1,88 @@
use libpulse_binding::{sample::{Spec, Format}, def::BufferAttr, error::PAErr, stream::Direction};
use libpulse_simple_binding::Simple;
pub trait DataSource { pub trait DataSource {
fn recv(&mut self) -> Option<&[u8]>; // TODO convert in Result and make generic error fn recv(&mut self) -> Option<&[u8]>; // TODO convert in Result and make generic error
} }
pub struct PulseAudioSimple { pub mod file {
simple: Simple, use std::{fs::File, io::Read};
buffer: Vec<u8>,
}
impl PulseAudioSimple { pub struct FileSource {
pub fn new( file: File,
device: Option<&str>, channels: u8, rate: u32, buffer: u32, server_buffer: u32 buffer: Vec<u8>,
) -> Result<Self, 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 applications 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] })
} }
}
impl DataSource for PulseAudioSimple { impl FileSource {
fn recv(&mut self) -> Option<&[u8]> { #[allow(clippy::new_ret_no_self)]
match self.simple.read(&mut self.buffer) { pub fn new(path: &str, buffer: u32) -> Result<Box<dyn super::DataSource>, std::io::Error> {
Ok(()) => Some(&self.buffer), Ok(Box::new(
Err(e) => { FileSource {
eprintln!("[!] could not receive from pulseaudio: {}", e); file: File::open(path)?,
None 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<u8>,
}
impl PulseAudioSimpleDataSource {
#[allow(clippy::new_ret_no_self)]
pub fn new(
device: Option<&str>, channels: u8, rate: u32, buffer: u32, server_buffer: u32
) -> Result<Box<dyn super::DataSource>, 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 applications 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
}
}
}
}
}