Browse and listen to thousands of radio stations across the globe right from your terminal ๐ŸŒŽ ๐Ÿ“ป ๐ŸŽตโœจ
radio rust tokio web-radio command-line-tool tui

feat: add audio visualization

feat: add audio visualization

remove comments

+103 -8
Cargo.lock
··· 605 605 checksum = "fbdcdcb6d86f71c5e97409ad45898af11cbc995b4ee8112d59095a28d376c935" 606 606 607 607 [[package]] 608 + name = "convert_case" 609 + version = "0.4.0" 610 + source = "registry+https://github.com/rust-lang/crates.io-index" 611 + checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 612 + 613 + [[package]] 608 614 name = "cookie" 609 615 version = "0.14.4" 610 616 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 789 795 "num_cpus", 790 796 "serde", 791 797 "tokio", 798 + ] 799 + 800 + [[package]] 801 + name = "derive_more" 802 + version = "0.99.17" 803 + source = "registry+https://github.com/rust-lang/crates.io-index" 804 + checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" 805 + dependencies = [ 806 + "convert_case", 807 + "proc-macro2", 808 + "quote", 809 + "rustc_version 0.4.0", 810 + "syn 1.0.107", 792 811 ] 793 812 794 813 [[package]] ··· 1646 1665 ] 1647 1666 1648 1667 [[package]] 1668 + name = "num-complex" 1669 + version = "0.4.5" 1670 + source = "registry+https://github.com/rust-lang/crates.io-index" 1671 + checksum = "23c6602fda94a57c990fe0df199a035d83576b496aa29f4e634a8ac6004e68a6" 1672 + dependencies = [ 1673 + "num-traits", 1674 + ] 1675 + 1676 + [[package]] 1649 1677 name = "num-derive" 1650 1678 version = "0.3.3" 1651 1679 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1654 1682 "proc-macro2", 1655 1683 "quote", 1656 1684 "syn 1.0.107", 1685 + ] 1686 + 1687 + [[package]] 1688 + name = "num-integer" 1689 + version = "0.1.46" 1690 + source = "registry+https://github.com/rust-lang/crates.io-index" 1691 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1692 + dependencies = [ 1693 + "num-traits", 1657 1694 ] 1658 1695 1659 1696 [[package]] ··· 1907 1944 dependencies = [ 1908 1945 "proc-macro2", 1909 1946 "syn 1.0.107", 1947 + ] 1948 + 1949 + [[package]] 1950 + name = "primal-check" 1951 + version = "0.3.3" 1952 + source = "registry+https://github.com/rust-lang/crates.io-index" 1953 + checksum = "9df7f93fd637f083201473dab4fee2db4c429d32e55e3299980ab3957ab916a0" 1954 + dependencies = [ 1955 + "num-integer", 1910 1956 ] 1911 1957 1912 1958 [[package]] ··· 2238 2284 source = "registry+https://github.com/rust-lang/crates.io-index" 2239 2285 checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" 2240 2286 dependencies = [ 2241 - "semver", 2287 + "semver 0.9.0", 2288 + ] 2289 + 2290 + [[package]] 2291 + name = "rustc_version" 2292 + version = "0.4.0" 2293 + source = "registry+https://github.com/rust-lang/crates.io-index" 2294 + checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" 2295 + dependencies = [ 2296 + "semver 1.0.22", 2297 + ] 2298 + 2299 + [[package]] 2300 + name = "rustfft" 2301 + version = "6.2.0" 2302 + source = "registry+https://github.com/rust-lang/crates.io-index" 2303 + checksum = "43806561bc506d0c5d160643ad742e3161049ac01027b5e6d7524091fd401d86" 2304 + dependencies = [ 2305 + "num-complex", 2306 + "num-integer", 2307 + "num-traits", 2308 + "primal-check", 2309 + "strength_reduce", 2310 + "transpose", 2311 + "version_check", 2242 2312 ] 2243 2313 2244 2314 [[package]] ··· 2330 2400 dependencies = [ 2331 2401 "semver-parser", 2332 2402 ] 2403 + 2404 + [[package]] 2405 + name = "semver" 2406 + version = "1.0.22" 2407 + source = "registry+https://github.com/rust-lang/crates.io-index" 2408 + checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" 2333 2409 2334 2410 [[package]] 2335 2411 name = "semver-parser" ··· 2544 2620 checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" 2545 2621 dependencies = [ 2546 2622 "discard", 2547 - "rustc_version", 2623 + "rustc_version 0.2.3", 2548 2624 "stdweb-derive", 2549 2625 "stdweb-internal-macros", 2550 2626 "stdweb-internal-runtime", ··· 2587 2663 checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" 2588 2664 2589 2665 [[package]] 2666 + name = "strength_reduce" 2667 + version = "0.2.4" 2668 + source = "registry+https://github.com/rust-lang/crates.io-index" 2669 + checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" 2670 + 2671 + [[package]] 2590 2672 name = "strsim" 2591 2673 version = "0.10.0" 2592 2674 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2896 2978 2897 2979 [[package]] 2898 2980 name = "thiserror" 2899 - version = "1.0.38" 2981 + version = "1.0.58" 2900 2982 source = "registry+https://github.com/rust-lang/crates.io-index" 2901 - checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" 2983 + checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" 2902 2984 dependencies = [ 2903 2985 "thiserror-impl", 2904 2986 ] 2905 2987 2906 2988 [[package]] 2907 2989 name = "thiserror-impl" 2908 - version = "1.0.38" 2990 + version = "1.0.58" 2909 2991 source = "registry+https://github.com/rust-lang/crates.io-index" 2910 - checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" 2992 + checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" 2911 2993 dependencies = [ 2912 2994 "proc-macro2", 2913 2995 "quote", 2914 - "syn 1.0.107", 2996 + "syn 2.0.51", 2915 2997 ] 2916 2998 2917 2999 [[package]] ··· 3217 3299 ] 3218 3300 3219 3301 [[package]] 3302 + name = "transpose" 3303 + version = "0.2.3" 3304 + source = "registry+https://github.com/rust-lang/crates.io-index" 3305 + checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" 3306 + dependencies = [ 3307 + "num-integer", 3308 + "strength_reduce", 3309 + ] 3310 + 3311 + [[package]] 3220 3312 name = "try-lock" 3221 3313 version = "0.2.4" 3222 3314 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3239 3331 3240 3332 [[package]] 3241 3333 name = "tunein-cli" 3242 - version = "0.2.5" 3334 + version = "0.2.6" 3243 3335 dependencies = [ 3244 3336 "anyhow", 3245 3337 "clap", 3246 3338 "cpal", 3247 3339 "crossterm", 3340 + "derive_more", 3248 3341 "futures", 3249 3342 "futures-util", 3250 3343 "hyper", ··· 3256 3349 "ratatui", 3257 3350 "reqwest", 3258 3351 "rodio", 3352 + "rustfft", 3259 3353 "serde", 3260 3354 "surf", 3261 3355 "symphonia", 3262 3356 "termion", 3357 + "thiserror", 3263 3358 "tokio", 3264 3359 "tonic", 3265 3360 "tonic-build",
+6 -3
Cargo.toml
··· 8 8 name = "tunein-cli" 9 9 readme = "README.md" 10 10 repository = "https://github.com/tsirysndr/tunein-cli" 11 - version = "0.2.5" 11 + version = "0.2.6" 12 12 13 13 [[bin]] 14 14 name = "tunein" ··· 16 16 17 17 [workspace.metadata.cross.target.aarch64-unknown-linux-gnu] 18 18 pre-build = [ 19 - "dpkg --add-architecture $CROSS_DEB_ARCH", 20 - "apt-get update && apt-get --assume-yes install libasound2-dev libasound2-dev:$CROSS_DEB_ARCH protobuf-compiler" 19 + "dpkg --add-architecture $CROSS_DEB_ARCH", 20 + "apt-get update && apt-get --assume-yes install libasound2-dev libasound2-dev:$CROSS_DEB_ARCH protobuf-compiler", 21 21 ] 22 22 23 23 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html ··· 27 27 clap = "3.2.20" 28 28 cpal = "0.14.0" 29 29 crossterm = "0.27.0" 30 + derive_more = "0.99.17" 30 31 futures = "0.3.26" 31 32 futures-util = "0.3.26" 32 33 hyper = {version = "0.14.23", features = ["client", "stream", "tcp", "http1", "http2"]} ··· 38 39 ratatui = "0.26.1" 39 40 reqwest = {version = "0.11.14", features = ["blocking", "rustls-tls"], default-features = false} 40 41 rodio = {version = "0.16"} 42 + rustfft = "6.2.0" 41 43 serde = "1.0.197" 42 44 surf = {version = "2.3.2", features = ["h1-client-rustls"], default-features = false} 43 45 symphonia = {version = "0.5.1", features = ["aac", "alac", "mp3", "isomp4", "flac"]} 44 46 termion = "2.0.1" 47 + thiserror = "1.0.58" 45 48 tokio = {version = "1.24.2", features = ["tokio-macros", "macros", "rt", "rt-multi-thread"]} 46 49 tonic = "0.8.3" 47 50 tonic-web = "0.4.0"
+2 -2
README.md
··· 72 72 Or download the latest release for your platform [here](https://github.com/tsirysndr/tunein-cli/releases). 73 73 74 74 ## ๐Ÿ“ฆ Downloads 75 - - `Mac`: arm64: [tunein_v0.2.5_aarch64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.2.5/tunein_v0.2.5_aarch64-apple-darwin.tar.gz) intel: [tunein_v0.2.5_x86_64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.2.5/tunein_v0.2.5_x86_64-apple-darwin.tar.gz) 76 - - `Linux`: [tunein_v0.2.5_x86_64-unknown-linux-gnu.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.2.5/tunein_v0.2.5_x86_64-unknown-linux-gnu.tar.gz) 75 + - `Mac`: arm64: [tunein_v0.2.6_aarch64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.2.6/tunein_v0.2.6_aarch64-apple-darwin.tar.gz) intel: [tunein_v0.2.6_x86_64-apple-darwin.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.2.6/tunein_v0.2.6_x86_64-apple-darwin.tar.gz) 76 + - `Linux`: [tunein_v0.2.6_x86_64-unknown-linux-gnu.tar.gz](https://github.com/tsirysndr/tunein-cli/releases/download/v0.2.6/tunein_v0.2.6_x86_64-unknown-linux-gnu.tar.gz) 77 77 ## ๐Ÿš€ Usage 78 78 ``` 79 79 USAGE:
+1 -1
flake.nix
··· 48 48 inherit src; 49 49 50 50 pname = "tunein"; 51 - version = "0.2.3"; 51 + version = "0.2.6"; 52 52 53 53 buildInputs = [ 54 54 # Add additional build inputs here
+357 -111
src/app.rs
··· 1 - use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; 1 + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; 2 2 use ratatui::{ 3 3 prelude::*, 4 4 widgets::{block::*, *}, 5 5 }; 6 - use std::{io, process, thread, time::Duration}; 6 + use std::{ 7 + io, 8 + ops::Range, 9 + process, 10 + sync::{mpsc::Receiver, Arc, Mutex}, 11 + thread, 12 + time::{Duration, Instant}, 13 + }; 7 14 use tokio::sync::mpsc::UnboundedReceiver; 8 15 9 - use crate::{extract::get_currently_playing, tui}; 16 + use crate::{ 17 + extract::get_currently_playing, 18 + input::stream_to_matrix, 19 + tui, 20 + visualization::{ 21 + oscilloscope::Oscilloscope, spectroscope::Spectroscope, vectorscope::Vectorscope, 22 + Dimension, DisplayMode, GraphConfig, 23 + }, 24 + }; 10 25 11 - #[derive(Debug, Default)] 26 + #[derive(Debug, Default, Clone)] 12 27 pub struct State { 13 28 pub name: String, 14 29 pub now_playing: String, ··· 17 32 pub br: String, 18 33 } 19 34 20 - #[derive(Debug, Default)] 35 + pub enum CurrentDisplayMode { 36 + Oscilloscope, 37 + Vectorscope, 38 + Spectroscope, 39 + } 40 + 21 41 pub struct App { 22 - name: String, 23 - now_playing: String, 24 - genre: String, 25 - description: String, 26 - br: String, 27 - exit: bool, 42 + #[allow(unused)] 43 + channels: u8, 44 + graph: GraphConfig, 45 + oscilloscope: Oscilloscope, 46 + vectorscope: Vectorscope, 47 + spectroscope: Spectroscope, 48 + mode: CurrentDisplayMode, 49 + frame_rx: Receiver<minimp3::Frame>, 28 50 } 29 51 30 52 impl App { 31 - pub fn new() -> Self { 53 + pub fn new( 54 + ui: &crate::cfg::UiOptions, 55 + source: &crate::cfg::SourceOptions, 56 + frame_rx: Receiver<minimp3::Frame>, 57 + ) -> Self { 58 + let graph = GraphConfig { 59 + axis_color: Color::DarkGray, 60 + labels_color: Color::Cyan, 61 + palette: vec![Color::Red, Color::Yellow, Color::Green, Color::Magenta], 62 + scale: ui.scale as f64, 63 + width: source.buffer, // TODO also make bit depth customizable 64 + samples: source.buffer, 65 + sampling_rate: source.sample_rate, 66 + references: !ui.no_reference, 67 + show_ui: !ui.no_ui, 68 + scatter: ui.scatter, 69 + pause: false, 70 + marker_type: if ui.no_braille { 71 + Marker::Dot 72 + } else { 73 + Marker::Braille 74 + }, 75 + }; 76 + 77 + let oscilloscope = Oscilloscope::from_args(source); 78 + let vectorscope = Vectorscope::from_args(source); 79 + let spectroscope = Spectroscope::from_args(source); 80 + 32 81 Self { 33 - name: "".to_string(), 34 - now_playing: "".to_string(), 35 - genre: "".to_string(), 36 - description: "".to_string(), 37 - br: "".to_string(), 38 - exit: false, 82 + graph, 83 + oscilloscope, 84 + vectorscope, 85 + spectroscope, 86 + mode: CurrentDisplayMode::Spectroscope, 87 + channels: source.channels as u8, 88 + frame_rx, 39 89 } 40 90 } 41 91 } 42 92 93 + fn render_frame(state: Arc<Mutex<State>>, frame: &mut Frame) { 94 + let state = state.lock().unwrap(); 95 + let size = frame.size(); 96 + 97 + frame.render_widget( 98 + Block::new() 99 + .borders(Borders::TOP) 100 + .title(" TuneIn CLI ") 101 + .title_alignment(Alignment::Center), 102 + Rect { 103 + x: size.x, 104 + y: size.y, 105 + width: size.width, 106 + height: 1, 107 + }, 108 + ); 109 + 110 + render_line( 111 + "Station ", 112 + &state.name, 113 + Rect { 114 + x: size.x, 115 + y: size.y + 1, 116 + width: size.width, 117 + height: 1, 118 + }, 119 + frame, 120 + ); 121 + render_line( 122 + "Now Playing ", 123 + &state.now_playing, 124 + Rect { 125 + x: size.x, 126 + y: size.y + 2, 127 + width: size.width, 128 + height: 1, 129 + }, 130 + frame, 131 + ); 132 + render_line( 133 + "Genre ", 134 + &state.genre, 135 + Rect { 136 + x: size.x, 137 + y: size.y + 3, 138 + width: size.width, 139 + height: 1, 140 + }, 141 + frame, 142 + ); 143 + render_line( 144 + "Description ", 145 + &state.description, 146 + Rect { 147 + x: size.x, 148 + y: size.y + 4, 149 + width: size.width, 150 + height: 1, 151 + }, 152 + frame, 153 + ); 154 + render_line( 155 + "Bitrate ", 156 + &format!("{} kbps", &state.br), 157 + Rect { 158 + x: size.x, 159 + y: size.y + 5, 160 + width: size.width, 161 + height: 1, 162 + }, 163 + frame, 164 + ); 165 + } 166 + 167 + fn render_line(label: &str, value: &str, area: Rect, frame: &mut Frame) { 168 + let span1 = Span::styled(label, Style::new().fg(Color::LightBlue)); 169 + let span2 = Span::raw(value); 170 + 171 + let line = Line::from(vec![span1, span2]); 172 + let text: Text = Text::from(vec![line]); 173 + 174 + frame.render_widget(Paragraph::new(text), area); 175 + } 176 + 43 177 impl App { 44 178 /// runs the application's main loop until the user quits 45 179 pub async fn run( ··· 49 183 id: &str, 50 184 ) { 51 185 let new_state = cmd_rx.recv().await.unwrap(); 52 - self.name = new_state.name; 53 - self.genre = new_state.genre; 54 - self.description = new_state.description; 55 - self.br = new_state.br; 186 + let new_state = Arc::new(Mutex::new(new_state)); 187 + 188 + let id = id.to_string(); 189 + let new_state_clone = new_state.clone(); 56 190 57 - thread::spawn(|| loop { 58 - match event::read().unwrap() { 59 - // it's important to check that the event is a key press event as 60 - // crossterm also emits key release and repeat events on Windows. 61 - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { 62 - match key_event.code { 63 - KeyCode::Char('q') => { 64 - let _ = tui::restore(); 65 - process::exit(0); 66 - }, 67 - KeyCode::Char('d') 68 - if key_event.modifiers.contains(KeyModifiers::CONTROL) => 69 - { 70 - let _ = tui::restore(); 71 - process::exit(0); 72 - } 73 - KeyCode::Char('c') 74 - if key_event.modifiers.contains(KeyModifiers::CONTROL) => 75 - { 76 - let _ = tui::restore(); 77 - process::exit(0); 78 - } 79 - _ => {} 80 - } 81 - } 82 - _ => {} 83 - }; 191 + thread::spawn(move || loop { 192 + let rt = tokio::runtime::Runtime::new().unwrap(); 193 + rt.block_on(async { 194 + let mut new_state = new_state_clone.lock().unwrap(); 195 + // Get current playing if available, otherwise use state's value 196 + new_state.now_playing = get_currently_playing(&id).await.unwrap_or_default(); 197 + drop(new_state); 198 + std::thread::sleep(Duration::from_millis(10000)); 199 + }); 84 200 }); 85 201 202 + let mut fps = 0; 203 + let mut framerate = 0; 204 + let mut last_poll = Instant::now(); 205 + 86 206 loop { 87 - // Get current playing if available, otherwise use state's value 88 - let now_playing = get_currently_playing(id).await.unwrap_or_default(); 207 + let audio_frame = self.frame_rx.recv().unwrap(); 208 + let channels = 209 + stream_to_matrix(audio_frame.data.iter().cloned(), audio_frame.channels, 1.); 89 210 90 - // Update state with current playing 91 - self.now_playing = now_playing; 92 - terminal.draw(|frame| self.render_frame(frame)).unwrap(); 93 - std::thread::sleep(Duration::from_millis(10000)); 94 - } 95 - } 211 + fps += 1; 96 212 97 - fn render_frame(&self, frame: &mut Frame) { 98 - let areas = Layout::new( 99 - Direction::Vertical, 100 - [ 101 - Constraint::Length(1), 102 - Constraint::Length(1), 103 - Constraint::Length(1), 104 - Constraint::Length(1), 105 - Constraint::Length(1), 106 - Constraint::Min(0), 107 - ], 108 - ) 109 - .split(frame.size()); 213 + if last_poll.elapsed().as_secs() >= 1 { 214 + framerate = fps; 215 + fps = 0; 216 + last_poll = Instant::now(); 217 + } 218 + 219 + { 220 + let mut datasets = Vec::new(); 221 + let graph = self.graph.clone(); // TODO cheap fix... 222 + if self.graph.references { 223 + datasets.append(&mut self.current_display_mut().references(&graph)); 224 + } 225 + datasets.append(&mut self.current_display_mut().process(&graph, &channels)); 226 + terminal 227 + .draw(|f| { 228 + let mut size = f.size(); 229 + render_frame(new_state.clone(), f); 230 + if self.graph.show_ui { 231 + f.render_widget( 232 + make_header( 233 + &self.graph, 234 + &self.current_display().header(&self.graph), 235 + self.current_display().mode_str(), 236 + framerate, 237 + self.graph.pause, 238 + ), 239 + Rect { 240 + x: size.x, 241 + y: size.y + 6, 242 + width: size.width, 243 + height: 1, 244 + }, 245 + ); 246 + size.height -= 7; 247 + size.y += 7; 248 + } 249 + let chart = Chart::new(datasets.iter().map(|x| x.into()).collect()) 250 + .x_axis(self.current_display().axis(&self.graph, Dimension::X)) // TODO allow to have axis sometimes? 251 + .y_axis(self.current_display().axis(&self.graph, Dimension::Y)); 252 + f.render_widget(chart, size) 253 + }) 254 + .unwrap(); 255 + } 110 256 111 - frame.render_widget( 112 - Block::new() 113 - .borders(Borders::TOP) 114 - .title(" TuneIn CLI ") 115 - .title_alignment(Alignment::Center), 116 - areas[0], 117 - ); 257 + while event::poll(Duration::from_millis(0)).unwrap() { 258 + // process all enqueued events 259 + let event = event::read().unwrap(); 118 260 119 - self.render_line("Station ", &self.name, areas[1], frame); 120 - self.render_line("Now Playing ", &self.now_playing, areas[2], frame); 121 - self.render_line("Genre ", &self.genre, areas[3], frame); 122 - self.render_line("Description ", &self.description, areas[4], frame); 123 - self.render_line("Bitrate ", &format!("{} kbps", &self.br), areas[5], frame); 261 + if self.process_events(event.clone()).unwrap() { 262 + return; 263 + } 264 + self.current_display_mut().handle(event); 265 + } 266 + } 124 267 } 125 268 126 - fn render_line(&self, label: &str, value: &str, area: Rect, frame: &mut Frame) { 127 - let span1 = Span::styled(label, Style::new().fg(Color::LightBlue)); 128 - let span2 = Span::raw(value); 129 - 130 - let line = Line::from(vec![span1, span2]); 131 - let text: Text = Text::from(vec![line]); 269 + fn current_display_mut(&mut self) -> &mut dyn DisplayMode { 270 + match self.mode { 271 + CurrentDisplayMode::Oscilloscope => &mut self.oscilloscope as &mut dyn DisplayMode, 272 + CurrentDisplayMode::Vectorscope => &mut self.vectorscope as &mut dyn DisplayMode, 273 + CurrentDisplayMode::Spectroscope => &mut self.spectroscope as &mut dyn DisplayMode, 274 + } 275 + } 132 276 133 - frame.render_widget(Paragraph::new(text), area); 277 + fn current_display(&self) -> &dyn DisplayMode { 278 + match self.mode { 279 + CurrentDisplayMode::Oscilloscope => &self.oscilloscope as &dyn DisplayMode, 280 + CurrentDisplayMode::Vectorscope => &self.vectorscope as &dyn DisplayMode, 281 + CurrentDisplayMode::Spectroscope => &self.spectroscope as &dyn DisplayMode, 282 + } 134 283 } 135 284 136 - /// updates the application's state based on user input 137 - fn handle_events(&mut self) -> io::Result<()> { 138 - match event::read()? { 139 - // it's important to check that the event is a key press event as 140 - // crossterm also emits key release and repeat events on Windows. 141 - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { 142 - self.handle_key_event(key_event) 285 + fn process_events(&mut self, event: Event) -> Result<bool, io::Error> { 286 + let mut quit = false; 287 + if let Event::Key(key) = event { 288 + if let KeyModifiers::CONTROL = key.modifiers { 289 + match key.code { 290 + // mimic other programs shortcuts to quit, for user friendlyness 291 + KeyCode::Char('c') | KeyCode::Char('q') | KeyCode::Char('w') => quit = true, 292 + _ => {} 293 + } 143 294 } 144 - _ => {} 295 + let magnitude = match key.modifiers { 296 + KeyModifiers::SHIFT => 10.0, 297 + KeyModifiers::CONTROL => 5.0, 298 + KeyModifiers::ALT => 0.2, 299 + _ => 1.0, 300 + }; 301 + match key.code { 302 + KeyCode::Up => update_value_f(&mut self.graph.scale, 0.01, magnitude, 0.0..10.0), // inverted to act as zoom 303 + KeyCode::Down => update_value_f(&mut self.graph.scale, -0.01, magnitude, 0.0..10.0), // inverted to act as zoom 304 + KeyCode::Right => update_value_i( 305 + &mut self.graph.samples, 306 + true, 307 + 25, 308 + magnitude, 309 + 0..self.graph.width * 2, 310 + ), 311 + KeyCode::Left => update_value_i( 312 + &mut self.graph.samples, 313 + false, 314 + 25, 315 + magnitude, 316 + 0..self.graph.width * 2, 317 + ), 318 + KeyCode::Char('q') => quit = true, 319 + KeyCode::Char(' ') => self.graph.pause = !self.graph.pause, 320 + KeyCode::Char('s') => self.graph.scatter = !self.graph.scatter, 321 + KeyCode::Char('h') => self.graph.show_ui = !self.graph.show_ui, 322 + KeyCode::Char('r') => self.graph.references = !self.graph.references, 323 + KeyCode::Esc => { 324 + self.graph.samples = self.graph.width; 325 + self.graph.scale = 1.; 326 + } 327 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 328 + let _ = tui::restore(); 329 + process::exit(0); 330 + } 331 + KeyCode::Tab => { 332 + // switch modes 333 + match self.mode { 334 + CurrentDisplayMode::Oscilloscope => { 335 + self.mode = CurrentDisplayMode::Vectorscope 336 + } 337 + CurrentDisplayMode::Vectorscope => { 338 + self.mode = CurrentDisplayMode::Spectroscope 339 + } 340 + CurrentDisplayMode::Spectroscope => { 341 + self.mode = CurrentDisplayMode::Oscilloscope 342 + } 343 + } 344 + } 345 + _ => {} 346 + } 145 347 }; 146 - Ok(()) 348 + 349 + Ok(quit) 147 350 } 351 + } 148 352 149 - fn handle_key_event(&mut self, key_event: KeyEvent) { 150 - match key_event.code { 151 - KeyCode::Char('q') => self.exit(), 152 - KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { 153 - self.exit() 154 - } 155 - KeyCode::Char('c') if key_event.modifiers.contains(KeyModifiers::CONTROL) => { 156 - self.exit() 157 - } 158 - _ => {} 353 + pub fn update_value_f(val: &mut f64, base: f64, magnitude: f64, range: Range<f64>) { 354 + let delta = base * magnitude; 355 + if *val + delta > range.end { 356 + *val = range.end 357 + } else if *val + delta < range.start { 358 + *val = range.start 359 + } else { 360 + *val += delta; 361 + } 362 + } 363 + 364 + pub fn update_value_i(val: &mut u32, inc: bool, base: u32, magnitude: f64, range: Range<u32>) { 365 + let delta = (base as f64 * magnitude) as u32; 366 + if inc { 367 + if range.end - delta < *val { 368 + *val = range.end 369 + } else { 370 + *val += delta 159 371 } 372 + } else if range.start + delta > *val { 373 + *val = range.start 374 + } else { 375 + *val -= delta 160 376 } 377 + } 161 378 162 - fn exit(&mut self) { 163 - self.exit = true; 164 - } 379 + fn make_header<'a>( 380 + cfg: &GraphConfig, 381 + module_header: &'a str, 382 + kind_o_scope: &'static str, 383 + fps: usize, 384 + pause: bool, 385 + ) -> Table<'a> { 386 + Table::new( 387 + vec![Row::new(vec![ 388 + Cell::from(format!("{}::scope-tui", kind_o_scope)).style( 389 + Style::default() 390 + .fg(*cfg.palette.first().expect("empty palette?")) 391 + .add_modifier(Modifier::BOLD), 392 + ), 393 + Cell::from(module_header), 394 + Cell::from(format!("-{:.2}x+", cfg.scale)), 395 + Cell::from(format!("{}/{} spf", cfg.samples, cfg.width)), 396 + Cell::from(format!("{}fps", fps)), 397 + Cell::from(if cfg.scatter { "***" } else { "---" }), 398 + Cell::from(if pause { "||" } else { "|>" }), 399 + ])], 400 + vec![ 401 + Constraint::Percentage(35), 402 + Constraint::Percentage(25), 403 + Constraint::Percentage(7), 404 + Constraint::Percentage(13), 405 + Constraint::Percentage(6), 406 + Constraint::Percentage(6), 407 + Constraint::Percentage(6), 408 + ], 409 + ) 410 + .style(Style::default().fg(cfg.labels_color)) 165 411 }
+59
src/cfg.rs
··· 1 + use crate::music::Note; 2 + 3 + /// a simple oscilloscope/vectorscope for your terminal 4 + #[derive(Debug)] 5 + pub struct ScopeArgs { 6 + pub opts: SourceOptions, 7 + pub ui: UiOptions, 8 + } 9 + 10 + #[derive(Debug, Clone)] 11 + pub struct UiOptions { 12 + /// floating point vertical scale, from 0 to 1 13 + pub scale: f32, 14 + 15 + /// use vintage looking scatter mode instead of line mode 16 + pub scatter: bool, 17 + 18 + /// don't draw reference line 19 + pub no_reference: bool, 20 + 21 + /// hide UI and only draw waveforms 22 + pub no_ui: bool, 23 + 24 + /// don't use braille dots for drawing lines 25 + pub no_braille: bool, 26 + } 27 + 28 + #[derive(Debug, Clone)] 29 + pub struct SourceOptions { 30 + /// number of channels to open 31 + pub channels: usize, 32 + 33 + /// size of audio buffer, and width of scope 34 + pub buffer: u32, 35 + 36 + /// sample rate to use 37 + pub sample_rate: u32, 38 + 39 + /// tune buffer size to be in tune with given note (overrides buffer option) 40 + pub tune: Option<String>, 41 + } 42 + 43 + // TODO its convenient to keep this here but it's not really the best place... 44 + impl SourceOptions { 45 + pub fn tune(&mut self) { 46 + if let Some(txt) = &self.tune { 47 + // TODO make it less jank 48 + if let Ok(note) = txt.parse::<Note>() { 49 + self.buffer = note.tune_buffer_size(self.sample_rate); 50 + while self.buffer % (self.channels as u32 * 2) != 0 { 51 + // TODO customizable bit depth 52 + self.buffer += 1; // TODO jank but otherwise it doesn't align 53 + } 54 + } else { 55 + eprintln!("[!] Unrecognized note '{}', ignoring option", txt); 56 + } 57 + } 58 + } 59 + }
+11 -3
src/decoder.rs
··· 1 - use std::io::Read; 2 1 use std::time::Duration; 2 + use std::{io::Read, sync::mpsc::Sender}; 3 3 4 4 use minimp3::{Decoder, Frame}; 5 5 use rodio::Source; ··· 11 11 decoder: Decoder<R>, 12 12 current_frame: Frame, 13 13 current_frame_offset: usize, 14 + tx: Sender<Frame>, 14 15 } 15 16 16 17 impl<R> Mp3Decoder<R> 17 18 where 18 19 R: Read, 19 20 { 20 - pub fn new(mut data: R) -> Result<Self, R> { 21 + pub fn new(mut data: R, tx: Sender<Frame>) -> Result<Self, R> { 21 22 if !is_mp3(data.by_ref()) { 22 23 return Err(data); 23 24 } ··· 28 29 decoder, 29 30 current_frame, 30 31 current_frame_offset: 0, 32 + tx, 31 33 }) 32 34 } 33 35 } ··· 67 69 fn next(&mut self) -> Option<i16> { 68 70 if self.current_frame_offset == self.current_frame.data.len() { 69 71 match self.decoder.next_frame() { 70 - Ok(frame) => self.current_frame = frame, 72 + Ok(frame) => { 73 + match self.tx.send(frame.clone()) { 74 + Ok(_) => {} 75 + Err(_) => return None, 76 + } 77 + self.current_frame = frame 78 + } 71 79 _ => return None, 72 80 } 73 81 self.current_frame_offset = 0;
+10
src/format.rs
··· 1 + pub trait SampleParser<T> { 2 + fn parse(data: &[u8]) -> T; 3 + } 4 + 5 + pub struct Signed16PCM; 6 + impl SampleParser<f64> for Signed16PCM { 7 + fn parse(chunk: &[u8]) -> f64 { 8 + (chunk[0] as i16 | (chunk[1] as i16) << 8) as f64 9 + } 10 + }
+24
src/input.rs
··· 1 + pub type Matrix<T> = Vec<Vec<T>>; 2 + 3 + /// separate a stream of alternating channels into a matrix of channel streams: 4 + /// L R L R L R L R L R 5 + /// becomes 6 + /// L L L L L 7 + /// R R R R R 8 + pub fn stream_to_matrix<I, O>( 9 + stream: impl Iterator<Item = I>, 10 + channels: usize, 11 + norm: O, 12 + ) -> Matrix<O> 13 + where 14 + I: Copy + Into<O>, 15 + O: Copy + std::ops::Div<Output = O>, 16 + { 17 + let mut out = vec![vec![]; channels]; 18 + let mut channel = 0; 19 + for sample in stream { 20 + out[channel].push(sample.into() / norm); 21 + channel = (channel + 1) % channels; 22 + } 23 + out 24 + }
+4
src/main.rs
··· 3 3 4 4 mod app; 5 5 mod browse; 6 + mod cfg; 6 7 mod decoder; 7 8 mod extract; 9 + mod input; 10 + mod music; 8 11 mod play; 9 12 mod player; 10 13 mod search; 11 14 mod server; 12 15 mod tui; 16 + mod visualization; 13 17 14 18 fn cli() -> Command<'static> { 15 19 const VESRION: &str = env!("CARGO_PKG_VERSION");
+110
src/music.rs
··· 1 + use std::{num::ParseIntError, str::FromStr}; 2 + 3 + #[derive(Debug, PartialEq, Clone)] 4 + pub enum Tone { 5 + C, 6 + Db, 7 + D, 8 + Eb, 9 + E, 10 + F, 11 + Gb, 12 + G, 13 + Ab, 14 + A, 15 + Bb, 16 + B, 17 + } 18 + 19 + #[derive(Debug, thiserror::Error, derive_more::Display)] 20 + pub struct ToneError(); 21 + 22 + #[derive(Debug, PartialEq, Clone)] 23 + pub struct Note { 24 + tone: Tone, 25 + octave: u32, 26 + } 27 + 28 + #[derive(Debug, thiserror::Error, derive_more::From, derive_more::Display)] 29 + pub enum NoteError { 30 + InvalidOctave(ParseIntError), 31 + InalidNote(ToneError), 32 + } 33 + 34 + impl FromStr for Note { 35 + type Err = NoteError; 36 + 37 + fn from_str(txt: &str) -> Result<Self, Self::Err> { 38 + let trimmed = txt.trim(); 39 + let mut split = 0; 40 + for c in trimmed.chars() { 41 + if !c.is_ascii_digit() { 42 + split += 1; 43 + } else { 44 + break; 45 + } 46 + } 47 + Ok(Note { 48 + tone: trimmed[..split].parse::<Tone>()?, 49 + octave: trimmed[split..].parse::<u32>().unwrap_or(0), 50 + }) 51 + } 52 + } 53 + 54 + impl FromStr for Tone { 55 + type Err = ToneError; 56 + 57 + fn from_str(txt: &str) -> Result<Self, Self::Err> { 58 + match txt { 59 + "C" => Ok(Tone::C), 60 + "C#" | "Db" => Ok(Tone::Db), 61 + "D" => Ok(Tone::D), 62 + "D#" | "Eb" => Ok(Tone::Eb), 63 + "E" => Ok(Tone::E), 64 + "F" => Ok(Tone::F), 65 + "F#" | "Gb" => Ok(Tone::Gb), 66 + "G" => Ok(Tone::G), 67 + "G#" | "Ab" => Ok(Tone::Ab), 68 + "A" => Ok(Tone::A), 69 + "A#" | "Bb" => Ok(Tone::Bb), 70 + "B" => Ok(Tone::B), 71 + _ => Err(ToneError()), 72 + } 73 + } 74 + } 75 + 76 + impl Note { 77 + pub fn tune_buffer_size(&self, sample_rate: u32) -> u32 { 78 + let t = 1.0 / self.tone.freq(self.octave); // periodo ? 79 + let buf = (sample_rate as f32) * t; 80 + buf.round() as u32 81 + } 82 + } 83 + 84 + impl Tone { 85 + pub fn freq(&self, octave: u32) -> f32 { 86 + match octave { 87 + 0 => match self { 88 + Tone::C => 16.35, 89 + Tone::Db => 17.32, 90 + Tone::D => 18.35, 91 + Tone::Eb => 19.45, 92 + Tone::E => 20.60, 93 + Tone::F => 21.83, 94 + Tone::Gb => 23.12, 95 + Tone::G => 24.50, 96 + Tone::Ab => 25.96, 97 + Tone::A => 27.50, 98 + Tone::Bb => 29.14, 99 + Tone::B => 30.87, 100 + }, 101 + _ => { 102 + let mut freq = self.freq(0); 103 + for _ in 0..octave { 104 + freq *= 2.0; 105 + } 106 + freq 107 + } 108 + } 109 + } 110 + }
+19 -2
src/play.rs
··· 5 5 6 6 use crate::{ 7 7 app::{App, State}, 8 + cfg::{SourceOptions, UiOptions}, 8 9 decoder::Mp3Decoder, 9 10 extract::{extract_stream_url, get_currently_playing}, 10 11 tui, ··· 58 59 println!("{}", stream_url); 59 60 60 61 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<State>(); 62 + let (frame_tx, frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 61 63 62 - let mut app = App::new(); 64 + let ui = UiOptions { 65 + scale: 1.0, 66 + scatter: false, 67 + no_reference: true, 68 + no_ui: true, 69 + no_braille: false, 70 + }; 71 + 72 + let opts = SourceOptions { 73 + channels: 2, 74 + buffer: 1152, 75 + sample_rate: 44100, 76 + tune: None, 77 + }; 78 + 79 + let mut app = App::new(&ui, &opts, frame_rx); 63 80 64 81 thread::spawn(move || { 65 82 let client = reqwest::blocking::Client::new(); ··· 107 124 108 125 let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); 109 126 let sink = rodio::Sink::try_new(&handle).unwrap(); 110 - let decoder = Mp3Decoder::new(response).unwrap(); 127 + let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 111 128 sink.append(decoder); 112 129 113 130 loop {
+2 -1
src/player.rs
··· 51 51 } 52 52 53 53 fn handle_play(&mut self, url: String) -> Result<(), Error> { 54 + let (frame_tx, frame_rx) = std::sync::mpsc::channel::<minimp3::Frame>(); 54 55 let client = reqwest::blocking::Client::new(); 55 56 56 57 let response = client.get(url).send().unwrap(); ··· 74 75 self.stream = stream; 75 76 self.sink = rodio::Sink::try_new(&handle).unwrap(); 76 77 self.handle = handle; 77 - let decoder = Mp3Decoder::new(response).unwrap(); 78 + let decoder = Mp3Decoder::new(response, frame_tx).unwrap(); 78 79 self.sink.append(decoder); 79 80 self.sink.play(); 80 81 Ok(())
+105
src/visualization/mod.rs
··· 1 + pub mod oscilloscope; 2 + pub mod spectroscope; 3 + pub mod vectorscope; 4 + 5 + use crossterm::event::Event; 6 + use ratatui::{ 7 + style::{Color, Style}, 8 + symbols::Marker, 9 + widgets::{Axis, Dataset, GraphType}, 10 + }; 11 + 12 + use crate::input::Matrix; 13 + 14 + pub enum Dimension { 15 + X, 16 + Y, 17 + } 18 + 19 + #[derive(Debug, Clone)] 20 + pub struct GraphConfig { 21 + pub pause: bool, 22 + pub samples: u32, 23 + pub sampling_rate: u32, 24 + pub scale: f64, 25 + pub width: u32, 26 + pub scatter: bool, 27 + pub references: bool, 28 + pub show_ui: bool, 29 + pub marker_type: Marker, 30 + pub palette: Vec<Color>, 31 + pub labels_color: Color, 32 + pub axis_color: Color, 33 + } 34 + 35 + impl GraphConfig { 36 + pub fn palette(&self, index: usize) -> Color { 37 + *self 38 + .palette 39 + .get(index % self.palette.len()) 40 + .unwrap_or(&Color::White) 41 + } 42 + } 43 + 44 + #[allow(clippy::ptr_arg)] 45 + pub trait DisplayMode { 46 + // MUST define 47 + fn from_args(args: &crate::cfg::SourceOptions) -> Self 48 + where 49 + Self: Sized; 50 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis; // TODO simplify this 51 + fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet>; 52 + fn mode_str(&self) -> &'static str; 53 + 54 + // SHOULD override 55 + fn channel_name(&self, index: usize) -> String { 56 + format!("{}", index) 57 + } 58 + fn header(&self, _cfg: &GraphConfig) -> String { 59 + "".into() 60 + } 61 + fn references(&self, _cfg: &GraphConfig) -> Vec<DataSet> { 62 + vec![] 63 + } 64 + fn handle(&mut self, _event: Event) {} 65 + } 66 + 67 + pub struct DataSet { 68 + name: Option<String>, 69 + data: Vec<(f64, f64)>, 70 + marker_type: Marker, 71 + graph_type: GraphType, 72 + color: Color, 73 + } 74 + 75 + impl<'a> From<&'a DataSet> for Dataset<'a> { 76 + fn from(ds: &'a DataSet) -> Dataset<'a> { 77 + let mut out = Dataset::default(); // TODO creating a binding is kinda ugly, is it avoidable? 78 + if let Some(name) = &ds.name { 79 + out = out.name(name.clone()); 80 + } 81 + out.marker(ds.marker_type) 82 + .graph_type(ds.graph_type) 83 + .style(Style::default().fg(ds.color)) 84 + .data(&ds.data) 85 + } 86 + } 87 + 88 + // TODO this is pretty ugly but I need datasets which own the data 89 + impl DataSet { 90 + pub fn new( 91 + name: Option<String>, 92 + data: Vec<(f64, f64)>, 93 + marker_type: Marker, 94 + graph_type: GraphType, 95 + color: Color, 96 + ) -> Self { 97 + DataSet { 98 + name, 99 + data, 100 + marker_type, 101 + graph_type, 102 + color, 103 + } 104 + } 105 + }
+208
src/visualization/oscilloscope.rs
··· 1 + use crossterm::event::{Event, KeyCode, KeyModifiers}; 2 + use ratatui::{ 3 + style::Style, 4 + text::Span, 5 + widgets::{Axis, GraphType}, 6 + }; 7 + 8 + use crate::{ 9 + app::{update_value_f, update_value_i}, 10 + input::Matrix, 11 + }; 12 + 13 + use super::{DataSet, Dimension, DisplayMode, GraphConfig}; 14 + 15 + #[derive(Default)] 16 + pub struct Oscilloscope { 17 + pub triggering: bool, 18 + pub falling_edge: bool, 19 + pub threshold: f64, 20 + pub depth: u32, 21 + pub peaks: bool, 22 + } 23 + 24 + impl DisplayMode for Oscilloscope { 25 + fn from_args(_opts: &crate::cfg::SourceOptions) -> Self { 26 + Oscilloscope::default() 27 + } 28 + 29 + fn mode_str(&self) -> &'static str { 30 + "oscillo" 31 + } 32 + 33 + fn channel_name(&self, index: usize) -> String { 34 + match index { 35 + 0 => "L".into(), 36 + 1 => "R".into(), 37 + _ => format!("{}", index), 38 + } 39 + } 40 + 41 + fn header(&self, _: &GraphConfig) -> String { 42 + if self.triggering { 43 + format!( 44 + "{} {:.0}{} trigger", 45 + if self.falling_edge { "v" } else { "^" }, 46 + self.threshold, 47 + if self.depth > 1 { 48 + format!(":{}", self.depth) 49 + } else { 50 + "".into() 51 + }, 52 + ) 53 + } else { 54 + "live".into() 55 + } 56 + } 57 + 58 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 59 + let (name, bounds) = match dimension { 60 + Dimension::X => ("time -", [0.0, cfg.samples as f64]), 61 + Dimension::Y => ("| amplitude", [-cfg.scale, cfg.scale]), 62 + }; 63 + let mut a = Axis::default(); 64 + if cfg.show_ui { 65 + // TODO don't make it necessary to check show_ui inside here 66 + a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); 67 + } 68 + a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) 69 + } 70 + 71 + fn references(&self, cfg: &GraphConfig) -> Vec<DataSet> { 72 + vec![DataSet::new( 73 + None, 74 + vec![(0.0, 0.0), (cfg.samples as f64, 0.0)], 75 + cfg.marker_type, 76 + GraphType::Line, 77 + cfg.axis_color, 78 + )] 79 + } 80 + 81 + fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet> { 82 + let mut out = Vec::new(); 83 + 84 + let mut trigger_offset = 0; 85 + if self.depth == 0 { 86 + self.depth = 1 87 + } 88 + if self.triggering { 89 + for i in 0..data[0].len() { 90 + if triggered(&data[0], i, self.threshold, self.depth, self.falling_edge) { 91 + // triggered 92 + break; 93 + } 94 + trigger_offset += 1; 95 + } 96 + } 97 + 98 + if self.triggering { 99 + out.push(DataSet::new( 100 + Some("T".into()), 101 + vec![(0.0, self.threshold)], 102 + cfg.marker_type, 103 + GraphType::Scatter, 104 + cfg.labels_color, 105 + )); 106 + } 107 + 108 + for (n, channel) in data.iter().enumerate().rev() { 109 + let (mut min, mut max) = (0.0, 0.0); 110 + let mut tmp = Vec::new(); 111 + for (i, sample) in channel.iter().enumerate() { 112 + if *sample < min { 113 + min = *sample 114 + }; 115 + if *sample > max { 116 + max = *sample 117 + }; 118 + if i >= trigger_offset { 119 + tmp.push(((i - trigger_offset) as f64, *sample)); 120 + } 121 + } 122 + 123 + if self.peaks { 124 + out.push(DataSet::new( 125 + None, 126 + vec![(0.0, min), (0.0, max)], 127 + cfg.marker_type, 128 + GraphType::Scatter, 129 + cfg.palette(n), 130 + )) 131 + } 132 + 133 + out.push(DataSet::new( 134 + Some(self.channel_name(n)), 135 + tmp, 136 + cfg.marker_type, 137 + if cfg.scatter { 138 + GraphType::Scatter 139 + } else { 140 + GraphType::Line 141 + }, 142 + cfg.palette(n), 143 + )); 144 + } 145 + 146 + out 147 + } 148 + 149 + fn handle(&mut self, event: Event) { 150 + if let Event::Key(key) = event { 151 + let magnitude = match key.modifiers { 152 + KeyModifiers::SHIFT => 10.0, 153 + KeyModifiers::CONTROL => 5.0, 154 + KeyModifiers::ALT => 0.2, 155 + _ => 1.0, 156 + }; 157 + match key.code { 158 + KeyCode::PageUp => { 159 + update_value_f(&mut self.threshold, 250.0, magnitude, 0.0..32768.0) 160 + } 161 + KeyCode::PageDown => { 162 + update_value_f(&mut self.threshold, -250.0, magnitude, 0.0..32768.0) 163 + } 164 + KeyCode::Char('t') => self.triggering = !self.triggering, 165 + KeyCode::Char('e') => self.falling_edge = !self.falling_edge, 166 + KeyCode::Char('p') => self.peaks = !self.peaks, 167 + KeyCode::Char('=') => update_value_i(&mut self.depth, true, 1, 1.0, 1..65535), 168 + KeyCode::Char('-') => update_value_i(&mut self.depth, false, 1, 1.0, 1..65535), 169 + KeyCode::Char('+') => update_value_i(&mut self.depth, true, 10, 1.0, 1..65535), 170 + KeyCode::Char('_') => update_value_i(&mut self.depth, false, 10, 1.0, 1..65535), 171 + KeyCode::Esc => { 172 + self.triggering = false; 173 + } 174 + _ => {} 175 + } 176 + } 177 + } 178 + } 179 + 180 + #[allow(clippy::collapsible_else_if)] // TODO can this be made nicer? 181 + fn triggered(data: &[f64], index: usize, threshold: f64, depth: u32, falling_edge: bool) -> bool { 182 + if data.len() < index + (1 + depth as usize) { 183 + return false; 184 + } 185 + if falling_edge { 186 + if data[index] >= threshold { 187 + for i in 1..=depth as usize { 188 + if data[index + i] >= threshold { 189 + return false; 190 + } 191 + } 192 + true 193 + } else { 194 + false 195 + } 196 + } else { 197 + if data[index] <= threshold { 198 + for i in 1..=depth as usize { 199 + if data[index + i] <= threshold { 200 + return false; 201 + } 202 + } 203 + true 204 + } else { 205 + false 206 + } 207 + } 208 + }
+403
src/visualization/spectroscope.rs
··· 1 + use std::collections::VecDeque; 2 + 3 + use crossterm::event::{Event, KeyCode}; 4 + use ratatui::{ 5 + style::Style, 6 + text::Span, 7 + widgets::{Axis, GraphType}, 8 + }; 9 + 10 + use crate::{app::update_value_i, input::Matrix}; 11 + 12 + use super::{DataSet, Dimension, DisplayMode, GraphConfig}; 13 + 14 + use rustfft::{num_complex::Complex, FftPlanner}; 15 + 16 + #[derive(Default)] 17 + pub struct Spectroscope { 18 + pub sampling_rate: u32, 19 + pub buffer_size: u32, 20 + pub average: u32, 21 + pub buf: Vec<VecDeque<Vec<f64>>>, 22 + pub window: bool, 23 + pub log_y: bool, 24 + } 25 + 26 + fn magnitude(c: Complex<f64>) -> f64 { 27 + let squared = (c.re * c.re) + (c.im * c.im); 28 + squared.sqrt() 29 + } 30 + 31 + // got this from https://github.com/phip1611/spectrum-analyzer/blob/3c079ec2785b031d304bb381ff5f5fe04e6bcf71/src/windows.rs#L40 32 + pub fn hann_window(samples: &[f64]) -> Vec<f64> { 33 + let mut windowed_samples = Vec::with_capacity(samples.len()); 34 + let samples_len = samples.len() as f64; 35 + for (i, sample) in samples.iter().enumerate() { 36 + let two_pi_i = 2.0 * std::f64::consts::PI * i as f64; 37 + let idontknowthename = (two_pi_i / samples_len).cos(); 38 + let multiplier = 0.5 * (1.0 - idontknowthename); 39 + windowed_samples.push(sample * multiplier) 40 + } 41 + windowed_samples 42 + } 43 + 44 + impl DisplayMode for Spectroscope { 45 + fn from_args(opts: &crate::cfg::SourceOptions) -> Self { 46 + Spectroscope { 47 + sampling_rate: opts.sample_rate, 48 + buffer_size: opts.buffer, 49 + average: 1, 50 + buf: Vec::new(), 51 + window: false, 52 + log_y: true, 53 + } 54 + } 55 + 56 + fn mode_str(&self) -> &'static str { 57 + "spectro" 58 + } 59 + 60 + fn channel_name(&self, index: usize) -> String { 61 + match index { 62 + 0 => "L".into(), 63 + 1 => "R".into(), 64 + _ => format!("{}", index), 65 + } 66 + } 67 + 68 + fn header(&self, _: &GraphConfig) -> String { 69 + let window_marker = if self.window { "-|-" } else { "---" }; 70 + if self.average <= 1 { 71 + format!( 72 + "live {} {:.3}Hz bins", 73 + window_marker, 74 + self.sampling_rate as f64 / self.buffer_size as f64 75 + ) 76 + } else { 77 + format!( 78 + "{}x avg ({:.1}s) {} {:.3}Hz bins", 79 + self.average, 80 + (self.average * self.buffer_size) as f64 / self.sampling_rate as f64, 81 + window_marker, 82 + self.sampling_rate as f64 / (self.buffer_size * self.average) as f64, 83 + ) 84 + } 85 + } 86 + 87 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 88 + let (name, bounds) = match dimension { 89 + Dimension::X => ( 90 + "frequency -", 91 + [ 92 + 20.0f64.ln(), 93 + ((cfg.samples as f64 / cfg.width as f64) * 20000.0).ln(), 94 + ], 95 + ), 96 + Dimension::Y => ( 97 + if self.log_y { "| level" } else { "| amplitude" }, 98 + [if self.log_y { 0. } else { 0.0 }, cfg.scale * 7.5], // very arbitrary but good default 99 + ), 100 + // TODO super arbitraty! wtf! also ugly inline ifs, get this thing together! 101 + }; 102 + let mut a = Axis::default(); 103 + if cfg.show_ui { 104 + // TODO don't make it necessary to check show_ui inside here 105 + a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); 106 + } 107 + a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) 108 + } 109 + 110 + fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet> { 111 + if self.average == 0 { 112 + self.average = 1 113 + } // otherwise fft breaks 114 + if !cfg.pause { 115 + for (i, chan) in data.iter().enumerate() { 116 + if self.buf.len() <= i { 117 + self.buf.push(VecDeque::new()); 118 + } 119 + self.buf[i].push_back(chan.clone()); 120 + while self.buf[i].len() > self.average as usize { 121 + self.buf[i].pop_front(); 122 + } 123 + } 124 + } 125 + 126 + let mut out = Vec::new(); 127 + let mut planner: FftPlanner<f64> = FftPlanner::new(); 128 + let sample_len = self.buffer_size * self.average; 129 + let resolution = self.sampling_rate as f64 / sample_len as f64; 130 + let fft = planner.plan_fft_forward(sample_len as usize); 131 + 132 + for (n, chan_queue) in self.buf.iter().enumerate().rev() { 133 + let mut chunk = chan_queue.iter().flatten().copied().collect::<Vec<f64>>(); 134 + if self.window { 135 + chunk = hann_window(chunk.as_slice()); 136 + } 137 + let mut max_val = *chunk 138 + .iter() 139 + .max_by(|a, b| a.total_cmp(b)) 140 + .expect("empty dataset?"); 141 + if max_val < 1. { 142 + max_val = 1.; 143 + } 144 + let mut tmp: Vec<Complex<f64>> = chunk 145 + .iter() 146 + .map(|x| Complex { 147 + re: *x / max_val, 148 + im: 0.0, 149 + }) 150 + .collect(); 151 + fft.process(tmp.as_mut_slice()); 152 + out.push(DataSet::new( 153 + Some(self.channel_name(n)), 154 + tmp[..=tmp.len() / 2] 155 + .iter() 156 + .enumerate() 157 + .map(|(i, x)| { 158 + ( 159 + (i as f64 * resolution).ln(), 160 + if self.log_y { 161 + magnitude(*x).ln() 162 + } else { 163 + magnitude(*x) 164 + }, 165 + ) 166 + }) 167 + .collect(), 168 + cfg.marker_type, 169 + if cfg.scatter { 170 + GraphType::Scatter 171 + } else { 172 + GraphType::Line 173 + }, 174 + cfg.palette(n), 175 + )); 176 + } 177 + 178 + out 179 + } 180 + 181 + fn handle(&mut self, event: Event) { 182 + if let Event::Key(key) = event { 183 + match key.code { 184 + KeyCode::PageUp => update_value_i(&mut self.average, true, 1, 1., 1..65535), 185 + KeyCode::PageDown => update_value_i(&mut self.average, false, 1, 1., 1..65535), 186 + KeyCode::Char('w') => self.window = !self.window, 187 + KeyCode::Char('l') => self.log_y = !self.log_y, 188 + _ => {} 189 + } 190 + } 191 + } 192 + 193 + fn references(&self, cfg: &GraphConfig) -> Vec<DataSet> { 194 + let lower = 0.; // if self.log_y { -(cfg.scale * 5.) } else { 0. }; 195 + let upper = cfg.scale * 7.5; 196 + vec![ 197 + DataSet::new( 198 + None, 199 + vec![(0.0, 0.0), ((cfg.samples as f64).ln(), 0.0)], 200 + cfg.marker_type, 201 + GraphType::Line, 202 + cfg.axis_color, 203 + ), 204 + // TODO can we auto generate these? lol... 205 + DataSet::new( 206 + None, 207 + vec![(20.0f64.ln(), lower), (20.0f64.ln(), upper)], 208 + cfg.marker_type, 209 + GraphType::Line, 210 + cfg.axis_color, 211 + ), 212 + DataSet::new( 213 + None, 214 + vec![(30.0f64.ln(), lower), (30.0f64.ln(), upper)], 215 + cfg.marker_type, 216 + GraphType::Line, 217 + cfg.axis_color, 218 + ), 219 + DataSet::new( 220 + None, 221 + vec![(40.0f64.ln(), lower), (40.0f64.ln(), upper)], 222 + cfg.marker_type, 223 + GraphType::Line, 224 + cfg.axis_color, 225 + ), 226 + DataSet::new( 227 + None, 228 + vec![(50.0f64.ln(), lower), (50.0f64.ln(), upper)], 229 + cfg.marker_type, 230 + GraphType::Line, 231 + cfg.axis_color, 232 + ), 233 + DataSet::new( 234 + None, 235 + vec![(60.0f64.ln(), lower), (60.0f64.ln(), upper)], 236 + cfg.marker_type, 237 + GraphType::Line, 238 + cfg.axis_color, 239 + ), 240 + DataSet::new( 241 + None, 242 + vec![(70.0f64.ln(), lower), (70.0f64.ln(), upper)], 243 + cfg.marker_type, 244 + GraphType::Line, 245 + cfg.axis_color, 246 + ), 247 + DataSet::new( 248 + None, 249 + vec![(80.0f64.ln(), lower), (80.0f64.ln(), upper)], 250 + cfg.marker_type, 251 + GraphType::Line, 252 + cfg.axis_color, 253 + ), 254 + DataSet::new( 255 + None, 256 + vec![(90.0f64.ln(), lower), (90.0f64.ln(), upper)], 257 + cfg.marker_type, 258 + GraphType::Line, 259 + cfg.axis_color, 260 + ), 261 + DataSet::new( 262 + None, 263 + vec![(100.0f64.ln(), lower), (100.0f64.ln(), upper)], 264 + cfg.marker_type, 265 + GraphType::Line, 266 + cfg.axis_color, 267 + ), 268 + DataSet::new( 269 + None, 270 + vec![(200.0f64.ln(), lower), (200.0f64.ln(), upper)], 271 + cfg.marker_type, 272 + GraphType::Line, 273 + cfg.axis_color, 274 + ), 275 + DataSet::new( 276 + None, 277 + vec![(300.0f64.ln(), lower), (300.0f64.ln(), upper)], 278 + cfg.marker_type, 279 + GraphType::Line, 280 + cfg.axis_color, 281 + ), 282 + DataSet::new( 283 + None, 284 + vec![(400.0f64.ln(), lower), (400.0f64.ln(), upper)], 285 + cfg.marker_type, 286 + GraphType::Line, 287 + cfg.axis_color, 288 + ), 289 + DataSet::new( 290 + None, 291 + vec![(500.0f64.ln(), lower), (500.0f64.ln(), upper)], 292 + cfg.marker_type, 293 + GraphType::Line, 294 + cfg.axis_color, 295 + ), 296 + DataSet::new( 297 + None, 298 + vec![(600.0f64.ln(), lower), (600.0f64.ln(), upper)], 299 + cfg.marker_type, 300 + GraphType::Line, 301 + cfg.axis_color, 302 + ), 303 + DataSet::new( 304 + None, 305 + vec![(700.0f64.ln(), lower), (700.0f64.ln(), upper)], 306 + cfg.marker_type, 307 + GraphType::Line, 308 + cfg.axis_color, 309 + ), 310 + DataSet::new( 311 + None, 312 + vec![(800.0f64.ln(), lower), (800.0f64.ln(), upper)], 313 + cfg.marker_type, 314 + GraphType::Line, 315 + cfg.axis_color, 316 + ), 317 + DataSet::new( 318 + None, 319 + vec![(900.0f64.ln(), lower), (900.0f64.ln(), upper)], 320 + cfg.marker_type, 321 + GraphType::Line, 322 + cfg.axis_color, 323 + ), 324 + DataSet::new( 325 + None, 326 + vec![(1000.0f64.ln(), lower), (1000.0f64.ln(), upper)], 327 + cfg.marker_type, 328 + GraphType::Line, 329 + cfg.axis_color, 330 + ), 331 + DataSet::new( 332 + None, 333 + vec![(2000.0f64.ln(), lower), (2000.0f64.ln(), upper)], 334 + cfg.marker_type, 335 + GraphType::Line, 336 + cfg.axis_color, 337 + ), 338 + DataSet::new( 339 + None, 340 + vec![(3000.0f64.ln(), lower), (3000.0f64.ln(), upper)], 341 + cfg.marker_type, 342 + GraphType::Line, 343 + cfg.axis_color, 344 + ), 345 + DataSet::new( 346 + None, 347 + vec![(4000.0f64.ln(), lower), (4000.0f64.ln(), upper)], 348 + cfg.marker_type, 349 + GraphType::Line, 350 + cfg.axis_color, 351 + ), 352 + DataSet::new( 353 + None, 354 + vec![(5000.0f64.ln(), lower), (5000.0f64.ln(), upper)], 355 + cfg.marker_type, 356 + GraphType::Line, 357 + cfg.axis_color, 358 + ), 359 + DataSet::new( 360 + None, 361 + vec![(6000.0f64.ln(), lower), (6000.0f64.ln(), upper)], 362 + cfg.marker_type, 363 + GraphType::Line, 364 + cfg.axis_color, 365 + ), 366 + DataSet::new( 367 + None, 368 + vec![(7000.0f64.ln(), lower), (7000.0f64.ln(), upper)], 369 + cfg.marker_type, 370 + GraphType::Line, 371 + cfg.axis_color, 372 + ), 373 + DataSet::new( 374 + None, 375 + vec![(8000.0f64.ln(), lower), (8000.0f64.ln(), upper)], 376 + cfg.marker_type, 377 + GraphType::Line, 378 + cfg.axis_color, 379 + ), 380 + DataSet::new( 381 + None, 382 + vec![(9000.0f64.ln(), lower), (9000.0f64.ln(), upper)], 383 + cfg.marker_type, 384 + GraphType::Line, 385 + cfg.axis_color, 386 + ), 387 + DataSet::new( 388 + None, 389 + vec![(10000.0f64.ln(), lower), (10000.0f64.ln(), upper)], 390 + cfg.marker_type, 391 + GraphType::Line, 392 + cfg.axis_color, 393 + ), 394 + DataSet::new( 395 + None, 396 + vec![(20000.0f64.ln(), lower), (20000.0f64.ln(), upper)], 397 + cfg.marker_type, 398 + GraphType::Line, 399 + cfg.axis_color, 400 + ), 401 + ] 402 + } 403 + }
+116
src/visualization/vectorscope.rs
··· 1 + use ratatui::{ 2 + style::Style, 3 + text::Span, 4 + widgets::{Axis, GraphType}, 5 + }; 6 + 7 + use crate::input::Matrix; 8 + 9 + use super::{DataSet, Dimension, DisplayMode, GraphConfig}; 10 + 11 + #[derive(Default)] 12 + pub struct Vectorscope {} 13 + 14 + impl DisplayMode for Vectorscope { 15 + fn from_args(_opts: &crate::cfg::SourceOptions) -> Self { 16 + Vectorscope::default() 17 + } 18 + 19 + fn mode_str(&self) -> &'static str { 20 + "vector" 21 + } 22 + 23 + fn channel_name(&self, index: usize) -> String { 24 + format!("{}", index) 25 + } 26 + 27 + fn header(&self, _: &GraphConfig) -> String { 28 + "live".into() 29 + } 30 + 31 + fn axis(&self, cfg: &GraphConfig, dimension: Dimension) -> Axis { 32 + let (name, bounds) = match dimension { 33 + Dimension::X => ("left -", [-cfg.scale, cfg.scale]), 34 + Dimension::Y => ("| right", [-cfg.scale, cfg.scale]), 35 + }; 36 + let mut a = Axis::default(); 37 + if cfg.show_ui { 38 + // TODO don't make it necessary to check show_ui inside here 39 + a = a.title(Span::styled(name, Style::default().fg(cfg.labels_color))); 40 + } 41 + a.style(Style::default().fg(cfg.axis_color)).bounds(bounds) 42 + } 43 + 44 + fn references(&self, cfg: &GraphConfig) -> Vec<DataSet> { 45 + vec![ 46 + DataSet::new( 47 + None, 48 + vec![(-cfg.scale, 0.0), (cfg.scale, 0.0)], 49 + cfg.marker_type, 50 + GraphType::Line, 51 + cfg.axis_color, 52 + ), 53 + DataSet::new( 54 + None, 55 + vec![(0.0, -cfg.scale), (0.0, cfg.scale)], 56 + cfg.marker_type, 57 + GraphType::Line, 58 + cfg.axis_color, 59 + ), 60 + ] 61 + } 62 + 63 + fn process(&mut self, cfg: &GraphConfig, data: &Matrix<f64>) -> Vec<DataSet> { 64 + let mut out = Vec::new(); 65 + 66 + for (n, chunk) in data.chunks(2).enumerate() { 67 + let mut tmp = vec![]; 68 + match chunk.len() { 69 + 2 => { 70 + for i in 0..std::cmp::min(chunk[0].len(), chunk[1].len()) { 71 + if i > cfg.samples as usize { 72 + break; 73 + } 74 + tmp.push((chunk[0][i], chunk[1][i])); 75 + } 76 + } 77 + 1 => { 78 + for i in 0..chunk[0].len() { 79 + if i > cfg.samples as usize { 80 + break; 81 + } 82 + tmp.push((chunk[0][i], i as f64)); 83 + } 84 + } 85 + _ => continue, 86 + } 87 + // split it in two for easier coloring 88 + // TODO configure splitting in multiple parts? 89 + let pivot = tmp.len() / 2; 90 + out.push(DataSet::new( 91 + Some(self.channel_name((n * 2) + 1)), 92 + tmp[pivot..].to_vec(), 93 + cfg.marker_type, 94 + if cfg.scatter { 95 + GraphType::Scatter 96 + } else { 97 + GraphType::Line 98 + }, 99 + cfg.palette((n * 2) + 1), 100 + )); 101 + out.push(DataSet::new( 102 + Some(self.channel_name(n * 2)), 103 + tmp[..pivot].to_vec(), 104 + cfg.marker_type, 105 + if cfg.scatter { 106 + GraphType::Scatter 107 + } else { 108 + GraphType::Line 109 + }, 110 + cfg.palette(n * 2), 111 + )); 112 + } 113 + 114 + out 115 + } 116 + }