Compare commits

...

26 Commits

Author SHA1 Message Date
03d076cb94 Updated cpal dependency 2026-01-21 10:21:31 -05:00
ab8abd635b Upgrade egui and eframe to 0.31 and update all other dependencies 2025-05-30 11:25:40 -04:00
a8340a3fb9 Add dummy device for testing 2024-06-19 09:50:55 -04:00
edcf10f73c Add close fn to device trait 2024-06-19 09:24:20 -04:00
d1271c1f7d Clear waterfall texture buffer at startup 2024-06-14 14:52:42 -04:00
4724d23459 Fix memory leak caused by unbounded channels not freeing memory 2024-06-12 13:07:49 -04:00
7abb089c04 Add side panel 2024-06-03 23:01:03 -04:00
d878f66bd8 Clean up ui 2024-06-03 22:20:36 -04:00
9f18bb6920 Add logic to close device selection window 2024-06-03 20:20:16 -04:00
8978be49bd Remove unused code 2024-05-31 22:39:18 -04:00
f3216e2d8d Add simple device selector window 2024-05-31 22:36:45 -04:00
243fcdb31e Separate audio_fft.rs into two files audio.rs and fft.rs 2024-05-31 13:31:52 -04:00
02c0f54079 Fix texture offset still using wrong dimension 2024-05-13 19:12:40 -04:00
54826d50a6 Fix texture offset using wrong dimension 2024-05-13 15:33:52 -04:00
6445949083 Fix FFT scaling 2024-05-12 21:19:18 -04:00
dbbd6ab849 Fix bode plot window always grows to maximum height 2024-05-12 21:14:36 -04:00
24d9fc7972 Fix audio_fft outputing phase instead of magnitude 2024-05-12 15:17:59 -04:00
162220aa72 Add debug plots 2024-05-12 15:17:06 -04:00
fc9e04ffd2 Almost working audio fft input for waterfall 2024-05-09 19:23:07 -04:00
63ec587d08 Increase waterfall size and have it fill the screen 2024-05-07 19:23:27 -04:00
e37eb169ce Clamp LUT output 2024-05-07 17:58:36 -04:00
e5dc145ada Add mpsc to send data to waterfall 2024-05-07 17:56:14 -04:00
e59f6ce896 Avoid overflow panic 2024-05-06 21:29:06 -04:00
f6f6a5b1ef Fix wasm build 2024-05-06 17:14:26 -04:00
5cecec3f70 Set app to render every frame 2024-05-06 17:06:05 -04:00
636f67c6de Fix LUT on android 2024-05-06 17:05:02 -04:00
15 changed files with 3020 additions and 1086 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
target
.DS_Store
dist
heaptrack.*.zst

3130
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,34 +7,39 @@ edition = "2021"
# Common dependencies
[dependencies]
egui = "0.27.0"
log = "0.4.21"
anyhow = "1.0.98"
cpal = "0.17.1"
egui = "0.31"
egui_plot = "0.32"
log = "0.4.27"
realfft = "3.4.0"
# eframe features for non android targets
[target.'cfg(not(target_os = "android"))'.dependencies.eframe]
version = "0.27.0"
version = "0.31"
default-features = false
features = ["accesskit", "default_fonts", "glow"]
features = ["accesskit", "default_fonts", "glow", "wayland", "x11"]
# eframe features for android targets
[target.'cfg(target_os = "android")'.dependencies.eframe]
version = "0.27.0"
version = "0.31"
default-features = false
features = ["accesskit", "default_fonts", "glow", "android-native-activity"]
# android only dependencies
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.13.3"
android_logger = "0.15.0"
#android-activity = { version = "0.5", features = ["native-activity"] }
winit = { version = "0.29.4", features = ["android-native-activity"] }
winit = { version = "0.30.11", features = ["android-native-activity"] }
# native only dependencies
[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))'.dependencies]
env_logger = "0.10"
env_logger = "0.11"
# web only dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
web-sys = "0.3.70" # to access the DOM (to hide the loading text)
[profile.release]
opt-level = 2

View File

@@ -3,18 +3,18 @@
"short_name": "egui-template-pwa",
"icons": [
{
"src": "./icon-256.png",
"src": "./assets/icon-256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "./maskable_icon_x512.png",
"src": "./assets/maskable_icon_x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./icon-1024.png",
"src": "./assets/icon-1024.png",
"sizes": "1024x1024",
"type": "image/png"
}

View File

@@ -10,23 +10,23 @@
<title>eframe template</title>
<!-- config for our rust wasm binary. go to https://trunkrs.dev/assets/#rust for more customization -->
<link data-trunk rel="rust" data-target-name="eframe-template" data-wasm-opt="2" />
<link data-trunk rel="rust" data-target-name="waterfall" data-wasm-opt="2" />
<!-- this is the base url relative to which other urls will be constructed. trunk will insert this from the public-url option -->
<base data-trunk-public-url />
<link data-trunk rel="icon" href="assets/favicon.ico">
<link data-trunk rel="copy-file" href="assets/sw.js" />
<link data-trunk rel="copy-file" href="assets/manifest.json" />
<link data-trunk rel="copy-file" href="assets/icon-1024.png" />
<link data-trunk rel="copy-file" href="assets/icon-256.png" />
<link data-trunk rel="copy-file" href="assets/icon_ios_touch_192.png" />
<link data-trunk rel="copy-file" href="assets/maskable_icon_x512.png" />
<link data-trunk rel="copy-file" href="assets/sw.js"/>
<link data-trunk rel="copy-file" href="assets/manifest.json"/>
<link data-trunk rel="copy-file" href="assets/icon-1024.png" data-target-path="assets"/>
<link data-trunk rel="copy-file" href="assets/icon-256.png" data-target-path="assets"/>
<link data-trunk rel="copy-file" href="assets/icon_ios_touch_192.png" data-target-path="assets"/>
<link data-trunk rel="copy-file" href="assets/maskable_icon_x512.png" data-target-path="assets"/>
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="icon_ios_touch_192.png">
<link rel="apple-touch-icon" href="assets/icon_ios_touch_192.png">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="white">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#404040">
@@ -60,15 +60,16 @@
width: 100%;
}
/* Position canvas in center-top: */
/* Make canvas fill entire document: */
canvas {
margin-right: auto;
margin-left: auto;
display: block;
position: absolute;
top: 0%;
left: 50%;
transform: translate(-50%, 0%);
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.centered {
@@ -114,7 +115,6 @@
transform: rotate(360deg);
}
}
</style>
</head>
@@ -123,6 +123,14 @@
<!-- the id is hardcoded in main.rs . so, make sure both match. -->
<canvas id="the_canvas_id"></canvas>
<!-- the loading spinner will be removed in main.rs -->
<div class="centered" id="loading_text">
<p style="font-size:16px">
Loading…
</p>
<div class="lds-dual-ring"></div>
</div>
<!--Register Service Worker. this will cache the wasm / js scripts for offline use (for PWA functionality). -->
<!-- Force refresh (Ctrl + F5) to load the latest files instead of cached files -->
<script>

View File

@@ -5,6 +5,6 @@
# to the user in the error, instead of "error: invalid channel name '[toolchain]'".
[toolchain]
channel = "1.76.0"
channel = "1.87.0"
components = [ "rustfmt", "clippy" ]
targets = [ "wasm32-unknown-unknown", "aarch64-linux-android" ]

View File

@@ -1,18 +1,29 @@
use eframe::{egui_glow, glow};
use egui::mutex::Mutex;
use egui::{mutex::Mutex, ScrollArea};
use std::sync::Arc;
use crate::backend::{self, Backends};
pub mod debug_plot;
use debug_plot::DebugPlots;
mod waterfall;
use waterfall::Waterfall;
mod fft;
use fft::Fft;
pub mod turbo_colormap;
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
const FFT_SIZE: usize = 1024;
pub struct TemplateApp {
// Example stuff:
label: String,
value: f32,
plots: DebugPlots,
/// Behind an `Arc<Mutex<…>>` so we can pass it to [`egui::PaintCallback`] and paint later.
waterfall: Arc<Mutex<Waterfall>>,
fft: Fft,
backends: backend::Backends,
selected_backend: usize,
open_device: Option<Box<dyn backend::Device>>,
device_window_open: bool,
side_panel_open: bool,
}
impl TemplateApp {
@@ -24,16 +35,25 @@ impl TemplateApp {
// Load previous app state (if any).
// Note that you must enable the `persistence` feature for this to work.
let plots = DebugPlots::new();
let (fft, rx) = Fft::new(FFT_SIZE, plots.get_sender()).unwrap();
let wf_size = fft.output_len;
let gl = cc
.gl
.as_ref()
.expect("Could not get gl context from glow backend");
Self {
// Example stuff:
label: "Hello World!".to_owned(),
value: 2.7,
waterfall: Arc::new(Mutex::new(Waterfall::new(gl, 300, 300))),
plots,
waterfall: Arc::new(Mutex::new(Waterfall::new(gl, wf_size, wf_size, rx))),
fft,
backends: Backends::default(),
selected_backend: 0,
open_device: None,
device_window_open: true,
side_panel_open: false,
}
}
}
@@ -52,53 +72,65 @@ impl eframe::App for TemplateApp {
/// Called each time the UI needs repainting, which may be many times per second.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Put your widgets into a `SidePanel`, `TopBottomPanel`, `CentralPanel`, `Window` or `Area`.
// For inspiration and more examples, go to https://emilk.github.io/egui
ctx.request_repaint();
self.plots.update_plots();
// Menu bar panel
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
// The top panel is often a good place for a menu bar:
egui::menu::bar(ui, |ui| {
// NOTE: no File->Quit on web pages!
let is_web = cfg!(target_arch = "wasm32");
if !is_web {
ui.menu_button("File", |ui| {
if ui.button("Open Device").clicked() {
self.device_window_open = true;
}
if self.open_device.is_some() {
if ui.button("Close Device").clicked() {
if let Some(dev) = self.open_device.take() {
dev.close();
}
}
}
if !is_web {
if ui.button("Quit").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
});
ui.menu_button("View", |ui| {
ui.checkbox(&mut self.side_panel_open, "Side Panel");
});
self.plots.render_menu_buttons(ui);
ui.add_space(16.0);
egui::widgets::global_theme_preference_buttons(ui);
});
});
// Side panel
egui::SidePanel::right("Sid panel").show_animated(ctx, self.side_panel_open, |ui| {
if let Some(d) = &mut self.open_device {
d.show_settings(ui)
}
egui::widgets::global_dark_light_mode_buttons(ui);
});
});
// Central panel
egui::CentralPanel::default().show(ctx, |ui| {
// The central panel the region left after adding TopPanel's and SidePanel's
ui.heading("eframe template");
ui.horizontal(|ui| {
ui.label("Write something: ");
ui.text_edit_singleline(&mut self.label);
});
ui.add(egui::Slider::new(&mut self.value, 0.0..=10.0).text("value"));
if ui.button("Increment").clicked() {
self.value += 1.0;
}
ui.separator();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("The texture is being painted using ");
ui.hyperlink_to("glow", "https://github.com/grovesNL/glow");
ui.label(" (OpenGL).");
egui::TopBottomPanel::top("Plot")
.resizable(true)
.show_inside(ui, |_ui| {
// TODO: Add plot
});
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
powered_by_egui_and_eframe(ui);
egui::warn_if_debug_build(ui);
egui::Frame::canvas(ui.style()).show(ui, |ui| {
let available_space = ui.available_size();
let (rect, response) =
ui.allocate_exact_size(egui::Vec2::splat(300.0), egui::Sense::drag());
ui.allocate_exact_size(available_space, egui::Sense::drag());
let _angle = response.drag_motion().x * 0.01;
@@ -115,18 +147,70 @@ impl eframe::App for TemplateApp {
};
ui.painter().add(callback);
});
ui.separator();
ui.add(egui::github_link_file!(
"https://github.com/emilk/eframe_template/blob/main/",
"Source code."
));
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
powered_by_egui_and_eframe(ui);
egui::warn_if_debug_build(ui);
});
});
});
// Update debug plot windows
self.plots.render_plot_windows(ctx);
// Update device selection window
let mut device_window = egui::Window::new("Select Device")
.default_width(600.0)
.default_height(400.0)
.vscroll(false)
.resizable(true)
.collapsible(false);
if self.open_device.is_some() {
device_window = device_window.open(&mut self.device_window_open);
} else {
device_window = device_window.anchor(egui::Align2::CENTER_CENTER, [0., 0.]);
}
let mut close_device_window = false;
device_window.show(ctx, |ui| {
egui::SidePanel::left("Select Driver")
.resizable(true)
.default_width(150.0)
.width_range(80.0..=200.0)
.show_inside(ui, |ui| {
ScrollArea::vertical().show(ui, |ui| {
ui.with_layout(egui::Layout::top_down_justified(egui::Align::LEFT), |ui| {
for (i, b) in self.backends.0.iter().enumerate() {
ui.selectable_value(
&mut self.selected_backend,
i,
b.display_text(),
);
}
});
});
});
ui.vertical_centered(|ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
//if self._selected_backend < self._backends.0.len() {
if let Some(b) = self.backends.0.get_mut(self.selected_backend) {
//let mut b = &self._backends.0[self._selected_backend];
b.show_device_selection(ui);
if ui.add(egui::Button::new("Apply")).clicked() {
if let Some(dev) = self.open_device.take() {
dev.close()
};
if let Ok(device) =
b.build_device(self.fft.tx.clone(), self.plots.get_sender())
{
self.open_device = Some(device);
close_device_window = true;
}
}
} else {
ui.add(egui::Label::new("Select a Device Driver"));
}
});
});
});
if close_device_window {
self.device_window_open = false;
}
}
}
@@ -135,11 +219,13 @@ fn powered_by_egui_and_eframe(ui: &mut egui::Ui) {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("Powered by ");
ui.hyperlink_to("egui", "https://github.com/emilk/egui");
ui.label(" and ");
ui.label(", ");
ui.hyperlink_to(
"eframe",
"https://github.com/emilk/egui/tree/master/crates/eframe",
);
ui.label(" and ");
ui.hyperlink_to("glow", "https://github.com/grovesNL/glow");
ui.label(".");
});
}

144
src/app/debug_plot.rs Normal file
View File

