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

feat: add cpal source, refactor sources

now splitting stream in channels and parsing stream format are separate
but handled by the source, so that cpal source can skip format parsing.
added some nicer types, and also range now is +-1 because way easier
than 32k

sorry this is a huge commit, ive been messing with it for a while and
changed a lot across whole project, at this point i'm just committing it
because it can only get worse ehe
This commit is contained in:
əlemi 2024-03-18 03:36:38 +01:00
parent 299efd7012
commit 771987063d
Signed by: alemi
GPG key ID: A4895B84D311642C
15 changed files with 283 additions and 162 deletions

View file

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

View file

@ -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<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?
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 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 { "---" }),

View file

@ -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<f64>>) -> Vec<DataSet>;
fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>;
fn mode_str(&self) -> &'static str;
// SHOULD override

View file

@ -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<f64>>) -> Vec<DataSet> {
fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet> {
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;
}
}

View file

@ -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<f64>>) -> Vec<DataSet> {
fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet> {
if self.average == 0 { self.average = 1 } // otherwise fft breaks
if !cfg.pause {
for (i, chan) in data.iter().enumerate() {

View file

@ -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<f64>>) -> Vec<DataSet> {
fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet> {
let mut out = Vec::new();
for (n, chunk) in data.chunks(2).enumerate() {

67
src/input/cpal.rs Normal file
View file

@ -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<Matrix<f64>>,
#[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<Box<impl super::DataSource<f64>>, 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<f64> for DefaultAudioDeviceWithCPAL {
fn recv(&mut self) -> Option<super::Matrix<f64>> {
match self.rx.recv() {
Ok(x) => Some(x),
Err(e) => {
println!("error receiving from source? {e}");
None
},
}
}
}

42
src/input/file.rs Normal file
View file

@ -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<u8>,
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<Box<dyn super::DataSource<f64>>, std::io::Error> {
Ok(Box::new(
FileSource {
channels, sample_rate, limit_rate,
file: File::open(path)?,
buffer: vec![0u8; buffer * channels],
}
))
}
}
impl super::DataSource<f64> for FileSource {
fn recv(&mut self) -> Option<Matrix<f64>> {
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
}
}
}

11
src/input/format/mod.rs Normal file
View file

@ -0,0 +1,11 @@
pub trait SampleParser<T> {
fn parse(data: &[u8]) -> T;
}
pub struct Signed16PCM;
impl SampleParser<f64> for Signed16PCM {
fn parse(chunk: &[u8]) -> f64 {
(chunk[0] as i16 | (chunk[1] as i16) << 8) as f64
}
}

31
src/input/mod.rs Normal file
View file

@ -0,0 +1,31 @@
pub mod format;
#[cfg(feature = "pulseaudio")]
pub mod pulse;
pub mod file;
pub mod cpal;
pub type Matrix<T> = Vec<Vec<T>>;
pub trait DataSource<T> {
fn recv(&mut self) -> Option<Matrix<T>>; // 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<I, O>(stream: impl Iterator<Item = I>, channels: usize, norm: O) -> Matrix<O>
where I : Copy + Into<O>, O : Copy + std::ops::Div<Output = O>
{
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
}

61
src/input/pulse.rs Normal file
View file

@ -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<u8>,
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<Box<dyn super::DataSource<f64>>, 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 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 * channels as usize * 2],
channels: channels as usize
}))
}
}
impl super::DataSource<f64> for PulseAudioSimpleDataSource {
fn recv(&mut self) -> Option<super::Matrix<f64>> {
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
}
}
}
}

View file

@ -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<String>,
/// 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<String>,
/// timeout (in seconds) waiting for audio stream
#[arg(long, default_value_t = 60)]
timeout: u64,
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
@ -111,19 +124,34 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
#[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);

View file

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

View file

@ -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<Vec<f64>>;
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<Vec<f64>> {
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
}

View file

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