1
0
Fork 0
mirror of https://github.com/alemidev/scope-tui.git synced 2024-11-23 14:14:48 +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"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
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"]

View file

@ -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<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
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);

View file

@ -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<f64>>) -> Vec<DataSet>;
fn mode_str(&self) -> &'static str;

View file

@ -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"
}

View file

@ -23,6 +23,14 @@ fn complex_to_magnitude(c: Complex<f64>) -> 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<f64>>) -> Vec<DataSet> {
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());

View file

@ -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"
}

View file

@ -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<String>,
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<String>,
/// 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<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)]
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<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 Ok(note) = txt.parse::<Note>() {
@ -115,13 +107,24 @@ fn main() -> Result<(), std::io::Error> {
}
}
let source = PulseAudioSimple::new(
args.device.as_deref(),
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,
args.server_buffer
).unwrap();
*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) => {
if let Err(e) = res {
eprintln!("[!] Error executing app: {:?}", e);
Err(e)
}
}
Ok(())
}

View file

@ -1,19 +1,52 @@
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 {
pub mod file {
use std::{fs::File, io::Read};
pub struct FileSource {
file: File,
buffer: Vec<u8>,
}
impl FileSource {
#[allow(clippy::new_ret_no_self)]
pub fn new(path: &str, buffer: u32) -> Result<Box<dyn super::DataSource>, 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<u8>,
}
impl PulseAudioSimple {
impl PulseAudioSimpleDataSource {
#[allow(clippy::new_ret_no_self)]
pub fn new(
device: Option<&str>, channels: u8, rate: u32, buffer: u32, server_buffer: u32
) -> Result<Self, PAErr> {
) -> Result<Box<dyn super::DataSource>, PAErr> {
let spec = Spec {
format: Format::S16NE, // TODO allow more formats?
channels, rate,
@ -36,11 +69,11 @@ impl PulseAudioSimple {
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] })
Ok(Box::new(Self { simple, buffer: vec![0; buffer as usize] }))
}
}
impl DataSource for PulseAudioSimple {
impl super::DataSource for PulseAudioSimpleDataSource {
fn recv(&mut self) -> Option<&[u8]> {
match self.simple.read(&mut self.buffer) {
Ok(()) => Some(&self.buffer),
@ -51,3 +84,5 @@ impl DataSource for PulseAudioSimple {
}
}
}
}