@@ -0,0 +1,144 @@
use std::collections::HashMap;
use std::sync::mpsc;
use egui::{Context, Ui};
use egui_plot::{Line, Plot, PlotBounds, PlotPoints};
use realfft::num_complex::Complex32;
pub enum PlotData {
U8(Vec<u8>),
F32(Vec<f32>),
Bode32(Vec<Complex32>),
}
#[derive(Clone)]
pub struct DebugPlotSender {
tx: mpsc::SyncSender<(&'static str, PlotData)>,
}
impl DebugPlotSender {
pub fn send(
&self,
plot_name: &'static str,
plot_data: PlotData,
) -> Result<(), mpsc::SendError<PlotData>> {
match self.tx.try_send((plot_name, plot_data)) {
Err(mpsc::TrySendError::Full(_)) => {
log::warn!("Debug buffer is full!");
Ok(())
}
Err(mpsc::TrySendError::Disconnected((_, d))) => Err(mpsc::SendError(d)),
Ok(()) => Ok(()),
}
}
}
pub struct DebugPlots {
plots: HashMap<&'static str, PlotData>,
plot_en: HashMap<&'static str, bool>,
rx: mpsc::Receiver<(&'static str, PlotData)>,
tx: DebugPlotSender,
}
impl DebugPlots {
pub fn new() -> Self {
let (tx, rx) = mpsc::sync_channel(128);
DebugPlots {
plots: HashMap::new(),
plot_en: HashMap::new(),
rx,
tx: DebugPlotSender { tx },
}
}
pub fn get_sender(&self) -> DebugPlotSender {
self.tx.clone()
}
pub fn update_plots(&mut self) {
while let Ok((key, plot)) = self.rx.try_recv() {
if self.plots.insert(key, plot).is_none() {
self.plot_en.insert(key, false);
}
}
}
pub fn render_menu_buttons(&mut self, ui: &mut Ui) {
ui.menu_button("Debug Plots", |ui| {
for &k in self.plots.keys() {
if !self.plot_en.contains_key(k) {
self.plot_en.insert(k, false);
}
let enabled = self.plot_en.get_mut(k).unwrap();
ui.checkbox(enabled, k);
}
});
}
pub fn render_plot_windows(&mut self, ctx: &Context) {
for (key, plot) in self.plots.iter() {
let enabled = self.plot_en.get_mut(key).unwrap();
if *enabled {
DebugPlots::render_window(ctx, key, plot, enabled);
}
}
}
fn render_window(ctx: &Context, title: &'static str, plot: &PlotData, open: &mut bool) {
egui::Window::new(title).open(open).show(ctx, |ui| {
ui.heading(title);
match plot {
PlotData::U8(v) => {
ui.heading("u8 Plot");
let line = Line::new(
"Data",
PlotPoints::from_iter(
v.iter().enumerate().map(|(i, y)| [i as f64, *y as f64]),
),
);
let plot = Plot::new(title);
plot.show(ui, |plot_ui| {
plot_ui.line(line);
plot_ui.set_plot_bounds(PlotBounds::from_min_max(
[-1.0, -1.0],
[(v.len() + 1) as f64, core::u8::MAX as f64 + 1.0],
));
});
}
PlotData::F32(v) => {
ui.heading("f32 plot");
let line = Line::new("Data", PlotPoints::from_ys_f32(&v));
let plot = Plot::new(title);
plot.show(ui, |plot_ui| {
plot_ui.line(line);
plot_ui.set_plot_bounds(PlotBounds::from_min_max(
[-1.0, -2.0],
[(v.len() + 1) as f64, 2.0],
));
});
}
PlotData::Bode32(v) => {
ui.heading("Bode Plot");
let mag_line = Line::new(
"Magnitude",
PlotPoints::from_iter(v.iter().enumerate().map(|(i, c)| {
[
i as f64,
((c.re * c.re) + (c.im * c.im)).sqrt() as f64 / v.len() as f64,
]
})),
);
let phase_line = Line::new(
"Phase",
PlotPoints::from_iter(
v.iter()
.enumerate()
.map(|(i, c)| [i as f64, c.arg() as f64 / core::f64::consts::PI]),
),
);
let plot = Plot::new(title);
plot.show(ui, |plot_ui| {
plot_ui.line(mag_line);
plot_ui.line(phase_line);
plot_ui.set_plot_bounds(PlotBounds::from_min_max(
[0.0, -1.0],
[(v.len() + 1) as f64, 1.0],
));
});
}
};
});
}
}

92
src/app/fft.rs Normal file
View File

@@ -0,0 +1,92 @@
use anyhow::{anyhow, Result};
use realfft::RealFftPlanner;
use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
use super::debug_plot::{DebugPlotSender, PlotData};
pub struct Fft {
pub tx: SyncSender<Vec<f32>>,
pub output_len: usize,
}
impl Fft {
pub fn new(size: usize, plot_tx: DebugPlotSender) -> Result<(Self, mpsc::Receiver<Vec<u8>>)> {
let output_len = size / 2 + 1;
// Create mpsc queue
let (tx, rx) = mpsc::sync_channel(10);
let (in_tx, in_rx): (SyncSender<Vec<f32>>, Receiver<Vec<f32>>) = mpsc::sync_channel(10);
// Setup fft use f32 for now
let mut fft_planner = RealFftPlanner::<f32>::new();
let fft = fft_planner.plan_fft_forward(size);
let mut fft_in: Vec<f32> = Vec::with_capacity(size);
let mut fft_out = fft.make_output_vec();
let mut fft_scratch = fft.make_scratch_vec();
std::thread::spawn(move || {
while let Ok(samples) = in_rx.recv() {
let mut data = samples.as_slice();
while data.fill_vec(&mut fft_in, size).is_ok() {
assert_eq!(size, fft_in.len());
fft.process_with_scratch(&mut fft_in, &mut fft_out, &mut fft_scratch)
.unwrap();
plot_tx
.send("FFT Output", PlotData::Bode32(fft_out.clone()))
.unwrap();
fft_in.clear();
let output: Vec<u8> = fft_out
.iter()
.map(|c| {
(((c.re * c.re) + (c.im * c.im)).sqrt() / output_len as f32 * 255.0)
as u8
})
.collect();
assert_eq!(output_len, output.len());
plot_tx
.send("FFT Processed Output", PlotData::U8(output.clone()))
.unwrap();
match tx.try_send(output) {
Ok(_) => {}
Err(TrySendError::Full(_)) => log::warn!("Waterfall buffer full."),
Err(TrySendError::Disconnected(_)) => {
panic!("The fft thread has disconnected from the waterfall!")
}
}
}
}
});
Ok((
Self {
tx: in_tx,
output_len,
},
rx,
))
}
}
trait FillVec {
/// Takes elements from self and inserts them into out_vec
/// Returns Ok if out_vec is filled to size
/// Returns Err when out_vec is not fully filled (self will be empty)
fn fill_vec(&mut self, out_vec: &mut Vec<f32>, size: usize) -> Result<()>;
}
impl FillVec for &[f32] {
fn fill_vec(&mut self, out_vec: &mut Vec<f32>, size: usize) -> Result<()> {
let have = self.len();
if have == 0 {
anyhow::bail!("Self empty");
}
let need = size - out_vec.len();
let can_move = need.min(have);
out_vec.extend_from_slice(&self[..can_move]);
*self = &self[can_move..];
match out_vec.len() == size {
true => Ok(()),
false => Err(anyhow!("out_vec not full")),
}
}
}

View File

@@ -1,8 +1,11 @@
use eframe::glow::{self, PixelUnpackData, TEXTURE0, TEXTURE1, UNSIGNED_BYTE};
use eframe::glow::{
self, PixelUnpackData, CLAMP_TO_EDGE, TEXTURE0, TEXTURE1, TEXTURE_WRAP_S, UNSIGNED_BYTE,
};
use glow::HasContext as _;
use glow::{NEAREST, TEXTURE_1D, TEXTURE_2D, TEXTURE_MAG_FILTER, TEXTURE_MIN_FILTER};
use glow::{NEAREST, TEXTURE_2D, TEXTURE_MAG_FILTER, TEXTURE_MIN_FILTER};
use log;
use std::mem::{size_of, transmute};
use std::sync::mpsc::Receiver;
const SIZE_OF_F32: i32 = size_of::<f32>() as i32;
@@ -14,19 +17,25 @@ unsafe fn check_for_gl_errors(gl: &glow::Context, msg: &str) {
log::error!("Waterfall {}: GL ERROR {} ({:#X})", msg, err, err);
}
}
mod deadbeef_rand {
static mut RNG_SEED: u32 = 0x3d2faba7;
static mut RNG_BEEF: u32 = 0xdeadbeef;
pub fn rand() -> u8 {
unsafe fn clear_texture(gl: &glow::Context, bytes_per_row: usize, n_rows: usize) {
let blank_line = vec![0_u8; bytes_per_row];
for offset in 0..n_rows {
unsafe {
RNG_SEED = (RNG_SEED << 7) ^ ((RNG_SEED >> 25) + RNG_BEEF);
RNG_BEEF = (RNG_BEEF << 7) ^ ((RNG_BEEF >> 25) + 0xdeadbeef);
(RNG_SEED & 0xff) as u8
gl.tex_sub_image_2d(
glow::TEXTURE_2D,
0,
0,
offset as i32,
bytes_per_row as i32,
1,
glow::RED,
glow::UNSIGNED_BYTE,
PixelUnpackData::Slice(Some(&blank_line)),
);
check_for_gl_errors(&gl, &format!("clear texture with offset {}", offset));
}
}
}
use deadbeef_rand::rand;
use crate::app::turbo_colormap;
@@ -38,6 +47,9 @@ pub struct Waterfall {
vbo: glow::Buffer,
ebo: glow::Buffer,
offset: usize,
width: usize,
height: usize,
fft_in: Receiver<Vec<u8>>,
}
impl Waterfall {
@@ -54,16 +66,11 @@ impl Waterfall {
pub fn paint(&mut self, gl: &glow::Context, _angle: f32) {
use glow::HasContext as _;
let mut new_data: [u8; 300] = [0; 300];
for data in new_data.iter_mut() {
*data = rand();
}
unsafe {
// Bind our texturs
gl.active_texture(TEXTURE1);
check_for_gl_errors(&gl, "Active texture 1");
gl.bind_texture(glow::TEXTURE_1D, Some(self.color_lut));
gl.bind_texture(glow::TEXTURE_2D, Some(self.color_lut));
check_for_gl_errors(&gl, "bind lut");
gl.active_texture(TEXTURE0);
@@ -80,22 +87,27 @@ impl Waterfall {
check_for_gl_errors(&gl, "bind vao");
// Update texture
while let Ok(fft) = self.fft_in.try_recv() {
if fft.len() != self.width {
todo!();
}
gl.tex_sub_image_2d(
glow::TEXTURE_2D,
0,
0,
self.offset as i32,
300,
self.width as i32,
1,
glow::RED,
glow::UNSIGNED_BYTE,
PixelUnpackData::Slice(&new_data),
PixelUnpackData::Slice(Some(&fft)),
);
check_for_gl_errors(&gl, "update texture");
self.offset = (self.offset + 1) % 300;
check_for_gl_errors(&gl, &format!("update texture with offset {}", self.offset));
self.offset = (self.offset + 1) % self.height;
}
if let Some(uniform) = gl.get_uniform_location(self.program, "offset") {
gl.uniform_1_f32(Some(&uniform), self.offset as f32 / 300.0);
gl.uniform_1_f32(Some(&uniform), self.offset as f32 / self.height as f32);
}
check_for_gl_errors(&gl, "update uniform");
@@ -106,7 +118,7 @@ impl Waterfall {
check_for_gl_errors(&gl, "APP PAINT");
}
}
pub fn new(gl: &glow::Context, width: usize, height: usize) -> Self {
pub fn new(gl: &glow::Context, width: usize, height: usize, fft_in: Receiver<Vec<u8>>) -> Self {
let vertices: [f32; 32] = [
// positions // colors // texture coords
1.0, 1.0, 0.0, /**/ 1.0, 0.0, 0.0, /**/ 1.0, 1.0, // top right
@@ -127,8 +139,10 @@ impl Waterfall {
// Generate something to put into the texture Buffer
let mut buffer = vec![0; width * height];
// Add some stripes to the texture
let stripes = 8;
let slen = width / stripes;
for (i, val) in buffer.iter_mut().enumerate() {
*val = if i % 50 < 25 { 255 } else { 0 };
*val = if i % slen < slen / 2 { 255 } else { 0 };
//*val = 255;
}
@@ -191,7 +205,6 @@ impl Waterfall {
gl.tex_parameter_i32(TEXTURE_2D, TEXTURE_MAG_FILTER, NEAREST as i32);
check_for_gl_errors(&gl, "Set texture params");
//gl.tex_storage_2d(glow::TEXTURE_2D, 1, glow::R8, 300, 300);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
@@ -201,26 +214,39 @@ impl Waterfall {
0,
glow::RED,
glow::UNSIGNED_BYTE,
Some(&buffer),
//Some(&buffer), // This segfaults with large buffers
PixelUnpackData::Slice(None),
);
check_for_gl_errors(&gl, "Initializing Texture");
// Clear the texture
clear_texture(gl, width, height);
let color_lut = gl
.create_texture()
.expect("Waterfall: could not create LUT");
gl.bind_texture(TEXTURE_1D, Some(color_lut));
gl.tex_parameter_i32(TEXTURE_1D, TEXTURE_MIN_FILTER, NEAREST as i32);
gl.tex_parameter_i32(TEXTURE_1D, TEXTURE_MAG_FILTER, NEAREST as i32);
check_for_gl_errors(&gl, "Set LUT params");
gl.tex_image_1d(
TEXTURE_1D,
gl.bind_texture(TEXTURE_2D, Some(color_lut));
check_for_gl_errors(&gl, "Setup Bind LUT");
gl.tex_parameter_i32(TEXTURE_2D, TEXTURE_MIN_FILTER, NEAREST as i32);
check_for_gl_errors(&gl, "Set LUT MIN_FILTER");
gl.tex_parameter_i32(TEXTURE_2D, TEXTURE_MAG_FILTER, NEAREST as i32);
check_for_gl_errors(&gl, "Set LUT MAG_FILTER");
gl.tex_parameter_i32(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE as i32);
check_for_gl_errors(&gl, "Set LUT wrap mode");
gl.tex_image_2d(
TEXTURE_2D,
0,
glow::SRGB as i32,
if cfg!(target_os = "android") || cfg!(target_arch = "wasm32") {
glow::RGB
} else {
glow::SRGB
} as i32,
256,
1,
0,
glow::RGB,
UNSIGNED_BYTE,
Some(&turbo_colormap::TURBO_SRGB_BYTES),
PixelUnpackData::Slice(Some(&turbo_colormap::TURBO_SRGB_BYTES)),
);
check_for_gl_errors(&gl, "Initializing LUT");
@@ -243,6 +269,8 @@ impl Waterfall {
}
"#,
r#"
precision mediump float;
out vec4 FragColor;
in vec3 ourColor;
@@ -250,13 +278,13 @@ impl Waterfall {
// texture sampler
uniform sampler2D texture1;
uniform sampler1D LUT;
uniform sampler2D LUT;
uniform float offset;
void main()
{
float val = texture(texture1, vec2(TexCoord.x, TexCoord.y + offset)).x;
FragColor = texture(LUT, val);
FragColor = texture(LUT, vec2(val, 0));
}
"#,
);
@@ -311,6 +339,9 @@ impl Waterfall {
vbo,
ebo,
offset: 0_usize,
width,
height,
fft_in,
}
}
}

124
src/backend/audio.rs Normal file
View File

@@ -0,0 +1,124 @@
use anyhow::Result;
use cpal::{
self,
traits::{DeviceTrait, HostTrait},
BufferSize,
};
use std::sync::mpsc::{SyncSender, TrySendError};
use crate::app::debug_plot::DebugPlotSender;
pub struct Audio {
pub _stream: cpal::Stream,
}
impl Audio {
pub fn new(
device_id: cpal::DeviceId,
config: cpal::StreamConfig,
fft_input: SyncSender<Vec<f32>>,
_plot_tx: DebugPlotSender,
) -> Result<Self> {
let host = cpal::default_host();
let device = host
.device_by_id(&device_id)
.ok_or(anyhow::anyhow!("Can't open device."))?;
let _stream = device.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
match fft_input.try_send(data.to_vec()) {
Err(TrySendError::Disconnected(_)) => panic!(
"Error: Audio backend has lost connection to frontend! Can not continue!"
),
Err(TrySendError::Full(_)) => log::warn!("Audio Backend buffer full."),
Ok(()) => {}
};
},
move |err| log::error!("Audio Thread Error: {err}"),
None,
)?;
Ok(Self { _stream })
}
}
impl crate::backend::Device for Audio {
fn show_settings(&mut self, ui: &mut egui::Ui) {
ui.label("TODO");
}
fn can_tune(&self) -> bool {
false
}
fn tune(&mut self, _freq: usize) -> anyhow::Result<()> {
anyhow::bail!("Can't tune this device")
}
fn close(self: Box<Self>) {
drop(self);
}
}
pub struct AudioBackend {
host: cpal::Host,
devices: Vec<cpal::Device>,
current_device: usize,
}
impl AudioBackend {
pub fn new() -> Self {
let host = cpal::default_host();
let devices = host.devices().unwrap().collect();
let current_device = 0;
Self {
host,
devices,
current_device,
}
}
fn update_devices(&mut self) {
self.devices.clear();
self.devices = self.host.devices().unwrap().collect();
self.current_device = 0;
}
}
impl super::Backend for AudioBackend {
fn display_text(&self) -> &'static str {
"Audio"
}
fn show_device_selection(&mut self, ui: &mut egui::Ui) {
egui::ComboBox::from_label("Device")
.selected_text(
self.devices[self.current_device]
.id()
.map(|id| id.1)
.unwrap_or("UNKNOWN DEVICE".into()),
)
.show_index(ui, &mut self.current_device, self.devices.len(), |i| {
self.devices[i]
.id()
.map(|id| id.1)
.unwrap_or("UNKNOWN DEVICE".into())
});
if ui.add(egui::Button::new("Refresh")).clicked() {
self.update_devices();
}
}
fn build_device(
&mut self,
fft_input: SyncSender<Vec<f32>>,
_plot_tx: DebugPlotSender,
) -> anyhow::Result<Box<dyn super::Device>> {
let config = cpal::StreamConfig {
channels: 1,
sample_rate: 44100,
buffer_size: BufferSize::Default,
};
Ok(Box::new(Audio::new(
self.devices[self.current_device].id()?,
config,
fft_input,
_plot_tx,
)?))
}
}

109
src/backend/dummy.rs Normal file
View File

@@ -0,0 +1,109 @@
use anyhow::Result;
use core::panic;
use std::{
sync::mpsc::{self, RecvTimeoutError, SyncSender, TrySendError},
time::{Duration, Instant},
usize,
};
use crate::app::debug_plot::{DebugPlotSender, PlotData};
const LUT_LEN: usize = 4096;
pub struct DummyDevice {
close: SyncSender<()>,
}
impl DummyDevice {
pub fn new(
sample_rate: usize,
fft_input: SyncSender<Vec<f32>>,
_plot_tx: DebugPlotSender,
) -> Result<Self> {
let sin_lut: Vec<f32> = (0..LUT_LEN)
.map(|i| ((i as f32 / LUT_LEN as f32) * std::f32::consts::TAU).sin())
.collect();
let (close, close_rx) = mpsc::sync_channel(0);
let buffer_size: usize = 2048;
let loop_interval = Duration::from_secs_f32((1. / sample_rate as f32) * buffer_size as f32);
let freq = (sample_rate / 4) as f32;
let phase_delta = sin_lut.len() as f32 * (freq / sample_rate as f32);
std::thread::spawn(move || {
let mut phase = 0_f32;
loop {
let start = Instant::now();
let samples: Vec<f32> = (0..buffer_size)
.map(|_i| {
phase = (phase + phase_delta) % sin_lut.len() as f32;
sin_lut[phase as usize]
})
.collect();
_plot_tx
.send("Dummy output", PlotData::F32(samples.clone()))
.unwrap();
match fft_input.try_send(samples) {
Ok(_) => {}
Err(TrySendError::Full(_)) => log::warn!("Dummy Backend buffer full."),
Err(TrySendError::Disconnected(_)) => {
panic!("Dummy device lost connection to frontend!")
}
}
match close_rx.recv_timeout(loop_interval - start.elapsed()) {
Ok(_) => break,
Err(RecvTimeoutError::Disconnected) => {
panic!("Dummy device lost connection to frontend!")
}
Err(RecvTimeoutError::Timeout) => {}
}
}
});
Ok(Self { close })
}
}
impl crate::backend::Device for DummyDevice {
fn show_settings(&mut self, ui: &mut egui::Ui) {
ui.label("TODO");
}
fn can_tune(&self) -> bool {
false
}
fn tune(&mut self, _freq: usize) -> anyhow::Result<()> {
anyhow::bail!("Can't tune this device")
}
fn close(self: Box<Self>) {
self.close.send(()).unwrap();
}
}
pub struct DummyBackend {
sample_rate: usize,
}
impl DummyBackend {
pub fn new() -> Self {
Self { sample_rate: 48000 }
}
}
impl super::Backend for DummyBackend {
fn display_text(&self) -> &'static str {
"Dummy"
}
fn show_device_selection(&mut self, ui: &mut egui::Ui) {
ui.label("TODO");
}
fn build_device(
&mut self,
fft_input: SyncSender<Vec<f32>>,
_plot_tx: DebugPlotSender,
) -> anyhow::Result<Box<dyn super::Device>> {
Ok(Box::new(DummyDevice::new(
self.sample_rate,
fft_input,
_plot_tx,
)?))
}
}

47
src/backend/mod.rs Normal file
View File

@@ -0,0 +1,47 @@
use std::sync::mpsc::SyncSender;
use egui::Ui;
use crate::app::debug_plot::DebugPlotSender;
mod audio;
mod dummy;
pub trait Device {
fn show_settings(&mut self, ui: &mut Ui);
fn can_tune(&self) -> bool;
fn tune(&mut self, freq: usize) -> anyhow::Result<()>;
fn close(self: Box<Self>);
}
pub trait Backend {
fn display_text(&self) -> &'static str;
fn show_device_selection(&mut self, ui: &mut Ui);
fn build_device(
&mut self,
fft_input: SyncSender<Vec<f32>>,
_plot_tx: DebugPlotSender,
) -> anyhow::Result<Box<dyn Device>>;
}
pub struct Backends(pub Vec<Box<dyn Backend>>);
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
impl Default for Backends {
fn default() -> Self {
Backends(vec![
Box::new(audio::AudioBackend::new()),
Box::new(dummy::DummyBackend::new()),
])
}
}
#[cfg(target_arch = "wasm32")]
impl Default for Backends {
fn default() -> Self {
Backends(vec![Box::new(dummy::DummyBackend::new())])
}
}
#[cfg(target_os = "android")]
impl Default for Backends {
fn default() -> Self {
Backends(vec![Box::new(dummy::DummyBackend::new())])
}
}

View File

@@ -1,12 +1,16 @@
#![warn(clippy::all, rust_2018_idioms)]
pub mod app;
mod backend;
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: winit::platform::android::activity::AndroidApp) {
use winit::platform::android::activity::WindowManagerFlags;
use winit::platform::android::EventLoopBuilderExtAndroid;
android_logger::init_once(
android_logger::Config::default().with_max_level(log::LevelFilter::Debug),
);
// Disable LAYOUT_IN_SCREEN to keep app from drawing under the status bar
// winit does not currently do anything with MainEvent::InsetsChanged events
@@ -17,18 +21,15 @@ fn android_main(app: winit::platform::android::activity::AndroidApp) {
// Alternatively we can hide the system bars by setting the app to fullscreen
//app.set_window_flags(WindowManagerFlags::FULLSCREEN, WindowManagerFlags::empty());
android_logger::init_once(
android_logger::Config::default().with_max_level(log::LevelFilter::Debug),
);
let mut options = eframe::NativeOptions::default();
options.event_loop_builder = Some(Box::new(move |builder| {
builder.with_android_app(app);
}));
let options = eframe::NativeOptions {
android_app: Some(app),
..Default::default()
};
let res = eframe::run_native(
"eframe template",
options,
Box::new(|cc| Box::new(app::TemplateApp::new(cc))),
Box::new(|cc| Ok(Box::new(app::TemplateApp::new(cc)))),
);
if let Err(e) = res {
log::error!("{e:?}");

View File

@@ -1,5 +1,6 @@
mod app;
use app::TemplateApp;
mod backend;
//#[cfg(target_os = "android")]
//fn main() {}
@@ -22,26 +23,53 @@ fn main() -> eframe::Result<()> {
eframe::run_native(
"eframe template",
native_options,
Box::new(|cc| Box::new(TemplateApp::new(cc))),
Box::new(|cc| Ok(Box::new(TemplateApp::new(cc)))),
)
}
// When compiling to web using trunk:
#[cfg(target_arch = "wasm32")]
fn main() {
use eframe::wasm_bindgen::JsCast as _;
// Redirect `log` message to `console.log` and friends:
eframe::WebLogger::init(log::LevelFilter::Debug).ok();
let web_options = eframe::WebOptions::default();
wasm_bindgen_futures::spawn_local(async {
eframe::WebRunner::new()
let document = web_sys::window()
.expect("No window")
.document()
.expect("No document");
let canvas = document
.get_element_by_id("the_canvas_id")
.expect("Failed to find the_canvas_id")
.dyn_into::<web_sys::HtmlCanvasElement>()
.expect("the_canvas_id was not a HtmlCanvasElement");
let start_result = eframe::WebRunner::new()
.start(
"the_canvas_id", // hardcode it
canvas,
web_options,
Box::new(|cc| Box::new(TemplateApp::new(cc))),
Box::new(|cc| Ok(Box::new(TemplateApp::new(cc)))),
)
.await
.expect("failed to start eframe");
.await;
// Remove the loading text and spinner:
if let Some(loading_text) = document.get_element_by_id("loading_text") {
match start_result {
Ok(_) => {
loading_text.remove();
}
Err(e) => {
loading_text.set_inner_html(
"<p> The app has crashed. See the developer console for details. </p>",
);
panic!("Failed to start eframe: {e:?}");
}
}
}
});
}