mirror of
https://github.com/alemidev/scope-tui.git
synced 2024-11-14 02:39: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:
parent
e559798385
commit
9378621a26
8 changed files with 187 additions and 134 deletions
19
Cargo.toml
19
Cargo.toml
|
@ -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"]
|
||||
|
|
29
src/app.rs
29
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<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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
127
src/main.rs
127
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<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(())
|
||||
}
|
||||
|
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue