diff --git a/Cargo.toml b/Cargo.toml index 49133f6..752dec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ crossterm = { version = "0.27", optional = true } # for pulseaudio libpulse-binding = { version = "2.0", optional = true } libpulse-simple-binding = { version = "2.25", optional = true } +cpal = "0.15.3" [features] default = ["tui", "pulseaudio"] diff --git a/src/app.rs b/src/app.rs index 5410ce6..42749e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,5 @@ -use std::{io, time::{Duration, Instant}, ops::Range}; +use std::{io, ops::Range, time::{Duration, Instant}}; use ratatui::{ style::Color, widgets::{Table, Row, Cell}, symbols::Marker, backend::Backend, @@ -8,8 +8,7 @@ use ratatui::{ }; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; -use crate::{source::DataSource, display::{GraphConfig, oscilloscope::Oscilloscope, DisplayMode, Dimension, vectorscope::Vectorscope, spectroscope::Spectroscope}}; -use crate::parser::{SampleParser, Signed16PCM}; +use crate::{display::{oscilloscope::Oscilloscope, spectroscope::Spectroscope, vectorscope::Vectorscope, Dimension, DisplayMode, GraphConfig}, input::{Matrix, DataSource}}; pub enum CurrentDisplayMode { Oscilloscope, @@ -18,7 +17,7 @@ pub enum CurrentDisplayMode { } pub struct App { - channels: u8, + #[allow(unused)] channels: u8, graph: GraphConfig, oscilloscope: Oscilloscope, vectorscope: Vectorscope, @@ -33,9 +32,9 @@ impl From::<&crate::ScopeArgs> for App { axis_color: Color::DarkGray, labels_color: Color::Cyan, palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta], - 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), + 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, @@ -55,27 +54,24 @@ impl From::<&crate::ScopeArgs> for App { App { graph, oscilloscope, vectorscope, spectroscope, mode: CurrentDisplayMode::Oscilloscope, - channels: args.channels, + channels: args.channels as u8, } } } impl App { - pub fn run(&mut self, mut source: Box, terminal: &mut Terminal) -> Result<(), io::Error> { - // prepare globals - let fmt = Signed16PCM{}; // TODO some way to choose this? - + pub fn run(&mut self, mut source: Box>, terminal: &mut Terminal) -> Result<(), io::Error> { let mut fps = 0; let mut framerate = 0; let mut last_poll = Instant::now(); - let mut channels = vec![]; + let mut channels = Matrix::default(); loop { let data = source.recv() .ok_or(io::Error::new(io::ErrorKind::BrokenPipe, "data source returned null"))?; if !self.graph.pause { - channels = fmt.oscilloscope(data, self.channels); + channels = data; } fps += 1; @@ -107,7 +103,7 @@ impl App { .x_axis(self.current_display().axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes? .y_axis(self.current_display().axis(&self.graph, Dimension::Y)); f.render_widget(chart, size) - }).unwrap(); + })?; } while event::poll(Duration::from_millis(0))? { // process all enqueued events @@ -151,8 +147,8 @@ impl App { _ => 1.0, }; match key.code { - KeyCode::Up => update_value_i(&mut self.graph.scale, true, 250, magnitude, 0..65535), // inverted to act as zoom - KeyCode::Down => update_value_i(&mut self.graph.scale, false, 250, magnitude, 0..65535), // inverted to act as zoom + 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::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, @@ -169,7 +165,7 @@ impl App { }, KeyCode::Esc => { self.graph.samples = self.graph.width; - self.graph.scale = 20000; + self.graph.scale = 1.; }, _ => {}, } @@ -212,9 +208,9 @@ fn make_header<'a>(cfg: &GraphConfig, module_header: &'a str, kind_o_scope: &'st vec![ Row::new( vec![ - Cell::from(format!("{}::scope-tui", kind_o_scope)).style(Style::default().fg(*cfg.palette.get(0).expect("empty palette?")).add_modifier(Modifier::BOLD)), + Cell::from(format!("{}::scope-tui", kind_o_scope)).style(Style::default().fg(*cfg.palette.first().expect("empty palette?")).add_modifier(Modifier::BOLD)), Cell::from(module_header), - Cell::from(format!("-{}+", cfg.scale)), + Cell::from(format!("-{:.2}x+", cfg.scale)), Cell::from(format!("{}/{} spf", cfg.samples, cfg.width)), Cell::from(format!("{}fps", fps)), Cell::from(if cfg.scatter { "***" } else { "---" }), diff --git a/src/display/mod.rs b/src/display/mod.rs index 481c0d4..18fa72a 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -5,6 +5,8 @@ pub mod spectroscope; use crossterm::event::Event; use ratatui::{widgets::{Dataset, Axis, GraphType}, style::{Style, Color}, symbols::Marker}; +use crate::input::Matrix; + pub enum Dimension { X, Y } @@ -14,7 +16,7 @@ pub struct GraphConfig { pub pause: bool, pub samples: u32, pub sampling_rate: u32, - pub scale: u32, + pub scale: f64, pub width: u32, pub scatter: bool, pub references: bool, @@ -36,7 +38,7 @@ 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; + fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec; fn mode_str(&self) -> &'static str; // SHOULD override diff --git a/src/display/oscilloscope.rs b/src/display/oscilloscope.rs index 99cb019..f0eff28 100644 --- a/src/display/oscilloscope.rs +++ b/src/display/oscilloscope.rs @@ -1,7 +1,7 @@ use crossterm::event::{Event, KeyModifiers, KeyCode}; use ratatui::{widgets::{Axis, GraphType}, style::Style, text::Span}; -use crate::app::{update_value_f, update_value_i}; +use crate::{app::{update_value_f, update_value_i}, input::Matrix}; use super::{DisplayMode, GraphConfig, DataSet, Dimension}; @@ -47,7 +47,7 @@ impl DisplayMode for Oscilloscope { fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { let (name, bounds) = match dimension { Dimension::X => ("time -", [0.0, cfg.samples as f64]), - Dimension::Y => ("| amplitude", [-(cfg.scale as f64), cfg.scale as f64]), + Dimension::Y => ("| amplitude", [-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 @@ -62,7 +62,7 @@ impl DisplayMode for Oscilloscope { ] } - fn process(&mut self, cfg: &GraphConfig, data: &Vec>) -> Vec { + fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec { let mut out = Vec::new(); let mut trigger_offset = 0; @@ -71,9 +71,8 @@ impl DisplayMode for Oscilloscope { for i in 0..data[0].len() { if triggered(&data[0], i, self.threshold, self.depth, self.falling_edge) { // triggered break; - } else { - trigger_offset += 1; } + trigger_offset += 1; } } diff --git a/src/display/spectroscope.rs b/src/display/spectroscope.rs index 00280f0..da99133 100644 --- a/src/display/spectroscope.rs +++ b/src/display/spectroscope.rs @@ -3,7 +3,7 @@ use std::collections::VecDeque; use crossterm::event::{Event, KeyCode}; use ratatui::{widgets::{Axis, GraphType}, style::Style, text::Span}; -use crate::app::update_value_i; +use crate::{app::update_value_i, input::Matrix}; use super::{DisplayMode, GraphConfig, DataSet, Dimension}; @@ -90,7 +90,7 @@ impl DisplayMode for Spectroscope { a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) } - fn process(&mut self, cfg: &GraphConfig, data: &Vec>) -> Vec { + fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec { if self.average == 0 { self.average = 1 } // otherwise fft breaks if !cfg.pause { for (i, chan) in data.iter().enumerate() { diff --git a/src/display/vectorscope.rs b/src/display/vectorscope.rs index c8c95a1..1d2d9c1 100644 --- a/src/display/vectorscope.rs +++ b/src/display/vectorscope.rs @@ -1,5 +1,7 @@ use ratatui::{widgets::{Axis, GraphType}, style::Style, text::Span}; +use crate::input::Matrix; + use super::{DisplayMode, GraphConfig, DataSet, Dimension}; #[derive(Default)] @@ -41,7 +43,7 @@ impl DisplayMode for Vectorscope { ] } - fn process(&mut self, cfg: &GraphConfig, data: &Vec>) -> Vec { + fn process(&mut self, cfg: &GraphConfig, data: &Matrix) -> Vec { let mut out = Vec::new(); for (n, chunk) in data.chunks(2).enumerate() { diff --git a/src/input/cpal.rs b/src/input/cpal.rs new file mode 100644 index 0000000..eb70cba --- /dev/null +++ b/src/input/cpal.rs @@ -0,0 +1,67 @@ +use std::sync::mpsc; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +use super::{stream_to_matrix, Matrix}; + +pub struct DefaultAudioDeviceWithCPAL { + rx: mpsc::Receiver>, + #[allow(unused)] + stream: cpal::Stream, +} + +#[derive(Debug, thiserror::Error)] +pub enum AudioDeviceErrors { + #[error("{0}")] + Device(#[from] cpal::DevicesError), + + #[error("device not found")] + NotFound, + + #[error("{0}")] + BuildStream(#[from] cpal::BuildStreamError), + + #[error("{0}")] + PlayStream(#[from] cpal::PlayStreamError), +} + +impl DefaultAudioDeviceWithCPAL { + pub fn new(device: Option<&str>, channels: u32, sample_rate: u32, buffer: u32, timeout_secs: u64) -> Result>, AudioDeviceErrors> { + let host = cpal::default_host(); + let device = match device { + Some(name) => host + .input_devices()? + .find(|x| x.name().as_deref().unwrap_or("") == name) + .ok_or(AudioDeviceErrors::NotFound)?, + None => host + .default_input_device() + .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), + }; + let (tx, rx) = mpsc::channel(); + 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(()), + |e| eprintln!("error in input stream: {e}"), + Some(std::time::Duration::from_secs(timeout_secs)), + )?; + stream.play()?; + + Ok(Box::new(DefaultAudioDeviceWithCPAL { stream, rx })) + } +} + +impl super::DataSource for DefaultAudioDeviceWithCPAL { + fn recv(&mut self) -> Option> { + match self.rx.recv() { + Ok(x) => Some(x), + Err(e) => { + println!("error receiving from source? {e}"); + None + }, + } + } +} diff --git a/src/input/file.rs b/src/input/file.rs new file mode 100644 index 0000000..3aa3980 --- /dev/null +++ b/src/input/file.rs @@ -0,0 +1,42 @@ +use std::{fs::File, io::Read}; + +use super::{format::{SampleParser, Signed16PCM}, stream_to_matrix, Matrix}; + +pub struct FileSource { + file: File, + buffer: Vec, + channels: usize, + sample_rate: usize, + limit_rate: bool, + // TODO when all data is available (eg, file) limit data flow to make it + // somehow visualizable. must be optional because named pipes block + // TODO support more formats +} + +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> { + Ok(Box::new( + FileSource { + channels, sample_rate, limit_rate, + file: File::open(path)?, + buffer: vec![0u8; buffer * channels], + } + )) + } +} + +impl super::DataSource for FileSource { + fn recv(&mut self) -> Option> { + match self.file.read_exact(&mut self.buffer) { + Ok(()) => Some( + stream_to_matrix( + self.buffer.chunks(2).map(Signed16PCM::parse), + self.channels, + 32768.0, + ) + ), + Err(_e) => None, // TODO log it + } + } +} diff --git a/src/input/format/mod.rs b/src/input/format/mod.rs new file mode 100644 index 0000000..f823218 --- /dev/null +++ b/src/input/format/mod.rs @@ -0,0 +1,11 @@ + +pub trait SampleParser { + fn parse(data: &[u8]) -> T; +} + +pub struct Signed16PCM; +impl SampleParser for Signed16PCM { + fn parse(chunk: &[u8]) -> f64 { + (chunk[0] as i16 | (chunk[1] as i16) << 8) as f64 + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..4e4e61a --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,31 @@ +pub mod format; + +#[cfg(feature = "pulseaudio")] +pub mod pulse; + +pub mod file; + +pub mod cpal; + +pub type Matrix = Vec>; + +pub trait DataSource { + fn recv(&mut self) -> Option>; // TODO convert in Result and make generic error +} + +/// separate a stream of alternating channels into a matrix of channel streams: +/// L R L R L R L R L R +/// becomes +/// L L L L L +/// R R R R R +pub fn stream_to_matrix(stream: impl Iterator, channels: usize, norm: O) -> Matrix +where I : Copy + Into, O : Copy + std::ops::Div +{ + let mut out = vec![vec![]; channels]; + let mut channel = 0; + for sample in stream { + out[channel].push(sample.into() / norm); + channel = (channel + 1) % channels; + } + out +} diff --git a/src/input/pulse.rs b/src/input/pulse.rs new file mode 100644 index 0000000..dfe8e33 --- /dev/null +++ b/src/input/pulse.rs @@ -0,0 +1,61 @@ +use libpulse_binding::{sample::{Spec, Format}, def::BufferAttr, error::PAErr, stream::Direction}; +use libpulse_simple_binding::Simple; + +use super::{format::{SampleParser, Signed16PCM}, stream_to_matrix}; + +pub struct PulseAudioSimpleDataSource { + simple: Simple, + buffer: Vec, + channels: usize, +} + +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> { + 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 * channels as u32 * 2, + fragsize: buffer, + ..Default::default() + }; + let simple = Simple::new( + None, // Use the default server + "scope-tui", // Our application’s 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 * channels as usize * 2], + channels: channels as usize + })) + } +} + +impl super::DataSource for PulseAudioSimpleDataSource { + fn recv(&mut self) -> Option> { + match self.simple.read(&mut self.buffer) { + Ok(()) => Some(stream_to_matrix( + self.buffer.chunks(2).map(Signed16PCM::parse), + self.channels, + 32768.0, + )), + Err(e) => { + eprintln!("[!] could not receive from pulseaudio: {}", e); + None + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 07e9690..50cad92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ -mod parser; mod app; mod music; -mod source; +mod input; mod display; use app::App; @@ -37,23 +36,23 @@ pub struct ScopeArgs { /// number of channels to open #[arg(long, value_name = "N", default_value_t = 2)] - channels: u8, + 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 = 8192)] + #[arg(short, long, value_name = "SIZE", default_value_t = 2048)] buffer: u32, /// sample rate to use - #[arg(long, value_name = "HZ", default_value_t = 44100)] + #[arg(long, value_name = "HZ", default_value_t = 48000)] sample_rate: 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 + /// 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)] @@ -90,7 +89,21 @@ pub enum ScopeSource { 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> { @@ -111,19 +124,34 @@ fn main() -> Result<(), Box> { #[cfg(feature = "pulseaudio")] ScopeSource::Pulse { device, server_buffer } => { - source::pulseaudio::PulseAudioSimpleDataSource::new( + input::pulse::PulseAudioSimpleDataSource::new( device.as_deref(), - args.channels, + args.channels as u8, args.sample_rate, args.buffer, *server_buffer, )? }, - ScopeSource::File { path } => { - source::file::FileSource::new(path, args.buffer)? + ScopeSource::File { path, limit_rate } => { + input::file::FileSource::new( + path, + args.channels, + args.sample_rate as usize, + args.buffer as usize, + *limit_rate + )? }, + ScopeSource::Audio { device, timeout } => { + input::cpal::DefaultAudioDeviceWithCPAL::new( + device.as_deref(), + args.channels as u32, + args.sample_rate, + args.buffer, + *timeout, + )? + } }; let mut app = App::from(&args); diff --git a/src/music.rs b/src/music.rs index 56f5f13..e46938a 100644 --- a/src/music.rs +++ b/src/music.rs @@ -68,7 +68,7 @@ impl Note { pub fn tune_buffer_size(&self, sample_rate: u32) -> u32 { let t = 1.0 / self.tone.freq(self.octave); // periodo ? let buf = (sample_rate as f32) * t; - (buf * 4.0).round() as u32 + buf.round() as u32 } } diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index aa4ae3b..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,31 +0,0 @@ -// use libpulse_binding::sample::Format; - -// pub fn parser(fmt: Format) -> impl SampleParser { -// match fmt { -// Format::S16NE => Signed16PCM {}, -// _ => panic!("parser not implemented for this format") -// } -// } - -pub trait SampleParser { - fn oscilloscope(&self, data: &[u8], channels: u8) -> Vec>; - fn sample_size(&self) -> usize; -} - -pub struct Signed16PCM(); - -/// TODO these are kinda inefficient, can they be faster? -impl SampleParser for Signed16PCM { - fn oscilloscope(&self, data: &[u8], channels: u8) -> Vec> { - let mut out = vec![vec![]; channels as usize]; - let mut channel = 0; - for chunk in data.chunks(2) { - let buf = chunk[0] as i16 | (chunk[1] as i16) << 8; - out[channel].push(buf as f64); - channel = (channel + 1 ) % channels as usize; - } - out - } - - fn sample_size(&self) -> usize { 2 } // 16 bit, thus 2 bytes -} diff --git a/src/source.rs b/src/source.rs deleted file mode 100644 index ff214a8..0000000 --- a/src/source.rs +++ /dev/null @@ -1,88 +0,0 @@ -pub trait DataSource { - fn recv(&mut self) -> Option<&[u8]>; // TODO convert in Result and make generic error -} - -pub mod file { - use std::{fs::File, io::Read}; - - pub struct FileSource { - file: File, - buffer: Vec, - } - - impl FileSource { - #[allow(clippy::new_ret_no_self)] - pub fn new(path: &str, buffer: u32) -> Result, 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, - } - - 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> { - 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 application’s 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 - } - } - } - } - -}